graphic design tool
git clone https://git.lucas.co/cce-designer.git
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;