trahe.eu / rust

Holger Trahe · Rust engineering

How I build in Rust — and why.

Real code from my projects, next to the decisions behind it: the problem, what I chose, what I turned down, and what it bought. Each chapter links to the thing running live.

  1. 01One core, many targets
  2. 02The editor is a library
  3. 03Lenient in, precise out
  4. 04Same code on chip and browser
  5. 05The sort order is the index
  6. 06Know your silicon
  7. 07Ship data, not libraries
  8. 08Logic without I/O
  9. 09One copy in, views out
  10. 10One wasm, no bridge
  11. 11ML that stays on the device
  12. 12The UI is Rust, too

01 · Architecture · eulumdat-rs, gldf-rs

Lighting data everywhere — from one core

The problem

Lighting data — EULUMDAT (.ldt), IES and GLDF files — is needed in a browser editor, in Python scripts, on PHP servers, in iOS and Android apps, in the Windows Explorer preview pane and on HarmonyOS. Every platform speaks a different language.

What I did

One crate per format owns parsing, the calculations and the diagrams: eulumdat for LDT and IES, gldf-rs for GLDF. Each target is a thin adapter in its own idiom: PyO3 for Python, UniFFI for Swift and Kotlin, phper for PHP, a plain C ABI for HarmonyOS, a COM preview handler on Windows, wasm-bindgen and Leptos in the browser.

Why this way

  • The core does the work: PolarDiagram::from_eulumdat(…).to_svg(…) appears in every eulumdat adapter on the right, and the PHP extension is the same few lines around GldfProduct::load_gldf. Adapters only convert types and errors.
  • Each binding uses the ownership model its host expects: PyO3 wraps the Rust struct, UniFFI passes a record by value, the C ABI hands out an opaque handle that the caller frees.
  • A fix lands everywhere at once. When symmetric files expanded their C-planes wrongly, one change in the core — with regression tests on real files — fixed every app.

What I turned down

A port per platform
Photometry is full of edge cases (four symmetry types, LDT vs IES). Solved once in the core, solved everywhere.
One hand-written C API for every language
Each language would re-learn ownership and errors on its own. Generated bindings are idiomatic; the raw C ABI is kept for the one host that needs plain C types.
30crates in the eulumdat workspace
687#[test] functions in eulumdat-rs
6platforms with a native front end
eulumdat-rs/crates/eulumdat-py/src/types.rs:763rust
/// Generate a polar diagram SVG.
#[pyo3(signature = (width=500.0, height=500.0, theme=SvgTheme::Light))]
fn polar_svg(&self, width: f64, height: f64, theme: SvgTheme) -> String {
    let diagram = CorePolarDiagram::from_eulumdat(&self.inner);
    diagram.to_svg(width, height, &theme.to_core())
}

Keyword defaults come from #[pyo3(signature)] — from Python it reads like any other method.

eulumdat-rs/crates/eulumdat-ffi/src/diagram.rs:417rust
/// Generate polar diagram as SVG string
#[uniffi::export]
pub fn generate_polar_svg(ldt: &Eulumdat, width: f64, height: f64, theme: SvgThemeType) -> String {
    let core_ldt = to_core_eulumdat(ldt);
    let polar = PolarDiagram::from_eulumdat(&core_ldt);
    polar.to_svg(width, height, &theme.to_core())
}

#[uniffi::export] generates the Swift and Kotlin side; the record is converted to the core type first.

gldf-rs-php/src/lib.rs:10rust
fn gldf_to_xml(arguments: &mut [ZVal]) -> phper::Result<String> {
    let path = arguments[0].expect_z_str()?.to_str()?;
    let loaded: GldfProduct = GldfProduct::load_gldf(path).unwrap();
    Ok(loaded.to_xml().unwrap())
}
⋯
#[php_get_module]
pub fn get_module() -> Module {
    // New `Module` with extension info.
    let mut module = Module::new(
        env!("CARGO_CRATE_NAME"),
        env!("CARGO_PKG_VERSION"),
        env!("CARGO_PKG_AUTHORS"),
    );
⋯
    module
        .add_function("gldf_to_xml", gldf_to_xml)
        .argument(Argument::by_val("path"));
    module
        .add_function("gldf_to_json", gldf_to_json)
        .argument(Argument::by_val("path"));
⋯
    module
}

#[php_get_module] generates the extension's C entry point; each PHP function is a few lines over the gldf-rs API.

eulumdat-rs/crates/eulumdat-harmonyos-ffi/src/lib.rs:366rust
#[no_mangle]
pub unsafe extern "C" fn eulumdat_polar_svg(
    handle: *const EulumdatHandle,
    width: f64,
    height: f64,
    theme: i32,
) -> *mut c_char {
    if handle.is_null() {
        return ptr::null_mut();
    }

    let ldt = &(*handle).inner;
    let svg_theme = if theme == 1 {
        SvgTheme::dark()
    } else {
        SvgTheme::light()
    };

    let polar = PolarDiagram::from_eulumdat(ldt);
    let svg = polar.to_svg(width, height, &svg_theme);
    string_to_c(&svg)
}

Plain C types for the Cangjie side: a null check, an opaque handle, a string the caller frees.

eulumdat-rs/crates/eulumdat-windows-preview/src/handler.rs:146rust
/// Generate SVG from file content
fn generate_svg(content: &str, width: f64, height: f64) -> Result<String, String> {
    // Try LDT first, then IES
    let ldt = eulumdat::Eulumdat::parse(content)
        .or_else(|_| eulumdat::IesParser::parse(content))
        .map_err(|e| e.to_string())?;

    let polar = eulumdat::diagram::PolarDiagram::from_eulumdat(&ldt);
    let theme = eulumdat::diagram::SvgTheme::light();
    Ok(polar.to_svg(width, height, &theme))
}

The preview pane tries LDT first, then IES — and draws with the same two calls.

02 · Architecture · gldf-rs

166 edits, one reducer, four front ends

The problem

GLDF — the Global Lighting Data Format — is a zip of XML, photometry and 3D files. Its editor at gldf.ch grew inside the web front end. Then a terminal UI, a CLI and Python scripting needed exactly the same editing operations.

What I did

I moved every edit into a gldf-rs-editor crate: a serde-tagged GldfAction enum and one reducer, apply(action). A front end deserializes an action and calls it. The only side effect — remembering the UI language — sits behind a trait.

Why this way

  • One state machine, four consumers: the browser, the ratatui TUI, the CLI and Python feed the same reducer, so they cannot disagree about what an edit does.
  • #[serde(tag = "action", content = "data")] turns the action list into a JSON protocol for free — a Python script and the browser send the same payload.
  • The reducer cannot reach localStorage or the file system: UiLanguageStore is implemented by the browser, by the TUI and by a no-op for tests.
  • Errors come back one way — last_error — and each binding turns them into its own idiom: a ValueError in Python, a string for JavaScript.

What I turned down

Editor logic inside the web app
Where it started. Every other front end would have needed its own copy, drifting apart edit by edit.
A custom RPC format per front end
serde already gives one stable shape: {"action": …, "data": …}.
166actions in one enum
1reducer: apply()
4front ends on it
gldf-rs/crates/gldf-rs-editor/src/action.rs:54rust
/// Actions that can be performed on the GLDF state.
///
/// Serialized as `{"action": "<variant>", "data": <variant payload>}`
⋯
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "action", content = "data")]
#[allow(clippy::large_enum_variant, dead_code)]
pub enum GldfAction {
    /// Load a new GLDF product (without embedded file bytes).
    Load(GldfProduct),

Adjacently tagged: every action serializes as {"action": "Load", "data": …}.

gldf-rs/crates/gldf-rs-python/src/lib.rs:183rust
fn apply_json(&mut self, action_json: &str) -> PyResult<()> {
    let action: gldf_rs_editor::GldfAction = serde_json::from_str(action_json)
        .map_err(|e| PyValueError::new_err(format!("parse action: {}", e)))?;
    self.inner.apply(action);
    if let Some(err) = self.inner.last_error.as_deref() {
        return Err(PyValueError::new_err(err.to_string()));
    }
    Ok(())
}

Python sends JSON, Rust parses and applies — the reducer's error becomes a ValueError.

gldf-rs/crates/gldf-rs-wasm/src/app.rs:194rust
#[wasm_bindgen::prelude::wasm_bindgen(js_name = gldf_apply_action_json)]
pub fn apply_action_json(json: &str) -> String {
    let action: GldfAction = match serde_json::from_str(json) {
        Ok(a) => a,
        Err(e) => return format!("parse: {}", e),
    };
    let mut result = String::new();
    GLDF_CTX.with(|cell| {
        match cell.get() {
            Some(ctx) => {
                ctx.update(|s| {
                    s.apply(action);
                    if let Some(err) = &s.last_error {
                        result = format!("apply: {}", err);
                    }
                });
            }
            None => result = "GldfContext not yet initialised".to_string(),
        }
    });
    result
}

The same parse-and-apply, inside a Leptos signal update so the UI re-renders.

gldf-rs/crates/gldf-rs-editor/src/ui_language.rs:1rust
//! UI translation language — picked from the viewer's chrome, independent
//! of the document's locale. Persisted via a frontend-supplied store so
//! the state crate stays free of browser- and filesystem-specific calls.
⋯
/// Storage adapter for the user's UI language preference. The WASM
/// frontend persists to `localStorage`; the TUI persists to a config file
/// under `$XDG_CONFIG_HOME`; tests use [`NullLangStore`] which does
/// nothing.
pub trait UiLanguageStore: Send + Sync {
    /// Read the last-chosen UI language, if any.
    fn load(&self) -> Option<String>;
    /// Persist the chosen UI language.
    fn store(&self, lang: &str);
}

localStorage, a config file or nothing: the front end decides, the state crate stays pure.

03 · Parsing · gldf-rs, tm25ray

Real files don't follow the spec

The problem

GLDF rc.3 has typos in element names that rc.4 corrects — and files of both kinds are in circulation. TM-25 ray files (millions of measured light rays per LED) differ by vendor in how they write “unknown” and whether an optional block exists.

What I did

Accept every spelling that exists, keep one canonical model, write exactly what the target expects. For TM-25: match vendor quirks by exact bit pattern and settle ambiguities with arithmetic, not heuristics.

Why this way

  • serde's alias reads the rc.3 typo and the rc.4 spelling into one field. rc.3 export rewrites the names back, so saving a file never silently bumps its schema version.
  • The corrections are proposed upstream (globallightingdata/gldf#30); gldf-rs bundles both XSDs and can validate against either until rc.4 is published.
  • Lumileds writes the “unknown” NaN byte-swapped. Read little-endian it becomes 2.36e-38 — a plausible-looking number that would poison every derived value. Only that exact pattern counts as unknown.
  • Rays are decoded from a borrowed &[u8]: no per-ray allocation, and the whole block can be uploaded to a GPU buffer unchanged.

What I turned down

Reject anything but the newest schema
Real files use the old spelling. Refusing them helps nobody.
Detect “unknown” by float class
Tried first: it also matched about 0.4 % of ordinary values and would have zeroed real measurements.
Guess whether the optional trailer exists
The file size decides exactly: the ray block must end at the end of the file.
~0.4 %of real values the first heuristic would have zeroed
91 Mrays/s streamed and decoded (20M rays, M2 Max)
2GLDF schema versions read and written
gldf-rs/crates/gldf-rs-lib/src/gldf/general_definitions/lightsources.rs:172rust
/// The rated chromaticity coordinate values of the light source.
///
/// On serialize we emit the rc.4-corrected element name
/// `RatedChromaticityCoordinateValues`; on deserialize we accept
/// either spelling via the `alias` (rc.3 files in the wild) and the
/// rc.4 corrected form. rc.3 export wraps `to_xml()` in a downconvert
/// pass that rewrites the element name back.
#[serde(
    rename = "RatedChromaticityCoordinateValues",
    alias = "RatedChromacityCoordinateValues",
    skip_serializing_if = "Option::is_none"
)]
pub rated_chromaticity_coordinate_values: Option<RatedChromaticityCoordinateValues>,

rename is what we write, alias is what we also accept.

gldf-rs/crates/gldf-rs-lib/src/lib.rs:140rust
/// Rewrite rc.4-canonical spellings back to their rc.3 typo'd forms.
///
/// The rc.4 schema kept both spellings valid via `<xs:choice>` wrappers
/// and deprecated-form enum aliases, so this rewrite is needed only for
/// consumers that haven't bumped to rc.4 yet. Pure string replacement —
/// the substrings we replace don't appear anywhere except as element
/// names (`<RatedChromaticityCoordinateValues>` open/close tags) and as
/// the literal enum values inside `<Cie97LampType>...</Cie97LampType>`
/// element bodies, so global replace is safe.
fn downconvert_rc4_to_rc3(xml: &str) -> String {
    xml
        // Element name (rc.4 only added this; rc.3 only knew the typo'd form)
        .replace(
            "RatedChromaticityCoordinateValues",
            "RatedChromacityCoordinateValues",
        )
        // Cie97LampType enum values
        .replace("Fluorescent Triphosphor", "Flourescent Triphosphor")
        .replace("Fluorescent Halophosphate", "Flourescent Halophosphate")
}

A plain string rewrite — and the doc comment says why it is safe.

tm25ray/src/header.rs:765rust
/// True for the bit patterns vendors use to mean "not known".
///
/// Normally that is a NaN written little-endian like every other field
/// (ams OSRAM: `01 00 C0 7F`). Lumileds LUXEON files instead write the
/// sentinel **byte-swapped** (`7F 80 00 01`), which read little-endian is not
/// a NaN at all but the denormal 2.36e-38 — small enough to look like a real
/// measurement and poison anything derived from it. The ray data in those same
/// files is ordinary little-endian, so this is a quirk of the sentinel only.
///
/// Matched exactly, never by bit class: a value is "unknown" only if it is a
/// real NaN or precisely that one swapped pattern.
fn is_unknown(v: f32) -> bool {
    // Exact patterns only. An earlier version asked "would the swapped bytes
    // be a NaN?", which also matched ~0.4 % of ordinary positive values and
    // would have silently zeroed real measurements.
    const BYTE_SWAPPED_SENTINEL: u32 = 0x0100_807F;
    v.is_nan() || v.to_bits() == BYTE_SWAPPED_SENTINEL
}

One exact bit pattern, no clever classification — the comment records the version that was wrong.

tm25ray/src/ray.rs:215rust
/// Zero-copy view over a ray block.
#[derive(Clone, Debug)]
pub struct RayView<'a> {
    layout: RayLayout,
    data: &'a [u8],
    len: usize,
}
⋯
    /// Raw bytes of record `i`.
    pub fn raw(&self, i: usize) -> Option<&'a [u8]> {
        if i >= self.len {
            return None;
        }
        let s = i * self.layout.record_size;
        Some(&self.data[s..s + self.layout.record_size])
    }

    pub fn get(&self, i: usize) -> Option<Ray> {
        self.raw(i).map(|r| self.layout.decode(r))
    }
⋯
    /// The whole block as bytes (for uploading to a GPU buffer as-is).
    pub fn bytes(&self) -> &'a [u8] {
        &self.data[..self.len * self.layout.record_size]
    }

A view borrows the file's bytes; records are sliced on demand, and the GPU gets the block as-is.

04 · Embedded · geodb-rs

The same firmware on a chip and in your browser

The problem

geodb-rs puts a world city database on a microcontroller: an STM32F769I-DISCO (Cortex-M7, 216 MHz) with an 800×480 touch screen and a software-rendered globe. I wanted people to try it without the board — and without a second version that slowly drifts away from the real one.

What I did

geodb-fw-core is no_std and does not allocate. It takes plain borrowed slices — the city image, the framebuffer — and closures for the clock. The board points it at flash; the browser build points it at a buffer the page fills with the very same geodb.fw file.

Why this way

  • No platform trait is needed, because only the memory differs: the chip reads the image in place from flash, the browser hands in a leaked Vec. Same parser, same ui::draw, same screen.
  • The image sits in its own flash sectors outside both program slots, so a firmware update leaves the cities alone. A damaged or cut-off image falls back to a built-in placeholder instead of a dead board.
  • The browser build has no wasm-bindgen: a handful of extern "C" exports, called from one HTML page. Errors cross as plain integers (-1 … -4).

What I turned down

A HAL trait with an implementation per target
Nothing to abstract: the core needs bytes and a clock, not a platform.
A separate preview renderer for the web
It would show what the board might draw. This shows what the board draws.
216 MHzCortex-M7 drawing the whole UI in software
1,267 KBflash image: 40,928 cities + elevation
109 KBthe firmware as wasm (44 KB brotli)
geodb-rs/crates/geodb-fw-core/src/lib.rs:1rust
//! The core of the geodb firmware, `no_std` and without `alloc`:
//!
//! - [`image`]: a flash-resident, zero-copy image of the cities. Geoids are
//!   32-bit Morton codes on 16-bit axes (0.0027° of latitude, about 305 m),
//!   sorted, so the sort order is the spatial index, as in `geodb-core`.
//! - [`geo`], [`query`]: the covering Z-order ranges of a circle (integer
//!   arithmetic) and radius / nearest queries with an f32 haversine, the
//!   method of the GPU kernels.
//! - [`render`]: a software globe into an RGB565 framebuffer, dots and text,
//!   the same code on the host (PNG previews) and on the board.
//! - [`ui`]: the whole 800 x 480 screen, drawn from the image.
//!
//! With the `std` feature, the `build` module writes the image from plain slices.

#![cfg_attr(not(any(feature = "std", test)), no_std)]

no_std unless a host tool or a test asks for std.

geodb-rs/firmware/stm32f769i-disco/src/main.rs:34rust
/// The city image (`make_image`) sits in the last flash sectors, outside both program slots, so a
/// program update leaves it alone: flash it once with
/// `probe-rs download --binary-format bin --base-address 0x080C0000 geodb.fw`.
const IMAGE_ADDR: usize = 0x080C_0000;
const IMAGE_MAX: usize = 0x14_0000;
⋯
/// The image in flash, when it parses (its header names the length); else the placeholder.
fn flash_image() -> &'static [u8] {
    if !ota::UPDATING_IMAGE.load(core::sync::atomic::Ordering::Relaxed) {
        let len = unsafe { ((IMAGE_ADDR + 60) as *const u32).read_volatile() } as usize;
        let bytes =
            unsafe { core::slice::from_raw_parts(IMAGE_ADDR as *const u8, len.min(IMAGE_MAX)) };
        if FwImage::parse(bytes).is_ok() {
            return bytes;
        }
    }
    PLACEHOLDER
}

The image is read in place from flash — and only used if it parses.

geodb-rs/crates/geodb-fw-web/src/lib.rs:1rust
//! The board's screen in the browser: `geodb-fw-core` on the firmware image
//! (`geodb.fw`), the same `ui::draw` the STM32F769I-DISCO runs, and the
//! board's main loop (touch, flick, spin, quick and full redraws).
//!
//! No wasm-bindgen: a handful of plain exports, driven by `web/index.html`.
⋯
/// A buffer of `len` bytes for the image; the page writes `geodb.fw` into it.
#[no_mangle]
pub extern "C" fn image_alloc(len: usize) -> *mut u8 {
    let buf: &'static mut [u8] = Box::leak(vec![0u8; len].into_boxed_slice());
    let ptr = buf.as_mut_ptr();
    IMAGE.with(|i| *i.borrow_mut() = Some(buf));
    ptr
}
⋯
/// Parses the image and draws the first screen: the number of cities, or -1
/// (no image), -2 (not a geodb firmware image), -3 (other version), -4 (damaged).
#[no_mangle]
pub extern "C" fn start() -> i32 {
    let Some(bytes) = IMAGE.with(|i| i.borrow_mut().take()) else {
        return -1;
    };
    let bytes: &'static [u8] = bytes;
    let img = match FwImage::parse(bytes) {
        Ok(img) => img,
        Err(geodb_fw_core::ImageError::Magic) => return -2,
        Err(geodb_fw_core::ImageError::Version) => return -3,
        Err(geodb_fw_core::ImageError::Layout) => return -4,
    };

The page writes geodb.fw into a leaked buffer; from there it is the same FwImage::parse.

05 · Data structures · geodb-rs

The sort order is the index

The problem

“Cities within 300 km of Munich” has to be fast on a 216 MHz chip with no heap — and on the GPU, across 153,000 cities in the browser.

What I did

Cities are stored sorted by geoid, a Morton code that interleaves the bits of latitude and longitude. A circle becomes a few contiguous ranges of that order; each range costs two binary searches, then an exact haversine test. The GPU takes the same ranges, cut into segments of 256 cities — one workgroup each.

Why this way

  • There is no separate index to build or to fit into flash: sorting is the index. The header is a table of offsets, validated before the first read — every section 4-byte aligned and in bounds.
  • Sorting turned the compact radius search from a 905 µs linear scan into 37 µs.
  • On the GPU the CPU computes the covering ranges once and the kernel tests only those cities: a batched query went from 6.4 to 2.6 µs.
  • WGSL has no 64-bit integers, so geoids are split into two u32 halves and compared as exact integer deltas. Running GPU and CPU side by side even exposed a CPU nearest-city bug — wrong on 501 of 2,000 queries.

What I turned down

An R-tree or k-d tree
More bytes in flash and more code on the chip, for what the sort order already gives.
Testing every city on the GPU
That was the first kernel. Covering ranges made it 2.5× faster per query.
An f32 flat-earth distance in the kernel
It disagreed with the CPU on 10 % of the counts. Haversine on integer deltas: ±1.
905 → 37 µsradius search once sorted by geoid
6.4 → 2.6 µsper batched GPU query
2.6 ms≈ 60k cities within 2,500 km, GPU (native, M2 Max)
geodb-rs/crates/geodb-fw-core/src/image.rs:1rust
//! The flash image, read in place (no copy, no allocation).
//!
//! Little-endian; every section starts on a 4-byte boundary, offsets count
//! from the start of the image. The 84-byte header:
//!
//! | at | field |
//! |---|---|
//! | 0 | magic `GDFW` |
//! | 4 | version u16, flags u16 |
//! | 8 | cities u32, countries u32, named cities u32 |
//! | 20 | coastline length u32 |
//! | 24 | offsets: geoids, country ids, named index, name offsets, names |
//! | 44 | names length; offsets: countries, country names, coastline |
//! | 60 | total length |
//! | 64 | colour picture (the Blue Marble, 256 x 128 RGB565): offset u32, length u32 (0: none) |
//! | 72 | elevation picture (packed, see [`crate::relief`]): offset u32, packed length u32 (0: none), width u16, height u16 |
⋯
        let names_len = u32_at(bytes, 44);
        let fits = |off: usize, len: usize| {
            off.is_multiple_of(4)
                && off >= HEADER_LEN
                && off.checked_add(len).is_some_and(|end| end <= bytes.len())
        };
        let ok = u32_at(bytes, 60) == bytes.len()
            && fits(img.off_geoids, img.cities * 4)
            && fits(img.off_country, img.cities)
            && fits(img.off_named, img.named * 4)
            && fits(img.off_name_off, (img.named + 1) * 4)
            && fits(img.off_names, names_len)
            && fits(img.off_countries, img.countries * 4)
            && fits(img.off_country_names, 0)
            && fits(img.off_coast, img.coast_len)
            && (img.marble_len == 0 || fits(img.off_marble, img.marble_len))
            && (img.elev_len == 0 || fits(img.off_elev, img.elev_len));

A hand-written format, read in place; one fits() check per section before anything trusts an offset.

geodb-rs/crates/geodb-fw-core/src/query.rs:20rust
/// Calls `f(city, haversine)` for every city within `radius_km` of
/// (lat, lon) (degrees), in geoid order; the haversine sin²(d / 2R)
/// orders like the distance. Returns how many cities it tested.
pub fn radius_index(
    &self,
    lat: f32,
    lon: f32,
    radius_km: f32,
    mut f: impl FnMut(u32, f32),
) -> usize {
    let limit = hav_of_km(radius_km);
    let cos_q = cos(lat * DEG_TO_RAD);
    let half = 0.5 * DEG_TO_RAD;
    let rg = crate::geo::ranges(lat, lon, radius_km);
    let mut tested = 0;
    for &(a, b) in rg.as_slice() {
        let (lo, hi) = (self.lower_bound(a), self.upper_bound(b));
        for i in lo..hi {
            tested += 1;
            let (la, lo) = to_deg(self.geoid(i));
            let s_lat = sin((la - lat) * half);
            let s_lon = sin((lo - lon) * half);
            let h = s_lat * s_lat + cos_q * cos(la * DEG_TO_RAD) * s_lon * s_lon;
            if h <= limit {
                f(i as u32, h);
            }
        }
    }
    tested
}

Ranges of the sort order, two binary searches each, then the haversine.

geodb-rs/crates/geodb-globe/src/gpu_query.rs:227rust
fn push_segments(&self, out: &mut Vec<[u32; 4]>, q: u32, center: u64, radius_km: f64) {
    for (start, end) in self.index_ranges(center, radius_km) {
        let mut at = start;
        while at < end {
            let len = (end - at).min(WORKGROUP);
            out.push([q, at, len, 0]);
            at += len;
        }
    }
}

The same ranges, cut into segments that fit one workgroup.

geodb-rs/crates/geodb-globe/src/gpu_query.wgsl:93wgsl
// One query, indexed: workgroup = segment, thread = city of the segment.
@compute @workgroup_size(256)
fn radius_single_seg(
    @builtin(workgroup_id) wid: vec3<u32>,
    @builtin(local_invocation_index) li: u32,
) {
    let s = wid.y * GRID + wid.x;
    if (s >= p.nseg) {
        return;
    }
    let seg = segments[s];
    if (li >= seg.z) {
        return;
    }
    let i = seg.y + li;
    if (hav(split(cities[i]), split(p.center)) <= p.h) {
        let k = atomicAdd(&counters[0], 1u);
        if (k < p.cap) {
            hits[k] = i;
        }
    }
}

Workgroup = segment, thread = city. Hits are appended with one atomic counter.

06 · Embedded · geodb-rs

Know your silicon

The problem

The Cortex-M7 on this board has a single-precision FPU. libm's sinf and cosf compute in software double precision there — about 20 µs per haversine — and the screen has 384,000 pixels to fill.

What I did

Short f32-only polynomials replace libm (Cephes-style, tested against f64). The globe projection is computed once per tilt and zoom into a fixed-point table; a spinning frame then only shifts the longitude and samples the texture with integer math into RGB565.

Why this way

  • sqrt is a bit-pattern guess and two Newton steps: about 40 cycles, relative error ~2e-7, no library call.
  • Spinning changes nothing in the table — the relative longitude depends only on tilt and zoom. A frame becomes integer work, paced by the panel refresh.
  • The globe is computed per 2×2-pixel block, and the code runs from flash with I- and D-cache on: at 216 MHz, flash has seven wait states.
  • Accuracy is a test, not a hope: trig_matches_f64 holds sin and cos to 2e-6 of f64.

What I turned down

libm
Software double precision on a single-precision FPU — about 20× slower.
Trigonometry per pixel, per frame
Turning the globe doesn't change the projection, so don't recompute it.
~40cycles for the f32 square root
61 → 23 msquick frame
32 fpsspinning globe on the chip
geodb-rs/crates/geodb-fw-core/src/fmath.rs:1rust
//! f32 math without f64: `libm`'s sinf, cosf, atan2f and friends compute in
//! software double precision, which is very slow on the Cortex-M7 targets
//! with a single-precision FPU (about 20 us per haversine). These are short
//! polynomials in f32 only (Cephes-style), accurate to ~1e-6 for sin/cos and
//! ~2e-5 rad for atan, and `sqrt` is a bit guess and two Newton steps.
⋯
/// Square root: a bit-pattern guess (3.5% off) and two Newton steps
/// (relative error ~2e-7), in f32 only: no library call, about 40 cycles.
pub fn sqrt(x: f32) -> f32 {
    if x <= 0.0 {
        return 0.0;
    }
    let mut y = f32::from_bits(0x1fbd_1df5 + (x.to_bits() >> 1));
    y = 0.5 * (y + x / y);
    0.5 * (y + x / y)
}

The magic constant halves the exponent; two Newton steps do the rest.

geodb-rs/crates/geodb-fw-core/src/render.rs:223rust
/// What one block of the globe needs from the view, computed once for a
/// tilt and zoom: the texture row and the longitude offset (fixed point),
/// and the shading. Turning the globe (spinning) changes none of it.
#[derive(Clone, Copy)]
pub struct LutCell {
    /// Longitude offset from the view centre, in texels x 256.
    du: i32,
    /// Texture row x 32 ([`EMPTY`] = the block is outside the disc; 1024 rows still fit a u16).
    v: u16,
    /// Brightness x 128.
    shade: u8,
    /// Strength of the blue rim, 0..255.
    rim: u8,
}
⋯
                let u = (centre + c.du).rem_euclid(wrap);
                let rgb = sample_fixed(tex, u, i32::from(c.v), rows);
                let (shade, rim) = (u32::from(c.shade), u32::from(c.rim));
                let mut out = [0u8; 3];
                for k in 0..3 {
                    out[k] = (((rgb[k] * shade) >> 7) + ((GLOW[k] * rim) >> 8)).min(255) as u8;
                }
                let color = rgb565(out[0], out[1], out[2]);

Eight bytes per block, computed once per view; the per-frame loop is shifts and multiplies.

07 · Size · geodb-rs

Ship data, not libraries

The problem

Search should find Москва when you type “Moskva”, and 東京 for “dongjing”. The general-purpose transliteration crate, deunicode, weighs hundreds of kilobytes — in a page that should start below 300 KB.

What I did

At build time, each dataset gets its own fold table: only the few thousand characters it actually contains, mapped to their transliteration. The tables ship as small data layers, split by script. The wasm folds names and queries with them and needs no deunicode.

Why this way

  • A dataset contains a few thousand distinct characters, so its table is a few KB rather than hundreds.
  • Split by script — Cyrillic, Arabic, Greek and kana 1.5 KB; Chinese and kanji 8 KB; Korean 2 KB — a page loads only what it needs.
  • Where a transliteration pads syllables (“Dong Jing”), names are indexed in both forms, because people type them without the space.
  • The table was verified equal to the full transliteration on every text of both data layers (1M+) and on fuzzed strings.

What I turned down

Compiling deunicode into the wasm
Hundreds of KB for every visitor, for scripts most will never type.
A search server
The globe is static files; search runs in the tab.
405 → 274 KBwasm, brotli, once deunicode left
1.5–8 KBper script table
19languages of city names
geodb-rs/crates/geodb-core/src/text.rs:161rust
/// What [`fold_key`] does to the non-ASCII characters of one dataset, as a
/// table: character -> its transliteration, lowercased. A dataset only ever
/// contains a few thousand distinct characters, so its table is a few KB
/// where the general transliteration (`deunicode`) is hundreds: the compact
/// globe files carry their own tables and the wasm needs no `deunicode`.
///
/// Entries that would be the plain lowercase of the character are left out
/// (a [`Folder`] falls back to that).
⋯
        let entries = chars
            .into_iter()
            .filter(|c| skip.is_none_or(|s| s.get(*c).is_none()))
            .filter_map(|c| {
                // Between two letters, so the padding a transliteration adds
                // ("Dong " for 東) is not trimmed off like at a string's end.
                let with = fold_key(&format!("a{c}a"));
                let folded = with
                    .strip_prefix('a')
                    .and_then(|w| w.strip_suffix('a'))
                    .map_or_else(|| fold_key(c.encode_utf8(&mut [0; 4])), str::to_string);
                let plain: String = c.to_lowercase().collect();
                (folded != plain).then_some((c, folded))
            })
            .collect();

Each character is folded between two letters, so padding like “Dong ” survives.

geodb-rs/crates/geodb-core/src/globe_search.rs:142rust
// A name's folded form, and where a transliteration pads syllables
// with spaces also the form typed without them ("Dong Jing" and
// "dongjing" for 東京).
let add = |f: &mut Vec<String>, s: &str| {
    f.push(folder.fold(s));
    f.extend(folder.compact(s));
};

Folded, and compacted without spaces: “Dong Jing” and “dongjing”.

08 · IoT · plantworks

Gateway logic with no I/O

The problem

The TRAHE SYSTEMS greenhouse console shows a DALI lighting bus and its MQTT traffic in the browser. The same gateway has to run on an ESP32-C6 over USB, on an STM32 with Ethernet, and next to a server. Four hosts, four ways to do concurrency.

What I did

The gateway is a set of pure functions in plantworks-core: a DALI frame, an input event or an MQTT message goes in together with the bus state; log rows and setpoint writes come out. No I/O, no clocks. Each host only wires its own transport around it.

Why this way

  • The demo is not a mock-up: the browser runs the gateway's real logic over a simulated bus — same frame codec, same topic contract, same rules.
  • Each host keeps its own concurrency: esp-idf threads on the ESP32-C6, Embassy async on the STM32, a timer in the browser. Pure functions fit all of them, and test without any of them.
  • DALI frames (IEC 62386-102) are typed: a Target enum becomes the exact address byte, and the standard's logarithmic dimming curve is one tested function — half light is level 229.
  • On the STM32, Embassy's select races a cancel-safe TCP read against Timer::at(next_tick): broker input and a steady 500 ms tick in one loop.
  • Honest status: the frame codec and gateway logic run on the devices today; the physical DALI driver on the ESP32's RMT peripheral is the next milestone.

What I turned down

A separate simulator for the demo
It would drift from the firmware. The rule: the demo is the product's logic with a simulated bus underneath.
async or I/O inside the logic
It would tie the gateway to one runtime and make every test need a network.
4hosts on one gateway crate
500 msgateway tick
331 KBof 2 MB flash, STM32 firmware
plantworks/crates/plantworks-core/src/gateway.rs:1rust
//! The DALI/MQTT **gateway logic**, shared by the browser demo and the ESP32
//! firmware. Pure: no I/O, no clocks. Callers feed it a `Plant` (simulated or
//! bound to a real bus), it returns log rows + setpoint writes.
⋯
//! In the browser the "bus" is the simulated plant; on the device the same
//! calls will be wired to the RMT bus driver (M1).
⋯
/// Output of an executor call: log rows + setpoint writes for the plant.
#[derive(Default, Debug)]
pub struct Out {
    pub rows: Vec<Row>,
    pub writes: Vec<(String, f64)>,
}
⋯
pub fn exec_forward(ctx: &Ctx, st: &mut BusState, out: &mut Out, f: [u8; 2]) {
⋯
pub fn exec_mqtt_in(ctx: &Ctx, st: &mut BusState, out: &mut Out, topic: &str, payload: &str) {
⋯
pub fn step(p: &Plant, st: &mut BusState) -> Out {

Every entry point takes state and returns output — nothing else.

plantworks/firmware/plantworks-gw/src/main.rs:179rust
let gs = gateway::gears(p, &st.ids);
let mut out = Out::default();
let dali_topic = format!("plantworks/{GATEWAY_ID}/dali");
{
    let ctx = Ctx::new(p, &gs, st.ids);
    if msg.topic == dali_topic {
        // raw bus access from the console: {"fwd":[a,d]} or {"evt":[b0,b1,b2]}
        let v: serde_json::Value = serde_json::from_str(&msg.payload).unwrap_or_default();
        let bytes = |k: &str| -> Vec<u8> {
            v.get(k).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|x| x.as_u64()).map(|x| x as u8).collect()).unwrap_or_default()
        };
        let fwd = bytes("fwd");
        let evt = bytes("evt");
        if fwd.len() == 2 {
            gateway::exec_forward(&ctx, st, &mut out, [fwd[0], fwd[1]]);
        } else if evt.len() == 3 {
            gateway::exec_event_frame(&ctx, st, &mut out, [evt[0], evt[1], evt[2]]);
        } else {
            emit_ack(&msg.topic, false, "dali message needs {\"fwd\":[a,d]} or {\"evt\":[b0,b1,b2]}");
            return;
        }
    } else {
        gateway::exec_mqtt_in(&ctx, st, &mut out, &msg.topic, &msg.payload);
    }
}

Raw frames from the console or MQTT messages: both end in the shared executor.

plantworks/crates/plantworks-hmi/src/views/dali.rs:199rust
run_local(&move |ctx, s, out| match c {
    Composed::Fwd(f) => gateway::exec_forward(ctx, s, out, f),
    Composed::Evt(f) => gateway::exec_event_frame(ctx, s, out, f),
});
⋯
run_local(&move |ctx, s, out| gateway::exec_mqtt_in(ctx, s, out, &t, &pl));

The same three calls, from a Leptos view.

plantworks/crates/plantworks-core/src/dali.rs:55rust
/// Address byte of a 16-bit forward frame (Part 102 §7.2):
/// short `0AAAAAAS`, group `100GGGGS`, broadcast `1111111S`; `S` = 1 for a
/// command, 0 for direct arc power (DAPC).
pub fn address_byte(t: Target, command: bool) -> u8 {
    let s = command as u8;
    match t {
        Target::Short(a) => ((a & 0x3F) << 1) | s,
        Target::Group(g) => 0x80 | ((g & 0x0F) << 1) | s,
        Target::Broadcast => 0xFE | s,
    }
}

/// Direct Arc Power Control: set `level` (0 = off, 1..=254, 255 = MASK).
pub fn dapc(t: Target, level: u8) -> [u8; 2] {
    [address_byte(t, false), level]
}

/// Addressed command with an opcode from [`opcode`].
pub fn command(t: Target, op: u8) -> [u8; 2] {
    [address_byte(t, true), op]
}
⋯
/// Logarithmic dimming curve (Part 102 §9.3): level 1 → 0.1 %, 254 → 100 %.
pub fn level_to_percent(level: u8) -> f64 {
    match level {
        0 => 0.0,
        255 => f64::NAN,
        l => 10f64.powf((l as f64 - 1.0) / (253.0 / 3.0) - 1.0),
    }
}

Bit layouts straight from the standard, with the section numbers in the docs.

plantworks/firmware/plantworks-gw-stm32/src/main.rs:720rust
let mut next_tick = Instant::now() + Duration::from_millis(TICK_MS);
let mut last_ping = Instant::now();
loop {
    let sock = link.sock.as_mut().unwrap();
    match select(sock.read(&mut buf), Timer::at(next_tick)).await {
        Either::First(Ok(0)) => return Err("broker closed the connection"),
        Either::First(Ok(got)) => {
            inbox.extend_from_slice(&buf[..got]);
            while let Some((first, body)) = mqtt::take_packet(&mut inbox) {
                if let Some(p) = mqtt::parse_publish(first, &body) {
                    if let Some(pk) = p.pkid {
                        link.sock.as_mut().unwrap().write_all(&mqtt::puback_packet(pk)).await.map_err(|_| "tcp write")?;
                    }
                    let payload = String::from_utf8_lossy(&p.payload).into_owned();
                    handle(&mut link, gw, &p.topic, &payload).await?;
                }
            }
        }
        Either::First(Err(_)) => return Err("tcp read"),
        Either::Second(()) => {
            next_tick += Duration::from_millis(TICK_MS);
            led_ok.toggle();
            tick(&mut link, gw, net).await?;
            if last_ping.elapsed() >= Duration::from_secs((KEEPALIVE_S / 2) as u64) {
                link.sock.as_mut().unwrap().write_all(&mqtt::pingreq_packet()).await.map_err(|_| "tcp write")?;
                last_ping = Instant::now();
            }
        }
    }
}

select() over a socket read and a deadline: no threads, no drift.

09 · Health IT · hl7kit

One copy in, views out

The problem

HL7 v2 messages carry hospital orders and results as delimited text. The dicomscope demo links a DICOM study to its HL7 order — on the user's own machine, because patient data must not be pasted into a hosted tool.

What I did

hl7kit takes one owned copy of the message and records only byte spans. Every segment, field and component is a Copy view that borrows that string for 'm. Values are slices; decoding escapes returns a Cow, which allocates only when there is an escape.

Why this way

  • Spans point back into the original text, so the viewer highlights a value in place. Searching for a value again — when it may appear twice — is a bug waiting to happen.
  • Owning the input keeps Message free of a lifetime parameter: easy to keep in a UI signal or a Python object.
  • Delimiters and the escape character come from MSH-2, never hard-coded. Unknown escape sequences stay verbatim; out-of-range spans give an empty string instead of a panic; #![forbid(unsafe_code)].
  • Linking compares both keys, Study Instance UID and accession number. Option<bool> separates “absent” from “different”, and a disagreement becomes a named diagnosis — it is exactly the case integration engineers hunt for.

What I turned down

An owned String per field
More allocations, and the link back to the source text is lost.
Hard-coded |^~\&
The message declares its own delimiters in MSH-2; some senders use others.
A hosted viewer
The no-network promise has to be auditable: CI greps the bundle for fetch, XMLHttpRequest and WebSocket.
1copy of the message
0allocations for a value without escapes
807 KBthe whole viewer, gzipped
hl7v2/crates/hl7kit/src/message.rs:121rust
/// A parsed HL7 v2 message.
///
/// The message owns a copy of the input text; all views borrow from it.
#[derive(Debug, Clone)]
pub struct Message {
    raw: String,
    encoding: Encoding,
    segments: Vec<SegmentNode>,
    warnings: Vec<Warning>,
}
⋯
/// A field view.
#[derive(Clone, Copy)]
pub struct Field<'m> {
    msg: &'m Message,
    node: &'m FieldNode,
    number: usize,
}

impl<'m> Field<'m> {
⋯
    /// Raw field text including separators of nested levels.
    pub fn value(&self) -> &'m str {
        self.node.span.slice(&self.msg.raw)
    }
    /// Field text with escape sequences decoded.
    pub fn decoded(&self) -> Cow<'m, str> {
        unescape(self.value(), self.msg.encoding)
    }

One String owns the text; a Field is three words wide and Copy.

hl7v2/crates/hl7kit/src/escape.rs:14rust
/// Returns the input unchanged (borrowed) when it contains no escape character.
pub fn unescape<'a>(text: &'a str, enc: Encoding) -> Cow<'a, str> {
    let esc = enc.escape as char;
    if !text.contains(esc) {
        return Cow::Borrowed(text);
    }
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(start) = rest.find(esc) {
        out.push_str(&rest[..start]);
        let after = &rest[start + esc.len_utf8()..];
        match after.find(esc) {
            None => {
                out.push_str(&rest[start..]);
                rest = "";
                break;
            }
            Some(end) => {
                let seq = &after[..end];
                match decode_sequence(seq, enc) {
                    Some(s) => out.push_str(&s),
                    None => {
                        out.push(esc);
                        out.push_str(seq);
                        out.push(esc);
                    }
                }
                rest = &after[end + esc.len_utf8()..];
            }
        }
    }
    out.push_str(rest);
    Cow::Owned(out)
}

The common case returns the borrowed input; only real escapes allocate.

hl7v2/demo/dicomscope-core/src/link/mod.rs:59rust
    /// `None` when either side is absent.
    pub fn matches(&self) -> Option<bool> {
        Some(self.dicom.as_deref()? == self.hl7.as_deref()?)
    }
⋯
    /// The two keys disagree with each other: both sides carry both a
    /// study UID and an accession number, one pair matches and the other
    /// does not. `None` when they agree or when either key is absent on
    /// either side.
    pub fn key_conflict(&self) -> Option<KeyConflict> {
        match (self.study_uid.matches(), self.accession.matches()) {
            (Some(true), Some(false)) => Some(KeyConflict::AccessionDiffers),
            (Some(false), Some(true)) => Some(KeyConflict::StudyUidDiffers),
            _ => None,
        }
    }
⋯
/// DICOM string values are space-padded to even length and UIDs NUL-padded;
/// HL7 values may carry stray whitespace. Compare after trimming both.
fn clean(v: Option<&str>) -> Option<String> {
    let s = v?.trim_end_matches('\0').trim();
    (!s.is_empty()).then(|| s.to_string())
}

Two Option<bool>s, one match — absent, equal and conflicting stay distinct.

10 · BIM · bimifc

One WebAssembly binary, no JavaScript bridge

The problem

bimifc.de opens IFC building models in the browser: a Leptos UI for the model tree and filters, Bevy with WebGPU for the 3D view. A stadium or a hospital means millions of vertices to hand from one side to the other.

What I did

Both compile into one wasm binary. With the unified feature, parsed geometry moves from Leptos to Bevy by ownership — vertex buffers in an Arc, no serialization. Discipline filters come from a three-step classifier shared by every viewer.

Why this way

  • Moving a Vec<f32> is free; serializing it across a JavaScript bridge is not. Replacing JSON with a binary hand-over already cut RAM 3–5×; the unified build drops the bridge.
  • The split path still exists behind #[cfg(not(feature = "unified"))] — one Cargo feature switches architectures.
  • IFC2x3 exporters (MagiCAD, Plancal) leave instance names empty and put the discipline on the type object. Class, then type object, then name: a clinic's MEP model went from 96.7 % “Other” to 5.7 %.
  • Small things decide whether a real file opens: an entity ends at ; — except inside a quoted string like 'SANITAIRE TOESTELLEN;'. The STEP parser builds on the open-source ifc-lite; that fix is mine.

What I turned down

Two wasm modules talking through JavaScript
Every model crossed the bridge twice in memory.
Trusting the instance class alone
Correct for IFC4, useless for most IFC2x3 MEP exports.
63ktriangles meshed in 30 ms (stadium model)
96.7 → 5.7 %MEP elements left unclassified
3–5×less RAM: binary instead of JSON
bimifc/crates/bimifc-leptos/src/bridge.rs:412rust
pub fn save_geometry(geometry: Vec<GeometryData>) {
    let start = js_sys::Date::now();
    let mesh_count = geometry.len();

    #[cfg(feature = "unified")]
    {
        // UNIFIED MODE: Direct memory transfer - no serialization!
        // Both Leptos and Bevy share the same WASM binary + memory space.
        use bimifc_bevy::{IfcMesh, MeshGeometry};
        use std::sync::Arc;

        let meshes: Vec<IfcMesh> = geometry
            .into_iter()
            .map(|g| IfcMesh {
                entity_id: g.entity_id,
                geometry: Arc::new(MeshGeometry::new(g.positions, g.normals, g.indices)),
                color: g.color,
                transform: g.transform,
                entity_type: g.entity_type,
                name: g.name,
                has_ifc_color: g.has_ifc_color,
⋯
                source_id: 0,
                discipline: g.discipline,
            })
            .collect();

        // Store in global for Bevy to pick up
        bimifc_bevy::set_pending_meshes(meshes);
⋯
    }

Leptos and Bevy share one memory: the buffers change owner, nothing is copied.

bimifc/crates/bimifc-federation/src/classify.rs:7rust
//! Three lookup paths, tried in order; first non-`Other` wins:
//!
//! 1. **Concrete IFC type name** — works for IFC4+ files that use
//!    `IfcCableSegment` / `IfcPipeSegment` / `IfcSpaceHeater` etc.
//! 2. **Defining IFC TypeObject** — IFC2x3 exporters (MagiCAD,
//!    Plancal) leave the instance Name null and attach the
//!    discipline-revealing class on `IfcRelDefinesByType →
//!    IfcDuctSegmentType` etc. The TUI scene module already does
//!    this; the classifier here is the rule it consults.
//! 3. **Instance Name keyword** — Revit-style exports name things
//!    helpfully ("Rectangular Duct", "Pipe Types: Waste", "Troffer
//!    Light…"). Last-resort when neither type-class layer matches.
⋯
pub fn classify_by_type_name(type_class_upper: &str, type_name: Option<&str>) -> Discipline {
    match type_class_upper {
        "IFCDUCTSEGMENTTYPE"
        | "IFCDUCTFITTINGTYPE"
⋯
        | "IFCSPACEHEATERTYPE" => return Discipline::Hvac,
        "IFCPIPESEGMENTTYPE"
⋯
        | "IFCTANKTYPE" => return Discipline::Plumbing,
        "IFCCABLESEGMENTTYPE"
⋯
        | "IFCOUTLETTYPE" => return Discipline::Electrical,
        "IFCLIGHTFIXTURETYPE" | "IFCLAMPTYPE" => return Discipline::Lighting,
        _ => {}
    }
    // Type class wasn't conclusive — try the type object's Name.
    if let Some(n) = type_name {
        return classify_by_name(n);
    }
    Discipline::Other
}

The module doc is the design: three lookups, first non-Other wins.

bimifc/crates/bimifc-parser/src/scanner.rs:112rust
/// Find the end of an entity (semicolon), handling quoted strings
fn find_entity_end(&mut self) -> Option<usize> {
    let bytes = self.content.as_bytes();
    let mut in_string = false;

    while self.pos < bytes.len() {
        match bytes[self.pos] {
            b'\'' => {
                // Check for escaped quote ''
                if in_string && self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'\'' {
                    self.pos += 2;
                    continue;
                }
                in_string = !in_string;
            }
            b';' if !in_string => {
                self.pos += 1;
                return Some(self.pos);
            }
            _ => {}
        }
        self.pos += 1;
    }

    None
}

A byte loop that knows about quotes — and about '' inside them.

11 · ML · Burn

Machine learning that never leaves the page

The problem

Removing a photo's background usually means uploading it. U²-Net does the job well — but it is a PyTorch model, and nobody should have to trust a server with their pictures.

What I did

The network is ported to Burn by hand, the weights converted to SafeTensors and compiled into the wasm. A Cargo feature picks the backend — WebGPU by default, a pure-CPU ndarray fallback — behind one type alias, so the model code doesn't know which one it runs on.

Why this way

  • Nothing is uploaded, and there is no model download: the weights are in the binary.
  • A broken weight mapping fails loudly — loading asserts that every parameter was filled — instead of producing garbage masks.
  • The mask is read back from the GPU once, asynchronously, then normalized on the CPU: no blocking reads on the browser's main thread.
  • Burn's std store feature pulled in a chain that didn't compile for wasm32; the no_std core was enough. On the way I fixed two things upstream: staging-buffer flushing in CubeCL's wgpu runtime, and a dtype match in burn-candle.

What I turned down

Inference on a server
Uploading people's photos is a privacy problem and a running cost. The browser has a GPU.
Downloading the model at runtime
One more request, one more thing to fail. The binary carries it.
4.6 MBweights compiled into the wasm
0bytes of your image uploaded
2fixes merged upstream
burn-wasm-image/src/state.rs:22rust
#[cfg(feature = "wgpu")]
pub use backend_wgpu::{Backend, init_backend};

#[cfg(all(feature = "ndarray", not(feature = "wgpu")))]
pub use backend_ndarray::{Backend, init_backend};

#[cfg(feature = "wgpu")]
mod backend_wgpu {
    use burn::backend::wgpu::{Wgpu, WgpuDevice, graphics::AutoGraphicsApi, init_setup_async};

    pub type Backend = Wgpu<f32, i32>;

    /// Initialize the WebGPU device. Must be awaited before first inference.
    pub async fn init_backend() -> WgpuDevice {
        let device = WgpuDevice::default();
        init_setup_async::<AutoGraphicsApi>(&device, Default::default()).await;
        device
    }
}

#[cfg(all(feature = "ndarray", not(feature = "wgpu")))]
mod backend_ndarray {
    use burn::backend::ndarray::{NdArray, NdArrayDevice};

    pub type Backend = NdArray<f32>;

    /// NdArray needs no async setup; returns the default device.
    pub async fn init_backend() -> NdArrayDevice {
        NdArrayDevice::default()
    }
}

Same Backend name, same init_backend(): the model never sees the difference.

burn-wasm-image/src/state.rs:54rust
/// Pretrained u2netp weights, converted to SafeTensors and embedded in the
/// binary. Run `scripts/convert_weights.py` to (re)generate this file.
static WEIGHTS: &[u8] = include_bytes!("../weights/u2netp.safetensors");
⋯
/// Build the model and load the embedded pretrained weights.
pub fn load_model(device: &<Backend as burn::tensor::backend::Backend>::Device) -> U2NetP<Backend> {
    let mut model = U2NetP::<Backend>::new(device);

    let result = model.apply(
        snapshots(),
        None,
        Some(Box::new(PyTorchToBurnAdapter)),
        false,
    );
⋯
    // Every model parameter must have been filled; missing params would mean the
    // remapping is broken and inference would be garbage.
    assert!(
        !result.applied.is_empty() && result.missing.is_empty(),
        "weight load incomplete: {} params missing",
        result.missing.len()
    );

    model
}

include_bytes! puts the model in the binary; the assert keeps a bad mapping from shipping.

burn-wasm-image/src/web.rs:84rust
// Build a planar CHW f32 buffer with ImageNet normalization.
// Reference divides by max; for 8-bit input max is 255, so /255.
let mut chw = vec![0f32; 3 * n];
for i in 0..n {
    for c in 0..3 {
        let v = rgba[i * 4 + c] as f32 / 255.0;
        chw[c * n + i] = (v - MEAN[c]) / STD[c];
    }
}

let input = Tensor::<Backend, 1>::from_data(
    TensorData::new(chw, [3 * n]),
    &self.device,
)
.reshape([1, 3, size, size]);

// Forward pass → sigmoid saliency map in [0, 1], shape [1,1,H,W].
let mask = model.forward(input);

// Read once (async, GPU-safe) then min-max normalize on the CPU —
// matches the reference `normPRED`. Avoids blocking GPU scalar reads.
let data = mask.into_data_async().await.expect("read mask data");
let values: Vec<f32> = data.to_vec().expect("mask to vec");
⋯
let denom = (max - min).max(1e-8);

values
    .into_iter()
    .map(|v| (((v - min) / denom).clamp(0.0, 1.0) * 255.0 + 0.5) as u8)
    .collect()

RGBA to a normalized CHW tensor, one forward pass, one await.

12 · Web · Leptos

The UI is Rust, too

The problem

This site, the demo page and the five-minute reel need server rendering — for search engines and a fast first paint — and real interactivity in the browser: video autoplay, a clock, keyboard control, the tabs next to this text.

What I did

Leptos 0.7 on Axum: one crate compiled twice, ssr for the server binary and hydrate for the WebAssembly bundle. Browser-only code sits behind #[cfg(feature = "hydrate")]; browser APIs come typed from web-sys. The only hand-written JavaScript is a few lines that set the theme before first paint.

Why this way

  • One language and one type system from the reel's JSON schema (serde) to the DOM: a renamed field fails the build, not the page.
  • Autoplay is the browser's decision. play() returns a Promise; awaiting it as a JsFuture and matching a NotAllowedError lets the reel switch to stills instead of freezing on a black frame.
  • Leptos encodes the whole view tree in its type. Returning AnyView from cards and sections keeps that type shallow — the fully nested one overflowed rustc's query depth in release SSR builds.
  • The highlighting on this page is a small Rust lexer with unit tests. It runs during SSR and again while hydrating, so both sides produce identical HTML.

What I turned down

A JavaScript framework
Two languages and two copies of every data model — the reel's schema would live in TypeScript and in Rust.
A permanent 308 for /landing-page → /landing-page/
Browsers cache it indefinitely. An older redirect pointing the other way made visitors loop, so both paths are now plain routes.
highlight.js or Prism
More JavaScript, a flash of unstyled code, and server and client HTML would differ.
portfolio_leptos/src/pages/reel.rs:183rust
    // Ask the video to play; a NotAllowedError means autoplay is blocked -> stills mode.
    let try_play = move || {
        #[cfg(feature = "hydrate")]
        if let Some(v) = video.get_untracked() {
            if let Ok(promise) = v.play() {
                leptos::task::spawn_local(async move {
                    if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await {
                        use wasm_bindgen::JsCast;
                        let not_allowed = err
                            .dyn_ref::<web_sys::DomException>()
                            .is_some_and(|e| e.name() == "NotAllowedError");
                        if not_allowed && !blocked.get_untracked() {
                            leptos::logging::log!("[reel] autoplay blocked, showing stills");
                            item_started.set(now_ms());
                            still_k.set(0);
                            blocked.set(true);
                        }
                    }
                });
            }
        }
    };

A rejected play() promise is a typed DomException in Rust — the reel checks its name and falls back to stills.

portfolio_leptos/src/pages/splash.rs:102rust
// Cards and sections return AnyView: erasing the types keeps the page's view type shallow
// (the fully nested type overflows rustc's query depth in release SSR builds).
fn demo_card(d: &'static Demo) -> AnyView {
⋯
    .into_any()
}

AnyView trades a little static dispatch for a view type rustc can still handle in release builds.

portfolio_leptos/src/app.rs:84rust
// The reel answers at /landing-page/ (canonical) and /landing-page, both without a
// redirect: browsers cache 308s, and an old one pointing the other way would loop.
<Route path=(StaticSegment("landing-page"), StaticSegment("/")) view=ReelPage/>
<Route path=StaticSegment("landing-page") view=ReelPage/>

Two routes, one page, no redirect — learned the hard way from a cached 308.

portfolio_leptos/src/pages/rust_dev/highlight.rs:241rust
// Identifiers: keyword, macro, type, function, or plain
if is_ident_start(c) {
    let n = b.iter().take_while(|&&ch| is_ident(ch)).count();
    let word = &rest[..n];
    if lang.keywords().contains(&word) {
        return (Some("kw"), n);
    }
    if lang == Lang::Rust && b.get(n) == Some(&b'!') && b.get(n + 1) != Some(&b'=') {
        return (Some("mc"), n + 1);
    }
    if lang.is_type(word) {
        return (Some("ty"), n);
    }
    if rest[n..].trim_start_matches(' ').starts_with('(') {
        return (Some("fn"), n);
    }
    return (None, n);
}

One pass, no regex: an identifier becomes a keyword, macro, type or function name by looking one token ahead.