From 295d3b1b5d49c339165277b47dd95f5554321af1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 24 Jun 2026 17:23:29 +0300 Subject: [PATCH] feat(ucs): per-viewport UCS applied inside floating viewports Extend UCS support to in-viewport (MSPACE) editing through the existing single-converter structure: one active_ucs drives every UCS-aware system, now in the Model tab AND inside a floating viewport. - Single predicate DocumentTab::editing_model_space() (Model tab or active viewport) replaces the model-only gate in ucs_wire_affine, ucs_rotation_angle, ucs_grid_basis, viewcube_ucs_mat, the coordinate readout and the cursor-plane pick. - Per-viewport UCS: refresh_active_ucs() sets active_ucs to the pane's UCS on every transition (the entered viewport's own UCS from its ucs_origin/x_axis/y_axis/ucs_per_viewport fields, the header model UCS in the Model tab, none in plain paper); persist_active_ucs() writes changes back to the viewport entity or the header so they round-trip. Wired into EnterViewport/ExitViewport, layout switch, load and the UCS command. - Visuals: grid_views now returns each pane's handle so every content viewport's grid aligns to its own UCS; the UCS icon draws inside the active viewport projected through the viewport camera. Co-Authored-By: Claude Opus 4.8 --- src/app/commands.rs | 5 ++- src/app/document.rs | 103 ++++++++++++++++++++++++++++++++++++++++---- src/app/update.rs | 26 ++++++++--- src/app/view.rs | 55 +++++++++++++++++------ src/scene/mod.rs | 8 ++-- 5 files changed, 164 insertions(+), 33 deletions(-) diff --git a/src/app/commands.rs b/src/app/commands.rs index 032a5e46..b48743e2 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -3834,8 +3834,11 @@ impl OpenCADStudio { } } } - // Keep the scene's ViewCube UCS in lock-step with active_ucs. + // Keep the scene's ViewCube UCS in lock-step with active_ucs and + // persist it to the active pane (per-viewport UCS inside a + // viewport, header model UCS in the Model tab) so it round-trips. self.tabs[i].sync_ucs_to_scene(); + self.tabs[i].persist_active_ucs(); } // ── Named Views (VIEW command) ──────────────────────────────── diff --git a/src/app/document.rs b/src/app/document.rs index 72822c62..af723b2b 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -171,24 +171,109 @@ impl DocumentTab { super::helpers::UcsXform::from_active(self.active_ucs.as_ref()) } - /// Adopt the document's saved current UCS (the header's model-space UCS) as - /// the active UCS, so the coordinate readout / icon / input follow the - /// file's coordinate system the moment it opens. An identity UCS clears it - /// back to plain WCS. Call wherever a document is loaded into the tab. - pub(super) fn adopt_active_ucs_from_header(&mut self) { + /// True when the active pane edits **model-space** geometry: the Model tab, + /// or inside a floating viewport (MSPACE). The UCS applies in both; plain + /// paper space (no active viewport) is excluded. Single predicate so every + /// UCS-aware system shares one rule. [[feedback_shared_infra]] + pub(super) fn editing_model_space(&self) -> bool { + self.scene.current_layout == "Model" || self.scene.active_viewport.is_some() + } + + /// The document's saved model-space UCS (header), as a `Ucs`. `None` when it + /// is identity (plain WCS). + fn model_ucs_from_header(&self) -> Option { let h = &self.scene.document.header; let mut u = Ucs::new(h.model_space_ucs_name.clone()); u.origin = h.model_space_ucs_origin; u.x_axis = h.model_space_ucs_x_axis; u.y_axis = h.model_space_ucs_y_axis; - self.active_ucs = if super::helpers::UcsXform::from_ucs(&u).is_identity() { + if super::helpers::UcsXform::from_ucs(&u).is_identity() { None } else { Some(u) + } + } + + /// A floating viewport's own per-viewport UCS, if it has one set. `None` + /// when the viewport uses world coordinates or the handle is not a viewport. + pub(super) fn ucs_from_viewport(&self, h: Handle) -> Option { + let vp = match self.scene.document.get_entity(h) { + Some(acadrust::EntityType::Viewport(vp)) => vp, + _ => return None, + }; + if !vp.ucs_per_viewport { + return None; + } + let mut u = Ucs::new("*VPUCS*"); + u.origin = vp.ucs_origin; + u.x_axis = vp.ucs_x_axis; + u.y_axis = vp.ucs_y_axis; + if super::helpers::UcsXform::from_ucs(&u).is_identity() { + None + } else { + Some(u) + } + } + + /// Set `active_ucs` to the UCS of the *current pane*: the entered viewport's + /// own per-viewport UCS, the model header UCS in the Model tab, or none in + /// plain paper space. Keeps the ViewCube in lock-step. Call on every pane + /// change (enter/exit viewport, layout / tab switch, load) so one field + /// drives all UCS-aware systems regardless of where editing happens. + pub(super) fn refresh_active_ucs(&mut self) { + self.active_ucs = if let Some(h) = self.scene.active_viewport { + self.ucs_from_viewport(h) + } else if self.scene.current_layout == "Model" { + self.model_ucs_from_header() + } else { + None }; self.sync_ucs_to_scene(); } + /// Persist `active_ucs` back to its pane's storage so it round-trips: the + /// entered viewport's per-viewport UCS fields, or the document header's + /// model-space UCS in the Model tab. No-op in plain paper space. Call after + /// any UCS change. + pub(super) fn persist_active_ucs(&mut self) { + use acadrust::types::Vector3; + if let Some(h) = self.scene.active_viewport { + let (o, x, y, per) = match &self.active_ucs { + Some(u) => (u.origin, u.x_axis, u.y_axis, true), + None => (Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, false), + }; + if let Some(acadrust::EntityType::Viewport(vp)) = + self.scene.document.get_entity_mut(h) + { + vp.ucs_origin = o; + vp.ucs_x_axis = x; + vp.ucs_y_axis = y; + vp.ucs_per_viewport = per; + } + } else if self.scene.current_layout == "Model" { + let h = &mut self.scene.document.header; + match &self.active_ucs { + Some(u) => { + h.model_space_ucs_origin = u.origin; + h.model_space_ucs_x_axis = u.x_axis; + h.model_space_ucs_y_axis = u.y_axis; + } + None => { + h.model_space_ucs_origin = Vector3::ZERO; + h.model_space_ucs_x_axis = Vector3::UNIT_X; + h.model_space_ucs_y_axis = Vector3::UNIT_Y; + } + } + } + } + + /// Adopt the active pane's UCS on load (the file's saved model-space UCS in + /// the Model tab). Thin wrapper over [`refresh_active_ucs`] kept for the + /// load call sites. + pub(super) fn adopt_active_ucs_from_header(&mut self) { + self.refresh_active_ucs(); + } + /// Push the active UCS rotation into the scene so the ViewCube composes with /// it. Call after any change to `active_ucs`. pub(super) fn sync_ucs_to_scene(&mut self) { @@ -199,7 +284,7 @@ impl DocumentTab { /// geometry. Columns are the UCS axes; translation is the UCS origin in wire /// space. Identity outside model space (no UCS there). pub(super) fn ucs_wire_affine(&self) -> glam::Mat4 { - if self.scene.current_layout != "Model" { + if !self.editing_model_space() { return glam::Mat4::IDENTITY; } let (o, x, y, z) = self.ucs_xform().axes(); @@ -221,7 +306,7 @@ impl DocumentTab { /// default rotation for new text-bearing objects so their text aligns to /// the user's coordinate system. Zero outside model space / with no UCS. pub(super) fn ucs_rotation_angle(&self) -> f64 { - if self.scene.current_layout != "Model" { + if !self.editing_model_space() { return 0.0; } let (_, x, ..) = self.ucs_xform().axes(); @@ -231,7 +316,7 @@ impl DocumentTab { /// Grid origin (render/wire space) and UCS→world rotation for grid snap and /// the grid overlay. Identity / origin-at-zero outside model space. pub(super) fn ucs_grid_basis(&self) -> (glam::Vec3, glam::Mat4) { - if self.scene.current_layout != "Model" { + if !self.editing_model_space() { return (glam::Vec3::ZERO, glam::Mat4::IDENTITY); } let xf = self.ucs_xform(); diff --git a/src/app/update.rs b/src/app/update.rs index e4ebef40..042bf05d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -5688,6 +5688,9 @@ impl OpenCADStudio { self.tabs[i].scene.active_viewport = None; self.tabs[i].scene.set_current_layout(name); self.tabs[i].scene.deselect_all(); + // UCS follows the pane: model header UCS in the Model tab, none + // in plain paper space (a viewport's UCS is adopted on entry). + self.tabs[i].refresh_active_ucs(); self.tabs[i].scene.restore_saved_camera(); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; // Grid/snap are per-view: load the layout we just entered (its @@ -6213,6 +6216,8 @@ impl OpenCADStudio { self.tabs[i].scene.normalize_active_viewport_view(); // Grid/snap follow the entered viewport. self.adopt_view_display(i); + // Adopt the entered viewport's own per-viewport UCS. + self.tabs[i].refresh_active_ucs(); self.refresh_properties(); self.command_line.push_output("MSPACE"); Task::none() @@ -6225,6 +6230,8 @@ impl OpenCADStudio { self.tabs[i].scene.active_viewport = None; // Grid/snap return to the paper sheet's own state. self.adopt_view_display(i); + // Paper space has no UCS — drop the viewport's UCS. + self.tabs[i].refresh_active_ucs(); self.refresh_properties(); self.command_line.push_output("PSPACE"); Task::none() @@ -8323,12 +8330,19 @@ impl OpenCADStudio { p: iced::Point, bounds: iced::Rectangle, ) -> glam::DVec3 { - let plane = self.tabs[i].active_ucs.as_ref().map(|ucs| { - ( - ucs_z_axis(ucs), - glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z), - ) - }); + // Constrain to the UCS plane only where the UCS applies (model space or + // inside a viewport); plain paper space uses the target plane. + let plane = self + .tabs[i] + .active_ucs + .as_ref() + .filter(|_| self.tabs[i].editing_model_space()) + .map(|ucs| { + ( + ucs_z_axis(ucs), + glam::DVec3::new(ucs.origin.x, ucs.origin.y, ucs.origin.z), + ) + }); let pick = |cam: &crate::scene::view::camera::Camera| match plane { Some((normal, origin)) => cam.pick_on_plane(p, bounds, normal, origin), None => cam.pick_on_target_plane(p, bounds), diff --git a/src/app/view.rs b/src/app/view.rs index f98540c3..adf2c635 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -155,11 +155,10 @@ impl OpenCADStudio { // stay independent: model tiles in model space; the sheet plus each // floating viewport (clipped to its rectangle) in paper space. One // enumeration for both, shared with the renderer. - // Align the model grid to the active UCS (origin in wire space, world - // axis directions); paper space stays plain WCS. - let (grid_origin, grid_axes): (glam::DVec3, _) = if is_paper { - (glam::DVec3::ZERO, (glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z)) - } else { + // Align each grid to its pane's UCS (origin in wire space, world + // axis directions): model tiles to the tab's model UCS, each content + // viewport to its own per-viewport UCS, the paper sheet to plain WCS. + let model_basis = { let (o, ux, uy, uz) = tab.ucs_xform().axes(); (o.as_dvec3(), (ux, uy, uz)) }; @@ -167,27 +166,53 @@ impl OpenCADStudio { .scene .grid_views(vw, vh) .into_iter() - .map(|(bounds, cam)| { + .map(|(bounds, cam, handle)| { + let (origin, axes): (glam::DVec3, _) = if is_paper { + match tab.ucs_from_viewport(handle) { + Some(u) => { + let (o, ux, uy, uz) = + super::helpers::UcsXform::from_ucs(&u).axes(); + (o.as_dvec3(), (ux, uy, uz)) + } + None => ( + glam::DVec3::ZERO, + (glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z), + ), + } + } else { + model_basis + }; let plane = grid_plane_from_camera(cam.pitch, cam.yaw); overlay::GridParams { view_rot: cam.view_proj_rte(bounds), eye: cam.eye_f64(), bounds, plane, - origin: grid_origin, - axes: grid_axes, + origin, + axes, } }) .collect(); - let ucs_icon = if self.show_ucs_icon && !is_paper { + // The UCS icon shows the active pane's UCS tripod: the model view, or + // (inside a floating viewport) projected through the viewport camera + // at the viewport's rect so it tracks the in-viewport UCS. + // Rotation-only projection (view_proj_rte): the icon shows axis + // DIRECTIONS only, so the full view_proj's huge UTM translation would + // cancel catastrophically in f32 and make the tripod jitter. + let ucs_icon = if !self.show_ucs_icon { + None + } else if let Some((vp_cam, full)) = tab.scene.viewport_edit_frame((vw, vh)) { + let (_, ux, uy, uz) = tab.ucs_xform().axes(); + Some(overlay::UcsIconParams { + view_proj: vp_cam.view_proj_rte(full), + bounds: full, + axes: (ux, uy, uz), + }) + } else if !is_paper { let cam = tab.scene.camera.borrow(); let (_, ux, uy, uz) = tab.ucs_xform().axes(); Some(overlay::UcsIconParams { - // Rotation-only projection: the icon shows axis DIRECTIONS - // only, so the full view_proj's huge UTM translation would - // just cancel catastrophically in f32 and make the tripod - // jitter. view_proj_rte drops the translation. (#utm-ucs) view_proj: cam.view_proj_rte(vp_bounds), bounds: vp_bounds, axes: (ux, uy, uz), @@ -916,7 +941,9 @@ impl OpenCADStudio { // user's coordinate system, not raw WCS (no-op without UCS). let cursor_coord = { let lc = tab.last_cursor_world; - if is_model { + // The readout follows the active pane's UCS — model space + // or inside a floating viewport (no-op without a UCS). + if tab.editing_model_space() { let wo = [0.0_f64; 3]; let wcs = glam::Vec3::new( lc.x + wo[0] as f32, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 914e405a..9c7a069f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3967,7 +3967,9 @@ impl Scene { /// the active UCS in model space, identity everywhere else. Render, /// hit-test, and click-snap all go through this so they stay in lock-step. pub fn viewcube_ucs_mat(&self) -> glam::Mat4 { - if self.current_layout == "Model" { + // UCS applies in model space and inside a floating viewport (MSPACE); + // plain paper space stays WCS. + if self.current_layout == "Model" || self.active_viewport.is_some() { self.viewcube_ucs } else { glam::Mat4::IDENTITY @@ -7366,11 +7368,11 @@ impl Scene { /// `active_viewports` enumeration the renderer uses, so the grid overlay can /// never drift from the views actually on screen (issue #121). The grid /// ignores render mode, so any value passes through. - pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera)> { + pub fn grid_views(&self, vw: f32, vh: f32) -> Vec<(iced::Rectangle, Camera, Handle)> { self.active_viewports(vw, vh, acadrust::entities::ViewportRenderMode::Wireframe2D) .into_iter() .filter(|inst| inst.grid_on) - .map(|inst| (inst.screen_rect, inst.camera)) + .map(|inst| (inst.screen_rect, inst.camera, inst.handle)) .collect() }