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 }