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 }