Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
collect_ignore_glob = ["gen_*.py"]
89 changes: 89 additions & 0 deletions docs/gen_drone_renders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""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 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")

import imageio.v3 as iio
import mkdocs_gen_files
import mujoco
import numpy as np

from crazyflow.drones import Drone

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 = max(0.28, 1.1 * model.stat.extent)
camera.azimuth, camera.elevation = 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]


for drone in Drone:
with mkdocs_gen_files.open(f"img/drones/{drone}.png", "wb") as fd:
fd.write(iio.imwrite("<bytes>", render(drone), extension=".png"))
2 changes: 1 addition & 1 deletion docs/get-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Adds `pytest` and `pytest-markdown-docs` for running the test suite and doc snip

```bash
uv run pytest -v tests # unit and integration tests
uv run pytest -v --markdown-docs --markdown-docs-syntax=superfences crazyflow/ docs/ --ignore=docs/gen_ref_pages.py
uv run pytest -v --markdown-docs --markdown-docs-syntax=superfences crazyflow/ docs/
```

## Verify the installation
Expand Down
45 changes: 37 additions & 8 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ Crazyflow is a research simulator for Crazyflie-style quadrotors that runs milli
></iframe>
<div class="carousel-caption">Obstacle avoidance with MPPI using Crazyflow for rollouts.</div>
</div>
<div class="carousel-slide">
<iframe
src="https://www.youtube.com/embed/QhOUePHSmyA?autoplay=1&amp;mute=1&amp;loop=1&amp;playlist=QhOUePHSmyA&amp;controls=0&amp;rel=0&amp;modestbranding=1&amp;playsinline=1"
title="End-to-end vision-based flight in Crazyflow"
allow="autoplay; encrypted-media; picture-in-picture; web-share"
allowfullscreen
loading="lazy"
></iframe>
<div class="carousel-caption">End-to-end vision-based flight on hardware with a policy trained in Crazyflow.</div>
</div>
<div class="carousel-slide">
<iframe
src="https://www.youtube.com/embed/iabmLwW8SWQ?autoplay=1&amp;mute=1&amp;loop=1&amp;playlist=iabmLwW8SWQ&amp;controls=0&amp;rel=0&amp;modestbranding=1&amp;playsinline=1"
Expand Down Expand Up @@ -85,18 +95,37 @@ 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 includes an MJCF model, meshes, and fitted dynamics parameters.

<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 class="carousel-slide carousel-slide-image">
<img src="img/drones/hb_x500.png" alt="hb_x500" loading="lazy" />
<div class="carousel-caption"><code>hb_x500</code> — Holybro X500 V2</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