Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions docs/gen_drone_renders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Render one image of every supported drone for the documentation.

This script is executed by the mkdocs-gen-files plugin during ``mkdocs build`` or
``mkdocs serve``. The images are written to ``img/drones/<name>.png`` in the virtual docs tree
and are never committed. Rendering is headless via EGL on Linux and CGL on macOS unless
``MUJOCO_GL`` is already set.
"""

import logging
import os
import sys
from pathlib import Path

if sys.platform == "linux":
os.environ.setdefault("MUJOCO_GL", "egl")
# libglvnd only searches /usr/share/glvnd, which does not exist on a minimal CI runner. Append
# the Mesa driver of this environment so EGL still finds a (software) device there.
_vendors = Path(sys.prefix, "share/glvnd/egl_vendor.d")
if _vendors.is_dir():
os.environ.setdefault(
"__EGL_VENDOR_LIBRARY_DIRS", f"/usr/share/glvnd/egl_vendor.d:{_vendors}"
)
elif sys.platform == "darwin":
os.environ.setdefault("MUJOCO_GL", "cgl")

try:
import mkdocs_gen_files
except ImportError:
pass # not running in a docs environment — nothing to generate
else:
import imageio.v3 as iio
import mujoco
import numpy as np

from crazyflow.drones import Drone

log = logging.getLogger("mkdocs.plugins.gen_drone_renders")

DRONE_DIR = Path("crazyflow/drones")
WIDTH, HEIGHT = 2000, 1500
MARGIN = 80

def render(name: str) -> np.ndarray:
"""Render a drone in front of a transparent background, cropped to its bounding box."""
spec = mujoco.MjSpec.from_file(str(DRONE_DIR / f"{name}.xml"))
for body in list(spec.worldbody.bodies):
if body.name != "drone":
spec.delete(body)
for material in spec.materials:
if 0 < material.rgba[3] < 1:
material.rgba[3] = 1
spec.visual.global_.offwidth = WIDTH
spec.visual.global_.offheight = HEIGHT
spec.visual.quality.shadowsize = 8192
spec.visual.quality.offsamples = 16
spec.visual.headlight.ambient = [0.35, 0.35, 0.35]
spec.visual.headlight.diffuse = [0.5, 0.5, 0.5]
spec.worldbody.add_light(
pos=[0.4, -0.3, 0.6], dir=[-0.55, 0.4, -0.8], diffuse=[0.6, 0.6, 0.6], castshadow=True
)
spec.worldbody.add_light(
pos=[-0.4, 0.3, 0.3], dir=[0.6, -0.45, -0.45], diffuse=[0.3, 0.3, 0.3], castshadow=False
)
model = spec.compile()
data = mujoco.MjData(model)
mujoco.mj_forward(model, data)

camera = mujoco.MjvCamera()
camera.lookat[:] = model.body("drone").pos
camera.distance, camera.azimuth, camera.elevation = 0.28, 135, -28
option = mujoco.MjvOption()
option.geomgroup[:] = 0
option.geomgroup[2] = 1

renderer = mujoco.Renderer(model, height=HEIGHT, width=WIDTH)
try:
renderer.update_scene(data, camera, option)
rgb = renderer.render()
renderer.enable_segmentation_rendering()
renderer.update_scene(data, camera, option)
mask = renderer.render()[..., 0] != -1
finally:
renderer.close()

rows, cols = np.nonzero(mask)
y0, y1 = max(rows.min() - MARGIN, 0), min(rows.max() + MARGIN, HEIGHT)
x0, x1 = max(cols.min() - MARGIN, 0), min(cols.max() + MARGIN, WIDTH)
alpha = mask.astype(np.uint8) * 255
return np.dstack([rgb, alpha])[y0:y1, x0:x1]

def gl_available() -> bool:
"""Check for a usable OpenGL context, so an unusable one is reported only once."""
try:
mujoco.Renderer(mujoco.MjModel.from_xml_string("<mujoco/>"), 4, 4).close()
except Exception as e:
log.warning(f"Could not create an OpenGL context, skipping the drone renders: {e!r}")
return False
return True

if gl_available():
for drone in Drone:
try:
image = render(drone)
except Exception as e:
log.warning(f"Could not render drone '{drone}': {e!r}")
continue
with mkdocs_gen_files.open(f"img/drones/{drone}.png", "wb") as fd:
fd.write(iio.imwrite("<bytes>", image, extension=".png"))
31 changes: 23 additions & 8 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,33 @@ Crazyflow is a research simulator for Crazyflie-style quadrotors that runs milli

## Supported drones

<!-- DRONE GRID: replace the placeholder image paths once renders are available.
The list of available drones comes from crazyflow.Drone.
Every configuration ships with an MJCF model, meshes, and fitted dynamics parameters. The renders below are generated from the bundled models when the docs are built.

<div class="drone-grid" markdown>
<div class="carousel-container">
<div class="carousel">
<div class="carousel-slide carousel-slide-image active">
<img src="img/drones/cf2x_L250.png" alt="cf2x_L250" loading="lazy" />
<div class="carousel-caption"><code>cf2x_L250</code> — Crazyflie 2.x</div>
</div>
<div class="carousel-slide carousel-slide-image">
<img src="img/drones/cf2x_P250.png" alt="cf2x_P250" loading="lazy" />
<div class="carousel-caption"><code>cf2x_P250</code> — Crazyflie 2.x with plus propellers</div>
</div>
<div class="carousel-slide carousel-slide-image">
<img src="img/drones/cf2x_T350.png" alt="cf2x_T350" loading="lazy" />
<div class="carousel-caption"><code>cf2x_T350</code> — Crazyflie 2.x with thrust upgrade kit</div>
</div>
<div class="carousel-slide carousel-slide-image">
<img src="img/drones/cf21B_500.png" alt="cf21B_500" loading="lazy" />
<div class="carousel-caption"><code>cf21B_500</code> — Crazyflie 2.1 Brushless with 500 mAh battery</div>
</div>
</div>

| Drone | Description |
|-------|-------------|
| ![cf2x_L250](img/drones/cf2x_L250.png){ width=120 } | **cf2x_L250** — Crazyflie 2.x, L250 propellers |
| ![cf2x_T350](img/drones/cf2x_T350.png){ width=120 } | **cf2x_T350** — Crazyflie 2.x, T350 propellers |
<button class="carousel-btn carousel-btn-prev" type="button" aria-label="Previous drone">&lt;</button>
<button class="carousel-btn carousel-btn-next" type="button" aria-label="Next drone">&gt;</button>

<div class="carousel-indicators"></div>
</div>
-->

All available drones are members of `crazyflow.Drone`. See [Available drone configurations](user-guide/dynamics/parametrize.md#available-drone-configurations) for the full list and how to select one.

Expand Down
59 changes: 40 additions & 19 deletions docs/javascripts/showcase.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
function initializeCarousel() {
const carouselContainer = document.querySelector(".carousel-container");
const carousels = [];
let focusedCarousel = null;

if (!carouselContainer || carouselContainer.dataset.carouselReady === "true") {
function initializeCarousel(carouselContainer) {
if (carouselContainer.dataset.carouselReady === "true") {
return;
}

Expand Down Expand Up @@ -66,27 +67,23 @@ function initializeCarousel() {
nextBtn.addEventListener("click", nextSlide);
}

document.addEventListener("keydown", (event) => {
if (!document.querySelector(".carousel-container")) {
return;
}

if (event.key === "ArrowLeft") {
prevSlide();
} else if (event.key === "ArrowRight") {
nextSlide();
}
});
const api = { container: carouselContainer, nextSlide, prevSlide };
const focus = () => {
focusedCarousel = api;
};
carouselContainer.addEventListener("pointerenter", focus);
carouselContainer.addEventListener("focusin", focus);

let touchStartX = 0;
let touchEndX = 0;

carousel.addEventListener(
"touchstart",
(event) => {
focus();
touchStartX = event.changedTouches[0].screenX;
},
false
{ passive: true }
);

carousel.addEventListener(
Expand All @@ -95,7 +92,7 @@ function initializeCarousel() {
touchEndX = event.changedTouches[0].screenX;
handleSwipe();
},
false
{ passive: true }
);

function handleSwipe() {
Expand All @@ -113,13 +110,37 @@ function initializeCarousel() {
}
}

carousels.push(api);
goToSlide(0);
}

function initializeCarousels() {
for (let i = carousels.length - 1; i >= 0; i--) {
if (!carousels[i].container.isConnected) {
carousels.splice(i, 1);
}
}
document.querySelectorAll(".carousel-container").forEach(initializeCarousel);
}

document.addEventListener("keydown", (event) => {
if (carousels.length === 0 || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) {
return;
}

const target = carousels.includes(focusedCarousel) ? focusedCarousel : carousels[0];

if (event.key === "ArrowLeft") {
target.prevSlide();
} else {
target.nextSlide();
}
});

if (typeof document$ !== "undefined") {
document$.subscribe(initializeCarousel);
document$.subscribe(initializeCarousels);
} else if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeCarousel);
document.addEventListener("DOMContentLoaded", initializeCarousels);
} else {
initializeCarousel();
initializeCarousels();
}
28 changes: 18 additions & 10 deletions docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@
text-align: center;
}

.carousel-slide-image {
background: linear-gradient(180deg, #fafafa, #d9d9d9);
}

.carousel-slide img {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 1.5rem 1.5rem 3rem;
display: block;
object-fit: contain;
}

.carousel-slide-image .carousel-caption {
background: none;
color: #1a1a1a;
}

.carousel-btn {
position: absolute;
top: 50%;
Expand Down Expand Up @@ -219,16 +237,6 @@
}
}

/* Drone grid */
.drone-grid table {
margin: 0 auto;
}

.drone-grid td {
vertical-align: middle;
text-align: center;
}

/* Example page media */
.example-media,
.example-media-grid {
Expand Down
2 changes: 2 additions & 0 deletions docs/user-guide/adding-drones.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Add `[<name>.core]`, `[<name>.state2attitude]`, `[<name>.attitude2force_torque]`

Add the platform to the table in [Parametrization](dynamics/parametrize.md#available-drone-configurations).

The docs build renders every `Drone` member from its MJCF file, so a new drone gets an image at `img/drones/<name>.png` without further work. Add a slide for it to the carousel on the [home page](../index.md#supported-drones).

## 5. Run the tests

The test suite parametrizes over `Drone` and over the supported drone-dynamics pairs, so the new drone is tested without any changes to the tests.
Expand Down
Loading
Loading