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

commitb69ab911670218fa9a0e7742b23d9679ba3c2a63
parent0a2499f2c5
authorLucas Galante <[email protected]>
date2026-09-22 12:25
feat: the node body has its own size; the grid pitch only places it

`node_w` / `node_h` join the zoomed pitch on `State`, all four read from
config at 100% (`configured_grid_geometry`: spacing_x/y and
node.width/height) and scaled TOGETHER by `scale_grid_geometry`, which is
the only relation between them. Frame All now scales down from the
configured geometry rather than a fixed 80 x 40 baseline, so it never
zooms in past 100%.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md     |  27 +++++++------
 src/app.rs    | 121 ++++++++++++++++++++++++++++++++++++++--------------------
 src/window.rs |   4 +-
 3 files changed, 95 insertions(+), 57 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bea8ef0..e513f16 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -716,22 +716,25 @@ everything, with framing everything on `shift+f` — the plugin's split.
 The network grid has ONE size per axis: `style.surface.graph.spacing_x` /
 `spacing_y` in config.kdl, the pitch — the distance from the centre of one
 grid line to the centre of the next. A node's `position` (col, row) names a
-lattice intersection, and the node body is CENTRED on it. The body's size
-follows from the pitch by one rule, `Graph::node_size_for_pitch` in cce-ui
-(four fifths of the x pitch, two thirds of the y — the proportions the old
-cell grid had), and `State::node_size` reads that same rule, so the cursor
-this app draws and the nodes the widget draws cannot disagree. Until
-2026-09-22 the grid was rounded CELLS with grout between them, configured as
-a cell size (also the node size) plus a gap, and a node filled its cell.
-
-`State::grid_pitch_x` / `grid_pitch_y` are the zoomed pitch; there is no
+lattice intersection, and the node body is CENTRED on it. The body has a
+size of its own, `style.surface.graph.node.width` / `height`, which the
+pitch does not touch: a denser grid moves nodes closer, it does not shrink
+them (a first cut derived the body from the pitch; it was disconnected the
+same day). Until 2026-09-22 the grid was rounded CELLS with grout between
+them, configured as a cell size (also the node size) plus a gap, and a node
+filled its cell.
+
+`State::grid_pitch_x` / `grid_pitch_y` and `node_w` / `node_h` are the
+zoomed geometry — `configured_grid_geometry` at 100%, scaled TOGETHER by
+`scale_grid_geometry`, which is the only relation between them; there is no
 other grid geometry on `State`. `cell_center`, `cell_rect` and `cell_at` are
 the three derivations every consumer goes through — the cursor outline, the
 click-to-cell of an empty-space press (`round`, not `floor`, because a cell
 is centred on its crossing and a click between two nodes belongs to the
-nearer), Frame Cursor, the zoom anchor. `configured_grid_pitch` is the 100%
-baseline Reset Zoom returns to; `MIN_PITCH_*` / `MAX_PITCH_*` are the old
-node-body zoom limits expressed on the pitch.
+nearer), Frame Cursor, the zoom anchor. The configured geometry is the 100%
+baseline Reset Zoom returns to and Frame All scales down from (never past
+100%); `MIN_PITCH_*` / `MAX_PITCH_*` are the old node-body zoom limits
+expressed on the pitch.
 
 The widget paints the lattice as lines (`paint_grid`: gap colour, network
 opacity, `style.surface.graph.line_width` px, each line centred on its
diff --git a/src/app.rs b/src/app.rs
index b3fb75d..d7630ea 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -932,28 +932,41 @@ impl Default for ViewportSettings {
     }
 }
 
-/// The network grid's pitch as configured — `style.surface.graph.spacing_x` /
-/// `spacing_y`: the distance from the centre of one grid line to the centre
-/// of the next, per axis. It is the grid's ONE size: nodes are centred on
-/// the lattice intersections, and the node body's size follows from the
-/// pitch (`Graph::node_size_for_pitch`, read through `State::node_size`).
-/// Until 2026-09-22 this was a cell size plus a gap, with a node filling
-/// its cell.
+/// The network grid's geometry as configured, at 100% zoom: the pitch
+/// (`style.surface.graph.spacing_x` / `spacing_y` — the distance from the
+/// centre of one grid line to the centre of the next, the grid's ONE size;
+/// nodes are centred on the lattice intersections) and the node body's own
+/// size (`style.surface.graph.node.width` / `height`), which the pitch does
+/// not touch: a denser grid moves nodes closer, it does not shrink them.
+/// Until 2026-09-22 the grid was a cell size plus a gap, with a node
+/// filling its cell.
 ///
 /// Config-owned, NOT state: it is user-authored, so the app reads it and
 /// never writes it back — the same split `../CLAUDE.md` describes for scroll
-/// behavior. Zoom scales it in memory; the configured value is the 100%
-/// baseline that Reset Zoom returns to.
-pub fn configured_grid_pitch() -> (f32, f32) {
-    (cce_ui::layout::graph_spacing_x(), cce_ui::layout::graph_spacing_y())
+/// behavior. Zoom scales all four in memory together; the configured values
+/// are the 100% baseline that Reset Zoom returns to.
+pub struct GridGeometry {
+    pub pitch_x: f32,
+    pub pitch_y: f32,
+    pub node_w: f32,
+    pub node_h: f32,
 }
 
-/// Zoom limits on the pitch. They are the old 30..500 x 15..250 limits on
-/// the node body, expressed on the pitch the body is now a share of.
-pub const MIN_PITCH_X: f32 = 30.0 / cce_ui::widget::Graph::NODE_W_OF_PITCH;
-pub const MAX_PITCH_X: f32 = 500.0 / cce_ui::widget::Graph::NODE_W_OF_PITCH;
-pub const MIN_PITCH_Y: f32 = 15.0 / cce_ui::widget::Graph::NODE_H_OF_PITCH;
-pub const MAX_PITCH_Y: f32 = 250.0 / cce_ui::widget::Graph::NODE_H_OF_PITCH;
+pub fn configured_grid_geometry() -> GridGeometry {
+    GridGeometry {
+        pitch_x: cce_ui::layout::graph_spacing_x(),
+        pitch_y: cce_ui::layout::graph_spacing_y(),
+        node_w: cce_ui::layout::graph_node_width(),
+        node_h: cce_ui::layout::graph_node_height(),
+    }
+}
+
+/// Zoom limits on the pitch — the old 30..500 x 15..250 limits on the node
+/// body, expressed on the pitch that body used to be a share of.
+pub const MIN_PITCH_X: f32 = 37.5;
+pub const MAX_PITCH_X: f32 = 625.0;
+pub const MIN_PITCH_Y: f32 = 22.5;
+pub const MAX_PITCH_Y: f32 = 375.0;
 
 #[derive(Serialize, Deserialize, Clone, Debug, Default)]
 pub struct DesignSettings {
@@ -1376,11 +1389,14 @@ pub struct State {
     pub grid_snap_enabled: bool,
     pub network_grid_visible: bool,
     /// The network grid's pitch at the current zoom — centre of one grid
-    /// line to the centre of the next, per axis. The grid's one size; the
-    /// node body follows from it (`node_size`), and a node's (col, row) is
-    /// the intersection its centre sits on.
+    /// line to the centre of the next, per axis. The grid's one size; a
+    /// node's (col, row) is the intersection its centre sits on.
     pub grid_pitch_x: f32,
     pub grid_pitch_y: f32,
+    /// The node body's size at the current zoom — its own, not the pitch's;
+    /// zoom scales the two together and nothing else relates them.
+    pub node_w: f32,
+    pub node_h: f32,
 
     pub pan_x: f32,
     pub pan_y: f32,
@@ -2749,9 +2765,7 @@ impl State {
                 self.zoom(1.0 / 1.15, None);
             }
             "Reset Zoom" => {
-                let (px, py) = configured_grid_pitch();
-                self.grid_pitch_x = px;
-                self.grid_pitch_y = py;
+                self.set_grid_geometry(configured_grid_geometry());
                 self.sync_grid_settings();
             }
             "Detach Circular Window" | "Detach Pane" => {
@@ -4188,7 +4202,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let scale = cce_ui::scale::scale_factor() as f64;
         let settings = DesignSettings::load();
         // Grid geometry is config-owned, not part of the saved state.
-        let cfg_grid = configured_grid_pitch();
+        let cfg_grid = configured_grid_geometry();
         let (lw, lh) = if is_detached_network {
             (400.0f32, 400.0f32)
         } else {
@@ -4445,8 +4459,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             square_viewport: settings.viewport.square,
             grid_snap_enabled: true,
             network_grid_visible: true,
-            grid_pitch_x: cfg_grid.0,
-            grid_pitch_y: cfg_grid.1,
+            grid_pitch_x: cfg_grid.pitch_x,
+            grid_pitch_y: cfg_grid.pitch_y,
+            node_w: cfg_grid.node_w,
+            node_h: cfg_grid.node_h,
             pan_x,
             pan_y,
             pan_velocity_x: 0.0,
@@ -4648,11 +4664,28 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         state
     }
 
-    /// The node body's size at the current zoom — the widget's rule applied
-    /// to this pitch, so the cursor drawn here and the nodes the widget
+    /// The node body's size at the current zoom — what `sync_grid_settings`
+    /// hands the widget, so the cursor drawn here and the nodes the widget
     /// draws cannot disagree.
     pub fn node_size(&self) -> (f32, f32) {
-        cce_ui::widget::Graph::node_size_for_pitch(self.grid_pitch_x, self.grid_pitch_y)
+        (self.node_w, self.node_h)
+    }
+
+    /// Put the grid at a geometry outright (Reset Zoom, Frame All's fit).
+    pub fn set_grid_geometry(&mut self, g: GridGeometry) {
+        self.grid_pitch_x = g.pitch_x;
+        self.grid_pitch_y = g.pitch_y;
+        self.node_w = g.node_w;
+        self.node_h = g.node_h;
+    }
+
+    /// Scale the grid geometry by one factor — the pitch and the node body
+    /// together, which is the only way the two are related.
+    pub fn scale_grid_geometry(&mut self, f: f32) {
+        self.grid_pitch_x *= f;
+        self.grid_pitch_y *= f;
+        self.node_w *= f;
+        self.node_h *= f;
     }
 
     /// The window-space centre of lattice cell (col, row) in the network
@@ -4692,12 +4725,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let network_grid_visible = self.network_grid_visible;
         let grid_pitch_x = self.grid_pitch_x;
         let grid_pitch_y = self.grid_pitch_y;
+        let (node_w, node_h) = self.node_size();
         let pan_x = self.pan_x;
         let pan_y = self.pan_y;
         let grid_snap_enabled = self.grid_snap_enabled;
         let graph = self.graph_mut();
         graph.set_show_network_grid(network_grid_visible);
         graph.set_grid_pitch(grid_pitch_x, grid_pitch_y);
+        graph.set_node_size(node_w, node_h);
         graph.set_grid_origin(active_node_area_x + pan_x, active_node_area_y + pan_y);
         graph.set_grid_snap_enabled(grid_snap_enabled);
         if let Some(graph) = self.slots.content.as_any_mut().downcast_mut::<cce_ui::widget::Graph>() {
@@ -4722,6 +4757,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             let g2 = &mut *self.slots.content2;
             g2.set_show_network_grid(network_grid_visible);
             g2.set_grid_pitch(grid_pitch_x, grid_pitch_y);
+            g2.set_node_size(node_w, node_h);
             g2.set_grid_origin(qx, qy);
             g2.set_grid_snap_enabled(grid_snap_enabled);
         }
@@ -4839,8 +4875,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         let old_px = self.grid_pitch_x;
         let old_py = self.grid_pitch_y;
 
+        // The x pitch is clamped and the y pitch follows by the SAME factor,
+        // so the node body — scaled by that factor too — keeps its shape.
         let new_px = (old_px * factor).clamp(MIN_PITCH_X, MAX_PITCH_X);
-        let new_py = (old_py * factor).clamp(MIN_PITCH_Y, MAX_PITCH_Y);
+        let factor = new_px / old_px;
+        let new_py = old_py * factor;
 
         if (new_px - old_px).abs() < 0.01 {
             return;
@@ -4859,8 +4898,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         self.pan_x = cx - area_x - col_f * new_px;
         self.pan_y = cy - area_y - row_f * new_py;
 
-        self.grid_pitch_x = new_px;
-        self.grid_pitch_y = new_py;
+        self.scale_grid_geometry(factor);
 
         self.sync_grid_settings();
     }
@@ -5670,19 +5708,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// being that there is one implementation behind every way of asking.
     pub(crate) fn frame_all_nodes(&mut self) {
             let active_nodes = self.current_dir().children.len();
-            // The framing baseline: a 100 x 60 pitch (80 x 40 node bodies),
-            // scaled down until everything fits.
-            let base_px = 100.0;
-            let base_py = 60.0;
+            // The framing baseline is the configured geometry (100% zoom),
+            // scaled down until everything fits — Frame All never zooms in
+            // past 100%.
+            let base = configured_grid_geometry();
             if active_nodes == 0 {
-                self.grid_pitch_x = base_px;
-                self.grid_pitch_y = base_py;
+                self.set_grid_geometry(base);
                 self.pan_x = 20.0;
                 self.pan_y = 20.0;
             } else {
                 // Node bodies' bounds, relative to the (0, 0) intersection,
-                // at the baseline pitch.
-                let (base_w, base_h) = cce_ui::widget::Graph::node_size_for_pitch(base_px, base_py);
+                // at the baseline.
+                let (base_px, base_py, base_w, base_h) = (base.pitch_x, base.pitch_y, base.node_w, base.node_h);
                 let mut b_xmin = f32::MAX;
                 let mut b_xmax = f32::MIN;
                 let mut b_ymin = f32::MAX;
@@ -5718,8 +5755,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
                 f = f.min(1.0).max(MIN_PITCH_X / base_px);
 
-                self.grid_pitch_x = (base_px * f).clamp(MIN_PITCH_X, MAX_PITCH_X);
-                self.grid_pitch_y = (base_py * f).clamp(MIN_PITCH_Y, MAX_PITCH_Y);
+                self.set_grid_geometry(base);
+                self.scale_grid_geometry(f);
                 let (node_w, node_h) = self.node_size();
 
                 let mut actual_xmin = f32::MAX;
diff --git a/src/window.rs b/src/window.rs
index 7b9b8e2..ad5ecff 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -187,9 +187,7 @@ impl State {
                             changed = true;
                         }
                         2 => { // Reset Zoom
-                            let (px, py) = crate::app::configured_grid_pitch();
-                            state.grid_pitch_x = px;
-                            state.grid_pitch_y = py;
+                            state.set_grid_geometry(crate::app::configured_grid_geometry());
                             state.sync_grid_settings();
                             changed = true;
                         }