git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit52db237920c85ca42e9e0742ae89e54f0ec3e876
parent4a73717f13
authorLucas Galante <[email protected]>
date2026-09-09 15:28
feat(heightfield): export the relief as a height field, in millimetres

Step 4 of the unit system, toolkit half. `scene/heightfield.rs`
integrates the height curves the plate shader only differentiates and
samples a frame's plate batches, in draw order, into a height field:
a plate is a slab one roll rise above what it stands on with its
perimeter roll descending to the ROLL_CUT rim; CSG features and free
recess / boss / ridge / trough / groove / fillet / sphere / roll carves
etch or raise through the same profile curves (analytic or the
installed LUTs, integrated once per export); pinned heights apply.
Superellipse corners use the shader's refined Lp distance so the walls
sit where the shaded ones do. Droplets are skipped.

Out comes a 16-bit greyscale PNG plus a JSON sidecar with the sample
pitch, the range in mm through the display metric, the datum, and
whether that metric was measured or only assumed. `CCE_HEIGHTMAP=<png>`
in any client's environment exports its third frame (settled layout,
metric known); `CCE_HEIGHTMAP_MM` resamples to a pitch;
`heightfield::request` does it from an app.

Also `Unit::M` (metres), for cce-designer's world unit.

Verified: cce-relief under a configured metric exported 1471×2741
samples at 0.2 mm spanning −10.7..+5.26 mm, the +5.26 being the root
plate's 9.3 px rise at that metric; the image reads as the relief. Five
unit tests cover the plate rise and rim, CSG etching, the free-carve
ratio, resampling and the PNG/sidecar.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                    |  16 ++
 src/backend/window_runner.rs |  16 ++
 src/scene/heightfield.rs     | 538 +++++++++++++++++++++++++++++++++++++++++++
 src/scene/mod.rs             |   1 +
 src/units.rs                 |  11 +-
 5 files changed, 580 insertions(+), 2 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9146c37..31e9dca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -223,6 +223,11 @@ Modules:
   intersections. `renders_own_subtree` is an escape hatch for legacy subtree painters.
 - `anim.rs` — `Animated<T>` (tween + spring + easing), the Phase 4 animation primitive replacing
   ad-hoc bool flips.
+- `heightfield.rs` — the relief as a height field: the geometry the plate shader shades
+  (plate rolls, CSG features, free carves) integrated back from the slopes it lights,
+  sampled per physical px and exported as a 16-bit PNG + JSON sidecar in millimetres
+  through the display metric. `CCE_HEIGHTMAP=<file>` in any client's environment, or
+  `heightfield::request` from an app. See the Units section.
 
 ### `WidgetHost` (formerly the `Element` god-trait)
 
@@ -312,6 +317,14 @@ carries the drop as `h=` (a length: `h=0.5mm`, or bare px) beside `w=` and
 its section's depth numbers read in mm when the metric is real, and Save
 writes `height` as a `(mm)` length then, px otherwise.
 
+**And the relief can leave the screen.** `scene/heightfield.rs` integrates the
+height curves the shader only differentiates and samples a frame's plates into a
+height field — plates stack, carves etch, exactly the composite model the shader
+lights — then writes it as a 16-bit PNG whose sidecar carries the pitch and range
+in millimetres via the metric. A pinned `height=(mm)0.3` is 0.3 mm in that file.
+The sidecar states the metric's source; on an assumed metric the millimetres are
+a guess, and a fabrication tool should say so.
+
 Why not millimetres inside: UI sizes are perceptual and angular, not physical
 — a hit target should not become 8 mm on a projector three metres away.
 Documents and fabrication content live in real units and convert at view
@@ -349,4 +362,7 @@ All opt-in, all read once, all quiet when unset — set one and run any client.
 - `CCE_FORCE_PPI=<f>` — pin the display metric (logical px per inch) regardless of what
   the outputs report; a headless shadow has no EDID and would run `assumed`. The live
   panel is 141.8.
+- `CCE_HEIGHTMAP=<file.png>` — export the client's third rendered frame as a relief
+  height field (16-bit greyscale PNG + `<file>.json` sidecar: pitch, range in mm, datum,
+  metric source); `CCE_HEIGHTMAP_MM=<mm>` resamples to that pitch. `scene/heightfield.rs`.
 - `CCE_UI_FAULT_RECONNECT=1` — exercise the Wayland reconnect path.
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index eb52367..040b495 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3906,6 +3906,22 @@ impl<A: Application> EngineState<A> {
         }
 
         let (mut verts, mut dl_batches, dl_images, plate_features) = tessellate_display_list(&dl, logical_w, logical_h, scale_factor as f32);
+        // A pending height-field export (`CCE_HEIGHTMAP`, or an app's
+        // `scene::heightfield::request`): the plates of THIS frame, sampled
+        // as the geometry the shader is about to shade.
+        if let Some(req) = crate::scene::heightfield::take_request() {
+            let s = scale_factor as f32;
+            let (pw, ph) = ((logical_w * s).round() as usize, (logical_h * s).round() as usize);
+            let hf = crate::scene::heightfield::HeightField::from_frame(&dl_batches, &plate_features, pw, ph, s);
+            let (lo, hi) = hf.range_px();
+            match crate::scene::heightfield::export_png(&hf, &req.path, req.mm_per_sample) {
+                Ok(()) => log::info!(
+                    "[heightfield] wrote {} ({}x{} px, {:.3}..{:.3} mm, metric {})",
+                    req.path.display(), pw, ph, lo / hf.px_per_mm, hi / hf.px_per_mm, hf.source.as_str()
+                ),
+                Err(e) => log::warn!("[heightfield] export to {} failed: {e}", req.path.display()),
+            }
+        }
         // custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
         let pre_custom = verts.len() as u32;
         self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
diff --git a/src/scene/heightfield.rs b/src/scene/heightfield.rs
new file mode 100644
index 0000000..347284b
--- /dev/null
+++ b/src/scene/heightfield.rs
@@ -0,0 +1,538 @@
+//! The relief as a height field: the geometry the plate shader shades, sampled
+//! per pixel and written out for fabrication.
+//!
+//! `shader2d.wgsl` never stores heights — it composes SLOPES per pixel and
+//! lights them. But every slope it uses is the derivative of a height curve
+//! this module integrates back: a plate is a slab whose face stands one roll
+//! rise above the surface beneath it and whose perimeter roll descends toward
+//! the silhouette; a carve cut into it (a CSG feature or a free recess, boss,
+//! ridge, trough, groove or fillet) subtracts or adds its drop through the
+//! same profile curve the shader's `carve_slope` differentiates. Heights are
+//! relative to the local surface, so plates stack and carves etch — a
+//! deboss, not a flat milling plane — exactly as the shader's composite
+//! model says.
+//!
+//! Units: physical px on the z axis too (one px of drop is one px of run),
+//! with the display metric ([`crate::units::metric`]) turning both into
+//! millimetres at export. That is what makes a pinned `height=(mm)0.3` an
+//! honest 0.3 mm here and a 1:1 print of the map a true relief. When the
+//! metric is only *assumed* the sidecar says so; a fabrication tool should
+//! refuse to trust it.
+//!
+//! Two ways in: `CCE_HEIGHTMAP=<file.png>` in any cce-ui client's
+//! environment exports its third rendered frame (settled layout, first
+//! metric known) and `CCE_HEIGHTMAP_MM=<mm per sample>` resamples to that
+//! pitch; or an app calls [`request`] itself. Out comes a 16-bit greyscale
+//! PNG (0 = the lowest point, 65535 = the highest) and a `<file>.json`
+//! sidecar with the pitch, the range in mm, the datum and the metric's
+//! source. Droplets and their scrims (decorative water) are skipped.
+
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::sync::Mutex;
+
+use crate::backend::window_runner::DlBatch;
+use crate::scene::relief_shade::{RECESS_DEPTH, ROLL_CUT};
+use crate::units::MetricSource;
+
+/// A sampled height field over one window, physical px on all three axes.
+#[derive(Debug, Clone)]
+pub struct HeightField {
+    pub width: usize,
+    pub height: usize,
+    /// Physical px per millimetre of the display it was rendered for.
+    pub px_per_mm: f32,
+    pub source: MetricSource,
+    /// Row-major heights in physical px, +z out of the screen, 0 = the
+    /// window's base surface (what the root plate stands on).
+    pub px: Vec<f32>,
+}
+
+/// One export request: where to write, and at what pitch (`None` = one
+/// sample per physical px).
+#[derive(Debug, Clone)]
+pub struct Request {
+    pub path: PathBuf,
+    pub mm_per_sample: Option<f32>,
+}
+
+static REQUEST: Mutex<Option<Request>> = Mutex::new(None);
+static FRAMES: AtomicU32 = AtomicU32::new(0);
+/// The frame the environment request fires on: layout has settled and the
+/// output metric has arrived by then.
+const ENV_FRAME: u32 = 3;
+
+/// Ask the runner to export the next frame's height field.
+pub fn request(path: impl Into<PathBuf>, mm_per_sample: Option<f32>) {
+    if let Ok(mut r) = REQUEST.lock() {
+        *r = Some(Request { path: path.into(), mm_per_sample });
+    }
+}
+
+/// The runner's per-frame poll: an explicit [`request`], or the environment's
+/// `CCE_HEIGHTMAP` once, on frame [`ENV_FRAME`].
+pub(crate) fn take_request() -> Option<Request> {
+    let n = FRAMES.fetch_add(1, Ordering::Relaxed) + 1;
+    if n == ENV_FRAME {
+        if let Ok(path) = std::env::var("CCE_HEIGHTMAP") {
+            if !path.is_empty() {
+                let mm = std::env::var("CCE_HEIGHTMAP_MM").ok().and_then(|v| v.parse::<f32>().ok()).filter(|v| *v > 0.0);
+                request(path, mm);
+            }
+        }
+    }
+    REQUEST.lock().ok().and_then(|mut r| r.take())
+}
+
+/// The height curves the shader differentiates, tabulated once per export.
+struct Profiles {
+    /// Carve: 0 on the plateau (v = 0) → 1 on the floor (v = 1).
+    carve: Vec<f32>,
+    /// Roll: 1 at the face join (f = 0) → the rim's remaining height at the
+    /// silhouette (f = 1), unit rise.
+    roll: Vec<f32>,
+}
+
+const TABLE: usize = 256;
+
+impl Profiles {
+    fn build(shape: f32) -> Self {
+        let carve_lut = crate::layout::bevel_profile_slopes();
+        let roll_lut = crate::layout::roll_profile_slopes();
+        let n = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+        // The shader's LUT sampling, slopes with its end tapers, integrated.
+        let lut_slope = |lut: &[f32], x: f32, both_ends: bool| -> f32 {
+            let xc = x.clamp(0.0, 1.0);
+            let xs = (xc * n - 0.5).clamp(0.0, n - 1.0);
+            let i0 = xs.floor() as usize;
+            let i1 = (i0 + 1).min(lut.len() - 1);
+            let fr = xs - xs.floor();
+            let win = if both_ends {
+                (xc.min(1.0 - xc) * n * 0.667).clamp(0.0, 1.0)
+            } else {
+                (xc * n * 0.667).clamp(0.0, 1.0)
+            };
+            (lut[i0] + (lut[i1] - lut[i0]) * fr) * win
+        };
+        let mut carve = Vec::with_capacity(TABLE + 1);
+        let mut roll = Vec::with_capacity(TABLE + 1);
+        let (mut hc, mut hr) = (0.0f32, 1.0f32);
+        for i in 0..=TABLE {
+            let x = i as f32 / TABLE as f32;
+            match &carve_lut {
+                Some(lut) => {
+                    if i > 0 {
+                        let xm = (i as f32 - 0.5) / TABLE as f32;
+                        hc += lut_slope(lut, xm, true) / TABLE as f32;
+                    }
+                    carve.push(hc);
+                }
+                None => carve.push(if shape > 2.001 {
+                    x * x * x * (x * (x * 6.0 - 15.0) + 10.0)
+                } else {
+                    x * x * (3.0 - 2.0 * x)
+                }),
+            }
+            match &roll_lut {
+                Some(lut) => {
+                    if i > 0 {
+                        let xm = (i as f32 - 0.5) / TABLE as f32;
+                        hr -= lut_slope(lut, xm, false) / TABLE as f32;
+                    }
+                    roll.push(hr);
+                }
+                None => {
+                    let fc = x * ROLL_CUT;
+                    roll.push(if shape > 2.001 {
+                        (1.0 - fc.powf(shape)).max(0.0).powf(1.0 / shape)
+                    } else {
+                        (1.0 - fc * fc).max(0.0).sqrt()
+                    })
+                }
+            }
+        }
+        Profiles { carve, roll }
+    }
+
+    fn sample(table: &[f32], x: f32) -> f32 {
+        let xs = x.clamp(0.0, 1.0) * TABLE as f32;
+        let i0 = (xs.floor() as usize).min(TABLE - 1);
+        let fr = xs - i0 as f32;
+        table[i0] + (table[i0 + 1] - table[i0]) * fr
+    }
+
+    fn carve_height(&self, v: f32) -> f32 {
+        Self::sample(&self.carve, v)
+    }
+
+    fn roll_height(&self, f: f32) -> f32 {
+        Self::sample(&self.roll, f)
+    }
+}
+
+/// Signed distance to a rounded box, positive outside — the distance part of
+/// the shader's `rr_sdf_grad`, superellipse corners and their first-order
+/// refinement included, so the sampled walls sit where the shaded ones do.
+fn rr_sdf(p: (f32, f32), rect: [f32; 4], radii: [f32; 4], shape: f32, roll: f32) -> f32 {
+    let c = (p.0 - rect[0], p.1 - rect[1]);
+    let (r_lo, r_hi) = if c.0 > 0.0 { (radii[1], radii[2]) } else { (radii[0], radii[3]) };
+    let r = if c.1 > 0.0 { r_hi } else { r_lo };
+    let q = (c.0.abs() - rect[2] + r, c.1.abs() - rect[3] + r);
+    if q.0 > 0.0 && q.1 > 0.0 {
+        if shape > 2.001 {
+            let lp = (q.0.powf(shape) + q.1.powf(shape)).powf(1.0 / shape).max(1e-4);
+            let g = ((q.0 / lp).powf(shape - 1.0), (q.1 / lp).powf(shape - 1.0));
+            let gm = (g.0 * g.0 + g.1 * g.1).sqrt().max(1e-4);
+            let d0 = (lp - r) / gm;
+            let dir = (g.0 / gm, g.1 / gm);
+            let roll = roll.max(2.0);
+            let step = d0.clamp(-roll, roll);
+            let q1 = ((q.0 - step * dir.0).max(1e-4), (q.1 - step * dir.1).max(1e-4));
+            let lp1 = (q1.0.powf(shape) + q1.1.powf(shape)).powf(1.0 / shape).max(1e-4);
+            let g1 = ((q1.0 / lp1).powf(shape - 1.0), (q1.1 / lp1).powf(shape - 1.0));
+            let gm1 = (g1.0 * g1.0 + g1.1 * g1.1).sqrt().max(1e-4);
+            let d1 = step + (lp1 - r) / gm1;
+            let w = smoothstep(roll, roll * 1.5 + 2.0, d0.abs());
+            return d1 + (d0 - d1) * w;
+        }
+        let len = (q.0 * q.0 + q.1 * q.1).sqrt().max(1e-4);
+        return len - r;
+    }
+    if q.0 > q.1 { q.0 - r } else { q.1 - r }
+}
+
+fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
+    let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
+    t * t * (3.0 - 2.0 * t)
+}
+
+/// Plate modes as `PlatePush::mode` carries them (see shader2d's `MODE_*`).
+const MODE_PLATE: i32 = 1;
+const MODE_RECESS: i32 = 2;
+const MODE_BOSS: i32 = 3;
+const MODE_RIDGE: i32 = 4;
+const MODE_SPHERE: i32 = 5;
+const MODE_FILLET_DOWN: i32 = 6;
+const MODE_FILLET_UP: i32 = 7;
+const MODE_GROOVE: i32 = 8;
+const MODE_TROUGH: i32 = 9;
+const MODE_ROLL: i32 = 11;
+
+impl HeightField {
+    /// Sample one frame's plate batches, in draw order, over a `width` ×
+    /// `height` physical-px window rendered at `scale`. Batches that are not
+    /// plates (plain geometry, text, images) have no height.
+    pub fn from_frame(batches: &[DlBatch], features: &[[f32; 12]], width: usize, height: usize, scale: f32) -> Self {
+        let metric = crate::units::metric();
+        let mut hf = HeightField {
+            width,
+            height,
+            px_per_mm: metric.physical_px_per_mm(),
+            source: metric.source,
+            px: vec![0.0; width * height],
+        };
+        let pinned_carve = crate::layout::bevel_height().map(|h| h * scale);
+        let pinned_roll = crate::layout::roll_height().map(|h| h * scale);
+        let mut profiles: Option<(f32, Profiles)> = None;
+        for b in batches {
+            let Some(p) = b.plate else { continue };
+            let mode = p.mode.round() as i32;
+            if !matches!(mode, 1..=9 | 11) {
+                continue;
+            }
+            let shape = p.shape.clamp(2.0, 16.0);
+            if profiles.as_ref().map_or(true, |(s, _)| (*s - shape).abs() > 1e-3) {
+                profiles = Some((shape, Profiles::build(shape)));
+            }
+            let prof = &profiles.as_ref().unwrap().1;
+            let t = p.light[3].max(0.001);
+            // Pixel bounds: the shape's box plus its wall, clipped.
+            let (mut x0, mut y0, mut x1, mut y1) = match mode {
+                MODE_SPHERE => (p.rect[0] - p.rect[2], p.rect[1] - p.rect[2], p.rect[0] + p.rect[2], p.rect[1] + p.rect[2]),
+                MODE_FILLET_DOWN | MODE_FILLET_UP => {
+                    let r = p.rect[2] + t;
+                    (p.rect[0] - r, p.rect[1] - r, p.rect[0] + r, p.rect[1] + r)
+                }
+                MODE_GROOVE => (0.0, 0.0, width as f32, height as f32),
+                _ => (
+                    p.rect[0] - p.rect[2] - t - 2.0,
+                    p.rect[1] - p.rect[3] - t - 2.0,
+                    p.rect[0] + p.rect[2] + t + 2.0,
+                    p.rect[1] + p.rect[3] + t + 2.0,
+                ),
+            };
+            if let Some(sc) = b.scissor {
+                x0 = x0.max(sc.x * scale);
+                y0 = y0.max(sc.y * scale);
+                x1 = x1.min((sc.x + sc.width) * scale);
+                y1 = y1.min((sc.y + sc.height) * scale);
+            }
+            let x0 = x0.floor().max(0.0) as usize;
+            let y0 = y0.floor().max(0.0) as usize;
+            let x1 = (x1.ceil().max(0.0) as usize).min(width);
+            let y1 = (y1.ceil().max(0.0) as usize).min(height);
+            if x0 >= x1 || y0 >= y1 {
+                continue;
+            }
+            let (f_off, f_cnt) = (p.host[0].max(0.0) as usize, p.host[1].max(0.0) as usize);
+            let carve_drop = pinned_carve.unwrap_or(RECESS_DEPTH * t);
+            let roll_rise = pinned_roll.unwrap_or(t);
+            for y in y0..y1 {
+                for x in x0..x1 {
+                    let pt = (x as f32 + 0.5, y as f32 + 0.5);
+                    if let Some(cr) = b.clip_rrect {
+                        if rr_sdf(pt, [cr[0], cr[1], cr[2], cr[3]], [cr[4]; 4], 2.0, t) > 0.0 {
+                            continue;
+                        }
+                    }
+                    let dh = match mode {
+                        MODE_PLATE => {
+                            // Positive inside, like the shader's `d`.
+                            let d = -rr_sdf(pt, p.rect, p.radii, shape, t);
+                            if d <= 0.0 {
+                                continue;
+                            }
+                            let f = 1.0 - (d / t).clamp(0.0, 1.0);
+                            let mut h = roll_rise * prof.roll_height(f);
+                            for feat in features.iter().skip(f_off).take(f_cnt) {
+                                let ft = feat[8].max(0.001);
+                                let fd = rr_sdf(pt, [feat[0], feat[1], feat[2], feat[3]], [feat[4], feat[5], feat[6], feat[7]], shape, ft);
+                                let v = (-fd / ft + 0.5).clamp(0.0, 1.0);
+                                if v > 0.0 {
+                                    // params.y: positive carves down, negative
+                                    // raises a boss.
+                                    h -= feat[9] * prof.carve_height(v);
+                                }
+                            }
+                            h
+                        }
+                        MODE_ROLL => {
+                            let d = -rr_sdf(pt, p.rect, p.radii, shape, t);
+                            if d <= 0.0 {
+                                continue;
+                            }
+                            let f = 1.0 - (d / t).clamp(0.0, 1.0);
+                            roll_rise * (prof.roll_height(f) - 1.0)
+                        }
+                        MODE_SPHERE => {
+                            let (dx, dy) = (pt.0 - p.rect[0], pt.1 - p.rect[1]);
+                            let r2 = p.rect[2] * p.rect[2];
+                            let d2 = dx * dx + dy * dy;
+                            if d2 >= r2 {
+                                continue;
+                            }
+                            (r2 - d2).sqrt()
+                        }
+                        MODE_GROOVE => {
+                            let s = (pt.0 - p.rect[0]) * p.radii[0] + (pt.1 - p.rect[1]) * p.radii[1];
+                            // Positive outside the band; the line is the low side.
+                            let fd = s.abs() - p.rect[2];
+                            let u = (fd / t + 0.5).clamp(0.0, 1.0);
+                            -carve_drop * (1.0 - prof.carve_height(u))
+                        }
+                        MODE_FILLET_DOWN | MODE_FILLET_UP => {
+                            let (cx, cy) = (pt.0 - p.rect[0], pt.1 - p.rect[1]);
+                            let dist = (cx * cx + cy * cy).sqrt().max(1e-4);
+                            let ang = cy.atan2(cx);
+                            let a0 = p.radii[0];
+                            let rel = (ang - a0).rem_euclid(std::f32::consts::TAU);
+                            if rel > std::f32::consts::FRAC_PI_2 {
+                                continue;
+                            }
+                            // Positive outside the arc's circle; outside is
+                            // the low side of a recessed fillet.
+                            let fd = dist - p.rect[2];
+                            let u = (fd / t + 0.5).clamp(0.0, 1.0);
+                            let sign = if mode == MODE_FILLET_DOWN { -1.0 } else { 1.0 };
+                            sign * carve_drop * prof.carve_height(u)
+                        }
+                        _ => {
+                            // Recess, boss, ridge, trough: the box step, u = 1
+                            // deep inside.
+                            let d = -rr_sdf(pt, p.rect, p.radii, shape, t);
+                            let u = (d / t + 0.5).clamp(0.0, 1.0);
+                            match mode {
+                                MODE_RECESS => -carve_drop * prof.carve_height(u),
+                                MODE_BOSS => carve_drop * prof.carve_height(u),
+                                MODE_RIDGE | MODE_TROUGH => {
+                                    let w = (2.0 * u).min(2.0 - 2.0 * u).clamp(0.0, 1.0);
+                                    let up = if mode == MODE_RIDGE { 1.0 } else { -1.0 };
+                                    up * 0.5 * carve_drop * prof.carve_height(w)
+                                }
+                                _ => 0.0,
+                            }
+                        }
+                    };
+                    hf.px[y * width + x] += dh;
+                }
+            }
+        }
+        hf
+    }
+
+    /// Height in mm at a sample.
+    pub fn mm_at(&self, x: usize, y: usize) -> f32 {
+        self.px[y * self.width + x] / self.px_per_mm
+    }
+
+    /// (lowest, highest) in px.
+    pub fn range_px(&self) -> (f32, f32) {
+        self.px.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), &v| (lo.min(v), hi.max(v)))
+    }
+
+    /// The field resampled to `mm_per_sample` (bilinear), or a copy at the
+    /// native pitch.
+    pub fn resampled(&self, mm_per_sample: Option<f32>) -> (Vec<f32>, usize, usize, f32) {
+        let Some(mm) = mm_per_sample.filter(|m| *m > 0.0) else {
+            return (self.px.clone(), self.width, self.height, 1.0 / self.px_per_mm);
+        };
+        let step = mm * self.px_per_mm; // source px per output sample
+        let w = ((self.width as f32 / step).round() as usize).max(1);
+        let h = ((self.height as f32 / step).round() as usize).max(1);
+        let mut out = Vec::with_capacity(w * h);
+        for j in 0..h {
+            for i in 0..w {
+                let sx = ((i as f32 + 0.5) * step - 0.5).clamp(0.0, (self.width - 1) as f32);
+                let sy = ((j as f32 + 0.5) * step - 0.5).clamp(0.0, (self.height - 1) as f32);
+                let (x0, y0) = (sx.floor() as usize, sy.floor() as usize);
+                let (x1, y1) = ((x0 + 1).min(self.width - 1), (y0 + 1).min(self.height - 1));
+                let (fx, fy) = (sx - x0 as f32, sy - y0 as f32);
+                let at = |x: usize, y: usize| self.px[y * self.width + x];
+                let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx;
+                let bot = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx;
+                out.push(top + (bot - top) * fy);
+            }
+        }
+        (out, w, h, mm)
+    }
+}
+
+/// Write the field as a 16-bit greyscale PNG (0 = lowest, 65535 = highest)
+/// plus a `<path>.json` sidecar carrying what the PNG cannot: the sample
+/// pitch and the height range in mm, the datum, and whether the metric
+/// behind those millimetres was measured or only assumed.
+pub fn export_png(hf: &HeightField, path: &Path, mm_per_sample: Option<f32>) -> std::io::Result<()> {
+    let (data, w, h, pitch_mm) = hf.resampled(mm_per_sample);
+    let (lo, hi) = data.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), &v| (lo.min(v), hi.max(v)));
+    let span = (hi - lo).max(1e-6);
+    let mut bytes = Vec::with_capacity(w * h * 2);
+    for v in &data {
+        let q = (((v - lo) / span) * 65535.0).round().clamp(0.0, 65535.0) as u16;
+        bytes.extend_from_slice(&q.to_be_bytes());
+    }
+    let file = std::fs::File::create(path)?;
+    let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
+    enc.set_color(png::ColorType::Grayscale);
+    enc.set_depth(png::BitDepth::Sixteen);
+    let mut writer = enc.write_header().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+    writer.write_image_data(&bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+    writer.finish().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+    let side = serde_json::json!({
+        "width": w,
+        "height": h,
+        "mm_per_sample": pitch_mm,
+        "px_per_mm_physical": hf.px_per_mm,
+        "min_mm": lo / hf.px_per_mm,
+        "max_mm": hi / hf.px_per_mm,
+        "datum": "0 = the window's base surface; values are heights above it, +z out of the screen",
+        "png": "16-bit greyscale, 0 = min_mm, 65535 = max_mm, linear",
+        "metric_source": hf.source.as_str(),
+        "metric_is_real": matches!(hf.source, MetricSource::Measured | MetricSource::Configured),
+    });
+    let mut side_path = path.as_os_str().to_owned();
+    side_path.push(".json");
+    std::fs::write(side_path, serde_json::to_string_pretty(&side).unwrap_or_default())?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::vk::PlatePush;
+
+    fn plate(rect: [f32; 4], t: f32, host: [f32; 4]) -> DlBatch {
+        DlBatch {
+            scissor: None,
+            clip_rrect: None,
+            start: 0,
+            end: 0,
+            plate: Some(PlatePush {
+                rect,
+                radii: [4.0; 4],
+                light: [0.0, 0.0, 1.0, t],
+                material: [1.0, 0.4, 24.0, 0.2],
+                host,
+                specular_tint: [1.0; 4],
+                mode: 1.0,
+                shape: 2.0,
+            }),
+            blur_behind: false,
+        }
+    }
+
+    #[test]
+    fn a_plate_stands_one_rise_above_nothing_and_rolls_to_its_rim() {
+        // 100×100 window, plate 60×60 centred, roll 10.
+        let b = plate([50.0, 50.0, 30.0, 30.0], 10.0, [0.0; 4]);
+        let hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
+        let face = hf.px[50 * 100 + 50];
+        assert!((face - 10.0).abs() < 1e-3, "face {face}");
+        assert_eq!(hf.px[5 * 100 + 5], 0.0, "outside the plate is the base");
+        // Just inside the silhouette (pixel centre 21.5, silhouette at 20:
+        // d = 1.5, f = 0.85) the rim is cut off at the ROLL_CUT height.
+        let rim = hf.px[50 * 100 + 21];
+        let expected = 10.0 * (1.0 - (ROLL_CUT * 0.85f32).powi(2)).sqrt();
+        assert!((rim - expected).abs() < 0.3, "rim {rim} vs {expected}");
+        assert!(rim < face);
+    }
+
+    #[test]
+    fn a_csg_recess_etches_its_depth_into_the_face() {
+        let b = plate([50.0, 50.0, 40.0, 40.0], 8.0, [0.0, 1.0, 0.0, 0.0]);
+        // A 20×20 recess at the centre, wall 6, dropping 4.
+        let feat = [50.0, 50.0, 10.0, 10.0, 2.0, 2.0, 2.0, 2.0, 6.0, 4.0, 0.0, 0.0];
+        let hf = HeightField::from_frame(&[b], &[feat], 100, 100, 1.0);
+        assert!((hf.px[50 * 100 + 50] - 4.0).abs() < 1e-3, "floor {}", hf.px[50 * 100 + 50]);
+        assert!((hf.px[50 * 100 + 75] - 8.0).abs() < 1e-3, "face {}", hf.px[50 * 100 + 75]);
+        // The wall lands between.
+        let wall = hf.px[50 * 100 + 60];
+        assert!(wall > 4.0 && wall < 8.0, "wall {wall}");
+    }
+
+    #[test]
+    fn a_free_recess_carves_the_analytic_ratio_of_its_wall() {
+        let mut b = plate([50.0, 50.0, 20.0, 20.0], 10.0, [-1e5, -1e5, 1e5, 1e5]);
+        b.plate.as_mut().unwrap().mode = 2.0;
+        let hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
+        let floor = hf.px[50 * 100 + 50];
+        assert!((floor + RECESS_DEPTH * 10.0).abs() < 1e-3, "floor {floor}");
+        assert_eq!(hf.px[5 * 100 + 5], 0.0);
+    }
+
+    #[test]
+    fn resample_keeps_the_face_height() {
+        let b = plate([50.0, 50.0, 40.0, 40.0], 8.0, [0.0; 4]);
+        let mut hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
+        hf.px_per_mm = 10.0; // 10 px per mm → 100 px = 10 mm
+        let (data, w, h, pitch) = hf.resampled(Some(0.5));
+        assert_eq!((w, h), (20, 20));
+        assert!((pitch - 0.5).abs() < 1e-6);
+        assert!((data[10 * 20 + 10] - 8.0).abs() < 1e-3);
+    }
+
+    #[test]
+    fn export_writes_png_and_sidecar() {
+        let b = plate([50.0, 50.0, 40.0, 40.0], 8.0, [0.0; 4]);
+        let hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
+        let dir = std::env::temp_dir().join(format!("cce-heightfield-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("map.png");
+        export_png(&hf, &path, None).unwrap();
+        let png = std::fs::read(&path).unwrap();
+        assert_eq!(&png[1..4], b"PNG");
+        let side: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("map.png.json")).unwrap()).unwrap();
+        assert_eq!(side["width"], 100);
+        assert_eq!(side["metric_source"], "assumed");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 6e40385..164512e 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -7,6 +7,7 @@
 
 pub mod anim;
 pub mod arena;
+pub mod heightfield;
 pub mod layout;
 pub mod paint;
 pub mod painter;
diff --git a/src/units.rs b/src/units.rs
index 44bcc65..6e04114 100644
--- a/src/units.rs
+++ b/src/units.rs
@@ -47,13 +47,14 @@ pub enum Unit {
     Px,
     Mm,
     Cm,
+    M,
     In,
     Pt,
 }
 
 impl Unit {
     /// Every unit, in the order a unit toggle should cycle them.
-    pub const ALL: [Unit; 5] = [Unit::Px, Unit::Mm, Unit::Cm, Unit::In, Unit::Pt];
+    pub const ALL: [Unit; 6] = [Unit::Px, Unit::Mm, Unit::Cm, Unit::M, Unit::In, Unit::Pt];
 
     /// The config suffix / KDL type annotation: `px`, `mm`, `cm`, `in`, `pt`.
     pub fn suffix(self) -> &'static str {
@@ -61,6 +62,7 @@ impl Unit {
             Unit::Px => "px",
             Unit::Mm => "mm",
             Unit::Cm => "cm",
+            Unit::M => "m",
             Unit::In => "in",
             Unit::Pt => "pt",
         }
@@ -73,6 +75,7 @@ impl Unit {
             "px" => Some(Unit::Px),
             "mm" => Some(Unit::Mm),
             "cm" => Some(Unit::Cm),
+            "m" | "metre" | "meter" | "metres" | "meters" => Some(Unit::M),
             "in" | "inch" | "inches" => Some(Unit::In),
             "pt" => Some(Unit::Pt),
             _ => None,
@@ -91,6 +94,7 @@ impl Unit {
             Unit::Px => None,
             Unit::Mm => Some(1.0),
             Unit::Cm => Some(10.0),
+            Unit::M => Some(1000.0),
             Unit::In => Some(MM_PER_INCH),
             Unit::Pt => Some(MM_PER_INCH / PT_PER_INCH),
         }
@@ -229,6 +233,9 @@ impl Len {
     pub const fn cm(value: f32) -> Self {
         Len::new(value, Unit::Cm)
     }
+    pub const fn m(value: f32) -> Self {
+        Len::new(value, Unit::M)
+    }
     pub const fn inches(value: f32) -> Self {
         Len::new(value, Unit::In)
     }
@@ -385,7 +392,7 @@ mod tests {
 
     #[test]
     fn parse_and_serialize_roundtrip() {
-        for s in ["2mm", "0.5in", "12px", "6pt", "1.25cm"] {
+        for s in ["2mm", "0.5in", "12px", "6pt", "1.25cm", "0.3m"] {
             let l = Len::parse(s).unwrap();
             assert_eq!(l.serialize(), s, "{s}");
         }