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

commite8ab24dfb254bc0f81c526e54b5151cfb24db578
parent4469a77d55
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 12:58
refactor: a page has no Orientation row

A page's orientation is its Width and Height: a paper preset is written
portrait, and a landscape sheet is the two typed the other way round. A
save from before is migrated with its sheet turned as its Orientation
said, and the row is dropped from every page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       | 27 +++++++++++++------------
 nodes/page.json |  5 ++---
 src/app.rs      | 11 ++++++-----
 src/main.rs     | 61 +++++++++++++++++++++++++++------------------------------
 src/page.rs     | 55 +++++++++++++++++++++++++--------------------------
 src/window.rs   |  2 +-
 6 files changed, 79 insertions(+), 82 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9db32d4..c661743 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1974,21 +1974,22 @@ slider clamps, and no one range holds both 0.25 inches and 1920 pixels.
 
 **The size IS Width and Height, and a preset writes them** (since
 2026-09-30). `page_node_frame` reads Width by Height in Units and nothing
-else; Preset, Orientation and Units are rows that SET those two when they
-are picked (`page::follow_page_rows`, called by the params pane's
-write-back and MCP's `set_param`, and what it overwrites is part of the
-undo step). Preset writes its size in the page's Units — the four paper
-sizes turned by Orientation, the three raster ones (`HD`, `4K`, `Square`)
-as they lie, Orientation set to say which way; Orientation swaps the two
-when they lie the other way; Units converts them, so the sheet keeps its
-size. There is no `Custom` preset: until then Width and Height were read
-only under it, a second way to say the size that the presets could not
-share. Preset names what was last picked, and a size typed in afterwards
-is the size. A save from before is carried over ONCE in the template merge
+else; Preset and Units are rows that SET those two when they are picked
+(`page::follow_page_rows`, called by the params pane's write-back and
+MCP's `set_param`, and what it overwrites is part of the undo step).
+Preset writes its size in the page's Units — the four paper sizes
+portrait, the three raster ones (`HD`, `4K`, `Square`) as they lie; Units
+converts them, so the sheet keeps its size. There is no `Custom` preset
+(until then Width and Height were read only under it, a second way to say
+the size that the presets could not share) and no Orientation row: a
+landscape sheet is Width and Height typed the other way round. Preset
+names what was last picked, and a size typed in afterwards is the size. A
+save from before is carried over ONCE in the template merge
 (`page::migrate_preset_rows`), recognised by the Width row's `show_when`
 still reading `Preset == Custom`, which the merge then replaces: a named
-preset is written into Width and Height as it was drawn, and a Custom page
-keeps its size and names Letter. `picking_a_page_preset_writes_its_size`
+preset is written into Width and Height, a sheet turned as its old
+Orientation row said, and a Custom page keeps its size and names Letter.
+The Orientation row is dropped from every page. `picking_a_page_preset_writes_its_size`
 is the test.
 
 **`page_shape`** draws a rectangle (with a corner radius), an ellipse, a
diff --git a/nodes/page.json b/nodes/page.json
index bf0a354..da8c5d2 100644
--- a/nodes/page.json
+++ b/nodes/page.json
@@ -4,11 +4,10 @@
  "inputs": 0,
  "outputs": 1,
  "params": [
-  { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,HD,4K,Square", "default": "Letter", "description": "Picking one writes its size into Width and Height, in Units: Letter, A4, Legal and Tabloid are paper sizes, HD, 4K and Square pixel sizes. Shows the last one picked." },
+  { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,HD,4K,Square", "default": "Letter", "description": "Picking one writes its size into Width and Height, in Units: Letter, A4, Legal and Tabloid as portrait sheets, HD, 4K and Square as pixel sizes. Shows the last one picked." },
   { "name": "Units", "type": "choice:Inches,Millimetres,Centimetres,Pixels", "default": "Inches", "description": "Unit Width and Height, and every length on the nodes drawing onto this page, are written in. Changing it converts Width and Height, so the page keeps its size." },
-  { "name": "Width", "type": "float", "default": "8.5", "description": "Page width in Units; with Height, the page's size. A preset writes it, and any other width can be typed. In Pixels its physical size depends on Resolution." },
+  { "name": "Width", "type": "float", "default": "8.5", "description": "Page width in Units; with Height, the page's size. A preset writes it, and any other width can be typed; swap it with Height for landscape. In Pixels its physical size depends on Resolution." },
   { "name": "Height", "type": "float", "default": "11.0", "description": "Page height in Units; with Width, the page's size. A preset writes it, and any other height can be typed. In Pixels its physical size depends on Resolution." },
-  { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait", "description": "Portrait makes the page taller than wide, Landscape wider than tall, by swapping Width and Height. A paper preset is picked in this orientation." },
   { "name": "Resolution", "type": "spinbox", "default": "300", "min": 18, "max": 1200, "step": 6, "description": "Pixels per inch (DPI). More pixels for a paper size; for pixel presets it sets the physical size. Stored in exported PNGs so prints come out at size." },
   { "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "description": "Background colour the whole page is filled with before anything is drawn on it." },
   { "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "description": "Opacity of the background fill, 0 to 1. At 0 the page is clear and only what is drawn on it shows; clear areas are see-through in the viewport and PNG." },
diff --git a/src/app.rs b/src/app.rs
index 1ebe810..5f284e9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1202,9 +1202,10 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
         if node.node_type == "camera" {
             node.params.retain(|p| p.name != "Square Aspect" && p.name != "Show Camera Pivot");
         }
-        // A page's size is its Width and Height now, and Preset has no
-        // Custom: a save from before is carried over once, ahead of the
-        // merge that would take the old rows' conditions away.
+        // A page's size is its Width and Height now, Preset has no Custom
+        // and there is no Orientation row: a save from before is carried
+        // over once, ahead of the merge that would take the old rows'
+        // conditions away.
         if node.node_type == "page" {
             crate::page::migrate_preset_rows(node);
         }
@@ -4097,11 +4098,11 @@ impl State {
                         }
                     }
 
-                    // A page's Preset, Orientation and Units set its Width
+                    // A page's Preset and Units set its Width
                     // and Height; what they overwrite is part of the step.
                     let mut followed = false;
                     if child.node_type == "page" {
-                        let setters: Vec<ParamDef> = was.iter().filter(|w| matches!(w.name.as_str(), "Preset" | "Orientation" | "Units")).cloned().collect();
+                        let setters: Vec<ParamDef> = was.iter().filter(|w| matches!(w.name.as_str(), "Preset" | "Units")).cloned().collect();
                         for w in setters {
                             for r in crate::page::follow_page_rows(child, &w) {
                                 followed = true;
diff --git a/src/main.rs b/src/main.rs
index a7ea575..be1a2c8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6553,7 +6553,6 @@ mod tests {
                 "page",
                 &[
                     ("Preset", "Letter"),
-                    ("Orientation", "Portrait"),
                     ("Resolution", "72"),
                     ("Color", "1.00:1.00:1.00"),
                 ],
@@ -12636,7 +12635,7 @@ mod tests {
         };
         let chain = |bypassed: &[&str]| {
             let mut nodes = vec![
-                node("p", "page1", "page", &[("Preset", "Letter"), ("Orientation", "Portrait"), ("Resolution", "72"), ("Color", "1.00:1.00:1.00")]),
+                node("p", "page1", "page", &[("Preset", "Letter"), ("Resolution", "72"), ("Color", "1.00:1.00:1.00")]),
                 node("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")]),
                 node("b", "border1", "page_border", &[("Input", "grid1"), ("Width", "0.1"), ("Inset", "0.25"), ("Color", "1.00:0.00:0.00")]),
                 node("e", "export1", "export", &[("Input", "border1")]),
@@ -16782,17 +16781,17 @@ mod tests {
         assert_eq!(clear.origin, [1.0, 2.0, 3.0]);
     }
 
-    /// A page's size is its Width and Height, always: Preset has no Custom,
-    /// and picking a preset WRITES its size there, in the page's Units — a
-    /// sheet turned by Orientation, a raster size as it lies, with
-    /// Orientation set to say which way. Orientation swaps the two, Units
-    /// converts them, and a save from before carries over once.
+    /// A page's size is its Width and Height, always: Preset has no Custom
+    /// and there is no Orientation row. Picking a preset WRITES its size
+    /// there, in the page's Units (a sheet portrait, a raster size as it
+    /// lies), Units converts them, and a save from before carries over once,
+    /// a landscape sheet as it was drawn.
     #[test]
     fn picking_a_page_preset_writes_its_size() {
         use crate::page::{follow_page_rows, migrate_preset_rows, resolve_page};
         let size = |node: &FsNode| {
             let row = |n: &str| node.params.iter().find(|p| p.name == n).unwrap().text().to_string();
-            (row("Width"), row("Height"), row("Orientation"))
+            (row("Width"), row("Height"))
         };
         let pick = |node: &mut FsNode, row: &str, value: &str| -> Vec<String> {
             let p = node.params.iter_mut().find(|p| p.name == row).unwrap();
@@ -16801,47 +16800,45 @@ mod tests {
             follow_page_rows(node, &was).into_iter().map(|p| p.name).collect()
         };
         let templates_root = crate::app::load_fs_tree();
+        let templates = crate::app::flatten_node_templates(&templates_root);
         let template = templates_root.children.iter().find(|t| t.node_type == "page").unwrap().clone();
         let preset = template.params.iter().find(|p| p.name == "Preset").unwrap();
         assert!(!preset.choice_options().iter().any(|o| o == "Custom"), "{:?}", preset.choice_options());
-        for row in ["Width", "Height", "Orientation"] {
+        assert!(template.params.iter().all(|p| p.name != "Orientation"));
+        for row in ["Width", "Height"] {
             assert!(template.params.iter().find(|p| p.name == row).unwrap().show_when.is_empty(), "{row} is always shown");
         }
 
         let mut page = template.clone();
         assert_eq!(pick(&mut page, "Preset", "A4"), ["Width", "Height"], "what a pick overwrites is handed back, for undo");
-        assert_eq!(size(&page), ("8.268".into(), "11.693".into(), "Portrait".into()));
-        assert_eq!(pick(&mut page, "Orientation", "Landscape"), ["Width", "Height"]);
-        assert_eq!(size(&page), ("11.693".into(), "8.268".into(), "Landscape".into()));
-        // A sheet is picked in the orientation the row names.
-        pick(&mut page, "Preset", "Letter");
-        assert_eq!(size(&page), ("11.00".into(), "8.50".into(), "Landscape".into()));
+        assert_eq!(size(&page), ("8.268".into(), "11.693".into()));
+        pick(&mut page, "Preset", "Tabloid");
+        assert_eq!(size(&page), ("11.00".into(), "17.00".into()), "a sheet is written portrait");
         // Units converts: the sheet keeps its size.
         pick(&mut page, "Units", "Millimetres");
-        assert_eq!(size(&page), ("279.40".into(), "215.90".into(), "Landscape".into()));
-        // A raster size is written as it lies, in the page's unit, and
-        // Orientation follows it.
-        pick(&mut page, "Orientation", "Portrait");
+        assert_eq!(size(&page), ("279.40".into(), "431.80".into()));
+        // A raster size is written as it lies, in the page's unit.
         pick(&mut page, "Units", "Pixels");
         pick(&mut page, "Preset", "HD");
-        assert_eq!(size(&page), ("1920".into(), "1080".into(), "Landscape".into()));
+        assert_eq!(size(&page), ("1920".into(), "1080".into()));
         let root = image_root(vec![page.clone()]);
         let img = resolve_page(&root, &root.children[0], &mut Vec::new()).unwrap();
         assert_eq!((img.width, img.height), (1920, 1080));
-        // Another row changes nothing; nor does turning a page already turned.
+        // Another row changes nothing.
         assert!(pick(&mut page, "Resolution", "72").is_empty());
-        assert!(pick(&mut page, "Orientation", "Landscape").is_empty());
         // A size typed in is the size, whatever Preset still names.
         pick(&mut page, "Width", "640");
         let root = image_root(vec![page.clone()]);
         assert_eq!(resolve_page(&root, &root.children[0], &mut Vec::new()).unwrap().width, 640);
 
-        // A save from before: its rows carry the old conditions. A named
-        // preset is written into Width and Height, turned as it was drawn;
-        // a Custom one keeps its size and names Letter.
+        // A save from before: its rows carry the old conditions and an
+        // Orientation row. A named preset is written into Width and Height,
+        // turned as it was drawn; a Custom one keeps its size and names
+        // Letter; the Orientation row goes.
         let old = |preset: &str, w: &str, h: &str, orientation: &str| {
             let mut n = template.clone();
-            for (row, v) in [("Preset", preset), ("Width", w), ("Height", h), ("Orientation", orientation)] {
+            n.params.push(crate::app::ParamDef::new("Orientation", "choice:Portrait,Landscape", orientation));
+            for (row, v) in [("Preset", preset), ("Width", w), ("Height", h)] {
                 let p = n.params.iter_mut().find(|p| p.name == row).unwrap();
                 p.set_type("text");
                 p.set_text(v);
@@ -16853,14 +16850,14 @@ mod tests {
         };
         let mut tabloid = old("Tabloid", "8.5", "11.0", "Landscape");
         migrate_preset_rows(&mut tabloid);
-        assert_eq!(size(&tabloid), ("17.00".into(), "11.00".into(), "Landscape".into()));
+        assert_eq!(size(&tabloid), ("17.00".into(), "11.00".into()));
+        assert!(tabloid.params.iter().all(|p| p.name != "Orientation"));
         let mut custom = old("Custom", "3", "2", "Portrait");
         migrate_preset_rows(&mut custom);
-        assert_eq!(size(&custom), ("3".into(), "2".into(), "Portrait".into()));
+        assert_eq!(size(&custom), ("3".into(), "2".into()));
         assert_eq!(custom.params.iter().find(|p| p.name == "Preset").unwrap().text(), "Letter");
         // Once: the merge takes the old conditions away, so a page loaded
         // a second time is left as it is.
-        let templates = crate::app::flatten_node_templates(&templates_root);
         let mut loaded = image_root(vec![old("Custom", "3", "2", "Portrait")]);
         crate::app::merge_template_defs(&mut loaded, &templates);
         assert_eq!(size(&loaded.children[0]), size(&custom));
@@ -16868,8 +16865,8 @@ mod tests {
         assert_eq!(size(&loaded.children[0]), size(&custom), "a second load wrote Letter over a typed size");
         let mut merged = image_root(vec![old("A4", "8.5", "11.0", "Portrait")]);
         crate::app::merge_template_defs(&mut merged, &templates);
-        assert_eq!(size(&merged.children[0]), ("8.268".into(), "11.693".into(), "Portrait".into()));
-        assert!(merged.children[0].params.iter().all(|p| p.invalid().is_none()));
+        assert_eq!(size(&merged.children[0]), ("8.268".into(), "11.693".into()));
+        assert!(merged.children[0].params.iter().all(|p| p.invalid().is_none() && p.name != "Orientation"));
 
         // Through MCP, and undone as one step.
         let mut state = State::new(false);
@@ -16879,7 +16876,7 @@ mod tests {
         state
             .apply_action(crate::app::McpAction::SetParam { slot, name: "Preset".into(), value: "Tabloid".into() }, &mut redraw)
             .unwrap();
-        assert_eq!(size(&state.current_dir().children[slot]), ("11.00".into(), "17.00".into(), "Portrait".into()));
+        assert_eq!(size(&state.current_dir().children[slot]), ("11.00".into(), "17.00".into()));
         state.run_command("undo");
         assert_eq!(size(&state.current_dir().children[slot]), before, "the pick and what it wrote are one step");
     }
diff --git a/src/page.rs b/src/page.rs
index 4ef1954..02760a4 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -788,8 +788,8 @@ fn toggle_of(node: &FsNode, name: &str) -> bool {
 }
 
 /// The frame a `page` node describes: Width by Height in its Units. The
-/// Preset, Orientation and Units rows do not enter into it — they set
-/// Width and Height when they are picked ([`follow_page_rows`]).
+/// Preset and Units rows do not enter into it — they set Width and Height
+/// when they are picked ([`follow_page_rows`]).
 fn page_node_frame(target: &FsNode) -> PageFrame {
     let unit = PageUnit::parse(&node_param_str(target, "Units", "Inches"));
     let dpi = page_dpi(target);
@@ -807,8 +807,9 @@ fn page_dpi(node: &FsNode) -> f32 {
     node_param_f32(node, "Resolution", 300.0).round().clamp(1.0, 2400.0)
 }
 
-/// A preset's size in inches: a sheet turned by `landscape`, a raster
-/// size as it lies, at `dpi`.
+/// A preset's size in inches: a sheet portrait, or turned by `landscape`
+/// (which only a save from before carries), a raster size as it lies, at
+/// `dpi`.
 fn preset_inches(name: &str, dpi: f32, landscape: bool) -> Option<[f32; 2]> {
     if let Some([w, h]) = preset_size(name) {
         return Some(if landscape { [h, w] } else { [w, h] });
@@ -819,33 +820,31 @@ fn preset_inches(name: &str, dpi: f32, landscape: bool) -> Option<[f32; 2]> {
 /// What a `page` node's Width and Height become when one of the rows that
 /// SET them was just changed, `was` being that row as it stood before:
 ///
-/// - **Preset** writes the preset's size, in the node's Units. A sheet is
-///   turned by Orientation; a raster size (HD, 4K, Square) is written as
-///   it lies, and Orientation is set to say which way that is.
-/// - **Orientation** swaps Width and Height when they lie the other way.
+/// - **Preset** writes the preset's size, in the node's Units: a sheet
+///   portrait, a raster size (HD, 4K, Square) as it lies. A landscape
+///   sheet is its Width and Height typed the other way round; there is no
+///   Orientation row.
 /// - **Units** converts them from the old unit, so the sheet keeps its
 ///   size and only the numbers change.
 ///
 /// A Width or Height that is an expression is left to it. Returns the rows
-/// rewritten, as they were, for undo; empty when `was` is none of the
-/// three or nothing moved. There is no Custom preset: a size typed into
-/// Width and Height is the size, and Preset names what was last picked.
+/// rewritten, as they were, for undo; empty when `was` is neither or
+/// nothing moved. There is no Custom preset: a size typed into Width and
+/// Height is the size, and Preset names what was last picked.
 pub fn follow_page_rows(node: &mut FsNode, was: &crate::app::ParamDef) -> Vec<crate::app::ParamDef> {
+    follow(node, was, false)
+}
+
+fn follow(node: &mut FsNode, was: &crate::app::ParamDef, landscape: bool) -> Vec<crate::app::ParamDef> {
     let unit = PageUnit::parse(&node_param_str(node, "Units", "Inches"));
     let dpi = page_dpi(node);
     let (w, h) = (node_param_f32(node, "Width", 8.5), node_param_f32(node, "Height", 11.0));
-    let landscape = node_param_str(node, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape");
-    let mut orientation = None;
     let (nw, nh) = match was.name.as_str() {
         "Preset" => {
             let preset = node_param_str(node, "Preset", "Letter");
             let Some([iw, ih]) = preset_inches(&preset, dpi, landscape) else { return Vec::new() };
-            if preset_size(&preset).is_none() && iw != ih {
-                orientation = Some(if iw > ih { "Landscape" } else { "Portrait" });
-            }
             (unit.from_inches(iw, dpi), unit.from_inches(ih, dpi))
         }
-        "Orientation" if w != h && landscape != (w > h) => (h, w),
         "Units" => {
             let old = PageUnit::parse(was.text());
             (unit.from_inches(old.to_inches(w, dpi), dpi), unit.from_inches(old.to_inches(h, dpi), dpi))
@@ -853,29 +852,29 @@ pub fn follow_page_rows(node: &mut FsNode, was: &crate::app::ParamDef) -> Vec<cr
         _ => return Vec::new(),
     };
     let mut rewritten = Vec::new();
-    let mut set = |name: &str, text: String| {
+    for (name, value) in [("Width", nw), ("Height", nh)] {
+        let text = row_text(unit, value);
         if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
             if !p.is_expr() && p.text() != text {
                 rewritten.push(p.clone());
                 p.set_text(text);
             }
         }
-    };
-    set("Width", row_text(unit, nw));
-    set("Height", row_text(unit, nh));
-    if let Some(o) = orientation {
-        set("Orientation", o.to_string());
     }
     rewritten
 }
 
 /// A `page` node from before Width and Height were always the size: its
-/// named preset is written into them once, and a Custom one — whose Width
-/// and Height already were the size — names Letter. Told apart by the
-/// Width row's `show_when`, which such a save carries as `Preset ==
-/// Custom` until the template merge replaces it.
+/// named preset is written into them once, a sheet turned as its
+/// Orientation row said, and a Custom one — whose Width and Height already
+/// were the size — names Letter. Told apart by the Width row's
+/// `show_when`, which such a save carries as `Preset == Custom` until the
+/// template merge replaces it. The Orientation row itself goes, from this
+/// save and any other: a page's orientation is its Width and Height.
 pub fn migrate_preset_rows(node: &mut FsNode) {
     let old = node.params.iter().any(|p| p.name == "Width" && p.show_when.contains("Custom"));
+    let landscape = node_param_str(node, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape");
+    node.params.retain(|p| p.name != "Orientation");
     if !old {
         return;
     }
@@ -885,7 +884,7 @@ pub fn migrate_preset_rows(node: &mut FsNode) {
             p.set_text("Letter".to_string());
         }
     } else {
-        follow_page_rows(node, &preset);
+        follow(node, &preset, landscape);
     }
 }
 
diff --git a/src/window.rs b/src/window.rs
index fe780ab..bfe0e1d 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -367,7 +367,7 @@ impl State {
                         if as_expr {
                             p.set_expr(true);
                         }
-                        // A page's Preset, Orientation and Units set its
+                        // A page's Preset and Units set its
                         // Width and Height, and the step holds all of it.
                         let followed = if child.node_type == "page" {
                             crate::page::follow_page_rows(child, &before)