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

commitc579069a123b402b43072626e8fee9ce4a807e77
parentc0b9df8059
authorLucas Galante <[email protected]>
date2026-09-18 23:19
feat(page): a 2D page context, so the COP family has somewhere to live

The COP family the plugin carries — gem_page, gem_border, gem_grid,
gem_text_box, gem_graph — is printed output, and printed output is not
geometry. src/page.rs is a second context: its currency is a Page (a
sheet in inches, a DPI, straight-alpha RGBA), its origin is the top-left
corner with y running down, and nothing in it has a point id, an
attribute or a normal. Four nodes compose one — page, page_grid,
page_border, page_text — and is_page_node is the one place that says the
two contexts do not mix. export is the only node in both: what reaches
it decides the format, so a page writes a PNG and geometry writes the
mesh format its Format parameter names. Adding PNG to that parameter
would be a lie about what it can do with a mesh.

Resolution is a property of the page, not of the export. The raster is
size x DPI and the pHYs chunk says so, so a printer lays the sheet out
at the size it was composed at instead of guessing 96. Inches rather
than millimetres because paper is specified in inches by the family this
came from; the geometry graph's World Unit declaration does not reach a
sheet of paper.

Rect coverage is exact area, not a test of the pixel centre: a printed
grid is mostly hairlines, and a binary fill snaps every rule to whole
pixels, so a ruled sheet comes out with lines alternating between one
and two pixels wide down its length — which reads as a wobble in the
paper rather than as aliasing. Grid rules are centred ON their
coordinate so a second grid at twice the cell size lands exactly on the
first's, which is the only reason to draw two.

gem_graph is deliberately not ported. It is 29 parameters doing what
these four do chained, and collapsing that is the whole premise of
"fifty operators, ten nodes".

The preview pane took three things, and missing any one of them looks
exactly like the others — a pane that is visible, correctly placed and
blank:

- a PAGE_IDX arm in paint_widget, because the fall-through branch serves
  LEGACY widgets and a modern-paint one draws nothing there;
- the viewport's key in the draw_order sort, because the viewport is
  full-bleed and the panes float over it, so a pane taking its rect must
  take its depth — drawn last it covered the collapsed stubs, whose
  labels then ghosted through from the later text pass;
- a line in test_widget_roster_indices_are_dense, the only one of the
  three that fails loudly.

Also: mark_srgb, shared by both PNG writers. Encoder::set_srgb is
deprecated and its replacement writes ONLY the sRGB chunk, dropping the
gAMA and cHRM fallbacks the spec asks for beside it — so the obvious
deprecation fix silently changed the thumbnail's output for old
decoders. Both writers go through one helper now, and the thumbnail is
byte-identical to what it was.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md              |  58 +++++
 nodes/page.json        |  14 ++
 nodes/page_border.json |  12 ++
 nodes/page_grid.json   |  14 ++
 nodes/page_text.json   |  18 ++
 shapeshifter.md        |  24 ++-
 src/app.rs             |  61 +++++-
 src/export_cli.rs      |  21 ++
 src/main.rs            | 286 ++++++++++++++++++++++++-
 src/page.rs            | 557 +++++++++++++++++++++++++++++++++++++++++++++++++
 src/render.rs          |  60 ++++++
 src/slots.rs           |   8 +-
 src/thumbnail.rs       |   2 +-
 13 files changed, 1128 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7deb75c..9c5505d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -360,6 +360,64 @@ since an edge with four faces has no single pair to flip between).
 field is exact near the surface and flat far from it. A boolean builds both
 operands on ONE grid so the two fields line up sample for sample.
 
+### The 2D page context
+
+`src/page.rs` is a second context, not a second kind of geometry node. Its
+currency is a `Page` — a printed sheet: inches, a DPI, and straight-alpha RGBA
+pixels — its origin is the top-left corner with y running DOWN, and nothing in
+it has a point id, an attribute or a normal. Four nodes compose one: `page`
+(the sheet: preset or custom size, orientation, resolution, colour),
+`page_grid`, `page_border` and `page_text`.
+
+The two contexts do not mix, and `is_page_node` is the one place that says so.
+A page node contributes nothing to the viewport's geometry and a geometry node
+cannot feed a page: page chains resolve through `resolve_page`, never through
+`generate_single_node_geometry_with_errors`. `export` is the only node in
+both — it passes either through, and what reaches it decides the format, so a
+page writes a PNG and geometry writes the mesh format its Format parameter
+names. There is no PNG option on that parameter, because offering one for a
+mesh would be a lie.
+
+**Resolution is a property of the page, not of the export.** The raster is
+size × DPI, and `write_png` puts that in the pHYs chunk, so a printer lays the
+file out at the size it was composed at instead of guessing 96. pHYs is pixels
+per metre — the only unit PNG offers — so the DPI round-trips through a
+conversion and comes back a hair off (300 stores as 11811 px/m, reads as
+299.9994). Inches rather than millimetres because paper is specified in inches
+by the family this came from; the geometry graph's World Unit declaration does
+not reach here.
+
+Rect coverage is exact area, not a test of the pixel centre. A printed grid is
+mostly hairlines, and a binary fill snaps every rule to whole pixels, so a
+ruled sheet comes out with lines alternating between one and two pixels wide
+down its length — which reads as a wobble in the paper rather than as
+aliasing. Grid rules are centred ON their coordinate so a second grid at twice
+the cell size lands exactly on the first's, which is the only reason to draw
+two. Text shapes and rasterizes through cosmic-text, the toolkit's own font
+stack, with system fonts loaded because a page names its font by family.
+
+**The preview pane** (`PAGE_IDX`, an `ImageView`) takes the viewport's rect
+when the displayed level holds a page, and the viewport stands down — the same
+rule the viewport already follows about showing its editor's level. Three
+things were needed to make a new pane actually appear, and missing any one of
+them looks identical to the others:
+
+- A `PAGE_IDX` arm in `paint_widget`. The fall-through branch serves LEGACY
+  widgets — it emits a plate and the widget's legacy views — so a modern-paint
+  widget whose whole look lives in `Paint::paint` lands there and draws
+  nothing. The pane was visible, correctly placed and blank.
+- The viewport's key in the `draw_order` sort. The viewport is full-bleed and
+  the other panes float OVER it, so a pane taking its rect must take its depth;
+  drawn last, it covered the collapsed stubs and their labels ghosted through
+  from the later text pass.
+- An entry in `test_widget_roster_indices_are_dense`, which is hand-listed and
+  fails loudly — the one of the three that tells you itself.
+
+The GPU image is owned by `State::page_image` and freed when replaced;
+`ImageView` only borrows the id. `gem_graph`, the source family's
+everything-at-once node, is deliberately not ported: it is these four chained,
+and that collapse is the whole premise of "fifty operators, ten nodes".
+
 ### Runtime paths point into the source tree
 
 Node templates (`nodes/*.json`) and `default_project.json` are located via
diff --git a/nodes/page.json b/nodes/page.json
new file mode 100644
index 0000000..0d4a9ec
--- /dev/null
+++ b/nodes/page.json
@@ -0,0 +1,14 @@
+{
+ "name": "Page",
+ "type": "page",
+ "inputs": 0,
+ "outputs": 1,
+ "params": [
+  { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,Custom", "default": "Letter" },
+  { "name": "Width", "type": "slider", "default": "8.5", "min": 0.5, "max": 48.0, "step": 0.25, "show_when": "Preset == Custom" },
+  { "name": "Height", "type": "slider", "default": "11.0", "min": 0.5, "max": 48.0, "step": 0.25, "show_when": "Preset == Custom" },
+  { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait" },
+  { "name": "Resolution", "type": "spinbox", "default": "300", "min": 18, "max": 1200, "step": 6 },
+  { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0 }
+ ]
+}
diff --git a/nodes/page_border.json b/nodes/page_border.json
new file mode 100644
index 0000000..f26c96e
--- /dev/null
+++ b/nodes/page_border.json
@@ -0,0 +1,12 @@
+{
+ "name": "Page Border",
+ "type": "page_border",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "Width", "type": "slider", "default": "0.06", "min": 0.001, "max": 2.0, "step": 0.005 },
+  { "name": "Inset", "type": "slider", "default": "0.40", "min": 0.0, "max": 4.0, "step": 0.05 },
+  { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 }
+ ]
+}
diff --git a/nodes/page_grid.json b/nodes/page_grid.json
new file mode 100644
index 0000000..b1544a5
--- /dev/null
+++ b/nodes/page_grid.json
@@ -0,0 +1,14 @@
+{
+ "name": "Page Grid",
+ "type": "page_grid",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "Cell Size", "type": "slider", "default": "0.25", "min": 0.01, "max": 4.0, "step": 0.01 },
+  { "name": "Line Width", "type": "slider", "default": "0.01", "min": 0.001, "max": 0.25, "step": 0.001 },
+  { "name": "Line Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 },
+  { "name": "Fill Cells", "type": "toggle", "default": "false" },
+  { "name": "Cell Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "show_when": "Fill Cells == true" }
+ ]
+}
diff --git a/nodes/page_text.json b/nodes/page_text.json
new file mode 100644
index 0000000..8be0daa
--- /dev/null
+++ b/nodes/page_text.json
@@ -0,0 +1,18 @@
+{
+ "name": "Page Text",
+ "type": "page_text",
+ "inputs": 1,
+ "outputs": 1,
+ "params": [
+  { "name": "Input", "type": "text", "default": "" },
+  { "name": "Text", "type": "text", "default": "Title" },
+  { "name": "Font", "type": "text", "default": "" },
+  { "name": "Size", "type": "slider", "default": "0.25", "min": 0.02, "max": 4.0, "step": 0.01 },
+  { "name": "Color", "type": "float3", "default": "0.00:0.00:0.00", "min": 0.0, "max": 1.0 },
+  { "name": "X", "type": "slider", "default": "4.25", "min": 0.0, "max": 48.0, "step": 0.05 },
+  { "name": "Y", "type": "slider", "default": "0.80", "min": 0.0, "max": 48.0, "step": 0.05 },
+  { "name": "Horizontal", "type": "choice:Left,Center,Right", "default": "Center" },
+  { "name": "Vertical", "type": "choice:Top,Middle,Bottom", "default": "Top" },
+  { "name": "Leading", "type": "slider", "default": "1.25", "min": 0.5, "max": 3.0, "step": 0.05 }
+ ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index 9cd4721..0d8d95c 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -385,7 +385,29 @@ Touches: `shortcut.rs`, `app.rs`, `slots.rs`, `cce-ui`.
 > to draw it. Both new nodes now have resolver-level tests, not just unit tests
 > on the field.
 >
-> Still outstanding for this phase: the 2D page context for the COP family.
+> **The 2D page context landed, and Phase 4 is done.** `src/page.rs` composes a
+> printed sheet — inches, a DPI, straight-alpha RGBA — and four nodes build one:
+> `page`, `page_grid`, `page_border`, `page_text`. It previews in the viewport's
+> pane and exports a PNG whose pHYs chunk carries its physical size, so a
+> printer lays the sheet out at the size it was composed at.
+>
+> It is a genuinely separate context, as proposed: page chains resolve through
+> `resolve_page` and contribute nothing to the geometry the viewport draws.
+> `export` is the only node in both, and what reaches it decides the format.
+> `gem_graph` — the source family's everything-at-once node, 29 parameters — is
+> deliberately not ported: it is the other four chained, which is the whole
+> premise of the fifty-to-ten collapse.
+>
+> The blank-pane lesson: a new pane needs a `paint_widget` arm (the
+> fall-through serves LEGACY widgets, so a modern-paint one draws nothing), a
+> place in the `draw_order` sort (the viewport is full-bleed and panes float
+> over it), and a line in the hand-listed roster test. Only the third fails
+> loudly. Two shadow runs went into finding the first, and one of those was
+> spent chasing a second designer process my kill had silently failed to stop —
+> both were writing to one log, so I was reading one process's state against
+> another's.
+>
+> Still outstanding: nothing in Phase 4.
 
 Furthest out because it needs infrastructure nothing else does: a **volume
 representation** (SDF or sparse grid) for shelling, offsetting and boolean work,
diff --git a/src/app.rs b/src/app.rs
index 527a215..b6624fd 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -34,7 +34,7 @@ use wayland_client::{
     Connection, QueueHandle, Proxy,
 };
 
-use cce_ui::widget::{Adapted, Breadcrumb, MenuBar, MenuController, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, WidgetHost, GraphNode, Graph, Button, Label, Dropdown};
+use cce_ui::widget::{Adapted, Breadcrumb, ImageView, MenuBar, MenuController, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, WidgetHost, GraphNode, Graph, Button, Label, Dropdown};
 use cce_ui::widget::UiContext;
 use crate::playbar::Playbar;
 use crate::viewport_3d::Viewport3D;
@@ -1097,6 +1097,9 @@ pub struct State {
     /// Solved simulation states, kept across frames so playing forward costs one
     /// step per frame instead of re-solving from the start frame every redraw.
     pub sim_cache: crate::geometry::SimCache,
+    /// The GPU image behind the page pane. Owned here — `ImageView` only
+    /// borrows an id — so replacing a page frees the one it replaces.
+    pub page_image: Option<u32>,
     /// Frame the scene was last built at, so the timeline moving can invalidate it.
     pub last_sim_frame: i32,
     pub plate_menu_slot: Option<usize>,
@@ -2242,6 +2245,27 @@ impl State {
             return;
         }
         let (format, scale) = crate::geometry::export_settings(&node);
+        let path = std::path::PathBuf::from(shellexpand_home(&file));
+
+        // A page is a different thing to get out of the computer, and the node
+        // does not need to be told which it is holding: what reaches its input
+        // decides. A page writes a PNG that carries its own physical size; the
+        // Format parameter names a MESH format and has nothing to say here.
+        if let Some(page) = crate::page::resolve_page(&self.fs_root, &node, &mut Vec::new()) {
+            let (w, h) = (page.width, page.height);
+            match page.write_png(&path) {
+                Ok(()) => self.update_status_text(&format!(
+                    "Exported {} as PNG ({}x{} at {} DPI) to {}",
+                    node.name,
+                    w,
+                    h,
+                    page.dpi,
+                    path.display()
+                )),
+                Err(e) => self.update_status_text(&format!("Export failed: {e}")),
+            }
+            return;
+        }
 
         let (frame, start) = (self.sim_frame(), self.sim_start_frame());
         let mut sim_cache = std::mem::take(&mut self.sim_cache);
@@ -2270,7 +2294,6 @@ impl State {
             return;
         }
 
-        let path = std::path::PathBuf::from(shellexpand_home(&file));
         match crate::export::write(&geom, &path, format, scale) {
             Ok(bytes) => self.update_status_text(&format!(
                 "Exported {} as {} ({} bytes) to {}",
@@ -3889,6 +3912,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 bc.set_raised(true);
                 bc
             },
+            page_view: {
+                // Contain, never crop: a page is a document, and a document
+                // shown with its margins cut off is a different document. No
+                // upscale past 1:1 either — a 72 DPI sheet blown up to fill
+                // the pane would look like the composition is soft when it is
+                // the preview that is.
+                let mut v = ImageView::new()
+                    .with_fit(cce_ui::scene::layout::FitMode::Contain { max_upscale: 1.0 })
+                    .with_bg([0.12, 0.12, 0.13, 1.0]);
+                v.set_visible(false);
+                v
+            },
         });
 
         if let Some(viewport) = slots.viewport.as_any_mut().downcast_mut::<Viewport3D>() {
@@ -3975,6 +4010,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             viewport_menu_active: false,
             viewport_menu_actions: Vec::new(),
             sim_cache: crate::geometry::SimCache::default(),
+            page_image: None,
             last_sim_frame: i32::MIN,
             plate_menu_slot: None,
             plate_menu_actions: Vec::new(),
@@ -4890,6 +4926,27 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
 
         self.apply_detached_panes();
+        // The 2D page context takes the viewport's rect whenever the displayed
+        // level holds a page, and the viewport stands down: one pane, one
+        // thing in it. Placed here, after every layout branch has run, rather
+        // than inside each of them — the rect it wants is always exactly the
+        // viewport's, so there is nothing per-branch to decide.
+        //
+        // The ImageView's own image is the flag. Composing a page is
+        // expensive and happens in rebuild_scene_geometry; layout runs on
+        // every resize, and a second copy of "is a page showing" would be a
+        // second thing to keep true.
+        let showing_page = self.slots.page_view.image.is_some();
+        self.positions[PAGE_IDX] = if showing_page {
+            self.positions[VIEWPORT_IDX]
+        } else {
+            (0.0, 0.0, 0.0, 0.0)
+        };
+        self.slots.page_view.set_visible(showing_page && self.slots.viewport.visible());
+        if showing_page {
+            self.slots.viewport.set_visible(false);
+        }
+
         self.apply_collapsed_panes();
     }
 
diff --git a/src/export_cli.rs b/src/export_cli.rs
index 1d0c243..3557016 100644
--- a/src/export_cli.rs
+++ b/src/export_cli.rs
@@ -39,6 +39,27 @@ pub fn run(
     // it means in the window — the same contract --thumbnail makes.
     let mut sim = crate::geometry::EvalSim::new(frame.unwrap_or(0), 1, &mut sim_cache);
 
+    // A named node in the PAGE context writes a PNG instead — the same rule
+    // the Export node follows: what is being exported decides the format, not
+    // the file name. Without a node name there is no page to mean, since a
+    // level can hold several.
+    if let Some(name) = &node {
+        let target = crate::geometry::find_node_by_name(&proj.root, name)
+            .ok_or_else(|| format!("no node named '{name}'"))?;
+        if let Some(page) = crate::page::resolve_page(&proj.root, target, &mut Vec::new()) {
+            page.write_png(out)?;
+            return Ok(format!(
+                "page {}x{} at {} DPI ({:.2} x {:.2} in) -> {}",
+                page.width,
+                page.height,
+                page.dpi,
+                page.size[0],
+                page.size[1],
+                out.display()
+            ));
+        }
+    }
+
     let geom = match &node {
         Some(name) => {
             // Borrowed, NOT cloned. Several generators find their place in the
diff --git a/src/main.rs b/src/main.rs
index 54f5202..cc27277 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -23,6 +23,7 @@ pub mod project;
 pub mod render;
 pub mod shortcut;
 pub mod slots;
+pub mod page;
 pub mod thumbnail;
 
 #[cfg(test)]
@@ -634,7 +635,7 @@ mod tests {
             SPLITTER2_IDX, PARAM_IDX, CANVAS_IDX, LEFT_MENUBAR_IDX,
             RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, STATUS_IDX, BREADCRUMB_IDX,
             SPREADSHEET_IDX, SPREADSHEET_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
-            NETWORK_PANEL2_IDX, CONTENT2_IDX, BREADCRUMB2_IDX,
+            NETWORK_PANEL2_IDX, CONTENT2_IDX, BREADCRUMB2_IDX, PAGE_IDX,
         ];
         assert_eq!(roster.len(), WIDGET_COUNT, "roster length vs WIDGET_COUNT");
         for (i, idx) in roster.iter().enumerate() {
@@ -3754,6 +3755,289 @@ mod tests {
         d
     }
 
+    /// The page nodes end to end, through the resolver — not just the raster.
+    ///
+    /// Written before the UI was wired, because the Boolean node taught this
+    /// exact lesson one commit ago: arithmetic that passes every unit test and
+    /// wiring nobody has exercised look identical until something asks for the
+    /// result.
+    #[test]
+    fn test_the_page_nodes_compose_a_sheet_through_the_resolver() {
+        use crate::page::{displayed_page, is_page_node, resolve_page};
+
+        fn pnode(id: &str, name: &str, ty: &str, params: &[(&str, &str)]) -> FsNode {
+            FsNode {
+                id: id.to_string(),
+                name: name.to_string(),
+                node_type: ty.to_string(),
+                children: vec![],
+                params: params
+                    .iter()
+                    .map(|(n, v)| crate::app::ParamDef {
+                        name: n.to_string(),
+                        label: String::new(),
+                        param_type: "text".to_string(),
+                        default: v.to_string(),
+                        options: vec![],
+                        min: None,
+                        max: None,
+                        step: None,
+                        show_when: String::new(),
+                    })
+                    .collect(),
+                geometry_visible: true,
+                position: (0.0, 0.0),
+                inputs: 1,
+                outputs: 1,
+            }
+        }
+
+        let root = pnode("r", "root", "node", &[]);
+        let mut root = root;
+        root.children = vec![
+            pnode(
+                "p",
+                "page1",
+                "page",
+                &[
+                    ("Preset", "Letter"),
+                    ("Orientation", "Portrait"),
+                    ("Resolution", "72"),
+                    ("Color", "1.00:1.00:1.00"),
+                ],
+            ),
+            pnode(
+                "g",
+                "grid1",
+                "page_grid",
+                &[
+                    ("Input", "page1"),
+                    ("Cell Size", "0.5"),
+                    ("Line Width", "0.02"),
+                    ("Line Color", "0.00:0.00:0.00"),
+                    ("Fill Cells", "false"),
+                ],
+            ),
+            pnode(
+                "b",
+                "border1",
+                "page_border",
+                &[("Input", "grid1"), ("Width", "0.1"), ("Inset", "0.25"), ("Color", "1.00:0.00:0.00")],
+            ),
+        ];
+
+        assert!(is_page_node("page_grid") && !is_page_node("sphere"));
+
+        let page = resolve_page(&root, &root.children[2], &mut Vec::new())
+            .expect("the page chain resolved to nothing");
+        assert_eq!((page.width, page.height), (612, 792), "Letter at 72 DPI");
+
+        let at = |x: u32, y: u32| page.pixels[(y * page.width + x) as usize];
+        // The border is red where it was asked for, and nowhere else.
+        let b = at(20, 400);
+        assert!(b[0] > 0.9 && b[1] < 0.1, "no border ink at the left edge: {b:?}");
+        assert!(at(300, 400)[1] > 0.5, "the border filled the sheet");
+        // The grid ruled the interior: 0.5 inches at 72 DPI is every 36 px.
+        assert!(at(36 * 4, 400)[0] < 0.4, "no rule at 2.0 inches");
+        assert!(at(36 * 4 + 18, 400)[0] > 0.9, "the cell between rules is not clear");
+
+        // An orphan composite is not a page: a border with nothing under it
+        // resolves to nothing rather than inventing a sheet.
+        let orphan = pnode("o", "border2", "page_border", &[("Input", "nothing")]);
+        let mut lone = root.clone();
+        lone.children.push(orphan);
+        assert!(
+            resolve_page(&lone, lone.children.last().unwrap(), &mut Vec::new()).is_none(),
+            "a border with no page under it invented one"
+        );
+
+        // A cycle terminates rather than recursing forever.
+        let mut looped = root.clone();
+        looped.children[0] = pnode("p", "page1", "page_border", &[("Input", "border1")]);
+        assert!(resolve_page(&looped, &looped.children[2], &mut Vec::new()).is_none());
+
+        // The level's LAST visible page node is what gets displayed.
+        // Green, not red: the red border and the white sheet both read 1.0 in
+        // the red channel, so testing that one proves nothing either way.
+        let border_ink = |p: &crate::page::Page| p.pixels[(400 * p.width + 20) as usize][1];
+        let shown = displayed_page(&root, &root).expect("nothing displayed");
+        assert!(border_ink(&shown) < 0.1, "the border chain is not what showed");
+        let mut hidden = root.clone();
+        hidden.children[2].geometry_visible = false;
+        let shown = displayed_page(&hidden, &hidden).expect("nothing displayed");
+        assert!(border_ink(&shown) > 0.9, "a hidden node still displayed");
+    }
+
+    /// Text lands on the sheet, and alignment moves it.
+    #[test]
+    fn test_page_text_puts_ink_where_it_is_aligned() {
+        use crate::page::{HAlign, Page, TextSpec, VAlign};
+        let ink = |halign, valign| {
+            let mut p = Page::new([4.0, 2.0], 72, [1.0, 1.0, 1.0, 1.0]);
+            crate::page::with_fonts_for_test(|fonts, cache| {
+                p.text(
+                    fonts,
+                    cache,
+                    &TextSpec {
+                        text: "Hg",
+                        size: 0.5,
+                        at: [2.0, 1.0],
+                        halign,
+                        valign,
+                        ..Default::default()
+                    },
+                );
+            });
+            // The centroid of the ink, in pixels.
+            let (mut sx, mut sy, mut n) = (0.0f64, 0.0f64, 0.0f64);
+            for y in 0..p.height {
+                for x in 0..p.width {
+                    let v = 1.0 - p.pixels[(y * p.width + x) as usize][0] as f64;
+                    if v > 0.5 {
+                        sx += x as f64;
+                        sy += y as f64;
+                        n += 1.0;
+                    }
+                }
+            }
+            assert!(n > 0.0, "no ink at all");
+            (sx / n, sy / n)
+        };
+
+        let (cx, cy) = ink(HAlign::Center, VAlign::Middle);
+        assert!((cx - 144.0).abs() < 25.0, "centred text sits at x={cx}, not the middle");
+        assert!((cy - 72.0).abs() < 25.0, "middled text sits at y={cy}, not the middle");
+
+        let (lx, _) = ink(HAlign::Left, VAlign::Middle);
+        let (rx, _) = ink(HAlign::Right, VAlign::Middle);
+        assert!(lx > cx && cx > rx, "alignment did not move the ink: {lx} {cx} {rx}");
+    }
+
+    /// A page's raster is its physical size times its resolution — the
+    /// property that makes DPI a page parameter rather than an export one.
+    #[test]
+    fn test_a_page_is_its_physical_size_times_its_resolution() {
+        use crate::page::Page;
+        let p = Page::new([8.5, 11.0], 300, [1.0; 4]);
+        assert_eq!((p.width, p.height), (2550, 3300));
+        assert!((p.scale() - 300.0).abs() < 0.01);
+
+        // The same sheet at a different resolution is the same sheet.
+        let q = Page::new([8.5, 11.0], 72, [1.0; 4]);
+        assert_eq!((q.width, q.height), (612, 792));
+        assert!(
+            ((p.width as f32 / p.height as f32) - (q.width as f32 / q.height as f32)).abs() < 1e-3
+        );
+
+        // A sheet nobody could print clamps rather than allocating: aspect
+        // survives, resolution does not.
+        let huge = Page::new([100.0, 50.0], 1200, [1.0; 4]);
+        assert!(
+            (huge.width as u64) * (huge.height as u64) <= 356_000_000,
+            "{}x{} is not clamped",
+            huge.width,
+            huge.height
+        );
+        assert!(
+            ((huge.width as f32 / huge.height as f32) - 2.0).abs() < 0.01,
+            "the clamp changed the aspect: {}x{}",
+            huge.width,
+            huge.height
+        );
+    }
+
+    /// Rect coverage is exact area, not a test of the pixel centre.
+    ///
+    /// This is what keeps a ruled sheet's lines from alternating between one
+    /// and two pixels wide down its length — which prints as a wobble in the
+    /// paper rather than as aliasing.
+    #[test]
+    fn test_rect_coverage_is_exact_area() {
+        use crate::page::Page;
+        // Ten pixels per inch, so one pixel is a tenth of an inch and the
+        // arithmetic is readable.
+        let mut p = Page::new([1.0, 1.0], 10, [0.0, 0.0, 0.0, 1.0]);
+        assert_eq!((p.width, p.height), (10, 10));
+
+        // A rect covering exactly the left half of pixel (0,0).
+        p.rect(0.0, 0.0, 0.05, 0.1, [1.0, 1.0, 1.0, 1.0]);
+        let v = p.pixels[0][0];
+        assert!((v - 0.5).abs() < 1e-4, "half a pixel of white over black read {v}, not 0.5");
+
+        // A whole-pixel rect is fully opaque, and its neighbour is untouched.
+        let mut p = Page::new([1.0, 1.0], 10, [0.0, 0.0, 0.0, 1.0]);
+        p.rect(0.2, 0.0, 0.3, 0.1, [1.0, 1.0, 1.0, 1.0]);
+        assert!((p.pixels[2][0] - 1.0).abs() < 1e-4, "a whole pixel is not solid");
+        assert!(p.pixels[1][0] < 1e-4, "the rect bled into its neighbour");
+        assert!(p.pixels[3][0] < 1e-4, "the rect bled into its neighbour");
+
+        // Off the sheet entirely is a no-op, not a panic or a wrap.
+        p.rect(-5.0, -5.0, -4.0, -4.0, [1.0, 0.0, 0.0, 1.0]);
+        p.rect(50.0, 50.0, 60.0, 60.0, [1.0, 0.0, 0.0, 1.0]);
+        assert!(p.pixels.iter().all(|px| px[0] == px[1] && px[1] == px[2]), "red leaked in");
+    }
+
+    /// Two grids at cell and 2x cell share their rules exactly, which is the
+    /// whole reason for drawing a second one.
+    #[test]
+    fn test_a_second_grid_lands_on_the_first_ones_rules() {
+        use crate::page::Page;
+        let mut p = Page::new([2.0, 2.0], 100, [1.0, 1.0, 1.0, 1.0]);
+        p.grid(0.25, 0.02, [0.0; 4], [0.0, 0.0, 0.0, 1.0]);
+        // Column of the rule at x = 0.5 inches: 50 px in.
+        let row = 37; // anywhere between two horizontal rules
+        assert!(p.pixels[(row * p.width + 50) as usize][0] < 0.1, "no rule at 0.50 inches");
+        assert!(p.pixels[(row * p.width + 37) as usize][0] > 0.9, "the cell is not clear");
+
+        let mut q = Page::new([2.0, 2.0], 100, [1.0, 1.0, 1.0, 1.0]);
+        q.grid(0.5, 0.02, [0.0; 4], [0.0, 0.0, 0.0, 1.0]);
+        assert!(q.pixels[(row * q.width + 50) as usize][0] < 0.1, "the 2x grid missed the rule");
+
+        // And both rule the sheet's own edge, so neither looks like it stopped
+        // a line short.
+        assert!(p.pixels[(row * p.width) as usize][0] < 0.6, "the left edge is not ruled");
+    }
+
+    /// A border puts its ink INSIDE the sheet: half a border off the paper is
+    /// half a border.
+    #[test]
+    fn test_a_border_stays_on_the_paper() {
+        use crate::page::Page;
+        let mut p = Page::new([2.0, 2.0], 100, [1.0, 1.0, 1.0, 1.0]);
+        p.border(0.25, 0.0, [0.0, 0.0, 0.0, 1.0]);
+        let at = |x: u32, y: u32| p.pixels[(y * p.width + x) as usize][0];
+        assert!(at(0, 100) < 0.1, "the outermost pixel is not inked");
+        assert!(at(24, 100) < 0.1, "the border is thinner than asked");
+        assert!(at(30, 100) > 0.9, "the border is thicker than asked");
+        assert!(at(100, 100) > 0.9, "the border filled the page");
+        // All four sides, not just the two that a copy-paste would reach.
+        assert!(at(199, 100) < 0.1 && at(100, 0) < 0.1 && at(100, 199) < 0.1, "a side is missing");
+    }
+
+    /// The PNG carries the physical size, so a printer lays the sheet out at
+    /// the size it was composed at instead of guessing 96 DPI.
+    #[test]
+    fn test_the_png_knows_its_own_physical_size() {
+        use crate::page::Page;
+        let dir = std::env::temp_dir().join("cce-designer-page-tests");
+        std::fs::create_dir_all(&dir).unwrap();
+        let path = dir.join("sheet.png");
+        let p = Page::new([8.5, 11.0], 300, [1.0, 1.0, 1.0, 1.0]);
+        p.write_png(&path).expect("write");
+
+        let decoder = png::Decoder::new(std::fs::File::open(&path).unwrap());
+        let reader = decoder.read_info().unwrap();
+        let info = reader.info();
+        assert_eq!((info.width, info.height), (2550, 3300));
+        let dims = info.pixel_dims.expect("no pHYs chunk: the printer would guess");
+        assert!(matches!(dims.unit, png::Unit::Meter));
+        // pHYs is pixels per METRE, the only unit PNG offers, so the DPI
+        // round-trips through a conversion and comes back a hair off.
+        let dpi = dims.xppu as f32 / 39.370_08;
+        assert!((dpi - 300.0).abs() < 0.01, "the PNG says {dpi} DPI, not 300");
+        let _ = std::fs::remove_file(&path);
+    }
+
     #[test]
     fn test_a_thin_slab_is_solid_all_the_way_through() {
         // The flood fill decides what is enclosed, and it must not be able to
diff --git a/src/page.rs b/src/page.rs
new file mode 100644
index 0000000..b853558
--- /dev/null
+++ b/src/page.rs
@@ -0,0 +1,557 @@
+//! The 2D page context — a printed sheet, composited from layers.
+//!
+//! This is a SECOND context, deliberately not the geometry graph. Its currency
+//! is a [`Page`] rather than a `Detail`, its coordinates are inches rather than
+//! world units, its origin is the top-left corner with y running DOWN, and
+//! nothing in it has a point id, an attribute or a normal. The geometry graph
+//! describes a thing you will make; a page describes a thing you will print.
+//! Smuggling one into the other means a `Detail` that is secretly a raster and
+//! a viewport that has to guess which it is holding, so they stay apart: page
+//! nodes resolve through [`resolve_page`], never through
+//! `generate_single_node_geometry_with_errors`, and contribute no geometry to
+//! the viewport at all.
+//!
+//! Inches, not millimetres, because the page's own reason for existing is
+//! paper, and paper is specified in inches by the sources this came from (8.5 ×
+//! 11 is the default everywhere in the family). The World Unit declaration that
+//! governs the geometry graph does not reach here — a sheet is a sheet at any
+//! model scale.
+//!
+//! **Resolution is a property of the page, not of the export.** A page carries
+//! its DPI, the raster is that many pixels per inch, and the PNG says so in its
+//! pHYs chunk — so a printer, a slicer or a browser lays the file out at the
+//! physical size it was composed at instead of guessing 96. A page composed at
+//! 300 DPI and printed is 8.5 inches wide; the same pixels labelled 72 are
+//! nearly four feet.
+
+use std::path::Path;
+
+/// Declare a PNG's pixels to be sRGB, the way the spec asks for.
+///
+/// `Encoder::set_srgb` did this in one call and is deprecated; its replacement
+/// `set_source_srgb` writes ONLY the sRGB chunk, dropping the gAMA and cHRM
+/// fallbacks that PNG 11.3.2.5 says to write beside it for decoders that do
+/// not understand sRGB. Swapping one call for the other therefore changes the
+/// file — silently, and only for old decoders, which is the worst way for a
+/// deprecation fix to change behaviour. Both of this app's PNG writers go
+/// through here instead, so they agree and neither one drifts.
+pub fn mark_srgb<W: std::io::Write>(encoder: &mut png::Encoder<W>) {
+    encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
+    encoder.set_source_gamma(png::ScaledFloat::from_scaled(45455));
+    encoder.set_source_chromaticities(png::SourceChromaticities {
+        white: (png::ScaledFloat::from_scaled(31270), png::ScaledFloat::from_scaled(32900)),
+        red: (png::ScaledFloat::from_scaled(64000), png::ScaledFloat::from_scaled(33000)),
+        green: (png::ScaledFloat::from_scaled(30000), png::ScaledFloat::from_scaled(60000)),
+        blue: (png::ScaledFloat::from_scaled(15000), png::ScaledFloat::from_scaled(6000)),
+    });
+}
+
+/// One printed sheet: a physical size, a resolution, and the pixels in between.
+///
+/// Pixels are straight-alpha linear RGBA. Straight rather than premultiplied
+/// because every operation here composites INTO an opaque page, so the extra
+/// multiply buys nothing and the stored values stay the ones the parameters
+/// asked for.
+#[derive(Clone)]
+pub struct Page {
+    /// Physical size in inches, before orientation is applied.
+    pub size: [f32; 2],
+    /// Pixels per inch.
+    pub dpi: u32,
+    pub width: u32,
+    pub height: u32,
+    pub pixels: Vec<[f32; 4]>,
+}
+
+/// The largest page anyone composes by accident: a 1000 DPI A0 sheet is about
+/// 1.4 gigapixels, and the honest failure is a clamped resolution rather than
+/// an allocation that takes the app down. Chosen as roughly 13 × 19 inches (a
+/// large-format print) at 1200 DPI.
+const MAX_PIXELS: u64 = 356_000_000;
+
+impl Page {
+    /// A blank sheet filled with `color`.
+    ///
+    /// The size is clamped to something a printer could accept rather than
+    /// rejected: a page node whose size parameter is being dragged passes
+    /// through zero, and a context that returns an error there flickers.
+    pub fn new(size: [f32; 2], dpi: u32, color: [f32; 4]) -> Page {
+        let size = [size[0].max(0.01), size[1].max(0.01)];
+        let dpi = dpi.clamp(1, 2400);
+        let mut width = (size[0] * dpi as f32).round().max(1.0) as u32;
+        let mut height = (size[1] * dpi as f32).round().max(1.0) as u32;
+        if width as u64 * height as u64 > MAX_PIXELS {
+            // Scale both axes by the same factor so the aspect — the thing the
+            // page size actually means — survives the clamp.
+            let scale = (MAX_PIXELS as f64 / (width as f64 * height as f64)).sqrt();
+            width = ((width as f64 * scale) as u32).max(1);
+            height = ((height as f64 * scale) as u32).max(1);
+        }
+        Page { size, dpi, width, height, pixels: vec![color; (width * height) as usize] }
+    }
+
+    /// Pixels per inch as a float, measured from the raster rather than read
+    /// from `dpi` — after a clamp those disagree, and every drawing operation
+    /// wants the one that describes the pixels it is about to touch.
+    pub fn scale(&self) -> f32 {
+        self.width as f32 / self.size[0]
+    }
+
+    /// Paint the whole sheet.
+    pub fn fill(&mut self, color: [f32; 4]) {
+        for p in &mut self.pixels {
+            *p = color;
+        }
+    }
+
+    /// An axis-aligned rectangle in INCHES from the top-left corner.
+    ///
+    /// Coverage is exact area, not a coin flip on the pixel centre. A printed
+    /// grid is mostly hairlines — a 0.01 inch rule at 300 DPI is three pixels,
+    /// at 100 DPI is one — and a binary fill makes every line snap to whole
+    /// pixels, so a ruled sheet comes out with lines alternating between one
+    /// and two pixels wide down its length. The eye reads that as a wobble in
+    /// the paper, not as aliasing, and it survives printing.
+    pub fn rect(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: [f32; 4]) {
+        let s = self.scale();
+        let (px0, px1) = (x0.min(x1) * s, x0.max(x1) * s);
+        let (py0, py1) = (y0.min(y1) * s, y0.max(y1) * s);
+        if px1 <= 0.0 || py1 <= 0.0 || px0 >= self.width as f32 || py0 >= self.height as f32 {
+            return;
+        }
+        let i0 = px0.floor().max(0.0) as u32;
+        let j0 = py0.floor().max(0.0) as u32;
+        let i1 = (px1.ceil() as i64).clamp(0, self.width as i64) as u32;
+        let j1 = (py1.ceil() as i64).clamp(0, self.height as i64) as u32;
+        for j in j0..j1 {
+            let cy = (py1.min(j as f32 + 1.0) - py0.max(j as f32)).clamp(0.0, 1.0);
+            if cy <= 0.0 {
+                continue;
+            }
+            for i in i0..i1 {
+                let cx = (px1.min(i as f32 + 1.0) - px0.max(i as f32)).clamp(0.0, 1.0);
+                if cx > 0.0 {
+                    self.blend(i, j, color, cx * cy);
+                }
+            }
+        }
+    }
+
+    /// `color` over the pixel, its alpha scaled by `coverage`.
+    fn blend(&mut self, x: u32, y: u32, color: [f32; 4], coverage: f32) {
+        let a = color[3] * coverage;
+        if a <= 0.0 {
+            return;
+        }
+        let dst = &mut self.pixels[(y * self.width + x) as usize];
+        let out_a = a + dst[3] * (1.0 - a);
+        if out_a <= 0.0 {
+            *dst = [0.0; 4];
+            return;
+        }
+        for c in 0..3 {
+            // Straight alpha, so the destination's contribution is weighted by
+            // its own alpha and the result divided back out.
+            dst[c] = (color[c] * a + dst[c] * dst[3] * (1.0 - a)) / out_a;
+        }
+        dst[3] = out_a;
+    }
+
+    /// A ruled grid: cells of `cell` inches filled with `cell_color`, ruled
+    /// with lines `thickness` inches wide in `line_color`.
+    ///
+    /// Lines are centred ON their coordinate, not placed beside it, so a grid
+    /// and a second grid at twice the cell size share their rules exactly
+    /// instead of straddling them — which is the whole point of drawing two.
+    /// The sheet's own edges are ruled too: a grid that stops one line short
+    /// looks like a mistake rather than a margin.
+    pub fn grid(&mut self, cell: f32, thickness: f32, cell_color: [f32; 4], line_color: [f32; 4]) {
+        if cell_color[3] > 0.0 {
+            self.rect(0.0, 0.0, self.size[0], self.size[1], cell_color);
+        }
+        let cell = cell.max(1.0 / self.scale());
+        let half = (thickness * 0.5).max(0.25 / self.scale());
+        let mut x = 0.0;
+        while x <= self.size[0] + 1e-4 {
+            self.rect(x - half, 0.0, x + half, self.size[1], line_color);
+            x += cell;
+        }
+        let mut y = 0.0;
+        while y <= self.size[1] + 1e-4 {
+            self.rect(0.0, y - half, self.size[0], y + half, line_color);
+            y += cell;
+        }
+    }
+
+    /// A border `width` inches wide, drawn INSIDE the sheet's edge.
+    ///
+    /// Inside rather than centred on the edge, because half a border off the
+    /// paper is half a border: the printed page has no bleed here, and a
+    /// parameter that says 0.5 inches should put 0.5 inches of ink on the
+    /// sheet.
+    pub fn border(&mut self, width: f32, inset: f32, color: [f32; 4]) {
+        let (w, h) = (self.size[0], self.size[1]);
+        let width = width.max(0.0).min(w.min(h) * 0.5);
+        let (a, b) = (inset, inset + width);
+        self.rect(a, a, w - a, b, color);
+        self.rect(a, h - b, w - a, h - a, color);
+        self.rect(a, b, b, h - b, color);
+        self.rect(w - b, b, w - a, h - b, color);
+    }
+
+    /// The page as 8-bit sRGB RGBA, row-major from the top — what both the GPU
+    /// upload and the PNG encoder want.
+    pub fn to_rgba8(&self) -> Vec<u8> {
+        let mut out = Vec::with_capacity(self.pixels.len() * 4);
+        for p in &self.pixels {
+            for c in 0..4 {
+                out.push((p[c].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
+            }
+        }
+        out
+    }
+
+    /// Write a PNG that knows its own physical size.
+    ///
+    /// The pHYs chunk carries pixels per METRE, which is the only unit PNG
+    /// offers — so the DPI round-trips through a conversion and comes back a
+    /// hair off. That is the format's limit, not a bug to chase: 300 DPI
+    /// stores as 11811 px/m and reads back as 299.9994.
+    pub fn write_png(&self, path: &Path) -> Result<(), String> {
+        let file = std::fs::File::create(path)
+            .map_err(|e| format!("create {}: {e}", path.display()))?;
+        let mut encoder =
+            png::Encoder::new(std::io::BufWriter::new(file), self.width, self.height);
+        encoder.set_color(png::ColorType::Rgba);
+        encoder.set_depth(png::BitDepth::Eight);
+        mark_srgb(&mut encoder);
+        let per_metre = (self.scale() * 39.370_08).round() as u32;
+        encoder.set_pixel_dims(Some(png::PixelDimensions {
+            xppu: per_metre,
+            yppu: per_metre,
+            unit: png::Unit::Meter,
+        }));
+        let mut writer = encoder.write_header().map_err(|e| format!("png header: {e}"))?;
+        writer
+            .write_image_data(&self.to_rgba8())
+            .map_err(|e| format!("png write: {e}"))?;
+        writer.finish().map_err(|e| format!("png finish: {e}"))?;
+        Ok(())
+    }
+}
+
+/// Where a run of text sits in the box it is given.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum HAlign {
+    Left,
+    Center,
+    Right,
+}
+
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum VAlign {
+    Top,
+    Middle,
+    Bottom,
+}
+
+/// Everything the text operation needs. A struct rather than a dozen arguments
+/// because the node has a dozen parameters and threading them positionally is
+/// how the wrong two get swapped.
+pub struct TextSpec<'a> {
+    pub text: &'a str,
+    pub font: &'a str,
+    /// Cap height in inches — a "0.1 font size" on a printed page means a tenth
+    /// of an inch of type, not a tenth of a pixel or of the sheet.
+    pub size: f32,
+    pub color: [f32; 4],
+    /// Where the text box's own origin sits on the sheet, in inches.
+    pub at: [f32; 2],
+    pub halign: HAlign,
+    pub valign: VAlign,
+    /// Line spacing as a multiple of the font size.
+    pub leading: f32,
+}
+
+impl Default for TextSpec<'_> {
+    fn default() -> Self {
+        TextSpec {
+            text: "",
+            font: "",
+            size: 0.1,
+            color: [0.0, 0.0, 0.0, 1.0],
+            at: [0.5, 0.5],
+            halign: HAlign::Left,
+            valign: VAlign::Top,
+            leading: 1.25,
+        }
+    }
+}
+
+impl Page {
+    /// Draw shaped text onto the sheet.
+    ///
+    /// Shaping and rasterizing both come from cosmic-text, which the toolkit
+    /// already owns — the alternative is a second font stack in the same
+    /// process disagreeing with the first about what a font is called.
+    ///
+    /// The size is converted to pixels here, at the page's own scale, so the
+    /// same page composed at 150 and at 600 DPI prints identical type at
+    /// different sample counts. That is the property that makes resolution a
+    /// page parameter rather than an export one.
+    pub fn text(
+        &mut self,
+        fonts: &mut cce_ui::cosmic_text::FontSystem,
+        cache: &mut cce_ui::cosmic_text::SwashCache,
+        spec: &TextSpec,
+    ) {
+        use cce_ui::cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping};
+        if spec.text.is_empty() || spec.size <= 0.0 {
+            return;
+        }
+        let px = (spec.size * self.scale()).max(1.0);
+        let mut buffer = Buffer::new(fonts, Metrics::new(px, px * spec.leading.max(0.1)));
+        // No width or height limit: the box is measured from the shaped text
+        // rather than the text wrapped into a box. A printed label that
+        // silently wraps is worse than one that runs long, because the run-on
+        // is visible and the wrap looks deliberate.
+        buffer.set_size(fonts, None, None);
+        let attrs = if spec.font.trim().is_empty() {
+            Attrs::new()
+        } else {
+            Attrs::new().family(Family::Name(spec.font.trim()))
+        };
+        buffer.set_text(fonts, spec.text, attrs, Shaping::Advanced);
+        buffer.shape_until_scroll(fonts, false);
+
+        // Measure what was actually shaped, so alignment is against the ink
+        // rather than against the requested size.
+        let mut text_w: f32 = 0.0;
+        let mut lines = 0.0f32;
+        for run in buffer.layout_runs() {
+            text_w = text_w.max(run.line_w);
+            lines += 1.0;
+        }
+        let line_h = px * spec.leading.max(0.1);
+        let text_h = lines.max(1.0) * line_h;
+
+        let ox = spec.at[0] * self.scale()
+            - match spec.halign {
+                HAlign::Left => 0.0,
+                HAlign::Center => text_w * 0.5,
+                HAlign::Right => text_w,
+            };
+        let oy = spec.at[1] * self.scale()
+            - match spec.valign {
+                VAlign::Top => 0.0,
+                VAlign::Middle => text_h * 0.5,
+                VAlign::Bottom => text_h,
+            };
+
+        let color = cce_ui::cosmic_text::Color::rgba(255, 255, 255, 255);
+        let (w, h) = (self.width as i32, self.height as i32);
+        let mut hits: Vec<(u32, u32, f32)> = Vec::new();
+        buffer.draw(fonts, cache, color, |x, y, gw, gh, c| {
+            // cosmic-text hands back a filled rect per span; the alpha is the
+            // glyph's coverage. The colour it carries is the one passed in,
+            // which is why that is opaque white — the page's own colour is
+            // applied here, so a coloured glyph is not double-tinted.
+            let a = c.a() as f32 / 255.0;
+            if a <= 0.0 {
+                return;
+            }
+            for dy in 0..gh as i32 {
+                for dx in 0..gw as i32 {
+                    let (px, py) = (x + dx + ox as i32, y + dy + oy as i32);
+                    if px >= 0 && py >= 0 && px < w && py < h {
+                        hits.push((px as u32, py as u32, a));
+                    }
+                }
+            }
+        });
+        for (x, y, a) in hits {
+            self.blend(x, y, spec.color, a);
+        }
+    }
+}
+
+// ---------------------------------------------------------------------------
+// The node chain
+// ---------------------------------------------------------------------------
+
+use crate::app::FsNode;
+use crate::geometry::{find_node_by_name, node_param_f32, node_param_str, node_param_vec3};
+use glam::Vec3;
+
+/// Whether a node belongs to the page context rather than the geometry graph.
+///
+/// The two do not mix, and this is the one place that says so. A page node
+/// contributes nothing to the viewport's geometry and a geometry node cannot
+/// feed a page, so the network is really two networks sharing an editor — the
+/// same way Houdini's contexts do, and for the same reason: a raster and a
+/// mesh have no operation in common.
+pub fn is_page_node(node_type: &str) -> bool {
+    matches!(
+        node_type.to_ascii_lowercase().as_str(),
+        "page" | "page_grid" | "page_border" | "page_text"
+    )
+}
+
+/// Named sheet sizes, in inches, portrait.
+///
+/// A4 is metric and converts to 8.268 × 11.693 — carried at that precision
+/// rather than rounded, because a rounded A4 prints with a visible margin
+/// error at the bottom of the sheet.
+fn preset_size(name: &str) -> Option<[f32; 2]> {
+    match name.trim().to_ascii_lowercase().as_str() {
+        "letter" => Some([8.5, 11.0]),
+        "a4" => Some([8.267_717, 11.692_913]),
+        "legal" => Some([8.5, 14.0]),
+        "tabloid" => Some([11.0, 17.0]),
+        _ => None,
+    }
+}
+
+fn color_of(node: &FsNode, name: &str, fallback: Vec3) -> [f32; 4] {
+    let c = node_param_vec3(node, name, fallback);
+    [c.x, c.y, c.z, 1.0]
+}
+
+fn toggle_of(node: &FsNode, name: &str) -> bool {
+    matches!(node_param_str(node, name, "false").trim().to_ascii_lowercase().as_str(), "true" | "1" | "on")
+}
+
+/// Compose the page `target` describes, resolving its input chain.
+///
+/// `visited` guards cycles by node id exactly as the geometry resolvers do —
+/// the page context is a second graph, not a second kind of graph.
+pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -> Option<Page> {
+    if visited.contains(&target.id) {
+        return None;
+    }
+    visited.push(target.id.clone());
+
+    let kind = target.node_type.to_ascii_lowercase();
+    if kind == "page" {
+        let preset = node_param_str(target, "Preset", "Letter");
+        let size = preset_size(&preset).unwrap_or([
+            node_param_f32(target, "Width", 8.5),
+            node_param_f32(target, "Height", 11.0),
+        ]);
+        // Landscape is the same sheet turned, not a different sheet: swap the
+        // axes rather than asking for a second pair of numbers.
+        let size = if node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape")
+        {
+            [size[1], size[0]]
+        } else {
+            size
+        };
+        let dpi = node_param_f32(target, "Resolution", 300.0).round().max(1.0) as u32;
+        return Some(Page::new(size, dpi, color_of(target, "Color", Vec3::ONE)));
+    }
+
+    // Everything else composites onto its input, so a chain with no page at
+    // the bottom of it has nothing to draw on and resolves to nothing. That is
+    // the honest answer: a border with no page is not a page with a border —
+    // and it is also how an Export node in a GEOMETRY chain falls through to
+    // the geometry resolvers rather than being claimed by this one.
+    let input = find_node_by_name(root, node_param_str(target, "Input", "").trim())?;
+    let mut page = resolve_page(root, input, visited)?;
+
+    match kind.as_str() {
+        // Export belongs to neither context and passes through both. What it
+        // writes is decided by what reaches it: a page becomes a PNG, geometry
+        // becomes the mesh format its Format parameter names.
+        "export" => {}
+        "page_grid" => {
+            let cell_color = if toggle_of(target, "Fill Cells") {
+                color_of(target, "Cell Color", Vec3::ONE)
+            } else {
+                [0.0; 4]
+            };
+            page.grid(
+                node_param_f32(target, "Cell Size", 0.25),
+                node_param_f32(target, "Line Width", 0.01),
+                cell_color,
+                color_of(target, "Line Color", Vec3::ZERO),
+            );
+        }
+        "page_border" => page.border(
+            node_param_f32(target, "Width", 0.06),
+            node_param_f32(target, "Inset", 0.4),
+            color_of(target, "Color", Vec3::ZERO),
+        ),
+        "page_text" => {
+            let text = node_param_str(target, "Text", "");
+            let font = node_param_str(target, "Font", "");
+            let spec = TextSpec {
+                text: &text,
+                font: &font,
+                size: node_param_f32(target, "Size", 0.25),
+                color: color_of(target, "Color", Vec3::ZERO),
+                at: [node_param_f32(target, "X", 4.25), node_param_f32(target, "Y", 0.8)],
+                halign: match node_param_str(target, "Horizontal", "Center").as_str() {
+                    "Left" => HAlign::Left,
+                    "Right" => HAlign::Right,
+                    _ => HAlign::Center,
+                },
+                valign: match node_param_str(target, "Vertical", "Top").as_str() {
+                    "Middle" => VAlign::Middle,
+                    "Bottom" => VAlign::Bottom,
+                    _ => VAlign::Top,
+                },
+                leading: node_param_f32(target, "Leading", 1.25),
+            };
+            with_fonts(|fonts, cache| page.text(fonts, cache, &spec));
+        }
+        _ => return None,
+    }
+    Some(page)
+}
+
+/// The page context's font stack, created once.
+///
+/// System fonts included, because a page node names its font by family — the
+/// source family's default is "Lato" — and a font stack that only knows the
+/// bundled house faces would silently substitute for every named font a user
+/// actually owns. Shaping the app's own widgets stays on the toolkit's
+/// geometry font system; this one is for ink on paper.
+fn with_fonts<R>(
+    f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
+) -> R {
+    use std::sync::{Mutex, OnceLock};
+    type Stack = (cce_ui::cosmic_text::FontSystem, cce_ui::cosmic_text::SwashCache);
+    static FONTS: OnceLock<Mutex<Stack>> = OnceLock::new();
+    let stack = FONTS.get_or_init(|| {
+        Mutex::new((
+            cce_ui::create_font_system_with_system_fonts(),
+            cce_ui::cosmic_text::SwashCache::new(),
+        ))
+    });
+    let mut guard = stack.lock().unwrap();
+    let (fonts, cache) = &mut *guard;
+    f(fonts, cache)
+}
+
+/// The page a network level displays, if it displays one.
+///
+/// The same rule the viewport follows for geometry: draw what is visible at
+/// the level being shown. Several page chains at one level is ambiguous, so
+/// the LAST visible page node wins — the one furthest down the roster, which
+/// is the one most recently added.
+pub fn displayed_page(root: &FsNode, level: &FsNode) -> Option<Page> {
+    let target = level
+        .children
+        .iter()
+        .filter(|c| is_page_node(&c.node_type) && c.geometry_visible)
+        .next_back()?;
+    resolve_page(root, target, &mut Vec::new())
+}
+
+/// The font stack, for tests that draw text without a node behind them.
+#[cfg(test)]
+pub fn with_fonts_for_test<R>(
+    f: impl FnOnce(&mut cce_ui::cosmic_text::FontSystem, &mut cce_ui::cosmic_text::SwashCache) -> R,
+) -> R {
+    with_fonts(f)
+}
diff --git a/src/render.rs b/src/render.rs
index 29bdf91..a3f49e6 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -122,7 +122,13 @@ impl State {
 
         let mut draw_order: Vec<usize> = (0..WIDGET_COUNT).collect();
         draw_order.sort_by_key(|&i| {
+            // PAGE_IDX shares the viewport's layer, not the roster's tail.
+            // The viewport is full-bleed and the other panes float OVER it, so
+            // a pane that takes the viewport's rect has to take its depth too
+            // — drawn last it covers the collapsed stubs and the corner dots,
+            // which then show through as ghost text from the later label pass.
             let base_key = if i == VIEWPORT_IDX
+                || i == crate::slots::PAGE_IDX
                 || i == NETWORK_PANEL_IDX
                 || i == crate::slots::NETWORK_PANEL2_IDX
             {
@@ -285,6 +291,17 @@ impl State {
             let (wx, wy, ww2, wh2) = w.rect();
             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
             w.paint_self(&self.ui_context, pc);
+        } else if idx == crate::slots::PAGE_IDX {
+            // Modern-paint pane, like the spreadsheet: the designer authors the
+            // plate (span-widened radii, focus tint) and ImageView::paint fits
+            // the sheet into it. The fall-through branch below serves LEGACY
+            // widgets — it emits a plate and the widget's legacy views — so a
+            // widget whose whole look lives in Paint::paint lands there and
+            // draws nothing at all, which is exactly what this pane did before
+            // the branch existed: visible, correctly placed, and blank.
+            let (wx, wy, ww2, wh2) = w.rect();
+            append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
+            w.paint_self(&self.ui_context, pc);
         } else if idx == VIEWPORT_IDX {
             // The scene viewer's lip is the window's own root plate edge: the
             // 3D canvas is full-bleed (CANVAS_IDX covers the window; the other
@@ -854,6 +871,12 @@ impl State {
         if !self.show_viewport {
             return;
         }
+        // The readout describes the 3D world's scale on screen. A page is not
+        // in that world — it is a sheet of paper measured in inches — so over
+        // a page the number is not merely irrelevant, it is wrong.
+        if self.slots.page_view.image.is_some() {
+            return;
+        }
         let (vx, vy, vw, vh) = self.last_scene_view_rect;
         if vw <= 0.0 || vh <= 0.0 {
             return;
@@ -916,6 +939,38 @@ impl State {
         });
     }
 
+    /// Compose the 2D page the displayed level holds, if it holds one, and
+    /// hand it to the page pane.
+    ///
+    /// The page context's counterpart to the geometry rebuild, and it runs on
+    /// the same trigger for the same reason: a parameter changed, so what the
+    /// pane shows is stale. The raster goes to the GPU as an image the widget
+    /// only BORROWS — the id is owned here and freed when it is replaced, so a
+    /// page that is being scrubbed does not leak a texture per frame.
+    pub(crate) fn rebuild_page(&mut self) {
+        let page = crate::page::displayed_page(&self.fs_root, self.viewport_editor_dir());
+        if let Some(old) = self.page_image.take() {
+            cce_ui::vk::free_image(old);
+        }
+        match page {
+            Some(page) => {
+                let (w, h) = (page.width, page.height);
+                let id = cce_ui::vk::upload_rgba(page.to_rgba8(), w, h);
+                self.page_image = Some(id);
+                self.slots.page_view.set_image(Some((id, w, h)));
+                self.update_status_text(&format!(
+                    "Page: {:.2} x {:.2} in at {} DPI ({}x{})",
+                    page.size[0], page.size[1], page.dpi, w, h
+                ));
+            }
+            None => self.slots.page_view.set_image(None),
+        }
+        // Visibility and placement follow the image, and both are decided in
+        // rebuild_positions.
+        self.rebuild_positions();
+        self.apply_layout();
+    }
+
     pub(crate) fn rebuild_scene_geometry(&mut self) {
         let mut ocl_error = None;
         // The sim cache lives on State so playing forward steps each simnet once
@@ -986,6 +1041,11 @@ impl State {
             cce_ui::colors::to_linear_rgb,
         ));
         self.meta_points_dirty = true;
+
+        // Last, not first: the page's status line would otherwise be
+        // overwritten by the geometry pass's own, and a level showing a page
+        // has nothing to say about geometry.
+        self.rebuild_page();
     }
 
     /// The path tracer's scene: the sphere geometry (and the reference cube if
diff --git a/src/slots.rs b/src/slots.rs
index 60bfc0f..94b46e2 100644
--- a/src/slots.rs
+++ b/src/slots.rs
@@ -9,8 +9,8 @@
 //! that assert each slot's concrete type are hand-written, below the macro.
 
 use cce_ui::widget::{
-    Adapted, Breadcrumb, Graph, MenuBar, ParametersBg, Splitter, Spreadsheet, StatusBar,
-    WidgetHost,
+    Adapted, Breadcrumb, Graph, ImageView, MenuBar, ParametersBg, Splitter, Spreadsheet,
+    StatusBar, WidgetHost,
 };
 
 use crate::playbar::Playbar;
@@ -113,6 +113,10 @@ widget_roster! {
     NETWORK_PANEL2_IDX:      network_panel2:      PassivePlate,
     CONTENT2_IDX:            content2:            Graph,
     BREADCRUMB2_IDX:         breadcrumb2:         Breadcrumb,
+    // The 2D page context's surface, sharing the viewport's rect and shown in
+    // its place when the displayed level holds a page. Appended, like the
+    // second network editor, so established slot indexes stay stable.
+    PAGE_IDX:                page_view:           ImageView,
 }
 
 impl WidgetSlots {
diff --git a/src/thumbnail.rs b/src/thumbnail.rs
index a4f2fd0..bdec1ed 100644
--- a/src/thumbnail.rs
+++ b/src/thumbnail.rs
@@ -72,7 +72,7 @@ pub fn run(project: &Path, out: &Path, size: u32, samples: Option<u32>, frame: O
     let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), size, size);
     encoder.set_color(png::ColorType::Rgba);
     encoder.set_depth(png::BitDepth::Eight);
-    encoder.set_srgb(png::SrgbRenderingIntent::Perceptual);
+    crate::page::mark_srgb(&mut encoder);
     let mut writer = encoder.write_header().map_err(|e| format!("png header: {e}"))?;
     writer.write_image_data(&pixels).map_err(|e| format!("png write: {e}"))?;
     writer.finish().map_err(|e| format!("png finish: {e}"))?;