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

commit90aa6ece22f6e0f000d64b4fe9edeb69a7ee0b21
parent7101a8ae64
authorLucas Galante <[email protected]>
date2026-09-09 15:29
feat(viewport): a declared world unit, a scale readout, and View 1:1

Step 4 of the unit system, designer half. The Guides settings node gains
"World Unit" (mm / cm / m / in; `State::world_unit`, persisted with the
project) — what one world unit IS. Geometry never converts; the
declaration feeds two things through the display metric
(`cce_ui::units`):

- the viewport's bottom-left scale readout (`append_scale_readout`):
  `1:2.3`, `1 mm = 0.43 mm on screen`, marked when the metric is only
  assumed;
- the viewport context menu's View 1:1 (`view_one_to_one`), which moves
  the active camera along its own eye ray so the pivot plane shows one
  world unit at its true length — the default camera by zoom, a camera
  node by rewriting its Position, as Frame All does.

The projection is a perspective (vertical FOV 0.9), so 1:1 holds on the
pivot plane. A millimetre unit parks the camera a few hundred units
out, so the far plane now follows the camera distance and the zoom
clamp tops out at 400 (`Viewport3D::MAX_ZOOM`) instead of 20.

Tests: the Guides round trip covers the new choice, and a state test
drives View 1:1 to a ratio of 1 at cm and mm units.

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

 CLAUDE.md          | 12 ++++++-
 src/app.rs         | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/main.rs        | 41 +++++++++++++++++++++++
 src/project.rs     |  9 +++++
 src/render.rs      | 36 ++++++++++++++++++++
 src/viewport_3d.rs | 18 +++++++---
 6 files changed, 203 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6af308b..565ed4d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -206,7 +206,17 @@ toggle. `State::session_node()` / `in_settings_dir()` are the accessors —
 the latter walks the whole `current_path`, since a first-segment check
 stopped working the day the settings nodes gained a parent. Guides holds
 "Point Marker Size" (thousandths of a world unit), driving the per-node
-meta Point Markers overlay via `State::meta_marker_size`.
+meta Point Markers overlay via `State::meta_marker_size`, and **"World Unit"**
+(a `choice`: mm / cm / m / in, `State::world_unit`) — what one world unit IS.
+Geometry never converts; the declaration feeds two things through the display
+metric (`cce_ui::units`): the viewport's bottom-left **scale readout**
+(`append_scale_readout`: `1:2.3`, `1 mm = 0.43 mm on screen`, marked when the
+metric is only assumed) and the viewport context menu's **View 1:1**
+(`view_one_to_one`), which moves the active camera along its eye ray so the
+pivot plane shows one world unit at its true length — the default camera by
+zoom, a camera node by rewriting its Position, as Frame All does. The
+projection is a perspective (vertical FOV 0.9 rad), so 1:1 holds on the
+pivot plane only; `view_scale_ratio` is the readout's number.
 
 ### The meta node (per-node preferences)
 
diff --git a/src/app.rs b/src/app.rs
index 10a10a2..edb8989 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -273,6 +273,9 @@ pub enum NodeMenuAction {
 pub enum ViewportMenuAction {
     /// Move the active camera so the visible node geometry fills the view.
     FrameAll,
+    /// Put the pivot plane at true size: one world unit (the Guides "World
+    /// Unit") spans its real length on this display.
+    OneToOne,
     /// Follow whichever editor took the last node click (the default).
     PinFollow,
     /// Lock the viewport to one editor's level (CONTENT_IDX / CONTENT2_IDX).
@@ -1253,6 +1256,12 @@ pub struct State {
     /// sRGB color of the meta "Point Markers" overlay — the Guides subnet's
     /// "Point Marker Color" control (stored there as hex, like Grid Color).
     pub meta_marker_color: [f32; 3],
+    /// What one world unit IS — the Guides subnet's "World Unit" choice
+    /// (mm / cm / m / in), persisted with the project. Geometry never
+    /// converts; this is the declaration that lets the viewport state its
+    /// scale against the display metric (`cce_ui::units`) and `View 1:1`
+    /// put the pivot plane at true size.
+    pub world_unit: cce_ui::units::Unit,
     /// The param pane's completion lists — (input node name, geometry
     /// version) → (group names, attribute names) read off that input's
     /// evaluated geometry, feeding the textpick rows on group/attribute
@@ -2924,7 +2933,7 @@ impl State {
         if self.active_camera == "Default Camera" {
             // Fixed eye ray through the origin — fit with zoom alone.
             let base_len = Vec3::new(2.5, 1.8, 2.5).length();
-            self.viewport_mut().zoom = (dist / base_len).clamp(0.05, 20.0);
+            self.viewport_mut().zoom = (dist / base_len).clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
             self.viewport_mut().reset_velocity();
         } else {
             let camera_name = self.active_camera.clone();
@@ -2970,10 +2979,87 @@ impl State {
         self.sync_parameters_pane();
     }
 
+    /// One world unit in millimetres, per the Guides "World Unit".
+    pub fn world_unit_mm(&self) -> f32 {
+        let m = cce_ui::units::metric();
+        cce_ui::units::Len::new(1.0, self.world_unit).convert(cce_ui::units::Unit::Mm, &m).value
+    }
+
+    /// Camera distance to the pivot plane at which the view is 1:1 — one
+    /// world unit on that plane covers its real length on this display.
+    /// `get_matrices` is a perspective with vertical FOV 0.9 rad, so the
+    /// plane at distance D spans 2·D·tan(0.45) world units over the pane's
+    /// height in logical px, and the display metric says how many mm each
+    /// of those px is.
+    fn one_to_one_distance(&self) -> f32 {
+        let m = cce_ui::units::metric();
+        let vh_logical = (self.last_viewport_height.max(1) as f32) / (self.scale as f32).max(0.001);
+        let unit_mm = self.world_unit_mm().max(1e-6);
+        m.mm_per_px() * vh_logical / (2.0 * 0.45f32.tan() * unit_mm)
+    }
+
+    /// The view's scale on the pivot plane: how many millimetres of world
+    /// one millimetre of screen shows (1.0 = true size, 2.0 = half size).
+    pub fn view_scale_ratio(&self) -> f32 {
+        let m = cce_ui::units::metric();
+        let vh_logical = (self.last_viewport_height.max(1) as f32) / (self.scale as f32).max(0.001);
+        let base = self.last_viewport_camera_pos - self.last_viewport_pivot;
+        let d = base.length().max(1e-4) * self.last_viewport_zoom;
+        let world_mm_per_px = 2.0 * d * 0.45f32.tan() / vh_logical.max(1.0) * self.world_unit_mm();
+        world_mm_per_px / m.mm_per_px().max(1e-6)
+    }
+
+    /// `View 1:1`: move the active camera along its own eye ray so the
+    /// pivot plane sits at [`Self::one_to_one_distance`]. The default camera
+    /// zooms (its eye ray is fixed through the origin); a camera node has its
+    /// Position rewritten, as Frame All does. The zoom clamp can refuse a
+    /// very large or small unit — then the readout shows what was reached.
+    pub fn view_one_to_one(&mut self) {
+        let dist = self.one_to_one_distance();
+        if self.active_camera == "Default Camera" {
+            let base_len = Vec3::new(2.5, 1.8, 2.5).length();
+            self.viewport_mut().zoom = (dist / base_len).clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
+            self.viewport_mut().reset_velocity();
+        } else {
+            let camera_name = self.active_camera.clone();
+            let dir = self.current_dir_mut();
+            if let Some(node) = dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name) {
+                let parse3 = |s: &str| -> Option<Vec3> {
+                    let parts: Vec<&str> = s
+                        .split(|c| c == ':' || c == ',' || c == ' ')
+                        .filter(|s| !s.is_empty())
+                        .collect();
+                    if parts.len() >= 3 {
+                        if let (Ok(x), Ok(y), Ok(z)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
+                            return Some(Vec3::new(x, y, z));
+                        }
+                    }
+                    None
+                };
+                let pos = node.params.iter().find(|p| p.name == "Position")
+                    .and_then(|p| parse3(&p.default))
+                    .unwrap_or(Vec3::new(2.5, 1.8, 2.5));
+                let piv = node.params.iter().find(|p| p.name == "Pivot")
+                    .and_then(|p| parse3(&p.default))
+                    .unwrap_or(Vec3::ZERO);
+                let offset = pos - piv;
+                let dir_unit = if offset.length() > 1e-4 { offset.normalize() } else { Vec3::new(2.5, 1.8, 2.5).normalize() };
+                let new_pos = piv + dir_unit * dist;
+                if let Some(p) = node.params.iter_mut().find(|p| p.name == "Position") {
+                    p.default = format!("{:.3}:{:.3}:{:.3}", new_pos.x, new_pos.y, new_pos.z);
+                }
+                self.viewport_mut().zoom = 1.0;
+                self.viewport_mut().reset_velocity();
+            }
+        }
+        self.viewport_dirty = true;
+        self.sync_parameters_pane();
+    }
+
     /// Open the viewport right-click context menu at the cursor.
     fn open_viewport_context_menu(&mut self) {
-        let mut options = vec!["Frame All".to_string()];
-        let mut actions = vec![ViewportMenuAction::FrameAll];
+        let mut options = vec!["Frame All".to_string(), "View 1:1".to_string()];
+        let mut actions = vec![ViewportMenuAction::FrameAll, ViewportMenuAction::OneToOne];
         // The viewport's editor binding, as a radio group: follow the active
         // editor, or pin to one. Pin rows appear only while a second editor
         // exists — with one editor, following IS pinned.
@@ -3026,6 +3112,9 @@ impl State {
                     ViewportMenuAction::FrameAll => {
                         self.frame_all();
                     }
+                    ViewportMenuAction::OneToOne => {
+                        self.view_one_to_one();
+                    }
                     ViewportMenuAction::PinFollow => {
                         self.viewport_pin = None;
                         self.rebuild_scene_geometry();
@@ -3877,6 +3966,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             meta_normal_count: 0,
             meta_marker_size: 0.02,
             meta_marker_color: [0.85, 0.85, 1.0],
+            world_unit: cce_ui::units::Unit::Mm,
             pick_cache: None,
             last_scene_mvp: None,
             last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
diff --git a/src/main.rs b/src/main.rs
index 40f0865..c3dd743 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -603,6 +603,40 @@ mod tests {
         (s_idx, m_idx)
     }
 
+    /// `View 1:1` puts the pivot plane at true size: afterwards one world
+    /// unit spans its real length on the display, so the readout's ratio
+    /// is 1. Exercised on the default camera (zoom) at a centimetre world
+    /// unit; the cached viewport state the readout reads is set by hand,
+    /// as the render pass would.
+    #[test]
+    fn view_one_to_one_reaches_true_scale() {
+        let mut state = State::new(false);
+        state.ensure_menubar_subnets();
+        state.world_unit = cce_ui::units::Unit::Cm;
+        // The default camera's eye ray is fixed, so 1:1 is a zoom — the one
+        // number the readout's cached state can follow here without a
+        // render pass (a camera node's Position rewrite is cached by one).
+        state.active_camera = "Default Camera".to_string();
+        state.scale = 1.0;
+        state.last_viewport_width = 1200;
+        state.last_viewport_height = 800;
+        state.last_viewport_camera_pos = Vec3::new(2.5, 1.8, 2.5);
+        state.last_viewport_pivot = Vec3::ZERO;
+        state.last_viewport_zoom = state.viewport().zoom;
+        let before = state.view_scale_ratio();
+        state.view_one_to_one();
+        state.last_viewport_zoom = state.viewport().zoom;
+        let after = state.view_scale_ratio();
+        assert!((after - 1.0).abs() < 1e-3, "ratio {after} (was {before})");
+        assert!((state.world_unit_mm() - 10.0).abs() < 1e-4);
+        // A millimetre unit needs the camera ~10× farther; still reachable.
+        state.world_unit = cce_ui::units::Unit::Mm;
+        state.view_one_to_one();
+        state.last_viewport_zoom = state.viewport().zoom;
+        let mm = state.view_scale_ratio();
+        assert!((mm - 1.0).abs() < 1e-3, "mm ratio {mm}");
+    }
+
     /// The root meta node (nee Session): exists at root, typed "meta" but
     /// still a subnet, holds exactly the four settings nodes, and refuses
     /// deletion through the one gate every deletion route funnels into.
@@ -659,8 +693,15 @@ mod tests {
                 .expect("Guides has Point Marker Color");
             assert_eq!(c.param_type, "color");
             c.default = "#ff8000".to_string();
+            let u = guides.params.iter_mut().find(|p| p.name == "World Unit")
+                .expect("Guides has World Unit");
+            assert_eq!(u.param_type, "choice");
+            assert_eq!(u.default, "mm", "a world unit is a millimetre until declared otherwise");
+            u.default = "cm".to_string();
         }
         state.apply_settings_from_menubar_subnets();
+        assert_eq!(state.world_unit, cce_ui::units::Unit::Cm);
+        assert!((state.world_unit_mm() - 10.0).abs() < 1e-4);
         assert!((state.meta_marker_size - 0.05).abs() < 1e-6);
         assert!((state.meta_marker_color[0] - 1.0).abs() < 0.01);
         assert!((state.meta_marker_color[1] - 0.5).abs() < 0.01);
diff --git a/src/project.rs b/src/project.rs
index bf33abe..ba1f81c 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -900,6 +900,9 @@ impl State {
         ensure_param(guides_node, "Point Marker Size", "spinbox", &marker_size_seed, &[], Some(5.0), Some(100.0), Some(1.0));
         let marker_color_seed = color_to_hex(self.meta_marker_color);
         ensure_param(guides_node, "Point Marker Color", "color", &marker_color_seed, &[], None, None, None);
+        // What a world unit is in the real world. The geometry never
+        // converts; the viewport's scale readout and `View 1:1` do.
+        ensure_param(guides_node, "World Unit", "choice", self.world_unit.suffix(), &["mm", "cm", "m", "in"], None, None, None);
         for p in guides_node.params.iter_mut() {
             match p.name.as_str() {
                 "Show Grid Guide" => set_toggle(p, vp_show_grid),
@@ -1058,6 +1061,12 @@ impl State {
                             self.rebuild_scene_geometry();
                         }
                     }
+                    "World Unit" => if let Some(u) = cce_ui::units::Unit::parse(&p.default) {
+                        if u != self.world_unit {
+                            self.world_unit = u;
+                            self.viewport_dirty = true;
+                        }
+                    }
                     _ => {}
                 }
             }
diff --git a/src/render.rs b/src/render.rs
index 8f568a4..4411dbb 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -171,6 +171,7 @@ impl State {
         self.append_context_border(&mut pc);
         self.append_frame_text(&mut pc);
         self.append_meta_point_numbers(&mut pc);
+        self.append_scale_readout(&mut pc);
         self.append_curve_tool_overlay(&mut pc);
         self.append_popovers(&mut pc);
         self.append_dock_drag_overlay(&mut pc);
@@ -854,6 +855,41 @@ impl State {
         });
     }
 
+    /// The view's scale on the pivot plane, bottom-left of the pane: `1:2.3`
+    /// (the world shown at less than true size), `2.3:1` (magnified), or
+    /// `1:1`, with what one world unit is and how long it shows. Marked when
+    /// the display metric is only assumed — then the millimetres are the
+    /// CSS 96 ppi guess, not a measurement.
+    fn append_scale_readout(&self, pc: &mut PaintCtx) {
+        if !self.show_viewport {
+            return;
+        }
+        let (vx, vy, vw, vh) = self.last_scene_view_rect;
+        if vw <= 0.0 || vh <= 0.0 {
+            return;
+        }
+        let r = self.view_scale_ratio();
+        if !r.is_finite() || r <= 0.0 {
+            return;
+        }
+        let ratio = if (r - 1.0).abs() < 0.01 {
+            "1:1".to_string()
+        } else if r > 1.0 {
+            format!("1:{}", cce_ui::units::fmt_num((r * 100.0).round() / 100.0))
+        } else {
+            format!("{}:1", cce_ui::units::fmt_num((100.0 / r).round() / 100.0))
+        };
+        let m = cce_ui::units::metric();
+        let shown_mm = self.world_unit_mm() / r;
+        let mut text = format!("{ratio}  ·  1 {} = {} mm on screen", self.world_unit.suffix(), cce_ui::units::fmt_num((shown_mm * 100.0).round() / 100.0));
+        if !m.is_real() {
+            text.push_str("  ·  metric assumed");
+        }
+        pc.clip(rect(vx, vy, vw, vh), |pc| {
+            pc.text(text, vx + 8.0, vy + vh - 16.0, 10.0, [0xaa, 0xaa, 0xbb]);
+        });
+    }
+
     /// The curve viewer state's handles: each control point projected
     /// through the cached scene mvp (like the point numbers above), drawn as
     /// a ringed dot with its index, the control cage as faint segments
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index 7ab6538..a716338 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -132,12 +132,16 @@ impl Viewport3D {
     /// semantics), applied 1:1 — spreading fingers 2x halves the camera
     /// distance. Feeds the same accumulator as the ctrl-wheel zoom so the
     /// release inertia matches.
+    /// The zoom range. The top is generous because `View 1:1` on a small
+    /// world unit needs the camera far out; the far plane follows it.
+    pub const MAX_ZOOM: f32 = 400.0;
+
     pub fn pinch_zoom(&mut self, factor: f32) {
         if factor <= 0.0 {
             return;
         }
         let dy = factor.ln();
-        self.zoom = (self.zoom * (-dy).exp()).clamp(0.05, 20.0);
+        self.zoom = (self.zoom * (-dy).exp()).clamp(0.05, Self::MAX_ZOOM);
         self.is_zooming = true;
         self.last_zoom_time = std::time::Instant::now();
         self.zoom_accum += dy;
@@ -175,7 +179,11 @@ impl Viewport3D {
 
         // Geometry stays stationary in world space; the camera does the moving.
         let model = Mat4::IDENTITY;
-        let proj = Mat4::perspective_rh(0.9, aspect, 0.1, 100.0);
+        // The far plane follows the camera out: `View 1:1` on a millimetre
+        // world unit parks the camera a few hundred units away, and a fixed
+        // 100 would clip the pivot itself.
+        let far = (distance * self.zoom * 4.0).max(100.0);
+        let proj = Mat4::perspective_rh(0.9, aspect, 0.1, far);
 
         (proj, view_mat, model)
     }
@@ -207,7 +215,7 @@ impl cce_ui::widget::Input for Viewport3D {
                 MouseScrollDelta::LineDelta(_x, y) => {
                     let dy = *y * 0.15 * self.scroll_speed;
                     self.zoom *= (-dy).exp();
-                    self.zoom = self.zoom.clamp(0.05, 20.0);
+                    self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
                     self.is_zooming = false;
                     
                     let dt = 0.016;
@@ -217,7 +225,7 @@ impl cce_ui::widget::Input for Viewport3D {
                 MouseScrollDelta::PixelDelta(pos) => {
                     let dy = (pos.y as f32 / scale) * 0.005 * self.scroll_speed;
                     self.zoom *= (-dy).exp();
-                    self.zoom = self.zoom.clamp(0.05, 20.0);
+                    self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
                     self.is_zooming = true;
                     self.last_zoom_time = std::time::Instant::now();
                     self.zoom_accum += dy;
@@ -348,7 +356,7 @@ impl cce_ui::widget::Input for Viewport3D {
             } else {
                 let d_zoom = self.zoom_velocity * dt;
                 self.zoom *= (-d_zoom).exp();
-                self.zoom = self.zoom.clamp(0.05, 20.0);
+                self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
 
                 let decay = self.scroll_friction.powf(dt * 60.0);
                 self.zoom_velocity *= decay;