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

src/scene/heightfield.rs (32.6K)

  1 //! The relief as a height field: the geometry the plate shader shades, sampled
  2 //! per pixel and written out for fabrication.
  3 //!
  4 //! `shader2d.wgsl` never stores heights — it composes SLOPES per pixel and
  5 //! lights them. But every slope it uses is the derivative of a height curve
  6 //! this module integrates back: a plate is a slab whose face stands one roll
  7 //! rise above the surface beneath it and whose perimeter roll descends toward
  8 //! the silhouette; a carve cut into it (a CSG feature or a free recess, boss,
  9 //! ridge, trough, groove or fillet) subtracts or adds its drop through the
 10 //! same profile curve the shader's `carve_slope` differentiates. Heights are
 11 //! relative to the local surface, so plates stack and carves etch — a
 12 //! deboss, not a flat milling plane — exactly as the shader's composite
 13 //! model says.
 14 //!
 15 //! Units: physical px on the z axis too (one px of drop is one px of run),
 16 //! with the display metric ([`crate::units::metric`]) turning both into
 17 //! millimetres at export. That is what makes a pinned `height=(mm)0.3` an
 18 //! honest 0.3 mm here and a 1:1 print of the map a true relief. When the
 19 //! metric is only *assumed* the sidecar says so; a fabrication tool should
 20 //! refuse to trust it.
 21 //!
 22 //! Two ways in: `CCE_HEIGHTMAP=<file.png>` in any cce-ui client's
 23 //! environment exports its third rendered frame (settled layout, first
 24 //! metric known) and `CCE_HEIGHTMAP_MM=<mm per sample>` resamples to that
 25 //! pitch; or an app calls [`request`] itself. Out comes a 16-bit greyscale
 26 //! PNG (0 = the lowest point, 65535 = the highest) and a `<file>.json`
 27 //! sidecar with the pitch, the range in mm, the datum and the metric's
 28 //! source. Droplets and their scrims (decorative water) are skipped.
 29 
 30 use std::path::{Path, PathBuf};
 31 use std::sync::atomic::{AtomicU32, Ordering};
 32 use std::sync::Mutex;
 33 
 34 use crate::backend::window_runner::DlBatch;
 35 use crate::scene::relief_shade::{RECESS_DEPTH, ROLL_CUT};
 36 use crate::units::MetricSource;
 37 
 38 /// A sampled height field over one window, physical px on all three axes.
 39 #[derive(Debug, Clone)]
 40 pub struct HeightField {
 41     pub width: usize,
 42     pub height: usize,
 43     /// Physical px per millimetre of the display it was rendered for.
 44     pub px_per_mm: f32,
 45     pub source: MetricSource,
 46     /// Row-major heights in physical px, +z out of the screen, 0 = the
 47     /// window's base surface (what the root plate stands on).
 48     pub px: Vec<f32>,
 49 }
 50 
 51 /// One export request: where to write, and at what pitch (`None` = one
 52 /// sample per physical px).
 53 #[derive(Debug, Clone)]
 54 pub struct Request {
 55     pub path: PathBuf,
 56     pub mm_per_sample: Option<f32>,
 57 }
 58 
 59 static REQUEST: Mutex<Option<Request>> = Mutex::new(None);
 60 static FRAMES: AtomicU32 = AtomicU32::new(0);
 61 /// The frame the environment request fires on: layout has settled and the
 62 /// output metric has arrived by then.
 63 const ENV_FRAME: u32 = 3;
 64 
 65 /// Ask the runner to export the next frame's height field.
 66 pub fn request(path: impl Into<PathBuf>, mm_per_sample: Option<f32>) {
 67     if let Ok(mut r) = REQUEST.lock() {
 68         *r = Some(Request { path: path.into(), mm_per_sample });
 69     }
 70 }
 71 
 72 /// The runner's per-frame poll: an explicit [`request`], or the environment's
 73 /// `CCE_HEIGHTMAP` once, on frame [`ENV_FRAME`].
 74 pub(crate) fn take_request() -> Option<Request> {
 75     let n = FRAMES.fetch_add(1, Ordering::Relaxed) + 1;
 76     if n == ENV_FRAME {
 77         if let Ok(path) = std::env::var("CCE_HEIGHTMAP") {
 78             if !path.is_empty() {
 79                 let mm = std::env::var("CCE_HEIGHTMAP_MM").ok().and_then(|v| v.parse::<f32>().ok()).filter(|v| *v > 0.0);
 80                 request(path, mm);
 81             }
 82         }
 83     }
 84     REQUEST.lock().ok().and_then(|mut r| r.take())
 85 }
 86 
 87 /// The height curves the shader differentiates, tabulated once per export.
 88 struct Profiles {
 89     /// Carve: 0 on the plateau (v = 0) → 1 on the floor (v = 1).
 90     carve: Vec<f32>,
 91     /// Roll: 1 at the face join (f = 0) → the rim's remaining height at the
 92     /// silhouette (f = 1), unit rise.
 93     roll: Vec<f32>,
 94 }
 95 
 96 const TABLE: usize = 256;
 97 
 98 impl Profiles {
 99     fn build(shape: f32) -> Self {
100         let carve_lut = crate::layout::bevel_profile_slopes();
101         let roll_lut = crate::layout::roll_profile_slopes();
102         let n = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
103         // The shader's LUT sampling, slopes with its end tapers, integrated.
104         let lut_slope = |lut: &[f32], x: f32, both_ends: bool| -> f32 {
105             let xc = x.clamp(0.0, 1.0);
106             let xs = (xc * n - 0.5).clamp(0.0, n - 1.0);
107             let i0 = xs.floor() as usize;
108             let i1 = (i0 + 1).min(lut.len() - 1);
109             let fr = xs - xs.floor();
110             let win = if both_ends {
111                 (xc.min(1.0 - xc) * n * 0.667).clamp(0.0, 1.0)
112             } else {
113                 (xc * n * 0.667).clamp(0.0, 1.0)
114             };
115             (lut[i0] + (lut[i1] - lut[i0]) * fr) * win
116         };
117         let mut carve = Vec::with_capacity(TABLE + 1);
118         let mut roll = Vec::with_capacity(TABLE + 1);
119         let (mut hc, mut hr) = (0.0f32, 1.0f32);
120         for i in 0..=TABLE {
121             let x = i as f32 / TABLE as f32;
122             match &carve_lut {
123                 Some(lut) => {
124                     if i > 0 {
125                         let xm = (i as f32 - 0.5) / TABLE as f32;
126                         hc += lut_slope(lut, xm, true) / TABLE as f32;
127                     }
128                     carve.push(hc);
129                 }
130                 None => carve.push(if shape > 2.001 {
131                     x * x * x * (x * (x * 6.0 - 15.0) + 10.0)
132                 } else {
133                     x * x * (3.0 - 2.0 * x)
134                 }),
135             }
136             match &roll_lut {
137                 Some(lut) => {
138                     if i > 0 {
139                         let xm = (i as f32 - 0.5) / TABLE as f32;
140                         hr -= lut_slope(lut, xm, false) / TABLE as f32;
141                     }
142                     roll.push(hr);
143                 }
144                 None => {
145                     let fc = x * ROLL_CUT;
146                     roll.push(if shape > 2.001 {
147                         (1.0 - fc.powf(shape)).max(0.0).powf(1.0 / shape)
148                     } else {
149                         (1.0 - fc * fc).max(0.0).sqrt()
150                     })
151                 }
152             }
153         }
154         Profiles { carve, roll }
155     }
156 
157     fn sample(table: &[f32], x: f32) -> f32 {
158         let xs = x.clamp(0.0, 1.0) * TABLE as f32;
159         let i0 = (xs.floor() as usize).min(TABLE - 1);
160         let fr = xs - i0 as f32;
161         table[i0] + (table[i0 + 1] - table[i0]) * fr
162     }
163 
164     fn carve_height(&self, v: f32) -> f32 {
165         Self::sample(&self.carve, v)
166     }
167 
168     fn roll_height(&self, f: f32) -> f32 {
169         Self::sample(&self.roll, f)
170     }
171 }
172 
173 /// Signed distance to a rounded box, positive outside — the distance part of
174 /// the shader's `rr_sdf_grad`, superellipse corners and their first-order
175 /// refinement included, so the sampled walls sit where the shaded ones do.
176 fn rr_sdf(p: (f32, f32), rect: [f32; 4], radii: [f32; 4], shape: f32, roll: f32) -> f32 {
177     let c = (p.0 - rect[0], p.1 - rect[1]);
178     let (r_lo, r_hi) = if c.0 > 0.0 { (radii[1], radii[2]) } else { (radii[0], radii[3]) };
179     let r = if c.1 > 0.0 { r_hi } else { r_lo };
180     let q = (c.0.abs() - rect[2] + r, c.1.abs() - rect[3] + r);
181     if q.0 > 0.0 && q.1 > 0.0 {
182         if shape > 2.001 {
183             let lp = (q.0.powf(shape) + q.1.powf(shape)).powf(1.0 / shape).max(1e-4);
184             let g = ((q.0 / lp).powf(shape - 1.0), (q.1 / lp).powf(shape - 1.0));
185             let gm = (g.0 * g.0 + g.1 * g.1).sqrt().max(1e-4);
186             let d0 = (lp - r) / gm;
187             let dir = (g.0 / gm, g.1 / gm);
188             let roll = roll.max(2.0);
189             let step = d0.clamp(-roll, roll);
190             let q1 = ((q.0 - step * dir.0).max(1e-4), (q.1 - step * dir.1).max(1e-4));
191             let lp1 = (q1.0.powf(shape) + q1.1.powf(shape)).powf(1.0 / shape).max(1e-4);
192             let g1 = ((q1.0 / lp1).powf(shape - 1.0), (q1.1 / lp1).powf(shape - 1.0));
193             let gm1 = (g1.0 * g1.0 + g1.1 * g1.1).sqrt().max(1e-4);
194             let d1 = step + (lp1 - r) / gm1;
195             let w = smoothstep(roll, roll * 1.5 + 2.0, d0.abs());
196             return d1 + (d0 - d1) * w;
197         }
198         let len = (q.0 * q.0 + q.1 * q.1).sqrt().max(1e-4);
199         return len - r;
200     }
201     if q.0 > q.1 { q.0 - r } else { q.1 - r }
202 }
203 
204 fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
205     let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
206     t * t * (3.0 - 2.0 * t)
207 }
208 
209 /// Plate modes as `PlatePush::mode` carries them (see shader2d's `MODE_*`).
210 const MODE_PLATE: i32 = 1;
211 const MODE_RECESS: i32 = 2;
212 const MODE_BOSS: i32 = 3;
213 const MODE_RIDGE: i32 = 4;
214 const MODE_SPHERE: i32 = 5;
215 const MODE_FILLET_DOWN: i32 = 6;
216 const MODE_FILLET_UP: i32 = 7;
217 const MODE_GROOVE: i32 = 8;
218 const MODE_TROUGH: i32 = 9;
219 const MODE_ROLL: i32 = 11;
220 const MODE_LATTICE: i32 = 13;
221 const MODE_UNION: i32 = 14;
222 
223 impl HeightField {
224     /// Sample one frame's plate batches, in draw order, over a `width` ×
225     /// `height` physical-px window rendered at `scale`. Batches that are not
226     /// plates (plain geometry, text, images) have no height.
227     pub fn from_frame(batches: &[DlBatch], features: &[[f32; 12]], width: usize, height: usize, scale: f32) -> Self {
228         let metric = crate::units::metric();
229         let mut hf = HeightField {
230             width,
231             height,
232             px_per_mm: metric.physical_px_per_mm(),
233             source: metric.source,
234             px: vec![0.0; width * height],
235         };
236         let pinned_carve = crate::layout::bevel_height().map(|h| h * scale);
237         let pinned_roll = crate::layout::roll_height().map(|h| h * scale);
238         let mut profiles: Option<(f32, Profiles)> = None;
239         for b in batches {
240             let Some(p) = b.plate else { continue };
241             let mode = p.mode.round() as i32;
242             if !matches!(mode, 1..=9 | 11 | 13 | 14) {
243                 continue;
244             }
245             let shape = p.shape.clamp(2.0, 16.0);
246             if profiles.as_ref().map_or(true, |(s, _)| (*s - shape).abs() > 1e-3) {
247                 profiles = Some((shape, Profiles::build(shape)));
248             }
249             let prof = &profiles.as_ref().unwrap().1;
250             let t = p.light[3].max(0.001);
251             // Pixel bounds: the shape's box plus its wall, clipped.
252             let (mut x0, mut y0, mut x1, mut y1) = match mode {
253                 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]),
254                 MODE_FILLET_DOWN | MODE_FILLET_UP => {
255                     let r = p.rect[2] + t;
256                     (p.rect[0] - r, p.rect[1] - r, p.rect[0] + r, p.rect[1] + r)
257                 }
258                 // A lattice's shading is bounded by its cover quad, which the
259                 // batch does not record; the one consumer (cce-grid) covers
260                 // its whole surface, so the window is the honest bound.
261                 MODE_GROOVE | MODE_LATTICE => (0.0, 0.0, width as f32, height as f32),
262                 // A union's push rect is its boxes' bounding box.
263                 MODE_UNION => (
264                     p.rect[0] - p.rect[2] - t - 2.0,
265                     p.rect[1] - p.rect[3] - t - 2.0,
266                     p.rect[0] + p.rect[2] + t + 2.0,
267                     p.rect[1] + p.rect[3] + t + 2.0,
268                 ),
269                 _ => (
270                     p.rect[0] - p.rect[2] - t - 2.0,
271                     p.rect[1] - p.rect[3] - t - 2.0,
272                     p.rect[0] + p.rect[2] + t + 2.0,
273                     p.rect[1] + p.rect[3] + t + 2.0,
274                 ),
275             };
276             if let Some(sc) = b.scissor {
277                 x0 = x0.max(sc.x * scale);
278                 y0 = y0.max(sc.y * scale);
279                 x1 = x1.min((sc.x + sc.width) * scale);
280                 y1 = y1.min((sc.y + sc.height) * scale);
281             }
282             let x0 = x0.floor().max(0.0) as usize;
283             let y0 = y0.floor().max(0.0) as usize;
284             let x1 = (x1.ceil().max(0.0) as usize).min(width);
285             let y1 = (y1.ceil().max(0.0) as usize).min(height);
286             if x0 >= x1 || y0 >= y1 {
287                 continue;
288             }
289             let (f_off, f_cnt) = (p.host[0].max(0.0) as usize, p.host[1].max(0.0) as usize);
290             let carve_drop = pinned_carve.unwrap_or(RECESS_DEPTH * t);
291             let roll_rise = pinned_roll.unwrap_or(t);
292             for y in y0..y1 {
293                 for x in x0..x1 {
294                     let pt = (x as f32 + 0.5, y as f32 + 0.5);
295                     if let Some(cr) = b.clip_rrect {
296                         if rr_sdf(pt, [cr[0], cr[1], cr[2], cr[3]], [cr[4]; 4], 2.0, t) > 0.0 {
297                             continue;
298                         }
299                     }
300                     let dh = match mode {
301                         MODE_PLATE => {
302                             // Positive inside, like the shader's `d`.
303                             let d = -rr_sdf(pt, p.rect, p.radii, shape, t);
304                             if d <= 0.0 {
305                                 continue;
306                             }
307                             let f = 1.0 - (d / t).clamp(0.0, 1.0);
308                             let mut h = roll_rise * prof.roll_height(f);
309                             for feat in features.iter().skip(f_off).take(f_cnt) {
310                                 let ft = feat[8].max(0.001);
311                                 let fd = rr_sdf(pt, [feat[0], feat[1], feat[2], feat[3]], [feat[4], feat[5], feat[6], feat[7]], shape, ft);
312                                 let v = (-fd / ft + 0.5).clamp(0.0, 1.0);
313                                 if v > 0.0 {
314                                     // params.y: positive carves down, negative
315                                     // raises a boss.
316                                     h -= feat[9] * prof.carve_height(v);
317                                 }
318                             }
319                             h
320                         }
321                         MODE_ROLL => {
322                             let d = -rr_sdf(pt, p.rect, p.radii, shape, t);
323                             if d <= 0.0 {
324                                 continue;
325                             }
326                             let f = 1.0 - (d / t).clamp(0.0, 1.0);
327                             roll_rise * (prof.roll_height(f) - 1.0)
328                         }
329                         MODE_SPHERE => {
330                             let (dx, dy) = (pt.0 - p.rect[0], pt.1 - p.rect[1]);
331                             let r2 = p.rect[2] * p.rect[2];
332                             let d2 = dx * dx + dy * dy;
333                             if d2 >= r2 {
334                                 continue;
335                             }
336                             (r2 - d2).sqrt()
337                         }
338                         MODE_GROOVE => {
339                             let s = (pt.0 - p.rect[0]) * p.radii[0] + (pt.1 - p.rect[1]) * p.radii[1];
340                             // Positive outside the band; the line is the low side.
341                             let fd = s.abs() - p.rect[2];
342                             let u = (fd / t + 0.5).clamp(0.0, 1.0);
343                             -carve_drop * (1.0 - prof.carve_height(u))
344                         }
345                         MODE_LATTICE => {
346                             // Fold into the period about one cell's centre and
347                             // measure that cell — the union distance of every
348                             // well (see the shader's MODE_LATTICE). Positive
349                             // outside the cell; the wall runs from the edge
350                             // outward over t, floor at the edge.
351                             // Mitred, not offset: the wall's outer edge is the
352                             // cell grown by t with SHARP corners (crest lines
353                             // meet at a crossing as hips), and u is the
354                             // fraction across the band between the two
355                             // contours (1 at the cell edge, 0 at the outer).
356                             let (px, py) = (p.host[0].max(1e-3), p.host[1].max(1e-3));
357                             let c = (pt.0 - p.rect[0], pt.1 - p.rect[1]);
358                             let c = (c.0 - px * (c.0 / px).round(), c.1 - py * (c.1 / py).round());
359                             let d_in = rr_sdf(c, [0.0, 0.0, p.rect[2], p.rect[3]], p.radii, shape, t);
360                             let d_out = rr_sdf(c, [0.0, 0.0, p.rect[2] + t, p.rect[3] + t], [0.0; 4], shape, t);
361                             let frac = (d_in / (d_in - d_out).max(1e-3)).clamp(0.0, 1.0);
362                             -carve_drop * prof.carve_height(1.0 - frac)
363                         }
364                         MODE_UNION => {
365                             // Mitred per box (band between the box shrunk and
366                             // grown by t/2 at its own radius), union = the box
367                             // the point is deepest in (see the shader's
368                             // MODE_UNION). One profile.
369                             let hw = 0.5 * t;
370                             let mut best = f32::MIN;
371                             for feat in features.iter().skip(f_off).take(f_cnt) {
372                                 let radii = [feat[4], feat[5], feat[6], feat[7]];
373                                 let inner = [feat[0], feat[1], (feat[2] - hw).max(0.5), (feat[3] - hw).max(0.5)];
374                                 let outer = [feat[0], feat[1], feat[2] + hw, feat[3] + hw];
375                                 let d_in = rr_sdf(pt, inner, radii, shape, t);
376                                 let d_out = rr_sdf(pt, outer, radii, shape, t);
377                                 let frac = (d_in / (d_in - d_out).max(1e-3)).clamp(0.0, 1.0);
378                                 best = best.max((0.5 - frac) * t);
379                             }
380                             if best == f32::MIN {
381                                 continue;
382                             }
383                             let u = (best / t + 0.5).clamp(0.0, 1.0);
384                             let sign = if p.radii[0] > 0.5 { 1.0 } else { -1.0 };
385                             sign * carve_drop * prof.carve_height(u)
386                         }
387                         MODE_FILLET_DOWN | MODE_FILLET_UP => {
388                             let (cx, cy) = (pt.0 - p.rect[0], pt.1 - p.rect[1]);
389                             let dist = (cx * cx + cy * cy).sqrt().max(1e-4);
390                             let ang = cy.atan2(cx);
391                             let a0 = p.radii[0];
392                             let rel = (ang - a0).rem_euclid(std::f32::consts::TAU);
393                             if rel > std::f32::consts::FRAC_PI_2 {
394                                 continue;
395                             }
396                             // Positive outside the arc's circle; outside is
397                             // the low side of a recessed fillet.
398                             let fd = dist - p.rect[2];
399                             let u = (fd / t + 0.5).clamp(0.0, 1.0);
400                             let sign = if mode == MODE_FILLET_DOWN { -1.0 } else { 1.0 };
401                             sign * carve_drop * prof.carve_height(u)
402                         }
403                         _ => {
404                             // Recess, boss, ridge, trough: the box step, u = 1
405                             // deep inside.
406                             let d = -rr_sdf(pt, p.rect, p.radii, shape, t);
407                             let u = (d / t + 0.5).clamp(0.0, 1.0);
408                             match mode {
409                                 MODE_RECESS => -carve_drop * prof.carve_height(u),
410                                 MODE_BOSS => carve_drop * prof.carve_height(u),
411                                 MODE_RIDGE | MODE_TROUGH => {
412                                     let w = (2.0 * u).min(2.0 - 2.0 * u).clamp(0.0, 1.0);
413                                     let up = if mode == MODE_RIDGE { 1.0 } else { -1.0 };
414                                     up * 0.5 * carve_drop * prof.carve_height(w)
415                                 }
416                                 _ => 0.0,
417                             }
418                         }
419                     };
420                     hf.px[y * width + x] += dh;
421                 }
422             }
423         }
424         hf
425     }
426 
427     /// Height in mm at a sample.
428     pub fn mm_at(&self, x: usize, y: usize) -> f32 {
429         self.px[y * self.width + x] / self.px_per_mm
430     }
431 
432     /// (lowest, highest) in px.
433     pub fn range_px(&self) -> (f32, f32) {
434         self.px.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), &v| (lo.min(v), hi.max(v)))
435     }
436 
437     /// The field resampled to `mm_per_sample` (bilinear), or a copy at the
438     /// native pitch.
439     pub fn resampled(&self, mm_per_sample: Option<f32>) -> (Vec<f32>, usize, usize, f32) {
440         let Some(mm) = mm_per_sample.filter(|m| *m > 0.0) else {
441             return (self.px.clone(), self.width, self.height, 1.0 / self.px_per_mm);
442         };
443         let step = mm * self.px_per_mm; // source px per output sample
444         let w = ((self.width as f32 / step).round() as usize).max(1);
445         let h = ((self.height as f32 / step).round() as usize).max(1);
446         let mut out = Vec::with_capacity(w * h);
447         for j in 0..h {
448             for i in 0..w {
449                 let sx = ((i as f32 + 0.5) * step - 0.5).clamp(0.0, (self.width - 1) as f32);
450                 let sy = ((j as f32 + 0.5) * step - 0.5).clamp(0.0, (self.height - 1) as f32);
451                 let (x0, y0) = (sx.floor() as usize, sy.floor() as usize);
452                 let (x1, y1) = ((x0 + 1).min(self.width - 1), (y0 + 1).min(self.height - 1));
453                 let (fx, fy) = (sx - x0 as f32, sy - y0 as f32);
454                 let at = |x: usize, y: usize| self.px[y * self.width + x];
455                 let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx;
456                 let bot = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx;
457                 out.push(top + (bot - top) * fy);
458             }
459         }
460         (out, w, h, mm)
461     }
462 }
463 
464 /// Write the field as a 16-bit greyscale PNG (0 = lowest, 65535 = highest)
465 /// plus a `<path>.json` sidecar carrying what the PNG cannot: the sample
466 /// pitch and the height range in mm, the datum, and whether the metric
467 /// behind those millimetres was measured or only assumed.
468 pub fn export_png(hf: &HeightField, path: &Path, mm_per_sample: Option<f32>) -> std::io::Result<()> {
469     let (data, w, h, pitch_mm) = hf.resampled(mm_per_sample);
470     let (lo, hi) = data.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), &v| (lo.min(v), hi.max(v)));
471     let span = (hi - lo).max(1e-6);
472     let mut bytes = Vec::with_capacity(w * h * 2);
473     for v in &data {
474         let q = (((v - lo) / span) * 65535.0).round().clamp(0.0, 65535.0) as u16;
475         bytes.extend_from_slice(&q.to_be_bytes());
476     }
477     let file = std::fs::File::create(path)?;
478     let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
479     enc.set_color(png::ColorType::Grayscale);
480     enc.set_depth(png::BitDepth::Sixteen);
481     let mut writer = enc.write_header().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
482     writer.write_image_data(&bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
483     writer.finish().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
484     let side = serde_json::json!({
485         "width": w,
486         "height": h,
487         "mm_per_sample": pitch_mm,
488         "px_per_mm_physical": hf.px_per_mm,
489         "min_mm": lo / hf.px_per_mm,
490         "max_mm": hi / hf.px_per_mm,
491         "datum": "0 = the window's base surface; values are heights above it, +z out of the screen",
492         "png": "16-bit greyscale, 0 = min_mm, 65535 = max_mm, linear",
493         "metric_source": hf.source.as_str(),
494         "metric_is_real": matches!(hf.source, MetricSource::Measured | MetricSource::Configured),
495     });
496     let mut side_path = path.as_os_str().to_owned();
497     side_path.push(".json");
498     std::fs::write(side_path, serde_json::to_string_pretty(&side).unwrap_or_default())?;
499     Ok(())
500 }
501 
502 #[cfg(test)]
503 mod tests {
504     use super::*;
505     use crate::vk::PlatePush;
506 
507     fn plate(rect: [f32; 4], t: f32, host: [f32; 4]) -> DlBatch {
508         DlBatch {
509             scissor: None,
510             clip_rrect: None,
511             start: 0,
512             end: 0,
513             plate: Some(PlatePush {
514                 rect,
515                 radii: [4.0; 4],
516                 light: [0.0, 0.0, 1.0, t],
517                 material: [1.0, 0.4, 24.0, 0.2],
518                 host,
519                 specular_tint: [1.0; 4],
520                 mode: 1.0,
521                 shape: 2.0,
522             }),
523             blur_behind: false,
524         }
525     }
526 
527     #[test]
528     fn a_plate_stands_one_rise_above_nothing_and_rolls_to_its_rim() {
529         // 100×100 window, plate 60×60 centred, roll 10.
530         let b = plate([50.0, 50.0, 30.0, 30.0], 10.0, [0.0; 4]);
531         let hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
532         let face = hf.px[50 * 100 + 50];
533         assert!((face - 10.0).abs() < 1e-3, "face {face}");
534         assert_eq!(hf.px[5 * 100 + 5], 0.0, "outside the plate is the base");
535         // Just inside the silhouette (pixel centre 21.5, silhouette at 20:
536         // d = 1.5, f = 0.85) the rim is cut off at the ROLL_CUT height.
537         let rim = hf.px[50 * 100 + 21];
538         let expected = 10.0 * (1.0 - (ROLL_CUT * 0.85f32).powi(2)).sqrt();
539         assert!((rim - expected).abs() < 0.3, "rim {rim} vs {expected}");
540         assert!(rim < face);
541     }
542 
543     #[test]
544     fn a_csg_recess_etches_its_depth_into_the_face() {
545         let b = plate([50.0, 50.0, 40.0, 40.0], 8.0, [0.0, 1.0, 0.0, 0.0]);
546         // A 20×20 recess at the centre, wall 6, dropping 4.
547         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];
548         let hf = HeightField::from_frame(&[b], &[feat], 100, 100, 1.0);
549         assert!((hf.px[50 * 100 + 50] - 4.0).abs() < 1e-3, "floor {}", hf.px[50 * 100 + 50]);
550         assert!((hf.px[50 * 100 + 75] - 8.0).abs() < 1e-3, "face {}", hf.px[50 * 100 + 75]);
551         // The wall lands between.
552         let wall = hf.px[50 * 100 + 60];
553         assert!(wall > 4.0 && wall < 8.0, "wall {wall}");
554     }
555 
556     #[test]
557     fn a_free_recess_carves_the_analytic_ratio_of_its_wall() {
558         let mut b = plate([50.0, 50.0, 20.0, 20.0], 10.0, [-1e5, -1e5, 1e5, 1e5]);
559         b.plate.as_mut().unwrap().mode = 2.0;
560         let hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
561         let floor = hf.px[50 * 100 + 50];
562         assert!((floor + RECESS_DEPTH * 10.0).abs() < 1e-3, "floor {floor}");
563         assert_eq!(hf.px[5 * 100 + 5], 0.0);
564     }
565 
566     #[test]
567     fn a_lattice_is_one_surface_with_plateau_rails_and_mitred_crossings() {
568         // Period 50, cells 30 wide (gap 20), wall 10 = the half-gap, one
569         // cell centred at (25, 25). p_rect = cell centre + half-extents,
570         // p_host.xy = period, radii 4 (the fixture's).
571         let mut b = plate([25.0, 25.0, 15.0, 15.0], 10.0, [50.0, 50.0, 1e6, 1e6]);
572         b.plate.as_mut().unwrap().mode = 13.0;
573         let hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
574         let at = |x: usize, y: usize| hf.px[y * 100 + x];
575         let drop = RECESS_DEPTH * 10.0;
576         // Every cell floor, not just the one the push names: (25,25) and
577         // its period neighbour (75,75).
578         assert!((at(25, 25) + drop).abs() < 1e-3, "floor {}", at(25, 25));
579         assert!((at(75, 75) + drop).abs() < 1e-3, "neighbour floor {}", at(75, 75));
580         // The rail centre line between two cells is exactly one run from
581         // either edge: plateau, not a doubled wall. Pixel centres straddle
582         // the line by half a px (49.5 and 50.5 are each 9.5 from a cell), so
583         // the two samples sit a hair into opposite walls — equal, and within
584         // the wall's first half-px of drop.
585         assert!((at(49, 25) - at(50, 25)).abs() < 1e-3, "rail centre symmetric {} {}", at(49, 25), at(50, 25));
586         assert!(at(50, 25) > -0.1 && at(50, 25) <= 0.0, "rail centre {}", at(50, 25));
587         assert!(at(50, 25) > at(45, 25), "rail centre above the wall");
588         // The crossing where four cells meet: the four sharp-cornered outer
589         // contours meet exactly at the centre, so the crest is a point there
590         // (the pixel centre sits half a px into one quadrant's wall) — no
591         // flat lozenge, the mitre the per-cell rings never gave.
592         assert!(at(50, 50) <= 0.0 && at(50, 50) > -0.1, "crossing {}", at(50, 50));
593         // Just off the centre along the diagonal the hip is already falling:
594         // inside the outer box by 1.5 px on both axes, a definite carve —
595         // where an offset-curve outer contour (13.7 px from the cell, past
596         // the 10 px run) would be flat.
597         assert!(at(48, 48) < -0.1 && at(48, 48) > -drop, "hip {}", at(48, 48));
598         // On the rail centre line the outer contour is straight: plateau all
599         // the way up to the crossing's corner rounding (the same half-px
600         // straddle as above: 49.5 and 50.5 sit a hair into opposite walls).
601         assert!((at(49, 45) - at(50, 45)).abs() < 1e-3, "rail centre near crossing symmetric");
602         assert!(at(50, 45) > -0.1 && at(50, 45) <= 0.0, "rail centre near crossing {}", at(50, 45));
603         // Halfway out the wall is between floor and plateau, on both sides
604         // of the rail (one wall from each cell, symmetric). Pixel centres:
605         // 45.5 is 5.5 past the first cell's edge at 40, 54.5 is 5.5 before
606         // the neighbour's edge at 60.
607         let w1 = at(45, 25);
608         let w2 = at(54, 25);
609         assert!(w1 < 0.0 && w1 > -drop, "wall {w1}");
610         assert!((w1 - w2).abs() < 1e-3, "walls symmetric {w1} {w2}");
611     }
612 
613     #[test]
614     fn a_carve_union_is_one_wall_around_the_union_of_its_boxes() {
615         // An L: a 60×20 bar across the top and a 20×60 bar down the left,
616         // sharing the corner square (10..30). Wall 8, radii 4 (fixture).
617         let bar_h = [40.0, 20.0, 30.0, 10.0, 4.0, 4.0, 4.0, 4.0, 8.0, 0.0, 0.0, 0.0];
618         let bar_v = [20.0, 40.0, 10.0, 30.0, 4.0, 4.0, 4.0, 4.0, 8.0, 0.0, 0.0, 0.0];
619         let mut b = plate([40.0, 40.0, 30.0, 30.0], 8.0, [0.0, 2.0, 1e6, 1e6]);
620         b.plate.as_mut().unwrap().mode = 14.0;
621         b.plate.as_mut().unwrap().radii = [0.0; 4];
622         let hf = HeightField::from_frame(&[b], &[bar_h, bar_v], 100, 100, 1.0);
623         let at = |x: usize, y: usize| hf.px[y * 100 + x];
624         let drop = RECESS_DEPTH * 8.0;
625         // Deep inside either bar: the full drop, once.
626         assert!((at(55, 20) + drop).abs() < 1e-3, "top bar {}", at(55, 20));
627         assert!((at(20, 55) + drop).abs() < 1e-3, "left bar {}", at(20, 55));
628         // The shared corner square lies inside BOTH boxes. With one recess
629         // per box, each box's wall would run straight through the other's
630         // interior here (the vertical bar's right wall at x = 30 crosses the
631         // top bar). As a union the interior is flat floor: exactly one drop.
632         assert!((at(25, 20) + drop).abs() < 1e-3, "corner interior {}", at(25, 20));
633         assert!((at(20, 25) + drop).abs() < 1e-3, "corner interior {}", at(20, 25));
634         // Well outside: the base.
635         assert_eq!(at(80, 80), 0.0);
636         // The wall straddles the union outline by ±t/2 = 4: sampled 2 px
637         // outside the top bar's lower edge (y = 30), on the wall.
638         let wall = at(55, 32);
639         assert!(wall < 0.0 && wall > -drop, "wall {wall}");
640         // Mitred outer corner: the band's outer contour is the bar grown by
641         // t/2 at the bar's own radius 4, so (72.5, 8.5) — 1.1 px inside that
642         // contour's corner arc — carries a hair of wall, where an offset
643         // contour (radius 8, the point 4.5 px from the bar) would be flat.
644         let corner = at(72, 8);
645         assert!(corner < 0.0 && corner > -0.5 * drop, "mitred corner {corner}");
646     }
647 
648     #[test]
649     fn resample_keeps_the_face_height() {
650         let b = plate([50.0, 50.0, 40.0, 40.0], 8.0, [0.0; 4]);
651         let mut hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
652         hf.px_per_mm = 10.0; // 10 px per mm → 100 px = 10 mm
653         let (data, w, h, pitch) = hf.resampled(Some(0.5));
654         assert_eq!((w, h), (20, 20));
655         assert!((pitch - 0.5).abs() < 1e-6);
656         assert!((data[10 * 20 + 10] - 8.0).abs() < 1e-3);
657     }
658 
659     #[test]
660     fn export_writes_png_and_sidecar() {
661         let b = plate([50.0, 50.0, 40.0, 40.0], 8.0, [0.0; 4]);
662         let hf = HeightField::from_frame(&[b], &[], 100, 100, 1.0);
663         let dir = std::env::temp_dir().join(format!("cce-heightfield-{}", std::process::id()));
664         std::fs::create_dir_all(&dir).unwrap();
665         let path = dir.join("map.png");
666         export_png(&hf, &path, None).unwrap();
667         let png = std::fs::read(&path).unwrap();
668         assert_eq!(&png[1..4], b"PNG");
669         let side: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("map.png.json")).unwrap()).unwrap();
670         assert_eq!(side["width"], 100);
671         assert_eq!(side["metric_source"], "assumed");
672         let _ = std::fs::remove_dir_all(&dir);
673     }
674 }