graphic design tool
git clone https://git.lucas.co/cce-designer.git
src/page.rs (23K)
1 //! The 2D page context — a printed sheet, 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 coordinates are inches rather than
5 //! world units, its origin is the top-left corner with y running DOWN, and
6 //! nothing in it has a point id, an attribute or a normal. The geometry graph
7 //! describes a thing you will make; a page describes a thing you will print.
8 //! Smuggling one into the other means a `Detail` that is secretly a raster and
9 //! a viewport that has to guess which it is holding, so they stay apart: page
10 //! nodes resolve through [`resolve_page`], never through
11 //! `generate_single_node_geometry_with_errors`, and contribute no geometry to
12 //! the viewport at all.
13 //!
14 //! Inches, not millimetres, because the page's own reason for existing is
15 //! paper, and paper is specified in inches by the sources this came from (8.5 ×
16 //! 11 is the default everywhere in the family). The World Unit declaration that
17 //! governs the geometry graph does not reach here — a sheet is a sheet at any
18 //! model scale.
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 }
65
66 /// The largest page anyone composes by accident: a 1000 DPI A0 sheet is about
67 /// 1.4 gigapixels, and the honest failure is a clamped resolution rather than
68 /// an allocation that takes the app down. Chosen as roughly 13 × 19 inches (a
69 /// large-format print) at 1200 DPI.
70 const MAX_PIXELS: u64 = 356_000_000;
71
72 impl Page {
73 /// A blank sheet filled with `color`.
74 ///
75 /// The size is clamped to something a printer could accept rather than
76 /// rejected: a page node whose size parameter is being dragged passes
77 /// through zero, and a context that returns an error there flickers.
78 pub fn new(size: [f32; 2], dpi: u32, color: [f32; 4]) -> Page {
79 let size = [size[0].max(0.01), size[1].max(0.01)];
80 let dpi = dpi.clamp(1, 2400);
81 let mut width = (size[0] * dpi as f32).round().max(1.0) as u32;
82 let mut height = (size[1] * dpi as f32).round().max(1.0) as u32;
83 if width as u64 * height as u64 > MAX_PIXELS {
84 // Scale both axes by the same factor so the aspect — the thing the
85 // page size actually means — survives the clamp.
86 let scale = (MAX_PIXELS as f64 / (width as f64 * height as f64)).sqrt();
87 width = ((width as f64 * scale) as u32).max(1);
88 height = ((height as f64 * scale) as u32).max(1);
89 }
90 Page { size, dpi, width, height, pixels: vec![color; (width * height) as usize] }
91 }
92
93 /// Pixels per inch as a float, measured from the raster rather than read
94 /// from `dpi` — after a clamp those disagree, and every drawing operation
95 /// wants the one that describes the pixels it is about to touch.
96 pub fn scale(&self) -> f32 {
97 self.width as f32 / self.size[0]
98 }
99
100 /// Paint the whole sheet.
101 pub fn fill(&mut self, color: [f32; 4]) {
102 for p in &mut self.pixels {
103 *p = color;
104 }
105 }
106
107 /// An axis-aligned rectangle in INCHES from the top-left corner.
108 ///
109 /// Coverage is exact area, not a coin flip on the pixel centre. A printed
110 /// grid is mostly hairlines — a 0.01 inch rule at 300 DPI is three pixels,
111 /// at 100 DPI is one — and a binary fill makes every line snap to whole
112 /// pixels, so a ruled sheet comes out with lines alternating between one
113 /// and two pixels wide down its length. The eye reads that as a wobble in
114 /// the paper, not as aliasing, and it survives printing.
115 pub fn rect(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: [f32; 4]) {
116 let s = self.scale();
117 let (px0, px1) = (x0.min(x1) * s, x0.max(x1) * s);
118 let (py0, py1) = (y0.min(y1) * s, y0.max(y1) * s);
119 if px1 <= 0.0 || py1 <= 0.0 || px0 >= self.width as f32 || py0 >= self.height as f32 {
120 return;
121 }
122 let i0 = px0.floor().max(0.0) as u32;
123 let j0 = py0.floor().max(0.0) as u32;
124 let i1 = (px1.ceil() as i64).clamp(0, self.width as i64) as u32;
125 let j1 = (py1.ceil() as i64).clamp(0, self.height as i64) as u32;
126 for j in j0..j1 {
127 let cy = (py1.min(j as f32 + 1.0) - py0.max(j as f32)).clamp(0.0, 1.0);
128 if cy <= 0.0 {
129 continue;
130 }
131 for i in i0..i1 {
132 let cx = (px1.min(i as f32 + 1.0) - px0.max(i as f32)).clamp(0.0, 1.0);
133 if cx > 0.0 {
134 self.blend(i, j, color, cx * cy);
135 }
136 }
137 }
138 }
139
140 /// `color` over the pixel, its alpha scaled by `coverage`.
141 fn blend(&mut self, x: u32, y: u32, color: [f32; 4], coverage: f32) {
142 let a = color[3] * coverage;
143 if a <= 0.0 {
144 return;
145 }
146 let dst = &mut self.pixels[(y * self.width + x) as usize];
147 let out_a = a + dst[3] * (1.0 - a);
148 if out_a <= 0.0 {
149 *dst = [0.0; 4];
150 return;
151 }
152 for c in 0..3 {
153 // Straight alpha, so the destination's contribution is weighted by
154 // its own alpha and the result divided back out.
155 dst[c] = (color[c] * a + dst[c] * dst[3] * (1.0 - a)) / out_a;
156 }
157 dst[3] = out_a;
158 }
159
160 /// A ruled grid: cells of `cell` inches filled with `cell_color`, ruled
161 /// with lines `thickness` inches wide in `line_color`.
162 ///
163 /// Lines are centred ON their coordinate, not placed beside it, so a grid
164 /// and a second grid at twice the cell size share their rules exactly
165 /// instead of straddling them — which is the whole point of drawing two.
166 /// The sheet's own edges are ruled too: a grid that stops one line short
167 /// looks like a mistake rather than a margin.
168 pub fn grid(&mut self, cell: f32, thickness: f32, cell_color: [f32; 4], line_color: [f32; 4]) {
169 if cell_color[3] > 0.0 {
170 self.rect(0.0, 0.0, self.size[0], self.size[1], cell_color);
171 }
172 let cell = cell.max(1.0 / self.scale());
173 let half = (thickness * 0.5).max(0.25 / self.scale());
174 let mut x = 0.0;
175 while x <= self.size[0] + 1e-4 {
176 self.rect(x - half, 0.0, x + half, self.size[1], line_color);
177 x += cell;
178 }
179 let mut y = 0.0;
180 while y <= self.size[1] + 1e-4 {
181 self.rect(0.0, y - half, self.size[0], y + half, line_color);
182 y += cell;
183 }
184 }
185
186 /// A border `width` inches wide, drawn INSIDE the sheet's edge.
187 ///
188 /// Inside rather than centred on the edge, because half a border off the
189 /// paper is half a border: the printed page has no bleed here, and a
190 /// parameter that says 0.5 inches should put 0.5 inches of ink on the
191 /// sheet.
192 pub fn border(&mut self, width: f32, inset: f32, color: [f32; 4]) {
193 let (w, h) = (self.size[0], self.size[1]);
194 let width = width.max(0.0).min(w.min(h) * 0.5);
195 let (a, b) = (inset, inset + width);
196 self.rect(a, a, w - a, b, color);
197 self.rect(a, h - b, w - a, h - a, color);
198 self.rect(a, b, b, h - b, color);
199 self.rect(w - b, b, w - a, h - b, color);
200 }
201
202 /// The page as 8-bit sRGB RGBA, row-major from the top — what both the GPU
203 /// upload and the PNG encoder want.
204 pub fn to_rgba8(&self) -> Vec<u8> {
205 let mut out = Vec::with_capacity(self.pixels.len() * 4);
206 for p in &self.pixels {
207 for c in 0..4 {
208 out.push((p[c].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
209 }
210 }
211 out
212 }
213
214 /// Write a PNG that knows its own physical size.
215 ///
216 /// The pHYs chunk carries pixels per METRE, which is the only unit PNG
217 /// offers — so the DPI round-trips through a conversion and comes back a
218 /// hair off. That is the format's limit, not a bug to chase: 300 DPI
219 /// stores as 11811 px/m and reads back as 299.9994.
220 pub fn write_png(&self, path: &Path) -> Result<(), String> {
221 let file = std::fs::File::create(path)
222 .map_err(|e| format!("create {}: {e}", path.display()))?;
223 let mut encoder =
224 png::Encoder::new(std::io::BufWriter::new(file), self.width, self.height);
225 encoder.set_color(png::ColorType::Rgba);
226 encoder.set_depth(png::BitDepth::Eight);
227 mark_srgb(&mut encoder);
228 let per_metre = (self.scale() * 39.370_08).round() as u32;
229 encoder.set_pixel_dims(Some(png::PixelDimensions {
230 xppu: per_metre,
231 yppu: per_metre,
232 unit: png::Unit::Meter,
233 }));
234 let mut writer = encoder.write_header().map_err(|e| format!("png header: {e}"))?;
235 writer
236 .write_image_data(&self.to_rgba8())
237 .map_err(|e| format!("png write: {e}"))?;
238 writer.finish().map_err(|e| format!("png finish: {e}"))?;
239 Ok(())
240 }
241 }
242
243 /// Where a run of text sits in the box it is given.
244 #[derive(Clone, Copy, PartialEq, Debug)]
245 pub enum HAlign {
246 Left,
247 Center,
248 Right,
249 }
250
251 #[derive(Clone, Copy, PartialEq, Debug)]
252 pub enum VAlign {
253 Top,
254 Middle,
255 Bottom,
256 }
257
258 /// Everything the text operation needs. A struct rather than a dozen arguments
259 /// because the node has a dozen parameters and threading them positionally is
260 /// how the wrong two get swapped.
261 pub struct TextSpec<'a> {
262 pub text: &'a str,
263 pub font: &'a str,
264 /// Cap height in inches — a "0.1 font size" on a printed page means a tenth
265 /// of an inch of type, not a tenth of a pixel or of the sheet.
266 pub size: f32,
267 pub color: [f32; 4],
268 /// Where the text box's own origin sits on the sheet, in inches.
269 pub at: [f32; 2],
270 pub halign: HAlign,
271 pub valign: VAlign,
272 /// Line spacing as a multiple of the font size.
273 pub leading: f32,
274 }
275
276 impl Default for TextSpec<'_> {
277 fn default() -> Self {
278 TextSpec {
279 text: "",
280 font: "",
281 size: 0.1,
282 color: [0.0, 0.0, 0.0, 1.0],
283 at: [0.5, 0.5],
284 halign: HAlign::Left,
285 valign: VAlign::Top,
286 leading: 1.25,
287 }
288 }
289 }
290
291 impl Page {
292 /// Draw shaped text onto the sheet.
293 ///
294 /// Shaping and rasterizing both come from cosmic-text, which the toolkit
295 /// already owns — the alternative is a second font stack in the same
296 /// process disagreeing with the first about what a font is called.
297 ///
298 /// The size is converted to pixels here, at the page's own scale, so the
299 /// same page composed at 150 and at 600 DPI prints identical type at
300 /// different sample counts. That is the property that makes resolution a
301 /// page parameter rather than an export one.
302 pub fn text(
303 &mut self,
304 fonts: &mut cce_ui::cosmic_text::FontSystem,
305 cache: &mut cce_ui::cosmic_text::SwashCache,
306 spec: &TextSpec,
307 ) {
308 use cce_ui::cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};
309 if spec.text.is_empty() || spec.size <= 0.0 {
310 return;
311 }
312 let px = (spec.size * self.scale()).max(1.0);
313 let mut buffer = Buffer::new(fonts, Metrics::new(px, px * spec.leading.max(0.1)));
314 // No width or height limit: the box is measured from the shaped text
315 // rather than the text wrapped into a box. A printed label that
316 // silently wraps is worse than one that runs long, because the run-on
317 // is visible and the wrap looks deliberate.
318 buffer.set_size(fonts, None, None);
319 let attrs = if spec.font.trim().is_empty() {
320 Attrs::new()
321 } else {
322 Attrs::new().family(Family::Name(spec.font.trim()))
323 };
324 buffer.set_text(fonts, spec.text, attrs, Shaping::Advanced);
325 buffer.shape_until_scroll(fonts, false);
326
327 // Measure what was actually shaped, so alignment is against the ink
328 // rather than against the requested size.
329 let mut text_w: f32 = 0.0;
330 let mut lines = 0.0f32;
331 for run in buffer.layout_runs() {
332 text_w = text_w.max(run.line_w);
333 lines += 1.0;
334 }
335 let line_h = px * spec.leading.max(0.1);
336 let text_h = lines.max(1.0) * line_h;
337
338 let ox = spec.at[0] * self.scale()
339 - match spec.halign {
340 HAlign::Left => 0.0,
341 HAlign::Center => text_w * 0.5,
342 HAlign::Right => text_w,
343 };
344 let oy = spec.at[1] * self.scale()
345 - match spec.valign {
346 VAlign::Top => 0.0,
347 VAlign::Middle => text_h * 0.5,
348 VAlign::Bottom => text_h,
349 };
350
351 let color = cce_ui::cosmic_text::Color::rgba(255, 255, 255, 255);
352 let (w, h) = (self.width as i32, self.height as i32);
353 let mut hits: Vec<(u32, u32, f32)> = Vec::new();
354 buffer.draw(fonts, cache, color, |x, y, gw, gh, c| {
355 // cosmic-text hands back a filled rect per span; the alpha is the
356 // glyph's coverage. The colour it carries is the one passed in,
357 // which is why that is opaque white — the page's own colour is
358 // applied here, so a coloured glyph is not double-tinted.
359 let a = c.a() as f32 / 255.0;
360 if a <= 0.0 {
361 return;
362 }
363 for dy in 0..gh as i32 {
364 for dx in 0..gw as i32 {
365 let (px, py) = (x + dx + ox as i32, y + dy + oy as i32);
366 if px >= 0 && py >= 0 && px < w && py < h {
367 hits.push((px as u32, py as u32, a));
368 }
369 }
370 }
371 });
372 for (x, y, a) in hits {
373 self.blend(x, y, spec.color, a);
374 }
375 }
376 }
377
378 // ---------------------------------------------------------------------------
379 // The node chain
380 // ---------------------------------------------------------------------------
381
382 use crate::app::FsNode;
383 use crate::geometry::{find_node_by_name, node_param_f32, node_param_str, node_param_vec3};
384 use glam::Vec3;
385
386 /// Whether a node belongs to the page context rather than the geometry graph.
387 ///
388 /// The two do not mix, and this is the one place that says so. A page node
389 /// contributes nothing to the viewport's geometry and a geometry node cannot
390 /// feed a page, so the network is really two networks sharing an editor — the
391 /// same way Houdini's contexts do, and for the same reason: a raster and a
392 /// mesh have no operation in common.
393 pub fn is_page_node(node_type: &str) -> bool {
394 matches!(
395 node_type.to_ascii_lowercase().as_str(),
396 "page" | "page_grid" | "page_border" | "page_text"
397 )
398 }
399
400 /// Named sheet sizes, in inches, portrait.
401 ///
402 /// A4 is metric and converts to 8.268 × 11.693 — carried at that precision
403 /// rather than rounded, because a rounded A4 prints with a visible margin
404 /// error at the bottom of the sheet.
405 fn preset_size(name: &str) -> Option<[f32; 2]> {
406 match name.trim().to_ascii_lowercase().as_str() {
407 "letter" => Some([8.5, 11.0]),
408 "a4" => Some([8.267_717, 11.692_913]),
409 "legal" => Some([8.5, 14.0]),
410 "tabloid" => Some([11.0, 17.0]),
411 _ => None,
412 }
413 }
414
415 fn color_of(node: &FsNode, name: &str, fallback: Vec3) -> [f32; 4] {
416 let c = node_param_vec3(node, name, fallback);
417 [c.x, c.y, c.z, 1.0]
418 }
419
420 fn toggle_of(node: &FsNode, name: &str) -> bool {
421 matches!(node_param_str(node, name, "false").trim().to_ascii_lowercase().as_str(), "true" | "1" | "on")
422 }
423
424 /// Compose the page `target` describes, resolving its input chain.
425 ///
426 /// `visited` guards cycles by node id exactly as the geometry resolvers do —
427 /// the page context is a second graph, not a second kind of graph.
428 pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Page> {
429 if visited.contains(&target.id) {
430 return None;
431 }
432 visited.push(target.id.clone());
433
434 let kind = target.node_type.to_ascii_lowercase();
435 if kind == "page" {
436 let preset = node_param_str(target, "Preset", "Letter");
437 let size = preset_size(&preset).unwrap_or([
438 node_param_f32(target, "Width", 8.5),
439 node_param_f32(target, "Height", 11.0),
440 ]);
441 // Landscape is the same sheet turned, not a different sheet: swap the
442 // axes rather than asking for a second pair of numbers.
443 let size = if node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape")
444 {
445 [size[1], size[0]]
446 } else {
447 size
448 };
449 let dpi = node_param_f32(target, "Resolution", 300.0).round().max(1.0) as u32;
450 return Some(Page::new(size, dpi, color_of(target, "Color", Vec3::ONE)));
451 }
452
453 // Everything else composites onto its input, so a chain with no page at
454 // the bottom of it has nothing to draw on and resolves to nothing. That is
455 // the honest answer: a border with no page is not a page with a border —
456 // and it is also how an Export node in a GEOMETRY chain falls through to
457 // the geometry resolvers rather than being claimed by this one.
458 let input = find_node_by_name(root, node_param_str(target, "Input", "").trim())?;
459 let mut page = resolve_page(root, input, visited)?;
460
461 match kind.as_str() {
462 // Export belongs to neither context and passes through both. What it
463 // writes is decided by what reaches it: a page becomes a PNG, geometry
464 // becomes the mesh format its Format parameter names.
465 "export" => {}
466 "page_grid" => {
467 let cell_color = if toggle_of(target, "Fill Cells") {
468 color_of(target, "Cell Color", Vec3::ONE)
469 } else {
470 [0.0; 4]
471 };
472 page.grid(
473 node_param_f32(target, "Cell Size", 0.25),
474 node_param_f32(target, "Line Width", 0.01),
475 cell_color,
476 color_of(target, "Line Color", Vec3::ZERO),
477 );
478 }
479 "page_border" => page.border(
480 node_param_f32(target, "Width", 0.06),
481 node_param_f32(target, "Inset", 0.4),
482 color_of(target, "Color", Vec3::ZERO),
483 ),
484 "page_text" => {
485 let text = node_param_str(target, "Text", "");
486 let font = node_param_str(target, "Font", "");
487 let spec = TextSpec {
488 text: &text,
489 font: &font,
490 size: node_param_f32(target, "Size", 0.25),
491 color: color_of(target, "Color", Vec3::ZERO),
492 at: [node_param_f32(target, "X", 4.25), node_param_f32(target, "Y", 0.8)],
493 halign: match node_param_str(target, "Horizontal", "Center").as_str() {
494 "Left" => HAlign::Left,
495 "Right" => HAlign::Right,
496 _ => HAlign::Center,
497 },
498 valign: match node_param_str(target, "Vertical", "Top").as_str() {
499 "Middle" => VAlign::Middle,
500 "Bottom" => VAlign::Bottom,
501 _ => VAlign::Top,
502 },
503 leading: node_param_f32(target, "Leading", 1.25),
504 };
505 with_fonts(|fonts, cache| page.text(fonts, cache, &spec));
506 }
507 _ => return None,
508 }
509 Some(page)
510 }
511
512 /// The page context's font stack, created once.
513 ///
514 /// System fonts included, because a page node names its font by family — the
515 /// source family's default is "Lato" — and a font stack that only knows the
516 /// bundled house faces would silently substitute for every named font a user
517 /// actually owns. Shaping the app's own widgets stays on the toolkit's
518 /// geometry font system; this one is for ink on paper.
519 fn with_fonts<R>(
520 f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
521 ) -> R {
522 use std::sync::{Mutex, OnceLock};
523 type Stack = (cce_ui::cosmic_text::FontSystem, cce_ui::cosmic_text::SwashCache);
524 static FONTS: OnceLock<Mutex<Stack>> = OnceLock::new();
525 let stack = FONTS.get_or_init(|| {
526 Mutex::new((
527 cce_ui::create_font_system_with_system_fonts(),
528 cce_ui::cosmic_text::SwashCache::new(),
529 ))
530 });
531 let mut guard = stack.lock().unwrap();
532 let (fonts, cache) = &mut *guard;
533 f(fonts, cache)
534 }
535
536 /// The page a network level displays, if it displays one.
537 ///
538 /// The same rule the viewport follows for geometry: draw what is visible at
539 /// the level being shown. Several page chains at one level is ambiguous, so
540 /// the LAST visible page node wins — the one furthest down the roster, which
541 /// is the one most recently added.
542 pub fn displayed_page(root: &FsNode, level: &FsNode) -> Option<Page> {
543 let target = level
544 .children
545 .iter()
546 .filter(|c| is_page_node(&c.node_type) && c.geometry_visible)
547 .next_back()?;
548 resolve_page(root, target, &mut Vec::new())
549 }
550
551 /// The font stack, for tests that draw text without a node behind them.
552 #[cfg(test)]
553 pub fn with_fonts_for_test<R>(
554 f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
555 ) -> R {
556 with_fonts(f)
557 }