git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/page.rs (45.2K)

   1 //! The 2D page context — an image, composited from layers.
   2 //!
   3 //! This is a SECOND context, deliberately not the geometry graph. Its currency
   4 //! is a [`Page`] rather than a `Detail`, its origin is the top-left corner
   5 //! with y running DOWN, and nothing in it has a point id, an attribute or a
   6 //! normal. Smuggling one into the other means a `Detail` that is secretly a
   7 //! raster, so they stay apart: page nodes resolve through [`resolve_page`],
   8 //! never through `generate_single_node_geometry_with_errors`, and contribute
   9 //! no geometry. What the viewport shows of a page is the page itself, as a
  10 //! textured quad standing in the scene ([`PageShown::world_corners`]).
  11 //!
  12 //! A page is stored in inches, because its first reason for existing was
  13 //! paper. What its nodes are WRITTEN in is the page's [`PageUnit`] — inches,
  14 //! millimetres, centimetres or pixels — chosen on the `page` node and carried
  15 //! by the page to every node downstream, so a shape on a 1920 × 1080 image is
  16 //! placed in pixels and the same node on a Letter sheet in inches. In the
  17 //! scene a page is its physical size: the World Unit says what one world unit
  18 //! is, and a sheet 215.9 mm wide is 215.9 of them when that is a millimetre.
  19 //!
  20 //! **Resolution is a property of the page, not of the export.** A page carries
  21 //! its DPI, the raster is that many pixels per inch, and the PNG says so in its
  22 //! pHYs chunk — so a printer, a slicer or a browser lays the file out at the
  23 //! physical size it was composed at instead of guessing 96. A page composed at
  24 //! 300 DPI and printed is 8.5 inches wide; the same pixels labelled 72 are
  25 //! nearly four feet.
  26 
  27 use std::path::Path;
  28 
  29 /// Declare a PNG's pixels to be sRGB, the way the spec asks for.
  30 ///
  31 /// `Encoder::set_srgb` did this in one call and is deprecated; its replacement
  32 /// `set_source_srgb` writes ONLY the sRGB chunk, dropping the gAMA and cHRM
  33 /// fallbacks that PNG 11.3.2.5 says to write beside it for decoders that do
  34 /// not understand sRGB. Swapping one call for the other therefore changes the
  35 /// file — silently, and only for old decoders, which is the worst way for a
  36 /// deprecation fix to change behaviour. Both of this app's PNG writers go
  37 /// through here instead, so they agree and neither one drifts.
  38 pub fn mark_srgb<W: std::io::Write>(encoder: &mut png::Encoder<W>) {
  39     encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
  40     encoder.set_source_gamma(png::ScaledFloat::from_scaled(45455));
  41     encoder.set_source_chromaticities(png::SourceChromaticities {
  42         white: (png::ScaledFloat::from_scaled(31270), png::ScaledFloat::from_scaled(32900)),
  43         red: (png::ScaledFloat::from_scaled(64000), png::ScaledFloat::from_scaled(33000)),
  44         green: (png::ScaledFloat::from_scaled(30000), png::ScaledFloat::from_scaled(60000)),
  45         blue: (png::ScaledFloat::from_scaled(15000), png::ScaledFloat::from_scaled(6000)),
  46     });
  47 }
  48 
  49 /// One printed sheet: a physical size, a resolution, and the pixels in between.
  50 ///
  51 /// Pixels are straight-alpha linear RGBA. Straight rather than premultiplied
  52 /// because every operation here composites INTO an opaque page, so the extra
  53 /// multiply buys nothing and the stored values stay the ones the parameters
  54 /// asked for.
  55 #[derive(Clone)]
  56 pub struct Page {
  57     /// Physical size in inches, before orientation is applied.
  58     pub size: [f32; 2],
  59     /// Pixels per inch.
  60     pub dpi: u32,
  61     pub width: u32,
  62     pub height: u32,
  63     pub pixels: Vec<[f32; 4]>,
  64     /// What the lengths on this page's nodes are written in.
  65     pub unit: PageUnit,
  66     /// Where the page's CENTRE stands in the scene, in world units.
  67     pub origin: [f32; 3],
  68 }
  69 
  70 /// What a page's lengths are written in.
  71 ///
  72 /// A property of the PAGE, set on the node that makes it, and not of each
  73 /// node drawing on it: a chain whose text was placed in pixels and whose
  74 /// border was inset in inches is a chain nobody can read.
  75 #[derive(Clone, Copy, PartialEq, Debug)]
  76 pub enum PageUnit {
  77     Inches,
  78     Millimetres,
  79     Centimetres,
  80     Pixels,
  81 }
  82 
  83 impl PageUnit {
  84     /// The unit a `Units` row names. Anything else is inches, which is what
  85     /// a page saved before the row existed was written in.
  86     pub fn parse(name: &str) -> PageUnit {
  87         match name.trim().to_ascii_lowercase().as_str() {
  88             "millimetres" | "millimeters" | "mm" => PageUnit::Millimetres,
  89             "centimetres" | "centimeters" | "cm" => PageUnit::Centimetres,
  90             "pixels" | "px" => PageUnit::Pixels,
  91             _ => PageUnit::Inches,
  92         }
  93     }
  94 
  95     pub fn label(self) -> &'static str {
  96         match self {
  97             PageUnit::Inches => "in",
  98             PageUnit::Millimetres => "mm",
  99             PageUnit::Centimetres => "cm",
 100             PageUnit::Pixels => "px",
 101         }
 102     }
 103 
 104     /// A length in this unit as inches, on a raster of `px_per_inch`.
 105     pub fn to_inches(self, value: f32, px_per_inch: f32) -> f32 {
 106         match self {
 107             PageUnit::Inches => value,
 108             PageUnit::Millimetres => value / 25.4,
 109             PageUnit::Centimetres => value / 2.54,
 110             PageUnit::Pixels => value / px_per_inch.max(1e-6),
 111         }
 112     }
 113 
 114     /// The other way: inches as a length in this unit.
 115     pub fn from_inches(self, inches: f32, px_per_inch: f32) -> f32 {
 116         match self {
 117             PageUnit::Inches => inches,
 118             PageUnit::Millimetres => inches * 25.4,
 119             PageUnit::Centimetres => inches * 2.54,
 120             PageUnit::Pixels => inches * px_per_inch,
 121         }
 122     }
 123 }
 124 
 125 /// A page without its pixels: its size, its raster's size, its unit and
 126 /// where it stands. What placing something ON a page needs, at the cost of
 127 /// walking the chain to the `page` node and of nothing else — the viewport's
 128 /// handles ask for it on every frame they are drawn, and composing the
 129 /// raster to learn how big it is would be a sheet a frame.
 130 #[derive(Clone, Copy, PartialEq, Debug)]
 131 pub struct PageFrame {
 132     /// Physical size in inches.
 133     pub size: [f32; 2],
 134     pub dpi: u32,
 135     pub width: u32,
 136     pub height: u32,
 137     pub unit: PageUnit,
 138     /// The page's centre in the scene, world units.
 139     pub origin: [f32; 3],
 140 }
 141 
 142 impl PageFrame {
 143     /// The frame of a sheet `size` inches at `dpi`, clamped as a page is.
 144     ///
 145     /// The size is clamped to something a printer could accept rather than
 146     /// rejected: a page node whose size parameter is being dragged passes
 147     /// through zero, and a context that returns an error there flickers.
 148     pub fn new(size: [f32; 2], dpi: u32) -> PageFrame {
 149         let size = [size[0].max(0.01), size[1].max(0.01)];
 150         let dpi = dpi.clamp(1, 2400);
 151         let mut width = (size[0] * dpi as f32).round().max(1.0) as u32;
 152         let mut height = (size[1] * dpi as f32).round().max(1.0) as u32;
 153         if width as u64 * height as u64 > MAX_PIXELS {
 154             // Scale both axes by the same factor so the aspect — the thing the
 155             // page size actually means — survives the clamp.
 156             let scale = (MAX_PIXELS as f64 / (width as f64 * height as f64)).sqrt();
 157             width = ((width as f64 * scale) as u32).max(1);
 158             height = ((height as f64 * scale) as u32).max(1);
 159         }
 160         PageFrame { size, dpi, width, height, unit: PageUnit::Inches, origin: [0.0; 3] }
 161     }
 162 
 163     /// Pixels per inch, measured from the raster.
 164     pub fn scale(&self) -> f32 {
 165         self.width as f32 / self.size[0]
 166     }
 167 
 168     /// World units to the inch, in a world of `world_unit_mm` millimetres.
 169     fn world_per_inch(world_unit_mm: f32) -> f32 {
 170         25.4 / world_unit_mm.max(1e-6)
 171     }
 172 
 173     /// A place on the page, in the page's unit from its top-left corner, as
 174     /// a place in the scene. The page's y runs down and the world's up.
 175     pub fn to_world(&self, at: [f32; 2], world_unit_mm: f32) -> glam::Vec3 {
 176         let k = Self::world_per_inch(world_unit_mm);
 177         let s = self.scale();
 178         let x = self.unit.to_inches(at[0], s) - self.size[0] * 0.5;
 179         let y = self.size[1] * 0.5 - self.unit.to_inches(at[1], s);
 180         glam::Vec3::new(self.origin[0] + x * k, self.origin[1] + y * k, self.origin[2])
 181     }
 182 
 183     /// The other way. A place off the page's plane is the place on it
 184     /// straight behind: the page faces +Z, and its depth says nothing.
 185     pub fn from_world(&self, p: glam::Vec3, world_unit_mm: f32) -> [f32; 2] {
 186         let k = Self::world_per_inch(world_unit_mm);
 187         let s = self.scale();
 188         let x = (p.x - self.origin[0]) / k + self.size[0] * 0.5;
 189         let y = self.size[1] * 0.5 - (p.y - self.origin[1]) / k;
 190         [self.unit.from_inches(x, s), self.unit.from_inches(y, s)]
 191     }
 192 
 193     /// A number for a row in the page's unit: whole pixels, and thousandths
 194     /// of anything longer.
 195     pub fn row(&self, value: f32) -> String {
 196         row_text(self.unit, value)
 197     }
 198 }
 199 
 200 /// A number for a row in `unit`: whole pixels, and thousandths of anything
 201 /// longer ([`PageFrame::row`]).
 202 pub fn row_text(unit: PageUnit, value: f32) -> String {
 203     if unit == PageUnit::Pixels {
 204         format!("{}", value.round())
 205     } else {
 206         let s = format!("{value:.3}");
 207         let s = s.trim_end_matches('0');
 208         // Two decimals at the least, as the templates write them.
 209         let decimals = s.len() - s.find('.').map_or(s.len(), |i| i + 1);
 210         format!("{s}{}", "0".repeat(2usize.saturating_sub(decimals)))
 211     }
 212 }
 213 
 214 /// The largest page anyone composes by accident: a 1000 DPI A0 sheet is about
 215 /// 1.4 gigapixels, and the honest failure is a clamped resolution rather than
 216 /// an allocation that takes the app down. Chosen as roughly 13 × 19 inches (a
 217 /// large-format print) at 1200 DPI.
 218 const MAX_PIXELS: u64 = 356_000_000;
 219 
 220 impl Page {
 221     /// A blank sheet filled with `color`, clamped as [`PageFrame::new`]
 222     /// clamps it.
 223     pub fn new(size: [f32; 2], dpi: u32, color: [f32; 4]) -> Page {
 224         Page::blank(PageFrame::new(size, dpi), color)
 225     }
 226 
 227     /// The blank sheet a frame describes.
 228     pub fn blank(frame: PageFrame, color: [f32; 4]) -> Page {
 229         Page {
 230             size: frame.size,
 231             dpi: frame.dpi,
 232             width: frame.width,
 233             height: frame.height,
 234             pixels: vec![color; (frame.width * frame.height) as usize],
 235             unit: frame.unit,
 236             origin: frame.origin,
 237         }
 238     }
 239 
 240     /// A length written in the page's unit, in inches.
 241     pub fn len(&self, value: f32) -> f32 {
 242         self.unit.to_inches(value, self.scale())
 243     }
 244 
 245     /// Inches as a length in the page's unit — what a node's row would say.
 246     pub fn in_unit(&self, inches: f32) -> f32 {
 247         self.unit.from_inches(inches, self.scale())
 248     }
 249 
 250     /// Pixels per inch as a float, measured from the raster rather than read
 251     /// from `dpi` — after a clamp those disagree, and every drawing operation
 252     /// wants the one that describes the pixels it is about to touch.
 253     pub fn scale(&self) -> f32 {
 254         self.width as f32 / self.size[0]
 255     }
 256 
 257     /// Paint the whole sheet.
 258     pub fn fill(&mut self, color: [f32; 4]) {
 259         for p in &mut self.pixels {
 260             *p = color;
 261         }
 262     }
 263 
 264     /// An axis-aligned rectangle in INCHES from the top-left corner.
 265     ///
 266     /// Coverage is exact area, not a coin flip on the pixel centre. A printed
 267     /// grid is mostly hairlines — a 0.01 inch rule at 300 DPI is three pixels,
 268     /// at 100 DPI is one — and a binary fill makes every line snap to whole
 269     /// pixels, so a ruled sheet comes out with lines alternating between one
 270     /// and two pixels wide down its length. The eye reads that as a wobble in
 271     /// the paper, not as aliasing, and it survives printing.
 272     pub fn rect(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: [f32; 4]) {
 273         let s = self.scale();
 274         let (px0, px1) = (x0.min(x1) * s, x0.max(x1) * s);
 275         let (py0, py1) = (y0.min(y1) * s, y0.max(y1) * s);
 276         if px1 <= 0.0 || py1 <= 0.0 || px0 >= self.width as f32 || py0 >= self.height as f32 {
 277             return;
 278         }
 279         let i0 = px0.floor().max(0.0) as u32;
 280         let j0 = py0.floor().max(0.0) as u32;
 281         let i1 = (px1.ceil() as i64).clamp(0, self.width as i64) as u32;
 282         let j1 = (py1.ceil() as i64).clamp(0, self.height as i64) as u32;
 283         for j in j0..j1 {
 284             let cy = (py1.min(j as f32 + 1.0) - py0.max(j as f32)).clamp(0.0, 1.0);
 285             if cy <= 0.0 {
 286                 continue;
 287             }
 288             for i in i0..i1 {
 289                 let cx = (px1.min(i as f32 + 1.0) - px0.max(i as f32)).clamp(0.0, 1.0);
 290                 if cx > 0.0 {
 291                     self.blend(i, j, color, cx * cy);
 292                 }
 293             }
 294         }
 295     }
 296 
 297     /// `color` over the pixel, its alpha scaled by `coverage`.
 298     fn blend(&mut self, x: u32, y: u32, color: [f32; 4], coverage: f32) {
 299         let a = color[3] * coverage;
 300         if a <= 0.0 {
 301             return;
 302         }
 303         let dst = &mut self.pixels[(y * self.width + x) as usize];
 304         let out_a = a + dst[3] * (1.0 - a);
 305         if out_a <= 0.0 {
 306             *dst = [0.0; 4];
 307             return;
 308         }
 309         for c in 0..3 {
 310             // Straight alpha, so the destination's contribution is weighted by
 311             // its own alpha and the result divided back out.
 312             dst[c] = (color[c] * a + dst[c] * dst[3] * (1.0 - a)) / out_a;
 313         }
 314         dst[3] = out_a;
 315     }
 316 
 317     /// A ruled grid: cells of `cell` inches filled with `cell_color`, ruled
 318     /// with lines `thickness` inches wide in `line_color`.
 319     ///
 320     /// Lines are centred ON their coordinate, not placed beside it, so a grid
 321     /// and a second grid at twice the cell size share their rules exactly
 322     /// instead of straddling them — which is the whole point of drawing two.
 323     /// The sheet's own edges are ruled too: a grid that stops one line short
 324     /// looks like a mistake rather than a margin.
 325     pub fn grid(&mut self, cell: f32, thickness: f32, cell_color: [f32; 4], line_color: [f32; 4]) {
 326         if cell_color[3] > 0.0 {
 327             self.rect(0.0, 0.0, self.size[0], self.size[1], cell_color);
 328         }
 329         let cell = cell.max(1.0 / self.scale());
 330         let half = (thickness * 0.5).max(0.25 / self.scale());
 331         let mut x = 0.0;
 332         while x <= self.size[0] + 1e-4 {
 333             self.rect(x - half, 0.0, x + half, self.size[1], line_color);
 334             x += cell;
 335         }
 336         let mut y = 0.0;
 337         while y <= self.size[1] + 1e-4 {
 338             self.rect(0.0, y - half, self.size[0], y + half, line_color);
 339             y += cell;
 340         }
 341     }
 342 
 343     /// A border `width` inches wide, drawn INSIDE the sheet's edge.
 344     ///
 345     /// Inside rather than centred on the edge, because half a border off the
 346     /// paper is half a border: the printed page has no bleed here, and a
 347     /// parameter that says 0.5 inches should put 0.5 inches of ink on the
 348     /// sheet.
 349     pub fn border(&mut self, width: f32, inset: f32, color: [f32; 4]) {
 350         let (w, h) = (self.size[0], self.size[1]);
 351         let width = width.max(0.0).min(w.min(h) * 0.5);
 352         let (a, b) = (inset, inset + width);
 353         self.rect(a, a, w - a, b, color);
 354         self.rect(a, h - b, w - a, h - a, color);
 355         self.rect(a, b, b, h - b, color);
 356         self.rect(w - b, b, w - a, h - b, color);
 357     }
 358 
 359     /// The page as 8-bit sRGB RGBA, row-major from the top — what both the GPU
 360     /// upload and the PNG encoder want.
 361     pub fn to_rgba8(&self) -> Vec<u8> {
 362         // Filled in place rather than pushed, and clamped by the cast: a
 363         // float-to-int `as` saturates (NaN to 0), which is exactly what a
 364         // clamp to 0..=1 then gave, and leaves the loop free to vectorize.
 365         // The push-and-clamp version was ~40 ms for Letter at 300 DPI.
 366         let mut out = vec![0u8; self.pixels.len() * 4];
 367         for (o, p) in out.chunks_exact_mut(4).zip(&self.pixels) {
 368             for c in 0..4 {
 369                 o[c] = (p[c] * 255.0 + 0.5) as u8;
 370             }
 371         }
 372         out
 373     }
 374 
 375     /// Write a PNG that knows its own physical size.
 376     ///
 377     /// The pHYs chunk carries pixels per METRE, which is the only unit PNG
 378     /// offers — so the DPI round-trips through a conversion and comes back a
 379     /// hair off. That is the format's limit, not a bug to chase: 300 DPI
 380     /// stores as 11811 px/m and reads back as 299.9994.
 381     pub fn write_png(&self, path: &Path) -> Result<(), String> {
 382         let file = std::fs::File::create(path)
 383             .map_err(|e| format!("create {}: {e}", path.display()))?;
 384         let mut encoder =
 385             png::Encoder::new(std::io::BufWriter::new(file), self.width, self.height);
 386         encoder.set_color(png::ColorType::Rgba);
 387         encoder.set_depth(png::BitDepth::Eight);
 388         mark_srgb(&mut encoder);
 389         let per_metre = (self.scale() * 39.370_08).round() as u32;
 390         encoder.set_pixel_dims(Some(png::PixelDimensions {
 391             xppu: per_metre,
 392             yppu: per_metre,
 393             unit: png::Unit::Meter,
 394         }));
 395         let mut writer = encoder.write_header().map_err(|e| format!("png header: {e}"))?;
 396         writer
 397             .write_image_data(&self.to_rgba8())
 398             .map_err(|e| format!("png write: {e}"))?;
 399         writer.finish().map_err(|e| format!("png finish: {e}"))?;
 400         Ok(())
 401     }
 402 }
 403 
 404 /// The page the viewport is showing, without its pixels: enough to place
 405 /// the image in the scene and to fit a camera to it.
 406 #[derive(Clone, PartialEq, Debug)]
 407 pub struct PageShown {
 408     /// The displayed page node's id.
 409     pub node_id: String,
 410     /// Physical size in inches.
 411     pub size: [f32; 2],
 412     /// The raster's size in pixels.
 413     pub pixels: (u32, u32),
 414     /// The page's centre in the scene, world units.
 415     pub origin: [f32; 3],
 416 }
 417 
 418 impl PageShown {
 419     /// The image's size in the scene, in world units of `world_unit_mm`
 420     /// millimetres each. The World Unit is a declaration, and a page is the
 421     /// one thing in the scene that knows its own physical size, so this is
 422     /// the one place a length is converted INTO world units.
 423     pub fn world_size(&self, world_unit_mm: f32) -> [f32; 2] {
 424         let k = 25.4 / world_unit_mm.max(1e-6);
 425         [self.size[0] * k, self.size[1] * k]
 426     }
 427 
 428     /// The image's corners in the scene — top-left, top-right, bottom-right,
 429     /// bottom-left — standing in the XY plane about its origin and facing
 430     /// +Z. The image's y runs down and the world's up, so the top edge is +Y.
 431     pub fn world_corners(&self, world_unit_mm: f32) -> [[f32; 3]; 4] {
 432         let [w, h] = self.world_size(world_unit_mm);
 433         let [x, y, z] = self.origin;
 434         let (hw, hh) = (w * 0.5, h * 0.5);
 435         [
 436             [x - hw, y + hh, z],
 437             [x + hw, y + hh, z],
 438             [x + hw, y - hh, z],
 439             [x - hw, y - hh, z],
 440         ]
 441     }
 442 }
 443 
 444 /// Which outline a shape is.
 445 #[derive(Clone, Copy, PartialEq, Debug)]
 446 pub enum ShapeKind {
 447     Rectangle,
 448     Ellipse,
 449     /// A straight stroke `size[0]` long, drawn in the stroke colour at the
 450     /// stroke width, through the centre.
 451     Line,
 452     /// A polygon of `sides` corners on the ellipse the box holds, the first
 453     /// at the top.
 454     Polygon,
 455 }
 456 
 457 impl ShapeKind {
 458     pub fn parse(name: &str) -> ShapeKind {
 459         match name.trim().to_ascii_lowercase().as_str() {
 460             "ellipse" => ShapeKind::Ellipse,
 461             "line" => ShapeKind::Line,
 462             "polygon" => ShapeKind::Polygon,
 463             _ => ShapeKind::Rectangle,
 464         }
 465     }
 466 }
 467 
 468 /// One shape, in INCHES from the page's top-left corner, as every drawing
 469 /// operation here is; the node converts from the page's unit.
 470 pub struct ShapeSpec {
 471     pub kind: ShapeKind,
 472     pub center: [f32; 2],
 473     /// The box the shape fills, before it is turned.
 474     pub size: [f32; 2],
 475     /// Degrees, clockwise as the page is looked at.
 476     pub rotation: f32,
 477     /// Rectangle only.
 478     pub corner_radius: f32,
 479     /// Polygon only.
 480     pub sides: u32,
 481     /// None draws no fill.
 482     pub fill: Option<[f32; 4]>,
 483     /// Colour and width; the stroke is centred on the outline, half of it
 484     /// inside the shape and half out, as a drawing program's is.
 485     pub stroke: Option<([f32; 4], f32)>,
 486 }
 487 
 488 /// Signed distance from `p` to a closed polygon, negative inside.
 489 fn polygon_distance(p: [f32; 2], corners: &[[f32; 2]]) -> f32 {
 490     let n = corners.len();
 491     let mut d = f32::MAX;
 492     let mut inside = false;
 493     for i in 0..n {
 494         let (a, b) = (corners[i], corners[(i + 1) % n]);
 495         let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
 496         let (wx, wy) = (p[0] - a[0], p[1] - a[1]);
 497         let t = ((wx * ex + wy * ey) / (ex * ex + ey * ey).max(1e-12)).clamp(0.0, 1.0);
 498         let (dx, dy) = (wx - ex * t, wy - ey * t);
 499         d = d.min(dx * dx + dy * dy);
 500         // Even-odd crossing of the ray to the right of `p`.
 501         if (a[1] > p[1]) != (b[1] > p[1]) {
 502             let x = a[0] + (p[1] - a[1]) / (b[1] - a[1]) * ex;
 503             if x > p[0] {
 504                 inside = !inside;
 505             }
 506         }
 507     }
 508     if inside { -d.sqrt() } else { d.sqrt() }
 509 }
 510 
 511 impl Page {
 512     /// Draw one shape onto the page.
 513     ///
 514     /// Coverage comes from the signed DISTANCE to the outline, in pixels: a
 515     /// pixel whose centre is half a pixel inside is covered, half a pixel
 516     /// outside is not, and between them it is covered in proportion. That is
 517     /// what makes a turned rectangle's edge, and a circle's, a clean line
 518     /// where a test of the pixel centre leaves a staircase.
 519     pub fn shape(&mut self, spec: &ShapeSpec) {
 520         let s = self.scale();
 521         let (cx, cy) = (spec.center[0] * s, spec.center[1] * s);
 522         let line = spec.kind == ShapeKind::Line;
 523         // A line is its stroke: a box as long as the line and as thick as
 524         // the stroke is wide, filled with the stroke's colour.
 525         let (fill, stroke) = if line {
 526             (spec.stroke.map(|(c, _)| c), None)
 527         } else {
 528             (spec.fill, spec.stroke.filter(|(_, w)| *w > 0.0))
 529         };
 530         let thickness = spec.stroke.map(|(_, w)| w).unwrap_or(0.0) * s;
 531         let hw = (spec.size[0].abs() * s * 0.5).max(0.0);
 532         let hh = if line { (thickness * 0.5).max(0.5) } else { spec.size[1].abs() * s * 0.5 };
 533         if fill.is_none() && stroke.is_none() {
 534             return;
 535         }
 536         let stroke_half = stroke.map(|(_, w)| w * s * 0.5).unwrap_or(0.0);
 537 
 538         let corners: Vec<[f32; 2]> = if spec.kind == ShapeKind::Polygon {
 539             let n = spec.sides.clamp(3, 64);
 540             (0..n)
 541                 .map(|i| {
 542                     let a = std::f32::consts::TAU * i as f32 / n as f32;
 543                     [hw * a.sin(), -hh * a.cos()]
 544                 })
 545                 .collect()
 546         } else {
 547             Vec::new()
 548         };
 549         let radius = spec.corner_radius.max(0.0).min(spec.size[0].abs().min(spec.size[1].abs()) * 0.5) * s;
 550         let distance = |p: [f32; 2]| -> f32 {
 551             match spec.kind {
 552                 ShapeKind::Rectangle | ShapeKind::Line => {
 553                     let r = if line { 0.0 } else { radius };
 554                     let (qx, qy) = (p[0].abs() - (hw - r), p[1].abs() - (hh - r));
 555                     (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt() + qx.max(qy).min(0.0) - r
 556                 }
 557                 ShapeKind::Ellipse => {
 558                     let (a, b) = (hw.max(1e-3), hh.max(1e-3));
 559                     let k0 = ((p[0] / a).powi(2) + (p[1] / b).powi(2)).sqrt();
 560                     let k1 = ((p[0] / (a * a)).powi(2) + (p[1] / (b * b)).powi(2)).sqrt();
 561                     if k1 < 1e-9 { -a.min(b) } else { k0 * (k0 - 1.0) / k1 }
 562                 }
 563                 ShapeKind::Polygon => polygon_distance(p, &corners),
 564             }
 565         };
 566 
 567         let (sin, cos) = spec.rotation.to_radians().sin_cos();
 568         let reach = (hw * hw + hh * hh).sqrt() + stroke_half + 1.0;
 569         let i0 = (cx - reach).floor().max(0.0) as i64;
 570         let j0 = (cy - reach).floor().max(0.0) as i64;
 571         let i1 = ((cx + reach).ceil() as i64).min(self.width as i64);
 572         let j1 = ((cy + reach).ceil() as i64).min(self.height as i64);
 573         for j in j0..j1 {
 574             for i in i0..i1 {
 575                 let (dx, dy) = (i as f32 + 0.5 - cx, j as f32 + 0.5 - cy);
 576                 // Into the shape's own frame: the page turned back.
 577                 let p = [dx * cos + dy * sin, -dx * sin + dy * cos];
 578                 let d = distance(p);
 579                 if let Some(color) = fill {
 580                     let c = (0.5 - d).clamp(0.0, 1.0);
 581                     if c > 0.0 {
 582                         self.blend(i as u32, j as u32, color, c);
 583                     }
 584                 }
 585                 if let Some((color, _)) = stroke {
 586                     let c = (0.5 - (d.abs() - stroke_half)).clamp(0.0, 1.0);
 587                     if c > 0.0 {
 588                         self.blend(i as u32, j as u32, color, c);
 589                     }
 590                 }
 591             }
 592         }
 593     }
 594 }
 595 
 596 /// Where a run of text sits in the box it is given.
 597 #[derive(Clone, Copy, PartialEq, Debug)]
 598 pub enum HAlign {
 599     Left,
 600     Center,
 601     Right,
 602 }
 603 
 604 #[derive(Clone, Copy, PartialEq, Debug)]
 605 pub enum VAlign {
 606     Top,
 607     Middle,
 608     Bottom,
 609 }
 610 
 611 /// Everything the text operation needs. A struct rather than a dozen arguments
 612 /// because the node has a dozen parameters and threading them positionally is
 613 /// how the wrong two get swapped.
 614 pub struct TextSpec<'a> {
 615     pub text: &'a str,
 616     pub font: &'a str,
 617     /// Cap height in inches — a "0.1 font size" on a printed page means a tenth
 618     /// of an inch of type, not a tenth of a pixel or of the sheet.
 619     pub size: f32,
 620     pub color: [f32; 4],
 621     /// Where the text box's own origin sits on the sheet, in inches.
 622     pub at: [f32; 2],
 623     pub halign: HAlign,
 624     pub valign: VAlign,
 625     /// Line spacing as a multiple of the font size.
 626     pub leading: f32,
 627 }
 628 
 629 impl Default for TextSpec<'_> {
 630     fn default() -> Self {
 631         TextSpec {
 632             text: "",
 633             font: "",
 634             size: 0.1,
 635             color: [0.0, 0.0, 0.0, 1.0],
 636             at: [0.5, 0.5],
 637             halign: HAlign::Left,
 638             valign: VAlign::Top,
 639             leading: 1.25,
 640         }
 641     }
 642 }
 643 
 644 impl Page {
 645     /// Draw shaped text onto the sheet.
 646     ///
 647     /// Shaping and rasterizing both come from cosmic-text, which the toolkit
 648     /// already owns — the alternative is a second font stack in the same
 649     /// process disagreeing with the first about what a font is called.
 650     ///
 651     /// The size is converted to pixels here, at the page's own scale, so the
 652     /// same page composed at 150 and at 600 DPI prints identical type at
 653     /// different sample counts. That is the property that makes resolution a
 654     /// page parameter rather than an export one.
 655     pub fn text(
 656         &mut self,
 657         fonts: &mut cce_ui::cosmic_text::FontSystem,
 658         cache: &mut cce_ui::cosmic_text::SwashCache,
 659         spec: &TextSpec,
 660     ) {
 661         use cce_ui::cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};
 662         if spec.text.is_empty() || spec.size <= 0.0 {
 663             return;
 664         }
 665         let px = (spec.size * self.scale()).max(1.0);
 666         let mut buffer = Buffer::new(fonts, Metrics::new(px, px * spec.leading.max(0.1)));
 667         // No width or height limit: the box is measured from the shaped text
 668         // rather than the text wrapped into a box. A printed label that
 669         // silently wraps is worse than one that runs long, because the run-on
 670         // is visible and the wrap looks deliberate.
 671         buffer.set_size(fonts, None, None);
 672         let attrs = if spec.font.trim().is_empty() {
 673             Attrs::new()
 674         } else {
 675             Attrs::new().family(Family::Name(spec.font.trim()))
 676         };
 677         buffer.set_text(fonts, spec.text, attrs, Shaping::Advanced);
 678         buffer.shape_until_scroll(fonts, false);
 679 
 680         // Measure what was actually shaped, so alignment is against the ink
 681         // rather than against the requested size.
 682         let mut text_w: f32 = 0.0;
 683         let mut lines = 0.0f32;
 684         for run in buffer.layout_runs() {
 685             text_w = text_w.max(run.line_w);
 686             lines += 1.0;
 687         }
 688         let line_h = px * spec.leading.max(0.1);
 689         let text_h = lines.max(1.0) * line_h;
 690 
 691         let ox = spec.at[0] * self.scale()
 692             - match spec.halign {
 693                 HAlign::Left => 0.0,
 694                 HAlign::Center => text_w * 0.5,
 695                 HAlign::Right => text_w,
 696             };
 697         let oy = spec.at[1] * self.scale()
 698             - match spec.valign {
 699                 VAlign::Top => 0.0,
 700                 VAlign::Middle => text_h * 0.5,
 701                 VAlign::Bottom => text_h,
 702             };
 703 
 704         let color = cce_ui::cosmic_text::Color::rgba(255, 255, 255, 255);
 705         let (w, h) = (self.width as i32, self.height as i32);
 706         let mut hits: Vec<(u32, u32, f32)> = Vec::new();
 707         buffer.draw(fonts, cache, color, |x, y, gw, gh, c| {
 708             // cosmic-text hands back a filled rect per span; the alpha is the
 709             // glyph's coverage. The colour it carries is the one passed in,
 710             // which is why that is opaque white — the page's own colour is
 711             // applied here, so a coloured glyph is not double-tinted.
 712             let a = c.a() as f32 / 255.0;
 713             if a <= 0.0 {
 714                 return;
 715             }
 716             for dy in 0..gh as i32 {
 717                 for dx in 0..gw as i32 {
 718                     let (px, py) = (x + dx + ox as i32, y + dy + oy as i32);
 719                     if px >= 0 && py >= 0 && px < w && py < h {
 720                         hits.push((px as u32, py as u32, a));
 721                     }
 722                 }
 723             }
 724         });
 725         for (x, y, a) in hits {
 726             self.blend(x, y, spec.color, a);
 727         }
 728     }
 729 }
 730 
 731 // ---------------------------------------------------------------------------
 732 // The node chain
 733 // ---------------------------------------------------------------------------
 734 
 735 use crate::app::FsNode;
 736 use crate::geometry::{node_param_f32, node_param_str, node_param_vec3};
 737 use glam::Vec3;
 738 
 739 /// Whether a node belongs to the page context rather than the geometry graph.
 740 ///
 741 /// The two do not mix, and this is the one place that says so. A page node
 742 /// contributes nothing to the viewport's geometry and a geometry node cannot
 743 /// feed a page, so the network is really two networks sharing an editor — the
 744 /// same way Houdini's contexts do, and for the same reason: a raster and a
 745 /// mesh have no operation in common.
 746 pub fn is_page_node(node_type: &str) -> bool {
 747     matches!(
 748         node_type.to_ascii_lowercase().as_str(),
 749         "page" | "page_grid" | "page_border" | "page_text" | "page_shape"
 750     )
 751 }
 752 
 753 /// Named sheet sizes, in inches, portrait.
 754 ///
 755 /// A4 is metric and converts to 8.268 × 11.693 — carried at that precision
 756 /// rather than rounded, because a rounded A4 prints with a visible margin
 757 /// error at the bottom of the sheet.
 758 fn preset_size(name: &str) -> Option<[f32; 2]> {
 759     match name.trim().to_ascii_lowercase().as_str() {
 760         "letter" => Some([8.5, 11.0]),
 761         "a4" => Some([8.267_717, 11.692_913]),
 762         "legal" => Some([8.5, 14.0]),
 763         "tabloid" => Some([11.0, 17.0]),
 764         _ => None,
 765     }
 766 }
 767 
 768 /// Named raster sizes, in PIXELS, landscape as screens are. What such an
 769 /// image measures in inches is these over the page's Resolution.
 770 fn preset_pixels(name: &str) -> Option<[f32; 2]> {
 771     match name.trim().to_ascii_lowercase().as_str() {
 772         "hd" => Some([1920.0, 1080.0]),
 773         "4k" => Some([3840.0, 2160.0]),
 774         "square" => Some([1024.0, 1024.0]),
 775         _ => None,
 776     }
 777 }
 778 
 779 /// A colour row and an opacity row as one straight-alpha colour.
 780 fn color_with(node: &FsNode, name: &str, fallback: Vec3, opacity: &str) -> [f32; 4] {
 781     let c = node_param_vec3(node, name, fallback);
 782     [c.x, c.y, c.z, node_param_f32(node, opacity, 1.0).clamp(0.0, 1.0)]
 783 }
 784 
 785 fn color_of(node: &FsNode, name: &str, fallback: Vec3) -> [f32; 4] {
 786     let c = node_param_vec3(node, name, fallback);
 787     [c.x, c.y, c.z, 1.0]
 788 }
 789 
 790 fn toggle_of(node: &FsNode, name: &str) -> bool {
 791     crate::geometry::node_param_bool(node, name, false)
 792 }
 793 
 794 /// The frame a `page` node describes: Width by Height in its Units. The
 795 /// Preset and Units rows do not enter into it — they set Width and Height
 796 /// when they are picked ([`follow_page_rows`]).
 797 fn page_node_frame(target: &FsNode) -> PageFrame {
 798     let unit = PageUnit::parse(&node_param_str(target, "units", "Inches"));
 799     let dpi = page_dpi(target);
 800     let size = [
 801         unit.to_inches(node_param_f32(target, "width", 8.5), dpi),
 802         unit.to_inches(node_param_f32(target, "height", 11.0), dpi),
 803     ];
 804     let mut frame = PageFrame::new(size, dpi as u32);
 805     frame.unit = unit;
 806     frame.origin = node_param_vec3(target, "position", Vec3::ZERO).to_array();
 807     frame
 808 }
 809 
 810 fn page_dpi(node: &FsNode) -> f32 {
 811     node_param_f32(node, "resolution", 300.0).round().clamp(1.0, 2400.0)
 812 }
 813 
 814 /// A preset's size in inches: a sheet portrait, or turned by `landscape`
 815 /// (which only a save from before carries), a raster size as it lies, at
 816 /// `dpi`.
 817 fn preset_inches(name: &str, dpi: f32, landscape: bool) -> Option<[f32; 2]> {
 818     if let Some([w, h]) = preset_size(name) {
 819         return Some(if landscape { [h, w] } else { [w, h] });
 820     }
 821     preset_pixels(name).map(|[w, h]| [w / dpi, h / dpi])
 822 }
 823 
 824 /// What a `page` node's Width and Height become when one of the rows that
 825 /// SET them was just changed, `was` being that row as it stood before:
 826 ///
 827 /// - **Preset** writes the preset's size, in the node's Units: a sheet
 828 ///   portrait, a raster size (HD, 4K, Square) as it lies. A landscape
 829 ///   sheet is its Width and Height typed the other way round; there is no
 830 ///   Orientation row.
 831 /// - **Units** converts them from the old unit, so the sheet keeps its
 832 ///   size and only the numbers change.
 833 ///
 834 /// A Width or Height that is an expression is left to it. Returns the rows
 835 /// rewritten, as they were, for undo; empty when `was` is neither or
 836 /// nothing moved. There is no Custom preset: a size typed into Width and
 837 /// Height is the size, and Preset names what was last picked.
 838 pub fn follow_page_rows(node: &mut FsNode, was: &crate::app::ParamDef) -> Vec<crate::app::ParamDef> {
 839     follow(node, was, false)
 840 }
 841 
 842 fn follow(node: &mut FsNode, was: &crate::app::ParamDef, landscape: bool) -> Vec<crate::app::ParamDef> {
 843     let unit = PageUnit::parse(&node_param_str(node, "units", "Inches"));
 844     let dpi = page_dpi(node);
 845     let (w, h) = (node_param_f32(node, "width", 8.5), node_param_f32(node, "height", 11.0));
 846     let (nw, nh) = match was.name.as_str() {
 847         "preset" => {
 848             let preset = node_param_str(node, "preset", "Letter");
 849             let Some([iw, ih]) = preset_inches(&preset, dpi, landscape) else { return Vec::new() };
 850             (unit.from_inches(iw, dpi), unit.from_inches(ih, dpi))
 851         }
 852         "units" => {
 853             let old = PageUnit::parse(was.text());
 854             (unit.from_inches(old.to_inches(w, dpi), dpi), unit.from_inches(old.to_inches(h, dpi), dpi))
 855         }
 856         _ => return Vec::new(),
 857     };
 858     let mut rewritten = Vec::new();
 859     for (name, value) in [("width", nw), ("height", nh)] {
 860         let text = row_text(unit, value);
 861         if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
 862             if !p.is_expr() && p.text() != text {
 863                 rewritten.push(p.clone());
 864                 p.set_text(text);
 865             }
 866         }
 867     }
 868     rewritten
 869 }
 870 
 871 /// A `page` node from before Width and Height were always the size: its
 872 /// named preset is written into them once, a sheet turned as its
 873 /// Orientation row said, and a Custom one — whose Width and Height already
 874 /// were the size — names Letter. Told apart by the Width row's
 875 /// `show_when`, which such a save carries as `Preset == Custom` until the
 876 /// template merge replaces it. The Orientation row itself goes, from this
 877 /// save and any other: a page's orientation is its Width and Height.
 878 pub fn migrate_preset_rows(node: &mut FsNode) {
 879     let old = node.params.iter().any(|p| p.name == "width" && p.show_when.contains("Custom"));
 880     let landscape = node_param_str(node, "orientation", "Portrait").eq_ignore_ascii_case("Landscape");
 881     node.params.retain(|p| p.name != "orientation");
 882     if !old {
 883         return;
 884     }
 885     let Some(preset) = node.params.iter().find(|p| p.name == "preset").cloned() else { return };
 886     if preset.text().trim().eq_ignore_ascii_case("custom") {
 887         if let Some(p) = node.params.iter_mut().find(|p| p.name == "preset") {
 888             p.set_text("Letter".to_string());
 889         }
 890     } else {
 891         follow(node, &preset, landscape);
 892     }
 893 }
 894 
 895 /// The frame of the page `target` draws on: [`resolve_page`]'s walk up the
 896 /// chain, without the drawing. None where that would compose nothing — no
 897 /// page at the bottom, or a wire that comes back to itself.
 898 pub fn resolve_frame(root: &FsNode, target: &FsNode) -> Option<PageFrame> {
 899     let mut visited: Vec<&str> = Vec::new();
 900     let mut node = target;
 901     loop {
 902         if visited.contains(&node.id.as_str()) {
 903             return None;
 904         }
 905         visited.push(&node.id);
 906         let kind = node.node_type.to_ascii_lowercase();
 907         if kind == "page" && !crate::geometry::is_bypassed(node) {
 908             return Some(page_node_frame(node));
 909         }
 910         if !is_page_node(&kind) && kind != "export" {
 911             return None;
 912         }
 913         node = crate::geometry::param_node(root, node, "input")?;
 914     }
 915 }
 916 
 917 /// Compose the page `target` describes, resolving its input chain.
 918 ///
 919 /// `visited` guards cycles by node id exactly as the geometry resolvers do —
 920 /// the page context is a second graph, not a second kind of graph.
 921 pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Page> {
 922     if visited.contains(&target.id) {
 923         return None;
 924     }
 925     visited.push(target.id.clone());
 926 
 927     // Bypassed, a page node passes the sheet it was handed, and a sheet that
 928     // is bypassed is no sheet.
 929     if crate::geometry::is_bypassed(target) {
 930         let input = crate::geometry::param_node(root, target, "input")?;
 931         return resolve_page(root, input, visited);
 932     }
 933 
 934     let kind = target.node_type.to_ascii_lowercase();
 935     if kind == "page" {
 936         return Some(Page::blank(
 937             page_node_frame(target),
 938             color_with(target, "color", Vec3::ONE, "opacity"),
 939         ));
 940     }
 941 
 942     // Everything else composites onto its input, so a chain with no page at
 943     // the bottom of it has nothing to draw on and resolves to nothing. That is
 944     // the honest answer: a border with no page is not a page with a border —
 945     // and it is also how an Export node in a GEOMETRY chain falls through to
 946     // the geometry resolvers rather than being claimed by this one.
 947     // Sibling-first like every geometry wire; a whole-tree search by name
 948     // found the first same-named page node anywhere.
 949     let input = crate::geometry::param_node(root, target, "input")?;
 950     let mut page = resolve_page(root, input, visited)?;
 951 
 952     match kind.as_str() {
 953         // Export belongs to neither context and passes through both. What it
 954         // writes is decided by what reaches it: a page becomes a PNG, geometry
 955         // becomes the mesh format its Format parameter names.
 956         "export" => {}
 957         "page_grid" => {
 958             let cell_color = if toggle_of(target, "fill_cells") {
 959                 color_of(target, "cell_color", Vec3::ONE)
 960             } else {
 961                 [0.0; 4]
 962             };
 963             page.grid(
 964                 page.len(node_param_f32(target, "cell_size", 0.25)),
 965                 page.len(node_param_f32(target, "line_width", 0.01)),
 966                 cell_color,
 967                 color_of(target, "line_color", Vec3::ZERO),
 968             );
 969         }
 970         "page_border" => page.border(
 971             page.len(node_param_f32(target, "width", 0.06)),
 972             page.len(node_param_f32(target, "inset", 0.4)),
 973             color_of(target, "color", Vec3::ZERO),
 974         ),
 975         "page_shape" => {
 976             let spec = ShapeSpec {
 977                 kind: ShapeKind::parse(&node_param_str(target, "shape", "Rectangle")),
 978                 center: [
 979                     page.len(node_param_f32(target, "x", 0.0)),
 980                     page.len(node_param_f32(target, "y", 0.0)),
 981                 ],
 982                 size: [
 983                     page.len(node_param_f32(target, "width", 1.0)),
 984                     page.len(node_param_f32(target, "height", 1.0)),
 985                 ],
 986                 rotation: node_param_f32(target, "rotation", 0.0),
 987                 corner_radius: page.len(node_param_f32(target, "corner_radius", 0.0)),
 988                 sides: node_param_f32(target, "sides", 3.0).round().max(3.0) as u32,
 989                 fill: toggle_of(target, "fill")
 990                     .then(|| color_with(target, "fill_color", Vec3::splat(0.5), "fill_opacity")),
 991                 stroke: toggle_of(target, "stroke").then(|| {
 992                     (
 993                         color_with(target, "stroke_color", Vec3::ZERO, "stroke_opacity"),
 994                         page.len(node_param_f32(target, "stroke_width", 0.02)),
 995                     )
 996                 }),
 997             };
 998             page.shape(&spec);
 999         }
1000         "page_text" => {
1001             let text = node_param_str(target, "text", "");
1002             let font = node_param_str(target, "font", "");
1003             let spec = TextSpec {
1004                 text: &text,
1005                 font: &font,
1006                 size: page.len(node_param_f32(target, "size", 0.25)),
1007                 color: color_of(target, "color", Vec3::ZERO),
1008                 at: [
1009                     page.len(node_param_f32(target, "x", 4.25)),
1010                     page.len(node_param_f32(target, "y", 0.8)),
1011                 ],
1012                 halign: match node_param_str(target, "horizontal", "Center").as_str() {
1013                     "Left" => HAlign::Left,
1014                     "Right" => HAlign::Right,
1015                     _ => HAlign::Center,
1016                 },
1017                 valign: match node_param_str(target, "vertical", "Top").as_str() {
1018                     "Middle" => VAlign::Middle,
1019                     "Bottom" => VAlign::Bottom,
1020                     _ => VAlign::Top,
1021                 },
1022                 leading: node_param_f32(target, "leading", 1.25),
1023             };
1024             with_fonts(|fonts, cache| page.text(fonts, cache, &spec));
1025         }
1026         _ => return None,
1027     }
1028     Some(page)
1029 }
1030 
1031 /// The page context's font stack, created once.
1032 ///
1033 /// System fonts included, because a page node names its font by family — the
1034 /// source family's default is "Lato" — and a font stack that only knows the
1035 /// bundled house faces would silently substitute for every named font a user
1036 /// actually owns. Shaping the app's own widgets stays on the toolkit's
1037 /// geometry font system; this one is for ink on paper.
1038 fn with_fonts<R>(
1039     f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
1040 ) -> R {
1041     use std::sync::{Mutex, OnceLock};
1042     type Stack = (cce_ui::cosmic_text::FontSystem, cce_ui::cosmic_text::SwashCache);
1043     static FONTS: OnceLock<Mutex<Stack>> = OnceLock::new();
1044     let stack = FONTS.get_or_init(|| {
1045         Mutex::new((
1046             cce_ui::create_font_system_with_system_fonts(),
1047             cce_ui::cosmic_text::SwashCache::new(),
1048         ))
1049     });
1050     let mut guard = stack.lock().unwrap();
1051     let (fonts, cache) = &mut *guard;
1052     f(fonts, cache)
1053 }
1054 
1055 /// A fingerprint of everything [`resolve_page`] reads to compose `target`:
1056 /// each node on its input chain — id, type, whether it is bypassed, and its
1057 /// parameters as a save writes them — down to the sheet at the bottom. Two
1058 /// equal keys compose the same page. Nothing in the page context reads the
1059 /// frame or the scene — its parameters are read as written, never through
1060 /// `resolve_param_refs` — so nothing else needs to be in it; a page node
1061 /// that starts evaluating expressions must put the frame in here too.
1062 pub fn chain_key(root: &FsNode, target: &FsNode) -> u64 {
1063     use std::hash::{Hash, Hasher};
1064     let mut h = std::collections::hash_map::DefaultHasher::new();
1065     let mut visited: Vec<&str> = Vec::new();
1066     let mut node = target;
1067     loop {
1068         if visited.contains(&node.id.as_str()) {
1069             break;
1070         }
1071         visited.push(&node.id);
1072         let kind = node.node_type.to_ascii_lowercase();
1073         let bypassed = crate::geometry::is_bypassed(node);
1074         (&node.id, &kind, bypassed).hash(&mut h);
1075         if let Ok(json) = serde_json::to_string(&node.params) {
1076             json.hash(&mut h);
1077         }
1078         // The sheet ends the chain; so does anything that is not a page
1079         // node, which composes nothing whatever is under it.
1080         if (kind == "page" && !bypassed) || (!is_page_node(&kind) && kind != "export") {
1081             break;
1082         }
1083         match crate::geometry::param_node(root, node, "input") {
1084             Some(input) => node = input,
1085             None => {
1086                 "no input".hash(&mut h);
1087                 break;
1088             }
1089         }
1090     }
1091     h.finish()
1092 }
1093 
1094 /// The page a network level displays, if it displays one.
1095 ///
1096 /// The same rule the viewport follows for geometry: draw what is visible at
1097 /// the level being shown. Several page chains at one level is ambiguous, so
1098 /// the LAST visible page node wins — the one furthest down the roster, which
1099 /// is the one most recently added.
1100 pub fn displayed_page(root: &FsNode, level: &FsNode) -> Option<Page> {
1101     resolve_page(root, displayed_page_node(level)?, &mut Vec::new())
1102 }
1103 
1104 /// The node [`displayed_page`] draws.
1105 pub fn displayed_page_node(level: &FsNode) -> Option<&FsNode> {
1106     level
1107         .children
1108         .iter()
1109         .filter(|c| is_page_node(&c.node_type) && c.geometry_visible)
1110         .next_back()
1111 }
1112 
1113 /// The font stack, for tests that draw text without a node behind them.
1114 #[cfg(test)]
1115 pub fn with_fonts_for_test<R>(
1116     f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
1117 ) -> R {
1118     with_fonts(f)
1119 }