From 49fe48f89c5ec7f2ca482a3ef79ff77e0aff9d91 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:10:28 -0500 Subject: [PATCH 01/23] Add shared viewport frame, accepted snap, and measurement scale interfaces Contract module for the viewport snapping, viewport dimension measurement, and dimension association work that builds on it. Co-Authored-By: Claude Fable 5.1 --- src/command.rs | 2 +- src/scene/mod.rs | 1 + src/scene/viewport_ref.rs | 267 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/scene/viewport_ref.rs diff --git a/src/command.rs b/src/command.rs index e5b43ea76..50f17757c 100644 --- a/src/command.rs +++ b/src/command.rs @@ -179,7 +179,7 @@ pub struct SelectionEntity { } /// Association source with an optional sub-entity marker. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct DimensionAssociationSource { pub handle: Handle, pub marker: Option, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..03c7fef5f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -40,6 +40,7 @@ mod modify; pub mod named_parameters; mod named_parameters_persist; mod mspace; +pub mod viewport_ref; mod page_setup; mod paper; mod preview; diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs new file mode 100644 index 000000000..eae776d25 --- /dev/null +++ b/src/scene/viewport_ref.rs @@ -0,0 +1,267 @@ +//! Shared interfaces for paper-space snapping through layout viewports and +//! for viewport-aware dimension measurement. +//! +//! Three PRs build on this module concurrently: +//! * PR1 (viewport snap) constructs [`ViewportFrame`] from the live viewport +//! camera and fills [`SnapSourceRef`] / [`AcceptedSnap`] when a snap is +//! accepted in paper space. +//! * PR2 (dimension measurement) consumes [`AcceptedSnap`] and uses +//! [`MeasurementScale`] to split the user's DIMLFAC from viewport +//! compensation so the compensation is applied exactly once. +//! * PR3 (associations) turns [`SnapSourceRef`] into persistent +//! `AssocDimensionReference` chains (dimension -> viewport -> block path -> +//! entity -> feature) and resolves them back. +//! +//! Anything here is a contract between those branches. Extend by adding +//! fields/methods; do not rename or remove what exists without updating the +//! sibling branches. + +use acadrust::types::Handle; +use glam::{DMat3, DVec2, DVec3}; + +use crate::command::DimensionAssociationSource; +use crate::snap::SnapType; + +/// Planar orthographic mapping between a layout viewport's paper rectangle and +/// the model plane it displays. Handles pan (`model_target` / `paper_center`), +/// scale (paper units per model unit) and in-plane rotation (`twist`, +/// radians, counter-clockwise, applied to model geometry before scaling). +/// +/// PR1 owns construction (`Scene::viewport_frame`); everyone else only reads +/// it. All arithmetic is `f64`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewportFrame { + /// Viewport entity handle. + pub viewport: Handle, + /// Paper-space centre of the viewport rectangle. + pub paper_center: DVec2, + /// Model-space point displayed at `paper_center`. + pub model_target: DVec2, + /// Paper units per model unit (e.g. 0.1 for a 1:10 viewport). + pub scale: f64, + /// In-plane rotation (DXF `twist_angle`), radians. + pub twist: f64, + /// `true` when the viewport is display-locked (snapping still allowed). + pub locked: bool, +} + +impl ViewportFrame { + /// Model -> paper, as a 2-D affine matrix (homogeneous, column vectors). + pub fn model_to_paper_matrix(&self) -> DMat3 { + let (s, c) = self.twist.sin_cos(); + let rs = DMat3::from_cols( + DVec3::new(c * self.scale, s * self.scale, 0.0), + DVec3::new(-s * self.scale, c * self.scale, 0.0), + DVec3::new(0.0, 0.0, 1.0), + ); + let t_model = DMat3::from_translation(-self.model_target); + let t_paper = DMat3::from_translation(self.paper_center); + t_paper * rs * t_model + } + + /// Paper -> model (inverse of [`Self::model_to_paper_matrix`]). + pub fn paper_to_model_matrix(&self) -> DMat3 { + self.model_to_paper_matrix().inverse() + } + + /// Project a model-space point onto the paper sheet. `z` passes through. + pub fn model_to_paper(&self, p: DVec3) -> DVec3 { + let q = self.model_to_paper_matrix().transform_point2(p.truncate()); + DVec3::new(q.x, q.y, p.z) + } + + /// Lift a paper-sheet point into model space. `z` passes through. + pub fn paper_to_model(&self, p: DVec3) -> DVec3 { + let q = self.paper_to_model_matrix().transform_point2(p.truncate()); + DVec3::new(q.x, q.y, p.z) + } + + /// Model -> paper for a direction (rotation + scale, no translation). + pub fn model_to_paper_dir(&self, d: DVec2) -> DVec2 { + self.model_to_paper_matrix().transform_vector2(d) + } + + /// Factor a paper-space length must be multiplied by to recover the model + /// length it displays (`1 / scale`). This is the *viewport compensation* + /// used by [`MeasurementScale`]. + pub fn paper_to_model_length_factor(&self) -> f64 { + if self.scale.abs() < 1e-12 { + 1.0 + } else { + 1.0 / self.scale + } + } +} + +/// Identity of the geometry a snap landed on, in model space, including the +/// block-instance path when the entity lives inside a block reference. +/// +/// `source.handle` is the innermost entity (the one owning the feature). +/// `block_path` is the ordered list of INSERT handles from the outermost +/// instance in the space down to the instance that directly contains the +/// entity; empty for top-level geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct SnapSourceRef { + pub source: DimensionAssociationSource, + pub block_path: Vec, + pub snap_type: SnapType, +} + +/// A snap accepted by a point-input command, with enough information for +/// dimension commands to distinguish *placement* (paper) from *measurement* +/// (model) and for association creation to record where it came from. +/// +/// Invariant: when `viewport` is `Some`, `paper_point` is the point on the +/// sheet and `model_point` is that same point in model space through the +/// viewport frame. When `viewport` is `None` the snap was on paper-space (or +/// model-space) geometry directly and `paper_point == model_point`. +#[derive(Clone, Debug, PartialEq)] +pub struct AcceptedSnap { + /// Point in the coordinate system of the current space (paper when a + /// layout is active). + pub paper_point: DVec3, + /// Point in model space (identical to `paper_point` when `viewport` is + /// `None`). + pub model_point: DVec3, + /// The viewport the snap looked through, if any. + pub viewport: Option, + /// The frame in effect when the snap was accepted (so a later measurement + /// uses the same scale even if the viewport is changed afterwards). + pub frame: Option, + /// Geometry identity, when the snap landed on an entity feature (not a + /// grid/free point). + pub source: Option, +} + +impl AcceptedSnap { + /// A plain point in the current space with no viewport and no source. + pub fn free(point: DVec3) -> Self { + Self { + paper_point: point, + model_point: point, + viewport: None, + frame: None, + source: None, + } + } + + pub fn through_viewport(&self) -> bool { + self.viewport.is_some() + } +} + +/// The two independent factors that turn a raw measured distance into the +/// number a linear/aligned/radial dimension displays. +/// +/// * `user_lfac` is the drawing/style's DIMLFAC as the user intends it +/// (always positive here; the sign convention of DXF DIMLFAC is resolved by +/// [`MeasurementScale::from_dimlfac`]). +/// * `viewport_compensation` undoes the viewport's paper-per-model scale for +/// a dimension placed in paper space but measuring model geometry. It is +/// `1.0` for model-space dimensions and for paper-only dimensions. +/// +/// Angular dimensions never use either factor. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeasurementScale { + pub user_lfac: f64, + pub viewport_compensation: f64, +} + +impl MeasurementScale { + pub const IDENTITY: Self = Self { + user_lfac: 1.0, + viewport_compensation: 1.0, + }; + + /// Resolve the DXF DIMLFAC convention: + /// * `dimlfac > 0`: applies to every dimension. + /// * `dimlfac < 0`: `|dimlfac|` applies only to dimensions created in + /// paper space that measure model geometry through a viewport; model + /// space dimensions use `1.0`. + /// * `dimlfac == 0`: treated as `1.0`. + /// + /// `viewport` supplies the compensation when the dimension measures + /// through a viewport; pass `None` for model-space or paper-only + /// dimensions. + pub fn from_dimlfac(dimlfac: f64, viewport: Option<&ViewportFrame>) -> Self { + let through_viewport = viewport.is_some(); + let user_lfac = if dimlfac == 0.0 { + 1.0 + } else if dimlfac > 0.0 { + dimlfac + } else if through_viewport { + -dimlfac + } else { + 1.0 + }; + let viewport_compensation = viewport + .map(ViewportFrame::paper_to_model_length_factor) + .unwrap_or(1.0); + Self { + user_lfac, + viewport_compensation, + } + } + + /// Combined factor applied to a distance measured on the paper sheet. + pub fn paper_factor(&self) -> f64 { + self.user_lfac * self.viewport_compensation + } + + /// Factor applied to a distance already measured in model space (the + /// viewport compensation must not be applied again). + pub fn model_factor(&self) -> f64 { + self.user_lfac + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(scale: f64, twist: f64) -> ViewportFrame { + ViewportFrame { + viewport: Handle::NULL, + paper_center: DVec2::new(100.0, 50.0), + model_target: DVec2::new(1000.0, 2000.0), + scale, + twist, + locked: false, + } + } + + #[test] + fn round_trips_with_scale_and_twist() { + let f = frame(0.1, 0.3); + let m = DVec3::new(1234.5, 1987.25, 0.0); + let p = f.model_to_paper(m); + let back = f.paper_to_model(p); + assert!((back - m).length() < 1e-9); + } + + #[test] + fn scale_maps_lengths() { + let f = frame(0.1, 0.0); + let a = f.model_to_paper(DVec3::new(0.0, 0.0, 0.0)); + let b = f.model_to_paper(DVec3::new(100.0, 0.0, 0.0)); + assert!(((b - a).length() - 10.0).abs() < 1e-9); + assert!((f.paper_to_model_length_factor() - 10.0).abs() < 1e-12); + } + + #[test] + fn negative_dimlfac_only_applies_through_viewports() { + let f = frame(0.5, 0.0); + let through = MeasurementScale::from_dimlfac(-2.0, Some(&f)); + assert_eq!(through.user_lfac, 2.0); + assert!((through.viewport_compensation - 2.0).abs() < 1e-12); + assert!((through.paper_factor() - 4.0).abs() < 1e-12); + assert!((through.model_factor() - 2.0).abs() < 1e-12); + + let model = MeasurementScale::from_dimlfac(-2.0, None); + assert_eq!(model, MeasurementScale::IDENTITY); + + let positive = MeasurementScale::from_dimlfac(3.0, None); + assert_eq!(positive.user_lfac, 3.0); + assert_eq!(positive.viewport_compensation, 1.0); + } +} From 53576fabdf7b6ecc6ce28032f74863e549b048e8 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:27:13 -0500 Subject: [PATCH 02/23] feat(scene): build a planar ViewportFrame from a layout viewport's live camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Scene::viewport_frame` derives the paper<->model mapping (paper_center, model_target, paper-per-model scale, in-plane twist, lock state) from `camera_for_viewport` — the same camera the renderer displays the viewport's contents with — so a snap computed through the frame lands on the pixels the GPU drew. Oblique / mirrored / perspective viewports return None. Also generalises the MSPACE editing adapter: `viewport_edit_frame_for(handle)` returns the camera + screen rect for any viewport, and `viewport_edit_frame` becomes a wrapper over it for the active one. Adds `viewport_displays_paper_point` (rectangular bounds plus a non-rectangular clip boundary polygon, tested in paper coordinates) and public accessors for the layout's content viewports and their VP ON state. Co-Authored-By: Claude Fable 5.1 --- src/scene/mspace.rs | 172 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 1 deletion(-) diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index 6cad6e5c5..dc16c99f4 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -80,12 +80,162 @@ impl Scene { &self, canvas_px: (f32, f32), ) -> Option<(view::camera::Camera, iced::Rectangle)> { - let vp_handle = self.active_viewport?; + self.viewport_edit_frame_for(self.active_viewport?, canvas_px) + } + + /// [`viewport_edit_frame`] for an explicitly-named viewport, rather than + /// only the active (MSPACE) one. + /// + /// Paper-space snapping through viewports (PR1) needs the same camera + + /// screen rectangle pair for a viewport the user has NOT entered, so the + /// existing snap engine can run against that viewport's model wires on the + /// pixels the GPU actually drew them on. `viewport_edit_frame` is now a + /// thin wrapper over this. + pub fn viewport_edit_frame_for( + &self, + vp_handle: Handle, + canvas_px: (f32, f32), + ) -> Option<(view::camera::Camera, iced::Rectangle)> { let cam = self.camera_for_viewport(vp_handle)?; let full = self.viewport_screen_rect(vp_handle, canvas_px)?; Some((cam, full)) } + /// Planar paper <-> model mapping for a layout viewport, derived from the + /// *same* camera the renderer displays its contents with + /// ([`camera_for_viewport`]) so a snap computed through the frame lands on + /// the pixels the GPU drew. + /// + /// Returns `None` for a viewport whose view is not an in-plane orthographic + /// top view (oblique / isometric / perspective viewports are unsupported by + /// the planar frame — callers fall back to no viewport snapping there). + pub fn viewport_frame(&self, vp_handle: Handle) -> Option { + use crate::scene::viewport_ref::ViewportFrame; + + let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) { + Some(EntityType::Viewport(vp)) => ( + glam::DVec2::new(vp.center.x, vp.center.y), + vp.height, + vp.status.locked, + ), + _ => return None, + }; + if vp_height.abs() < 1e-9 { + return None; + } + let cam = self.camera_for_viewport(vp_handle)?; + if cam.projection != view::camera::Projection::Orthographic { + return None; + } + // Screen right/up of the viewport camera, in model space. A planar + // frame exists only when both stay in the model XY plane and form a + // right-handed pair (a plan view, possibly twisted). Anything else is + // an oblique 3-D view: unsupported. + let right = (cam.rotation * glam::Vec3::X).as_dvec3(); + let up = (cam.rotation * glam::Vec3::Y).as_dvec3(); + if right.z.abs() > 1e-6 || up.z.abs() > 1e-6 { + return None; + } + let normal = right.cross(up); + if normal.z < 1.0 - 1e-6 { + return None; // mirrored or degenerate + } + // Model units visible across the viewport's height -> paper per model. + let model_height = cam.ortho_size() as f64 * 2.0; + if model_height.abs() < 1e-12 { + return None; + } + let scale = vp_height / model_height; + // `ViewportFrame` rotates model geometry by `twist` (CCW) before + // scaling; the row that produces the paper X component is `right`, so + // cos(twist) = right.x and sin(twist) = -right.y. + let twist = (-right.y).atan2(right.x); + Some(ViewportFrame { + viewport: vp_handle, + paper_center, + model_target: glam::DVec2::new(cam.target.x as f64, cam.target.y as f64), + scale, + twist, + locked, + }) + } + + /// Does `paper` lie inside the viewport's displayed area? + /// + /// Always bounded by the viewport's paper rectangle; a viewport with a + /// `clip_boundary_handle` (non-rectangular / clipped viewport) additionally + /// has to contain the point inside that boundary polygon. Used so a + /// paper-space snap never reports a model feature the viewport does not + /// actually show. + pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else { + return false; + }; + let hw = (vp.width * 0.5).abs(); + let hh = (vp.height * 0.5).abs(); + if hw < 1e-9 || hh < 1e-9 { + return false; + } + if (paper.x - vp.center.x).abs() > hw || (paper.y - vp.center.y).abs() > hh { + return false; + } + if vp.clip_boundary_handle.is_null() { + return true; + } + let poly = self.clip_boundary_polygon(vp.clip_boundary_handle, vp.center.z as f32); + if poly.len() < 3 { + // A clip entity we cannot tessellate: fall back to the rectangle + // rather than refusing every snap in the viewport. + return true; + } + point_in_polygon_xy(paper, &poly) + } +} + +/// Even-odd point-in-polygon test on the XY plane (`poly` is a closed ring of +/// `[x, y, z]` paper coordinates; the closing edge is implicit). +fn point_in_polygon_xy(p: glam::DVec2, poly: &[[f32; 3]]) -> bool { + let mut inside = false; + let mut j = poly.len() - 1; + for i in 0..poly.len() { + let (xi, yi) = (poly[i][0] as f64, poly[i][1] as f64); + let (xj, yj) = (poly[j][0] as f64, poly[j][1] as f64); + if (yi > p.y) != (yj > p.y) { + let t = (p.y - yi) / (yj - yi); + if p.x < xi + t * (xj - xi) { + inside = !inside; + } + } + j = i; + } + inside +} + +#[cfg(test)] +mod clip_tests { + use super::point_in_polygon_xy; + + #[test] + fn concave_clip_boundary_rejects_the_notch() { + // An L: the notch at (8, 8) is outside even though it is inside the + // bounding rectangle. + let poly = [ + [0.0f32, 0.0, 0.0], + [10.0, 0.0, 0.0], + [10.0, 5.0, 0.0], + [5.0, 5.0, 0.0], + [5.0, 10.0, 0.0], + [0.0, 10.0, 0.0], + ]; + assert!(point_in_polygon_xy(glam::DVec2::new(2.0, 2.0), &poly)); + assert!(point_in_polygon_xy(glam::DVec2::new(8.0, 2.0), &poly)); + assert!(!point_in_polygon_xy(glam::DVec2::new(8.0, 8.0), &poly)); + assert!(!point_in_polygon_xy(glam::DVec2::new(-1.0, 5.0), &poly)); + } +} + +impl Scene { + /// Fold the active viewport's saved view onto the effective camera (the /// auto-fit centre for stale UTM views) and persist it into `view_target` / /// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the @@ -524,6 +674,26 @@ impl Scene { .map(|(h, _)| h) } + /// Content (floating) viewports of the current paper layout, in draw order + /// (the last entry draws on top). Empty in the Model layout. + pub fn layout_content_viewports(&self) -> Arc> { + if self.current_layout == "Model" { + return Arc::new(Vec::new()); + } + let (_, _, handles) = self.paper_viewport_handles(); + handles + } + + /// `true` when this viewport's contents are displayed (VP ON). The border + /// layer's visibility deliberately does NOT matter: a viewport whose frame + /// layer is off still shows its model content, so it still snaps. + pub fn viewport_displays_content(&self, vp_handle: Handle) -> bool { + matches!( + self.document.get_entity(vp_handle), + Some(EntityType::Viewport(vp)) if vp.status.is_on + ) + } + /// Return the handle of the first active user viewport in the current layout, /// or `None` if there are none. Used by the MS command. pub fn first_user_viewport(&self) -> Option { From 6def465ba5127ec1aeb15d1f3705d5c6278b9292 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:27:25 -0500 Subject: [PATCH 03/23] feat(snap): snap paper space through layout viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paper-space commands could not pick model geometry displayed by a layout viewport: `hit_test_wires` deliberately excludes viewport content in PSPACE, and that has to stay true for selection. This adds the capability as a separate query rather than by changing those semantics. `OpenCADStudio::paper_viewport_snap` walks the current layout's displayed content viewports top-most first, skips those whose display is off or whose clip boundary excludes the cursor, and for each one runs the EXISTING snap engine against that viewport's model wires using the viewport's own camera and screen rectangle (`viewport_edit_frame_for`). The accepted model point is mapped back onto the sheet with `ViewportFrame::model_to_paper`, so everything downstream keeps working in paper coordinates — LINE still draws at the projected paper point. Grid snap and the paper-space rubber-band origin are suppressed for that pass; results outside the clip are rejected so no snap lands on a clip cut. Locked viewports snap like unlocked ones. No projected geometry is rebuilt per pointer event: the wire set is the renderer's resident per-viewport Arc (`model_wires_for_viewport_arc`) and the broad phase reuses the shared interaction index over it. The result is merged with the ordinary paper-sheet snap by `snap::merge_snap`, which applies the engine's own priority-then-distance rule, in both the cursor-move and the click paths. `SnapResult` gains Copy fields `viewport: Option` and `source: Option`; the engine fills `source` from the owning wire wherever it knows it. `AcceptedSnap::from_snap` converts a result plus frame into the shared PR2/PR3 contract type. Co-Authored-By: Claude Fable 5.1 --- src/app/update/mod.rs | 1 + src/app/update/viewport.rs | 55 +++++++ src/app/update/viewport_snap.rs | 278 ++++++++++++++++++++++++++++++++ src/scene/viewport_ref.rs | 54 +++++++ src/snap.rs | 170 +++++++++++++++---- 5 files changed, 525 insertions(+), 33 deletions(-) create mode 100644 src/app/update/viewport_snap.rs diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9cfbe5769..6d97aedca 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -95,6 +95,7 @@ mod file; mod style; mod util; mod viewport; +mod viewport_snap; impl OpenCADStudio { pub(in crate::app) fn reset_modal_geometry(&mut self) { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c57bb947c..6290e08ea 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2089,6 +2089,31 @@ impl OpenCADStudio { ) }; + // Paper-space snapping THROUGH layout viewports (PR1). `edit_cam` + // is None here, so `cursor_world` is the paper point and the sheet + // fills the canvas (pane-local == canvas pixels). The viewport hit + // arrives already projected onto the sheet, so everything + // downstream keeps working in paper coordinates — LINE still draws + // at the projected paper point. + self.vp_snap_frame = None; + if edit_cam.is_none() && !needs_entity && !is_gathering && !needs_structure + && !needs_tan + { + if let Some((vp_hit, frame)) = + self.paper_viewport_snap(i, p_full, vp_size, cursor_world) + { + let merged = crate::snap::merge_snap( + self.tabs[i].snap_result, + Some(vp_hit), + p_full, + ); + if merged.is_some_and(|hit| hit.viewport.is_some()) { + self.vp_snap_frame = Some(frame); + } + self.tabs[i].snap_result = merged; + } + } + let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { !command.needs_entity_pick() && !command.needs_tangent_pick() @@ -2376,6 +2401,8 @@ impl OpenCADStudio { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, }); if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { cmd.set_acquisition_hint(Some(pick.label)); @@ -3646,6 +3673,24 @@ impl OpenCADStudio { } } } + // Paper-space snapping through layout viewports (PR1). Mirrors + // the cursor-move pass: the click recomputes snapping from + // scratch, so the viewport query has to run here too. The hit + // comes back already projected onto the sheet. + let mut click_frame: Option = None; + if edit_cam.is_none() && !needs_entity_click && !needs_tan { + if let Some((vp_hit, frame)) = + self.paper_viewport_snap(i, p_full, (vw, vh), raw) + { + let merged = + crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full); + if merged.is_some_and(|hit| hit.viewport.is_some()) { + click_frame = Some(frame); + } + snap_hit = merged; + } + } + self.pending_click_snap = snap_hit.map(|hit| (hit, click_frame)); // Snap runs in model space; the result is already model. let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); // When no UCS is active clamp to world XY; with a UCS the point is @@ -4100,6 +4145,16 @@ impl OpenCADStudio { } } self.last_point = Some(world_pt); + // Record what this point step accepted, alongside the point + // itself (PR1): paper point, model point, viewport, frame and + // geometry identity. Commands that don't care keep consuming + // `world_pt` exactly as before. + let pending = self.pending_click_snap.take(); + self.record_accepted_snap( + pending.map(|(hit, _)| hit), + pending.and_then(|(_, frame)| frame), + world_pt, + ); // The one-shot snap override is spent by this pick — // restore the running osnap configuration (#337). self.snapper.clear_override(); diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs new file mode 100644 index 000000000..898c55570 --- /dev/null +++ b/src/app/update/viewport_snap.rs @@ -0,0 +1,278 @@ +//! Paper-space snapping THROUGH layout viewports (PR1). +//! +//! In a paper layout with no active viewport, `Scene::hit_test_wires` returns +//! only the paper sheet's own entities — viewport contents are deliberately not +//! interactive, and that must stay true for *selection*. Snapping, however, +//! should see the model geometry a viewport displays, so a paper-space LINE / +//! DIMENSION can pick a real model endpoint and land on the paper pixel where +//! it is drawn. +//! +//! This module adds that as a SEPARATE query. It never changes +//! `hit_test_wires`. For each displayed content viewport under the cursor +//! (top-most first) it runs the EXISTING snap engine against that viewport's +//! resident model wires, using the viewport's own camera and screen rectangle +//! (`Scene::viewport_edit_frame_for` — the same adapter MSPACE editing uses), +//! then maps the accepted model point back onto the sheet with +//! [`ViewportFrame::model_to_paper`]. +//! +//! Performance: no projected geometry is rebuilt per pointer event. The wire +//! set comes from `model_wires_for_viewport_arc`, which is the renderer's own +//! resident, camera-independent `Arc`, and the cursor-local broad phase reuses +//! the shared interaction index built over it. + +use iced::Point; + +use acadrust::types::Handle; + +use crate::app::OpenCADStudio; +use crate::scene::viewport_ref::{AcceptedSnap, ViewportFrame}; +use crate::snap::{SnapResult, SnapType}; + +/// Bound on the retained accepted-snap history. A command's point steps are a +/// handful at most; the cap only stops a long polyline from growing forever. +const MAX_ACCEPTED_SNAPS: usize = 64; + +impl OpenCADStudio { + /// Snaps accepted by the active command's point steps, oldest first, in + /// step order. Consumed by the dimension commands in PR2/PR3. + #[allow(dead_code)] + pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] { + &self.accepted_snaps + } + + /// The most recently accepted snap, if any. + #[allow(dead_code)] + pub(crate) fn last_accepted_snap(&self) -> Option<&AcceptedSnap> { + self.accepted_snaps.last() + } + + /// `true` when any point the active command collected came through a + /// layout viewport. Dimension association inference (which compares a + /// dimension's own definition points against geometry in the *current* + /// space) must not run in that case: the definition points are paper + /// coordinates that correspond to no paper-sheet geometry, and the model + /// geometry they really describe lives at completely different + /// coordinates. PR3 replaces this guard with real viewport-aware + /// associations built from `AcceptedSnap::source`. + pub(crate) fn accepted_snaps_through_viewport(&self) -> bool { + self.accepted_snaps.iter().any(AcceptedSnap::through_viewport) + } + + /// `Scene::infer_dimension_sources`, suppressed when the dimension's points + /// were picked through a layout viewport (PR1). + /// + /// Inference works by looking for geometry near the dimension's own + /// definition points, in whatever space it is handed. A paper-space + /// dimension measured through a viewport has PAPER definition points that + /// happen to sit over projected model geometry, so unguarded inference + /// would either associate it with unrelated paper-sheet geometry at those + /// coordinates, or match model geometry using paper coordinates. Neither is + /// correct, and a wrong association is worse than none. Returning an empty + /// source list makes `attach_dimension_association` a no-op. + /// + /// Deliberately minimal: PR3 replaces this with a real viewport-aware + /// association built from `AcceptedSnap::source`. + pub(crate) fn infer_dimension_sources_guarded( + &self, + tab: usize, + dimension: Handle, + ) -> Vec> { + if self.accepted_snaps_through_viewport() { + return Vec::new(); + } + self.tabs[tab].scene.infer_dimension_sources(dimension) + } + + /// Forget the accepted snaps of a finished / abandoned command. + pub(crate) fn clear_accepted_snaps(&mut self) { + self.accepted_snaps.clear(); + } + + /// Record the snap a point step just accepted. `snap` is the displayed snap + /// result (already in the current space's coordinates) or `None` for a free + /// / typed point; `committed` is the point the command actually received + /// after ortho / polar / axis-lock / dynamic-input resolution. + pub(crate) fn record_accepted_snap( + &mut self, + snap: Option, + frame: Option, + committed: glam::DVec3, + ) { + let accepted = match snap { + // A viewport snap keeps its frame only when the hit really came + // through it, so a paper-sheet snap never claims a model point. + Some(hit) => { + let frame = hit.viewport.and(frame); + AcceptedSnap::from_snap(&hit, frame).with_paper_point(committed) + } + None => AcceptedSnap::free(committed), + }; + if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS { + self.accepted_snaps.remove(0); + } + self.accepted_snaps.push(accepted); + } + /// Snap the paper-space cursor to model geometry seen through a layout + /// viewport. + /// + /// * `i` — tab index. + /// * `cursor_canvas` — cursor in canvas pixels. + /// * `canvas` — canvas size in pixels. + /// * `cursor_paper` — the paper-space point under the cursor. + /// + /// Returns the hit expressed in PAPER coordinates (`world` projected onto + /// the sheet, `screen` in canvas pixels) together with the frame it came + /// through, so the caller can merge it with the ordinary paper-sheet snap + /// and later recover the model point. + /// + /// `None` in the Model layout, while a viewport is active (MSPACE already + /// snaps in model space), when snapping is off, or when the cursor is not + /// over a displayed viewport. + pub(in crate::app) fn paper_viewport_snap( + &mut self, + i: usize, + cursor_canvas: Point, + canvas: (f32, f32), + cursor_paper: glam::DVec3, + ) -> Option<(SnapResult, ViewportFrame)> { + if !self.snapper.snap_enabled { + return None; + } + { + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" || scene.active_viewport.is_some() { + return None; + } + } + if canvas.0 < 1.0 || canvas.1 < 1.0 { + return None; + } + + let handles = self.tabs[i].scene.layout_content_viewports(); + // Draw order puts the last-drawn viewport on top; probe top-most first + // so overlapping viewports resolve the same way they render. + for &handle in handles.iter().rev() { + if !self.tabs[i].scene.viewport_displays_content(handle) { + continue; + } + let Some(frame) = self.tabs[i].scene.viewport_frame(handle) else { + continue; // oblique / perspective viewport: unsupported + }; + // Clip first (rectangle + non-rectangular clip boundary), in paper + // coordinates, so a cursor over the cut-away part of a clipped + // viewport sees nothing. + if !self + .tabs[i] + .scene + .viewport_displays_paper_point(handle, cursor_paper.truncate()) + { + continue; + } + let Some((cam, rect)) = self + .tabs[i] + .scene + .viewport_edit_frame_for(handle, canvas) + else { + continue; + }; + if rect.width < 1.0 || rect.height < 1.0 { + continue; + } + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: rect.width, + height: rect.height, + }; + let local = Point::new(cursor_canvas.x - rect.x, cursor_canvas.y - rect.y); + let view_rot = cam.view_proj_rte(bounds); + let eye = cam.eye(); + // The cursor in MODEL space, through the frame — exactly the point + // the viewport camera projects back onto `local`. + let model_cursor = frame.paper_to_model(cursor_paper); + + let wires = self + .tabs[i] + .scene + .model_wires_for_viewport_arc(handle, bounds.height); + let candidates = self.tabs[i].scene.interaction_candidates_near( + wires, + model_cursor, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + ); + + // Object snap only. The grid belongs to the paper sheet, and the + // rubber-band origin / tracking points are paper-space points, so + // perpendicular and extension feet would be nonsense in model + // space. Save and restore, since `Snapper` is shared state. + let saved_grid = self.snapper.grid_snap_on; + let saved_from = self.snapper.from_point; + self.snapper.grid_snap_on = false; + self.snapper.from_point = None; + let hit = self.snapper.snap( + model_cursor, + local, + &candidates, + view_rot, + eye, + bounds, + glam::Vec3::ZERO, + (glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z), + None, + ); + self.snapper.grid_snap_on = saved_grid; + self.snapper.from_point = saved_from; + + let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else { + continue; + }; + // The wire set is the whole model, not a clipped copy, so the only + // way a feature outside the visible part could be offered is via + // the aperture reaching past the clip. Reject those: a snap must be + // a real feature the viewport actually displays. + let paper = frame.model_to_paper(hit.world); + if !self + .tabs[i] + .scene + .viewport_displays_paper_point(handle, paper.truncate()) + { + continue; + } + return Some((project_hit_to_paper(hit, &frame, rect, handle), frame)); + } + None + } +} + +/// Re-express a snap taken in a viewport's model space as a paper-space result: +/// the point projects onto the sheet through `frame`, and the pane-local pixel +/// positions shift by the viewport's screen origin so they share the canvas +/// frame with the ordinary paper snap. +fn project_hit_to_paper( + hit: SnapResult, + frame: &ViewportFrame, + rect: iced::Rectangle, + viewport: Handle, +) -> SnapResult { + let to_canvas = |p: Point| Point::new(p.x + rect.x, p.y + rect.y); + SnapResult { + world: frame.model_to_paper(hit.world), + screen: to_canvas(hit.screen), + snap_type: hit.snap_type, + // Tangent geometry is model-space and would be consumed as paper-space + // by TTR/TTT; drop it rather than hand over a wrong circle. + tangent_obj: None, + extension_base: hit.extension_base.map(to_canvas), + extension_base2: hit.extension_base2.map(to_canvas), + extension_origin: hit.extension_origin.map(|p| frame.model_to_paper(p)), + extension_dir: hit.extension_dir.map(|d| { + let mapped = frame.model_to_paper_dir(d.truncate()); + glam::DVec3::new(mapped.x, mapped.y, d.z) + }), + viewport: Some(viewport), + source: hit.source, + } +} diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index eae776d25..1122eee8c 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -148,6 +148,60 @@ impl AcceptedSnap { pub fn through_viewport(&self) -> bool { self.viewport.is_some() } + + /// Build an accepted snap from a [`crate::snap::SnapResult`]. + /// + /// * `frame` `Some`: the snap ran against MODEL geometry through that + /// viewport, so `snap.world` is a model point and the paper point is its + /// projection onto the sheet. + /// * `frame` `None`: the snap ran in the current space directly, so paper + /// and model point coincide. + /// + /// PR1 fills `source` from `SnapResult::source` (the owning entity handle + /// the snap engine attributed the feature to); `block_path` stays empty + /// until PR3 resolves block instance chains. + pub fn from_snap(snap: &crate::snap::SnapResult, frame: Option) -> Self { + let (paper_point, model_point) = match &frame { + Some(frame) => (frame.model_to_paper(snap.world), snap.world), + None => (snap.world, snap.world), + }; + Self { + paper_point, + model_point, + viewport: frame.map(|f| f.viewport), + frame, + source: snap.source.map(|source| SnapSourceRef { + source, + block_path: Vec::new(), + snap_type: snap.snap_type, + }), + } + } + + /// The paper point with the viewport context of `self`, but no geometry + /// identity — used when a command constrains a snapped point (ortho/polar, + /// axis lock) so the recorded point matches what was committed. + pub fn with_paper_point(mut self, paper_point: DVec3) -> Self { + // Compare in the sheet plane only: paper space is flat and callers + // clamp `z` to 0, which must not be mistaken for the point having been + // moved off the snapped feature (and must not discard the snapped + // model point's real elevation). + let moved = self + .paper_point + .truncate() + .distance_squared(paper_point.truncate()) + > 1e-18; + if moved { + // The committed point is no longer the feature that was snapped. + self.source = None; + self.model_point = match &self.frame { + Some(frame) => frame.paper_to_model(paper_point), + None => paper_point, + }; + } + self.paper_point = paper_point; + self + } } /// The two independent factors that turn a raw measured distance into the diff --git a/src/snap.rs b/src/snap.rs index e2ae67b2f..b388e0f96 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -10,7 +10,8 @@ use iced::{Point, Rectangle}; use cadkernel::geom2d::Curve; -use crate::command::TangentObject; +use crate::command::{DimensionAssociationSource, TangentObject}; +use acadrust::types::Handle; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; @@ -86,6 +87,33 @@ pub struct SnapResult { pub extension_origin: Option, /// Source direction paired with `extension_origin`. pub extension_dir: Option, + /// Layout viewport this snap was seen *through*, when the snap ran against + /// model geometry displayed by a paper-space viewport. `None` for ordinary + /// model-space / paper-sheet snaps. Filled by the paper-space viewport snap + /// query, never by the engine itself. (PR1) + pub viewport: Option, + /// Owning entity of the geometry the snap landed on, when the engine could + /// attribute the feature to a wire. Block sub-entities report the top-level + /// INSERT handle; the block path is resolved later by + /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. (PR1) + pub source: Option, +} + +impl SnapResult { + /// Handle of the entity the snap landed on, when known. + pub fn source_handle(&self) -> Option { + self.source.map(|s| s.handle) + } +} + +/// Entity handle carried by a wire (`WireModel::name` is the decimal handle +/// value). Returns `None` for preview / interim wires with a symbolic name. +#[inline] +pub(crate) fn wire_source(wire: &WireModel) -> Option { + wire.name + .parse::() + .ok() + .map(|v| DimensionAssociationSource::inferred(Handle::new(v))) } /// Object-snap-tracking alignment: the cursor projected onto a ray from an @@ -954,6 +982,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, }) } @@ -1087,6 +1117,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, }); best_rank = snap_tier(SnapType::Grid); best_sub = snap_priority(SnapType::Grid); @@ -1182,7 +1214,9 @@ impl Snapper { return best; } - let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { + let mut try_pt = |world: glam::DVec3, + snap_type: SnapType, + src: Option| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1209,12 +1243,16 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: src, }); } }; // ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ────── - let mut try_snap_hint = |world: DVec3, hint: SnapHint| { + let mut try_snap_hint = |world: DVec3, + hint: SnapHint, + src: Option| { let snap_type = match hint { SnapHint::Center => SnapType::Center, SnapHint::Node => SnapType::Node, @@ -1224,23 +1262,24 @@ impl Snapper { SnapHint::Endpoint => SnapType::Endpoint, }; if self.is_on(snap_type) { - try_pt(world, snap_type); + try_pt(world, snap_type, src); } }; if let Some(points) = wires.snap_points() { for point_ref in points { - let Some(&(world, hint)) = wires - .source_wire(point_ref.wire) - .and_then(|wire| wire.snap_pts.get(point_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(point_ref.wire) else { continue; }; - try_snap_hint(world, hint); + let Some(&(world, hint)) = wire.snap_pts.get(point_ref.index as usize) else { + continue; + }; + try_snap_hint(world, hint, wire_source(wire)); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for &(world, hint) in &wire.snap_pts { - try_snap_hint(world, hint); + try_snap_hint(world, hint, src); } } } @@ -1250,13 +1289,17 @@ impl Snapper { if self.is_on(SnapType::Endpoint) { if let Some(vertices) = wires.key_vertices() { for vertex_ref in vertices { - let Some(&point) = wires - .source_wire(vertex_ref.wire) - .and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(vertex_ref.wire) else { continue; }; - try_pt(DVec3::from_array(point), SnapType::Endpoint); + let Some(&point) = wire.key_vertices.get(vertex_ref.index as usize) else { + continue; + }; + try_pt( + DVec3::from_array(point), + SnapType::Endpoint, + wire_source(wire), + ); } // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains @@ -1270,17 +1313,22 @@ impl Snapper { continue; } if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, wire_source(wire)); } if wire.points.len() > 1 { - try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint); + try_pt( + wp_f64(wire, wire.points.len() - 1), + SnapType::Endpoint, + wire_source(wire), + ); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); if !wire.key_vertices.is_empty() { for &point in &wire.key_vertices { - try_pt(DVec3::from_array(point), SnapType::Endpoint); + try_pt(DVec3::from_array(point), SnapType::Endpoint, src); } } else { let closed = wire @@ -1289,10 +1337,14 @@ impl Snapper { .any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); if !closed { if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, src); } if wire.points.len() > 1 { - try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint); + try_pt( + wp_f64(wire, wire.points.len() - 1), + SnapType::Endpoint, + src, + ); } } } @@ -1319,16 +1371,17 @@ impl Snapper { let a = DVec3::from_array(a); let b = DVec3::from_array(b); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint); + try_pt((a + b) * 0.5, SnapType::Midpoint, wire_source(wire)); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for segment in wire.key_vertices.windows(2) { let a = DVec3::from_array(segment[0]); let b = DVec3::from_array(segment[1]); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint); + try_pt((a + b) * 0.5, SnapType::Midpoint, src); } } } @@ -1342,14 +1395,16 @@ impl Snapper { try_pt( nearest_on_segment(cursor_world, seg.a, seg.b), SnapType::Nearest, + wires.source_wire(seg.wire).and_then(wire_source), ); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for i in 0..wire.points.len().saturating_sub(1) { let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); - try_pt(p, SnapType::Nearest); + try_pt(p, SnapType::Nearest, src); } } } @@ -1362,14 +1417,19 @@ impl Snapper { if let Some(segments) = &local_segments { for seg in segments { if let Some(foot) = perp_foot(q, seg.a, seg.b) { - try_pt(foot, SnapType::Perpendicular); + try_pt( + foot, + SnapType::Perpendicular, + wires.source_wire(seg.wire).and_then(wire_source), + ); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for i in 0..wire.points.len().saturating_sub(1) { if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) { - try_pt(foot, SnapType::Perpendicular); + try_pt(foot, SnapType::Perpendicular, src); } } } @@ -1387,12 +1447,17 @@ impl Snapper { ray_segment_intersect_3d(origin, through, segment.a, segment.b) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt( + point, + SnapType::Intersection, + wires.source_wire(segment.wire).and_then(wire_source), + ); } } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for index in 0..wire.points.len().saturating_sub(1) { if let Some(point) = ray_segment_intersect_3d( origin, @@ -1401,7 +1466,7 @@ impl Snapper { wp_f64(wire, index + 1), ) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src); } } } @@ -1483,8 +1548,11 @@ impl Snapper { for (idx, &wire_i) in local_wires.iter().enumerate() { for &wire_j in &local_wires[idx + 1..] { if let Some(points) = exact_curve_intersections(wire_i, wire_j) { + // Two wires meet here; attribute the feature to the + // first. PR3 resolves the true pair. + let src = wire_source(wire_i); for point in points { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src); } resolved_pairs.insert(pair_key(wire_i, wire_j)); } @@ -1515,7 +1583,11 @@ impl Snapper { world_to_screen(pt, view_rot, eye, bounds), cursor_screen, ) <= f32::EPSILON; - try_pt(pt, SnapType::Intersection); + try_pt( + pt, + SnapType::Intersection, + wires.source_wire(a.wire).and_then(wire_source), + ); if exact_cursor { break 'intersection_sweep; } @@ -1535,8 +1607,9 @@ impl Snapper { // Curved pairs are solved exactly (bug #1052); see // `exact_curve_intersections`'s doc comment. if let Some(pts) = exact_curve_intersections(wire_i, wire_j) { + let src = wire_source(wire_i); for pt in pts { - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, src); } continue; } @@ -1560,7 +1633,7 @@ impl Snapper { continue; } if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, wire_source(wire_i)); } } } @@ -1586,7 +1659,7 @@ impl Snapper { bounds, self.osnap_radius_px, ) { - try_pt(ext, SnapType::Extension); + try_pt(ext, SnapType::Extension, None); } } } @@ -1633,7 +1706,7 @@ impl Snapper { // feet (which sit closer to the cursor on their own lines), // or the cursor would snap to a line instead of the crossing. let pt = glam::DVec3::new(a0.x + t1 * d1.x, a0.y + t1 * d1.y, a0.z); - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, None); } } } @@ -1686,6 +1759,7 @@ impl Snapper { try_pt( segment_a.a + ta as f64 * (segment_a.b - segment_a.a), SnapType::ApparentIntersection, + wires.source_wire(segment_a.wire).and_then(wire_source), ); if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { break 'apparent_sweep; @@ -1726,6 +1800,7 @@ impl Snapper { try_pt( wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection, + wire_source(wire_i), ); } } @@ -1947,6 +2022,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), }); } } @@ -2009,6 +2086,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), }); } }; @@ -2116,6 +2195,31 @@ fn snap_tier(t: SnapType) -> u8 { /// candidates are effectively equidistant (coincident features) the classic /// sub-priority — so an Endpoint still beats an Intersection sitting on the /// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. +/// Choose between two independently-computed snap results using the SAME +/// priority-then-distance rule the engine applies internally +/// ([`snap_better`]): a higher-priority snap inside the aperture wins even +/// when a lower-priority one is nearer, and distance only breaks ties. +/// +/// Used to merge the ordinary paper-sheet snap with the viewport snap taken +/// through a layout viewport (PR1). Both `screen` fields must be expressed in +/// the same pixel frame. +pub fn merge_snap(a: Option, b: Option, cursor: Point) -> Option { + match (a, b) { + (Some(a), Some(b)) => { + let (at, asub) = (snap_tier(a.snap_type), snap_priority(a.snap_type)); + let (bt, bsub) = (snap_tier(b.snap_type), snap_priority(b.snap_type)); + let bd2 = dist2(b.screen, cursor); + let ad2 = dist2(a.screen, cursor); + if snap_better(bt, bd2, bsub, (at, ad2, asub)) { + Some(b) + } else { + Some(a) + } + } + (some, None) | (None, some) => some, + } +} + fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { let (bt, bd2, bsub) = best; if tier != bt { From dbdddb7dc25b201a939463f6ca2dbef017adc4e2 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:27:33 -0500 Subject: [PATCH 04/23] feat(app): record accepted snaps per point step and guard dimension inference Point input now keeps an `AcceptedSnap` alongside each point a command collects: the paper point, the model point it came from, the viewport and frame it was seen through, and the geometry identity the snap engine attributed. Interactive picks record in the click handler, typed / dynamic / headless points record in `feed_command`, and the list is cleared whenever a command starts or the active space changes. Commands that don't care keep consuming the plain paper point unchanged; PR2 reads the model point and frame, PR3 the source. Dimension association inference is suppressed when the dimension's points came through a viewport. Inference matches geometry against the dimension's own definition points in whatever space it is handed, so paper definition points projected from a viewport would associate with unrelated paper-sheet geometry at those coordinates, or match model geometry using paper coordinates. An empty source list makes `attach_dimension_association` a no-op. PR3 replaces this with real viewport-aware associations. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 25 +++++++++++++------------ src/app/commands/mod.rs | 3 +++ src/app/mod.rs | 22 ++++++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index af720ea76..bdfe81e3f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -102,6 +102,10 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].snap_result = None; self.last_point = None; + // Points collected in the space being left are meaningless in the new + // one (PR1). + self.clear_accepted_snaps(); + self.pending_click_snap = None; self.snapper.from_point = None; self.snapper.clear_tracking(); self.otrack_active = None; @@ -368,6 +372,11 @@ impl OpenCADStudio { if !self.command_point_allowed(i, *point) { return Task::none(); } + // Typed / dynamic-input / headless points carry no snap, but the + // accepted-snap list must stay index-parallel with the points the + // command collects (PR1). Interactive picks record themselves in + // the click handler and never reach here. + self.record_accepted_snap(None, None, *point); } if default_start { let StepInput::Point(point) = &input else { @@ -1590,9 +1599,7 @@ impl OpenCADStudio { }; if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i] - .scene - .infer_dimension_sources(handle); + let sources = self.infer_dimension_sources_guarded(i, handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2063,9 +2070,7 @@ impl OpenCADStudio { let committed = self.commit_entity_handle(entity); if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i] - .scene - .infer_dimension_sources(handle); + let sources = self.infer_dimension_sources_guarded(i, handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2175,9 +2180,7 @@ impl OpenCADStudio { crate::command::DimensionAssociationInput::Infer(source) => { let sources: Vec<_> = source.map_or_else( || { - self.tabs[i] - .scene - .infer_dimension_sources(handle) + self.infer_dimension_sources_guarded(i, handle) }, |source| { if single_source_dimension { @@ -2302,9 +2305,7 @@ impl OpenCADStudio { crate::command::DimensionAssociationInput::Infer(source) => { source.map_or_else( || { - self.tabs[i] - .scene - .infer_dimension_sources(handle) + self.infer_dimension_sources_guarded(i, handle) .into_iter() .map(|source| { source.map( diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3e749827f..a8cf35bc5 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -120,6 +120,9 @@ impl OpenCADStudio { // Reset the last committed point so the first click of the new command // is not constrained by ortho/polar relative to a previous command's endpoint. self.last_point = None; + // A new command collects its own points, so the previous command's + // accepted snaps must not leak into it (PR1). + self.clear_accepted_snaps(); // Starting a command restarts the right-click cycle, so its first // right-click acts as Enter rather than opening the context menu. self.tabs[i] diff --git a/src/app/mod.rs b/src/app/mod.rs index 0ad215b09..a4a00956b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -707,6 +707,25 @@ pub(super) struct OpenCADStudio { show_layout_tabs: bool, /// Last point committed by a drawing command — used as ortho/polar base. last_point: Option, + /// Viewport frame the live snap marker is currently looking through (PR1). + /// Set by the cursor-move handler when the displayed snap came from a + /// layout viewport, cleared otherwise. Display/bookkeeping only — the point + /// itself is already projected onto the sheet. + pub(crate) vp_snap_frame: Option, + /// Snaps accepted by the active command's point steps, parallel to the + /// points it collected (PR1). A command that does not care keeps using the + /// plain paper point; dimension commands (PR2) read the model point, + /// viewport and frame from here, and PR3 reads `source`. Cleared whenever a + /// command starts or ends. + accepted_snaps: Vec, + /// Snap resolved by the click currently being handled, with the viewport + /// frame it came through (PR1). Set inside the click handler's point + /// resolution so the point-accept path further down can record the + /// [`crate::scene::viewport_ref::AcceptedSnap`] without recomputing. + pending_click_snap: Option<( + crate::snap::SnapResult, + Option, + )>, /// Endpoint + unit exit-tangent of the most recently drawn line/arc, so /// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing /// ended. `None` once a non-line/arc entity is committed. @@ -3689,6 +3708,9 @@ impl OpenCADStudio { show_file_tabs: true, show_layout_tabs: true, last_point: None, + vp_snap_frame: None, + accepted_snaps: Vec::new(), + pending_click_snap: None, main_window: None, thumbnail_capture_clean: false, #[cfg(not(target_arch = "wasm32"))] From 9ee809d38950f62cee4d4797b978ab5fa69872f6 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:23:34 -0500 Subject: [PATCH 05/23] Resolve the DIMLFAC sign convention and add the viewport measurement seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DXF gives DIMLFAC a sign convention: a positive value scales every dimension, a negative one applies |DIMLFAC| only to dimensions that live in a paper-space layout (where it compensates a viewport's scale). The linear formatter multiplied by the raw signed value, so a drawing with negative DIMLFAC (e.g. 115448.dxf) rendered negative measurements. Resolve the sign once, in `resolved_dimension_style`, the single place an effective style is derived, so dimension text, alternate units, exploded and baked text and the properties panel all agree. `extended_data` keeps the raw signed value, so DXF/DWG export is unchanged. The properties "Measurement" row now reports the value the text shows rather than the raw distance, while `actual_measurement` (group 42) stays raw as the format requires. Also adds the PR2 seams the dimension work builds on: * MeasurementScale::user_lfac_for_space / viewport_dimlfac_override — the persisted representation of viewport compensation. * Scene::viewport_frame and paper-point viewport resolution. * Model-geometry hit testing through a content viewport, including descent into block instances with the instance transform baked in. Co-Authored-By: Claude Fable 5.1 --- src/entities/dimension.rs | 87 ++++++- src/scene/mod.rs | 1 + src/scene/viewport_dim_seam.rs | 421 +++++++++++++++++++++++++++++++++ src/scene/viewport_ref.rs | 99 ++++++++ 4 files changed, 605 insertions(+), 3 deletions(-) create mode 100644 src/scene/viewport_dim_seam.rs diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index bc3fa9a2b..d455cbde5 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -1613,9 +1613,72 @@ pub(crate) fn resolved_dimension_style( style.dimtxsty = record.name.clone(); } } + + // DXF DIMLFAC sign convention. A negative DIMLFAC applies |DIMLFAC| only + // to dimensions that live in a paper-space layout (where it compensates a + // viewport's scale); model-space dimensions use 1.0. Resolving it here — + // the single place an effective style is derived — keeps dimension text, + // alternate units, the properties measurement row and the exploded / + // baked text all in agreement, while `extended_data` keeps the raw signed + // value for DXF/DWG export. See `MeasurementScale::viewport_dimlfac_override`. + if style.dimlfac < 0.0 { + style.dimlfac = crate::scene::viewport_ref::MeasurementScale::user_lfac_for_space( + style.dimlfac, + dimension_in_paper_space(dimension, document), + ); + } style } +/// Whether a dimension entity is owned by a paper-space layout block rather +/// than model space. Used only for the DIMLFAC sign convention, so it is +/// evaluated lazily (negative DIMLFAC is rare). +pub(crate) fn dimension_in_paper_space(dimension: &Dimension, document: &CadDocument) -> bool { + let owner = dimension.base().common.owner_handle; + if !owner.is_valid() { + return false; + } + let model_block = document + .block_records + .get("*Model_Space") + .map(|record| record.handle) + .filter(|handle| handle.is_valid()) + .or_else(|| { + document.objects.values().find_map(|object| match object { + acadrust::objects::ObjectType::Layout(layout) + if layout.name == "Model" && !layout.block_record.is_null() => + { + Some(layout.block_record) + } + _ => None, + }) + }); + match model_block { + Some(model) => owner != model, + None => false, + } +} + +/// The value a dimension's text reports for its measurement: the raw +/// geometric measurement times the resolved linear factor. Angular dimensions +/// never scale. +/// +/// `actual_measurement` (DXF group 42) stays **raw** — that is what the format +/// specifies and what export writes — so this is the one helper UI surfaces +/// use when they want the number the user reads. +pub(crate) fn displayed_measurement(dimension: &Dimension, style: Option<&DimStyle>) -> f64 { + let raw = dimension.measurement(); + if matches!(dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + return raw; + } + let lfac = style.map(|s| s.dimlfac).unwrap_or(1.0); + if lfac.abs() < 1e-12 { + raw + } else { + raw * lfac + } +} + /// Build the linear-dimension property groups from the assigned style plus /// any entity-level DSTYLE overrides. Conditional rows remain visible but are /// read-only while their controlling option is disabled. @@ -2097,7 +2160,13 @@ pub fn style_sections( property( t!("Measurement").as_ref(), "measurement", - PropValue::ReadOnly(format!("{:.4}", dimension.measurement())), + // The value the dimension text reports (DIMLFAC applied, + // including viewport compensation) — not the raw paper + // distance, which would disagree with what is drawn. + PropValue::ReadOnly(format!( + "{:.4}", + displayed_measurement(dimension, Some(s)) + )), ), property( t!("Text override").as_ref(), @@ -2196,7 +2265,12 @@ pub fn style_sections( number( t!("Dim scale linear").as_ref(), "dim_scale_linear", - real(ov::DIMLFAC, s.dimlfac), + // The *raw* signed DIMLFAC: a negative value is the + // paper-space / viewport-compensation convention, and + // hiding its sign would make the row uneditable in + // practice. `s.dimlfac` is sign-resolved, so fall back to + // the unresolved source style instead. + real(ov::DIMLFAC, style.dimlfac), true, ), number( @@ -6339,7 +6413,14 @@ fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { }) .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0, 1.0, "")); - let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac }; + // `resolved_dimension_style` has already applied the DXF sign convention, + // so a negative value here means the caller passed an unresolved style; + // fall back to 1.0 rather than reporting a negative length. + let lfac = if lfac.abs() < 1e-12 || lfac < 0.0 { + 1.0 + } else { + lfac + }; let scaled = measurement * lfac; let use_sub_units = zin & 4 != 0 && scaled.abs() < 1.0 && sub_factor.abs() > 1e-12; // For values below one unit, DIMMZF replaces DIMLFAC; applying it after diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 03c7fef5f..db832be0c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -41,6 +41,7 @@ pub mod named_parameters; mod named_parameters_persist; mod mspace; pub mod viewport_ref; +pub mod viewport_dim_seam; mod page_setup; mod paper; mod preview; diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs new file mode 100644 index 000000000..30a6ec35f --- /dev/null +++ b/src/scene/viewport_dim_seam.rs @@ -0,0 +1,421 @@ +//! PR2 seams onto the paper-space viewport world. +//! +//! Everything in this file exists so that viewport-aware dimension +//! measurement works *without* PR1 (viewport snapping) in the tree. Each seam +//! is small and self-contained; the merge notes say which ones PR1 replaces. +//! +//! ## Seams +//! * [`Scene::viewport_frame`] — **replaced by PR1.** PR1 owns construction of +//! [`ViewportFrame`] from the live viewport camera. This implementation +//! derives the frame straight from the DXF viewport record, which is correct +//! for plan (non-oblique) content viewports. +//! * [`Scene::content_viewport_at_paper_point`] / +//! [`Scene::viewport_frame_at_paper_point`] — PR1 supersedes these with its +//! snap-time viewport resolution; harmless to keep. +//! * [`Scene::dimension_pick_through_viewport`] — PR2 owns this permanently. +//! It is the explicit-object-pick path and does not depend on PR1. + +use super::*; + +use crate::command::EntityTransform; +use crate::scene::viewport_ref::ViewportFrame; +use acadrust::types::{Matrix4, Transform}; +use glam::{DVec2, DVec3}; + +/// A model entity resolved by clicking inside a paper-space content viewport. +#[derive(Clone, Debug)] +pub struct ViewportDimPick { + /// The viewport the click looked through. + pub frame: ViewportFrame, + /// Handle of the innermost model entity that owns the picked geometry. + pub entity_handle: Handle, + /// INSERT handles from the outermost instance down to the instance that + /// directly contains `entity_handle`; empty for top-level model geometry. + pub block_path: Vec, + /// The picked geometry with the block-instance transform *and* the + /// viewport model→paper transform applied, so paper-space dimension + /// commands can consume it unchanged. + pub paper_entity: EntityType, + /// The click position on the sheet. + pub paper_point: DVec3, + /// The click position in model space. + pub model_point: DVec3, +} + +impl Scene { + // ───────────────────────── PR1 SEAM: BEGIN ────────────────────────── + // Replace the body of `viewport_frame` with PR1's implementation on + // merge; the signature is the shared contract and must not change. + + /// Planar paper↔model mapping for one layout viewport. + /// + /// Derived from the viewport record rather than the render camera: + /// `paper_center = vp.center`, `model_target = vp.view_target`, + /// `scale = vp_effective_scale(...)`, `twist = vp.twist_angle`. + pub fn viewport_frame(&self, viewport: Handle) -> Option { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(viewport) else { + return None; + }; + let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); + if !scale.is_finite() || scale.abs() < 1e-12 { + return None; + } + Some(ViewportFrame { + viewport, + paper_center: DVec2::new(vp.center.x, vp.center.y), + model_target: DVec2::new(vp.view_target.x, vp.view_target.y), + scale, + twist: vp.twist_angle, + locked: vp.status.locked, + }) + } + // ────────────────────────── PR1 SEAM: END ─────────────────────────── + + /// The smallest *on* content viewport of the current layout whose paper + /// rectangle contains `paper`. `None` on the Model tab, while editing + /// inside a viewport (MSPACE), or when the point is on bare sheet. + pub fn content_viewport_at_paper_point(&self, paper: DVec3) -> Option { + if self.current_layout == "Model" || self.active_viewport.is_some() { + return None; + } + let (_, _, handles) = self.paper_viewport_handles(); + handles + .iter() + .filter_map(|handle| { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { + return None; + }; + if !vp.status.is_on || vp.width <= 0.0 || vp.height <= 0.0 { + return None; + } + let dx = (paper.x - vp.center.x).abs(); + let dy = (paper.y - vp.center.y).abs(); + (dx <= vp.width * 0.5 && dy <= vp.height * 0.5) + .then(|| (*handle, vp.width * vp.height)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(handle, _)| handle) + } + + /// [`Scene::content_viewport_at_paper_point`] resolved to a frame. + pub fn viewport_frame_at_paper_point(&self, paper: DVec3) -> Option { + self.viewport_frame(self.content_viewport_at_paper_point(paper)?) + } + + /// Resolve an explicit dimension object pick made on the sheet but landing + /// inside a content viewport, against the *model* geometry that viewport + /// displays. + /// + /// `aperture_paper` is the pick aperture in paper units. + pub fn dimension_pick_through_viewport( + &self, + paper: DVec3, + aperture_paper: f64, + ) -> Option { + let frame = self.viewport_frame_at_paper_point(paper)?; + let model_point = frame.paper_to_model(paper); + let aperture_model = + (aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9); + + let top = self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model)?; + let (entity, block_path) = self.resolve_measurable_entity(top, model_point)?; + + let mut paper_entity = entity; + crate::scene::view::dispatch::apply_transform( + &mut paper_entity, + &EntityTransform::Affine(viewport_model_to_paper_transform(&frame)), + ); + let entity_handle = paper_entity.as_entity().handle(); + Some(ViewportDimPick { + frame, + entity_handle: if entity_handle.is_valid() { entity_handle } else { top }, + block_path, + paper_entity, + paper_point: paper, + model_point, + }) + } + + /// Nearest resident model wire of `viewport` to `model_point`, within + /// `aperture` model units. Wires are the same tessellation the viewport + /// draws, so this matches what the user sees. + fn nearest_model_wire_handle( + &self, + viewport: Handle, + model_point: DVec3, + aperture: f64, + ) -> Option { + let wires = self.model_wires_for_viewport_arc(viewport, 0.0); + let mut best: Option<(f64, Handle)> = None; + for wire in wires.iter() { + if !wire.display_visible { + continue; + } + let Some(handle) = Self::handle_from_wire_name(&wire.name) else { + continue; + }; + let offset = wire + .render_instance + .as_ref() + .map(|inst| DVec3::from_array(inst.translation)) + .unwrap_or(DVec3::ZERO); + let distance = wire_polyline_distance(wire, offset, model_point); + if let Some(distance) = distance { + if distance <= aperture && best.as_ref().is_none_or(|(d, _)| distance < *d) { + best = Some((distance, handle)); + } + } + } + best.map(|(_, handle)| handle) + } + + /// Turn a picked handle into a measurable planar entity in model (WCS) + /// coordinates, descending through block instances and baking the + /// instance transform into the returned clone. + fn resolve_measurable_entity( + &self, + handle: Handle, + model_point: DVec3, + ) -> Option<(EntityType, Vec)> { + let entity = self.document.get_entity(handle)?; + match entity { + EntityType::Insert(_) => { + let mut path = Vec::new(); + let found = self.descend_block_instance( + entity.clone(), + Transform::identity(), + model_point, + &mut path, + 0, + )?; + Some((found.0, found.1)) + } + other => { + planar_pick_distance(other, model_point)?; + Some((other.clone(), Vec::new())) + } + } + } + + /// Depth-first search for the nearest measurable entity inside an INSERT. + /// Returns the entity already transformed into WCS plus the INSERT path. + fn descend_block_instance( + &self, + insert_entity: EntityType, + outer: Transform, + model_point: DVec3, + path: &mut Vec, + depth: usize, + ) -> Option<(EntityType, Vec)> { + const MAX_DEPTH: usize = 8; + if depth > MAX_DEPTH { + return None; + } + let EntityType::Insert(insert) = &insert_entity else { + return None; + }; + let insert_handle = insert.common.handle; + let local = crate::scene::render_graph::insert_transform(&self.document, insert); + let combined = local.then(&outer); + let block = self.document.block_records.get(&insert.block_name)?; + let children: Vec = block.entity_handles.clone(); + + let mut best: Option<(f64, EntityType, Vec)> = None; + for child in children { + let Some(child_entity) = self.document.get_entity(child) else { + continue; + }; + if matches!(child_entity, EntityType::Insert(_)) { + let mut nested = Vec::new(); + if let Some((entity, mut nested_path)) = self.descend_block_instance( + child_entity.clone(), + combined, + model_point, + &mut nested, + depth + 1, + ) { + if let Some(distance) = planar_pick_distance(&entity, model_point) { + if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { + let mut full = vec![insert_handle]; + full.append(&mut nested_path); + best = Some((distance, entity, full)); + } + } + } + continue; + } + let mut placed = child_entity.clone(); + crate::scene::view::dispatch::apply_transform( + &mut placed, + &EntityTransform::Affine(combined), + ); + let Some(distance) = planar_pick_distance(&placed, model_point) else { + continue; + }; + if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { + best = Some((distance, placed, vec![insert_handle])); + } + } + let (_, entity, found_path) = best?; + path.clear(); + path.extend(found_path.iter().copied()); + Some((entity, found_path)) + } +} + +/// Model→paper for a viewport, as an affine entity transform. Z passes +/// through unchanged so planar geometry keeps its elevation. +pub fn viewport_model_to_paper_transform(frame: &ViewportFrame) -> Transform { + let (sin, cos) = frame.twist.sin_cos(); + let s = frame.scale; + let a = cos * s; + let b = sin * s; + // t = paper_center - R*s*model_target + let tx = frame.paper_center.x - (a * frame.model_target.x - b * frame.model_target.y); + let ty = frame.paper_center.y - (b * frame.model_target.x + a * frame.model_target.y); + Transform::from_matrix(Matrix4 { + m: [ + [a, -b, 0.0, tx], + [b, a, 0.0, ty], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }) +} + +/// Distance from `point` to a wire's polyline, in XY. `None` when the wire has +/// no usable segment. +fn wire_polyline_distance( + wire: &crate::scene::WireModel, + offset: DVec3, + point: DVec3, +) -> Option { + let count = wire.points.len(); + if count == 0 { + return None; + } + let at = |i: usize| -> DVec3 { + let hi = wire.points[i]; + let lo = wire.points_low.get(i).copied().unwrap_or([0.0; 3]); + DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + offset + }; + let mut best = f64::INFINITY; + let mut previous: Option = None; + for i in 0..count { + let current = at(i); + if !current.x.is_finite() || !current.y.is_finite() { + previous = None; + continue; + } + match previous { + Some(start) => best = best.min(segment_distance_xy(start, current, point)), + None => best = best.min((current.truncate() - point.truncate()).length()), + } + previous = Some(current); + } + best.is_finite().then_some(best) +} + +fn segment_distance_xy(a: DVec3, b: DVec3, p: DVec3) -> f64 { + let ab = b.truncate() - a.truncate(); + let ap = p.truncate() - a.truncate(); + let len2 = ab.length_squared(); + if len2 < 1e-24 { + return ap.length(); + } + let t = (ap.dot(ab) / len2).clamp(0.0, 1.0); + (ap - ab * t).length() +} + +/// Distance from `point` to the planar geometry object-pick dimensioning +/// supports. `None` for entity types that cannot be dimensioned by picking. +pub fn planar_pick_distance(entity: &EntityType, point: DVec3) -> Option { + let p = point.truncate(); + match entity { + EntityType::Line(line) => Some(segment_distance_xy( + DVec3::new(line.start.x, line.start.y, line.start.z), + DVec3::new(line.end.x, line.end.y, line.end.z), + point, + )), + EntityType::Circle(circle) => { + let c = DVec2::new(circle.center.x, circle.center.y); + Some(((p - c).length() - circle.radius).abs()) + } + EntityType::Arc(arc) => { + let c = DVec2::new(arc.center.x, arc.center.y); + Some(((p - c).length() - arc.radius).abs()) + } + EntityType::LwPolyline(polyline) => polyline_distance( + polyline + .vertices + .iter() + .map(|v| DVec3::new(v.location.x, v.location.y, polyline.elevation)), + polyline.is_closed, + point, + ), + EntityType::Polyline2D(polyline) => polyline_distance( + polyline + .vertices + .iter() + .map(|v| DVec3::new(v.location.x, v.location.y, polyline.elevation)), + polyline.is_closed(), + point, + ), + _ => None, + } +} + +fn polyline_distance( + points: impl IntoIterator, + closed: bool, + point: DVec3, +) -> Option { + let points: Vec = points.into_iter().collect(); + if points.len() < 2 { + return None; + } + let count = if closed { points.len() } else { points.len() - 1 }; + (0..count) + .map(|i| segment_distance_xy(points[i], points[(i + 1) % points.len()], point)) + .reduce(f64::min) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame() -> ViewportFrame { + ViewportFrame { + viewport: Handle::NULL, + paper_center: DVec2::new(100.0, 50.0), + model_target: DVec2::new(1000.0, 2000.0), + scale: 0.1, + twist: 0.4, + locked: false, + } + } + + #[test] + fn affine_transform_matches_the_frame_mapping() { + let f = frame(); + let t = viewport_model_to_paper_transform(&f); + for model in [ + DVec3::new(1000.0, 2000.0, 0.0), + DVec3::new(1100.0, 2000.0, 0.0), + DVec3::new(940.5, 2113.25, 0.0), + ] { + let expected = f.model_to_paper(model); + let actual = t.transform_point(&acadrust::types::Vector3::new( + model.x, model.y, model.z, + )); + assert!( + (actual.x - expected.x).abs() < 1e-9 && (actual.y - expected.y).abs() < 1e-9, + "{actual:?} != {expected:?}" + ); + } + } +} diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index 1122eee8c..7c64f4e29 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -267,6 +267,65 @@ impl MeasurementScale { pub fn model_factor(&self) -> f64 { self.user_lfac } + + /// The DXF DIMLFAC sign convention, resolved without a [`ViewportFrame`]. + /// + /// Rendering an *existing* dimension only knows whether the entity lives + /// in a paper-space layout; the viewport compensation is already baked + /// into the persisted magnitude (see + /// [`MeasurementScale::viewport_dimlfac_override`]). This is the same rule + /// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()` + /// replaced by `paper_space`. + pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 { + if dimlfac == 0.0 { + 1.0 + } else if dimlfac > 0.0 { + dimlfac + } else if paper_space { + -dimlfac + } else { + 1.0 + } + } + + /// The persisted representation of viewport compensation, PR2's single + /// source of truth. + /// + /// A paper-space dimension that measures model geometry through a viewport + /// keeps its definition points in **paper** coordinates and carries a + /// **negative DIMLFAC override** whose magnitude is + /// `user_lfac * viewport_compensation`. Because a viewport frame is a + /// similarity (uniform scale + in-plane twist), the raw paper measurement + /// times that magnitude is exactly the model measurement times the user's + /// linear factor — so the compensation is applied exactly once, at + /// formatting time, and round-trips through DXF/DWG unchanged (AutoCAD + /// applies |DIMLFAC| to paper-space dimensions by the same rule). + /// + /// Returns `None` when no override is needed: + /// * the compensation is 1:1, or + /// * the drawing/style DIMLFAC is already negative with exactly this + /// magnitude (the drawing-wide DIMLFAC is set for this purpose). + /// + /// `style_dimlfac` is the effective DIMLFAC the dimension would inherit + /// *without* an override. + pub fn viewport_dimlfac_override(style_dimlfac: f64, compensation: f64) -> Option { + if !compensation.is_finite() || compensation.abs() < 1e-12 { + return None; + } + if (compensation - 1.0).abs() < 1e-12 { + return None; + } + if style_dimlfac < 0.0 + && ((-style_dimlfac) - compensation).abs() <= 1e-9 * compensation.abs().max(1.0) + { + return None; + } + // A positive style DIMLFAC is the user's own multiplier and must be + // preserved; a negative one of a *different* magnitude was meant for + // another viewport scale, so the user multiplier there is 1.0. + let user = if style_dimlfac > 0.0 { style_dimlfac } else { 1.0 }; + Some(-(user * compensation)) + } } #[cfg(test)] @@ -318,4 +377,44 @@ mod tests { assert_eq!(positive.user_lfac, 3.0); assert_eq!(positive.viewport_compensation, 1.0); } + + #[test] + fn user_lfac_for_space_matches_from_dimlfac() { + let f = frame(0.5, 0.0); + for lfac in [-2.0, 0.0, 1.0, 3.5] { + assert_eq!( + MeasurementScale::user_lfac_for_space(lfac, true), + MeasurementScale::from_dimlfac(lfac, Some(&f)).user_lfac + ); + assert_eq!( + MeasurementScale::user_lfac_for_space(lfac, false), + MeasurementScale::from_dimlfac(lfac, None).user_lfac + ); + } + } + + #[test] + fn override_encodes_compensation_once() { + // 1:10 viewport, no user factor -> -10, and a 10-unit paper span then + // formats as 100. + let ovr = MeasurementScale::viewport_dimlfac_override(1.0, 10.0).unwrap(); + assert!((ovr + 10.0).abs() < 1e-12); + let lfac = MeasurementScale::user_lfac_for_space(ovr, true); + assert!((10.0 * lfac - 100.0).abs() < 1e-9); + // Same dimension read back in model space must not scale. + assert_eq!(MeasurementScale::user_lfac_for_space(ovr, false), 1.0); + } + + #[test] + fn override_preserves_user_multiplier_and_skips_redundant_cases() { + assert_eq!(MeasurementScale::viewport_dimlfac_override(1.0, 1.0), None); + // Drawing-wide negative DIMLFAC already carries this compensation. + assert_eq!(MeasurementScale::viewport_dimlfac_override(-10.0, 10.0), None); + // Positive user factor is folded in. + let ovr = MeasurementScale::viewport_dimlfac_override(25.4, 10.0).unwrap(); + assert!((ovr + 254.0).abs() < 1e-9); + // Negative but for a different scale -> user multiplier is 1.0. + let ovr = MeasurementScale::viewport_dimlfac_override(-2.0, 10.0).unwrap(); + assert!((ovr + 10.0).abs() < 1e-12); + } } From 30c5ccbba1b4f1acbcf3e204a4a19b714745a928 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:27:05 -0500 Subject: [PATCH 06/23] Measure paper-space dimensions through the viewport they were picked in DIMLINEAR / DIMALIGNED / DIMRADIUS / DIMDIAMETER / DIMANGULAR created on a sheet now report the model measurement of the geometry seen through a viewport, at any viewport scale, while definition points, the dimension line and the text stay in paper coordinates and the annotation keeps its sheet size (DIMSCALE is untouched). A viewport frame is a similarity - uniform scale plus in-plane twist - so projecting the model span onto the paper axis and compensating gives exactly the raw paper measurement times 1/scale. No geometry is rewritten: the compensation is persisted once as a negative DIMLFAC override on the dimension and applied once by the linear formatter, so preview text, dimension text, the properties measurement row and DXF/DWG export all agree. User measurement multipliers fold into the same magnitude, and an explicit text override still bypasses formatting entirely, so it is never re-scaled. Angular dimensions are never compensated - a similarity preserves angles. Mixed input (one point through viewport A, another through viewport B or on the sheet) is refused rather than silently mis-measured: the command line explains why and the dimension falls back to plain paper measurement. A dimension measured through a viewport is left unassociated: its definition points are projected paper coordinates, so inferring a paper-space association would bind it to geometry that is not there. PR3 records the real dimension -> viewport -> entity chain. The result is one ordinary dimension entity carrying one style override, so undo/redo works through the existing history with no new out-of-document state. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 23 ++- src/app/dim_viewport.rs | 263 +++++++++++++++++++++++++++ src/app/mod.rs | 9 + src/command.rs | 8 + src/entities/dim_override.rs | 63 +++++++ src/modules/annotate/aligned_dim.rs | 6 + src/modules/annotate/angular_dim.rs | 6 + src/modules/annotate/diameter_dim.rs | 6 + src/modules/annotate/linear_dim.rs | 6 + src/modules/annotate/radius_dim.rs | 6 + 10 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 src/app/dim_viewport.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bdfe81e3f..133a7fd2a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -454,6 +454,17 @@ impl OpenCADStudio { } } if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); } + // PR2: remember which viewport (if any) each dimension point was + // picked through, before the command turns it into geometry. + if let StepInput::Point(p) = &input { + if self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.measures_through_viewports()) + { + self.record_dimension_accepted_snap(i, *p); + } + } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { @@ -2106,6 +2117,15 @@ impl OpenCADStudio { acadrust::entities::Dimension::Ordinate(_) ) ); + // PR2: a dimension placed on the sheet but measuring model + // geometry through a viewport carries the compensation as a + // negative DIMLFAC override. It is deliberately *not* + // associated: its definition points are projected paper + // coordinates, so inferring a paper-space association would + // bind it to geometry that is not there. PR3 records the real + // dimension -> viewport -> entity chain. + let measured_through_viewport = + self.apply_viewport_dimension_measurement(i, &mut entity); let inherited_dimension = if preserve_base_style { match &entity { acadrust::EntityType::Dimension(dimension) => Some(( @@ -2172,7 +2192,7 @@ impl OpenCADStudio { entity, preserve_base_style, ) { - if association_mode == 2 { + if association_mode == 2 && !measured_through_viewport { let mut changes = vec![ (handle, crate::scene::ChangeKind::Modified), ]; @@ -2216,6 +2236,7 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].snap_result = None; + self.clear_dimension_accepted_snaps(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs new file mode 100644 index 000000000..bf4510f94 --- /dev/null +++ b/src/app/dim_viewport.rs @@ -0,0 +1,263 @@ +//! Viewport-aware dimension measurement (PR2). +//! +//! A dimension created in a paper-space layout while measuring geometry seen +//! through a content viewport must *place* itself in paper coordinates but +//! *report* the model measurement. Because a viewport frame is a similarity +//! (uniform scale plus in-plane twist), the model measurement of any linear, +//! aligned or radial span is exactly the raw paper measurement times the +//! viewport compensation `1 / scale` — projecting the model span onto the +//! rotated paper axis and compensating is the same number. So no geometry is +//! rewritten: the compensation is persisted once, as a negative DIMLFAC +//! override on the dimension (see +//! [`MeasurementScale::viewport_dimlfac_override`]), and applied once, by the +//! linear formatter. +//! +//! Angular dimensions are never compensated — a similarity preserves angles. +//! +//! ## PR1 seam +//! [`OpenCADStudio::record_dimension_accepted_snap`] is the one function PR1 +//! replaces. PR1 records a real [`AcceptedSnap`] per accepted point (with the +//! snapped source and the frame in effect at snap time); this branch derives +//! the same information from the cursor's paper position and the content +//! viewport under it. Everything downstream consumes `AcceptedSnap` only. + +use acadrust::entities::Dimension; +use acadrust::xdata::XDataValue; +use acadrust::EntityType; +use glam::DVec3; + +use crate::entities::dim_override; +use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; + +use super::OpenCADStudio; + +/// How a pending dimension should be measured. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum DimensionMeasureSpace { + /// Plain measurement in the space the definition points live in. + Direct, + /// Every measuring point came through one viewport; compensate by its + /// scale. + Viewport(ViewportFrame), + /// The points came from different viewports, or from a viewport and the + /// bare sheet. Falls back to `Direct` with a warning — never a silently + /// wrong number. + Mixed, +} + +impl OpenCADStudio { + // ───────────────────────── PR1 SEAM: BEGIN ────────────────────────── + + /// Record the snap accepted for one collected point of the active + /// command. + /// + /// PR1 replaces the body with the real snap result (which knows the + /// snapped entity, the block path and the frame at snap time). Until then + /// the viewport is resolved from the paper position of the accepted point, + /// which is correct for every point that lands inside a content viewport + /// and yields a free snap everywhere else. + pub(crate) fn record_dimension_accepted_snap(&mut self, i: usize, point: DVec3) { + self.sync_dimension_snap_owner(i); + let snap = match self.tabs[i].scene.viewport_frame_at_paper_point(point) { + Some(frame) => AcceptedSnap { + paper_point: point, + model_point: frame.paper_to_model(point), + viewport: Some(frame.viewport), + frame: Some(frame), + source: None, + }, + None => AcceptedSnap::free(point), + }; + self.dim_accepted_snaps.push(snap); + } + + // ────────────────────────── PR1 SEAM: END ─────────────────────────── + + /// Record an accepted snap produced by the explicit object-pick path (a + /// pick resolved through a content viewport). Independent of PR1. + pub(crate) fn record_dimension_viewport_pick( + &mut self, + i: usize, + frame: ViewportFrame, + paper_point: DVec3, + model_point: DVec3, + ) { + self.sync_dimension_snap_owner(i); + // An object pick supplies both extension origins at once; record it + // twice so the "every measuring point came through one viewport" + // test sees a complete set. + for _ in 0..2 { + self.dim_accepted_snaps.push(AcceptedSnap { + paper_point, + model_point, + viewport: Some(frame.viewport), + frame: Some(frame), + source: None, + }); + } + } + + /// The accepted snap for the `idx`-th collected point of the active + /// command, if one was recorded. + pub(crate) fn accepted_snap_for_point(&self, idx: usize) -> Option<&AcceptedSnap> { + self.dim_accepted_snaps.get(idx) + } + + /// Drop recorded snaps when the active command changes, so one command + /// never sees another's points. + fn sync_dimension_snap_owner(&mut self, i: usize) { + let name = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.name().to_string()); + if self.dim_accepted_snaps_owner != name { + self.dim_accepted_snaps.clear(); + self.dim_accepted_snaps_owner = name; + } + } + + pub(crate) fn clear_dimension_accepted_snaps(&mut self) { + self.dim_accepted_snaps.clear(); + self.dim_accepted_snaps_owner = None; + } + + /// Classify how the pending dimension should be measured from the snaps + /// recorded for it. + fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { + let scene = &self.tabs[i].scene; + // Only a dimension being created *on the sheet* can measure through a + // viewport. Inside MSPACE the command already works in model space. + if scene.current_layout == "Model" || scene.active_viewport.is_some() { + return DimensionMeasureSpace::Direct; + } + let snaps = &self.dim_accepted_snaps; + if snaps.is_empty() { + return DimensionMeasureSpace::Direct; + } + let mut frame: Option = None; + let mut any_free = false; + for snap in snaps { + match snap.frame { + Some(f) => match frame { + None => frame = Some(f), + Some(existing) if existing.viewport == f.viewport => {} + Some(_) => return DimensionMeasureSpace::Mixed, + }, + None => any_free = true, + } + } + match (frame, any_free) { + (None, _) => DimensionMeasureSpace::Direct, + // A dimension-line / text placement point on bare sheet is normal + // and expected, so only *measuring* points matter. The commands + // collect the measuring points first, so a trailing free point is + // the placement click; a free point before a viewport point is a + // genuine mixed-space measurement. + (Some(f), false) => DimensionMeasureSpace::Viewport(f), + (Some(f), true) => { + if self.dimension_leading_points_share_viewport(f) { + DimensionMeasureSpace::Viewport(f) + } else { + DimensionMeasureSpace::Mixed + } + } + } + } + + /// True when every snap up to the first free one came through `frame`'s + /// viewport and no later snap came through a viewport. + fn dimension_leading_points_share_viewport(&self, frame: ViewportFrame) -> bool { + let mut seen_free = false; + for snap in &self.dim_accepted_snaps { + match snap.frame { + Some(f) => { + if seen_free || f.viewport != frame.viewport { + return false; + } + } + None => seen_free = true, + } + } + true + } + + /// Apply viewport compensation to a dimension about to be committed. + /// + /// Returns `true` when the dimension measures through a viewport, which + /// tells the caller not to infer a paper-space association from the + /// projected definition points (PR3 creates the real viewport + /// associations). + pub(crate) fn apply_viewport_dimension_measurement( + &mut self, + i: usize, + entity: &mut EntityType, + ) -> bool { + let EntityType::Dimension(dimension) = entity else { + return false; + }; + let is_angular = matches!( + dimension, + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) + ); + let frame = match self.dimension_measure_space(i) { + DimensionMeasureSpace::Direct => return false, + DimensionMeasureSpace::Mixed => { + self.command_line.push_warning( + crate::t!( + "Mixed-space measurement is not supported: the points come from different viewports, or from a viewport and the sheet. Measuring on the sheet; viewport scale is not compensated." + ) + .as_ref(), + ); + return false; + } + DimensionMeasureSpace::Viewport(frame) => frame, + }; + + // A similarity preserves angles, so an angular dimension reads the + // model angle already. It still measured through a viewport, so the + // caller must skip association inference. + if is_angular { + return true; + } + + let compensation = frame.paper_to_model_length_factor(); + let style_dimlfac = self.pending_dimension_style_dimlfac(i, entity); + if let Some(value) = + MeasurementScale::viewport_dimlfac_override(style_dimlfac, compensation) + { + dim_override::set_on_entity( + entity, + dim_override::DIMLFAC, + Some(XDataValue::Real(value)), + ); + } + true + } + + /// The DIMLFAC the pending dimension would inherit *without* a viewport + /// override: its own existing override if it has one, otherwise its named + /// dimension style's value. + fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 { + let EntityType::Dimension(dimension) = entity else { + return 1.0; + }; + if let Some(value) = dim_override::real( + &dimension.base().common.extended_data, + dim_override::DIMLFAC, + ) { + return value; + } + let name = dimension.base().style_name.as_str(); + self.tabs[i] + .scene + .document + .dim_styles + .iter() + .find(|style| { + style.name.eq_ignore_ascii_case(name) + || (name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard")) + }) + .map(|style| style.dimlfac) + .unwrap_or(1.0) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index a4a00956b..7a204e85e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; +mod dim_viewport; mod document; mod drafting_settings; pub(crate) mod expr_eval; @@ -508,6 +509,12 @@ pub(super) struct OpenCADStudio { cursor_size: i32, /// Selection-box size setting (PICKBOX, 0..=50). pick_box: i32, + /// PR2: accepted snaps for the points the active dimension command has + /// collected, in collection order. Transient command state — the committed + /// result is an ordinary dimension entity, so undo/redo needs nothing here. + pub(crate) dim_accepted_snaps: Vec, + /// Name of the command `dim_accepted_snaps` belongs to. + pub(crate) dim_accepted_snaps_owner: Option, /// Use REFEDIT rather than BEDIT when double-clicking an attribute-free block. double_click_block_refedit: bool, /// Open ATTEDIT when double-clicking a block with attributes. @@ -3634,6 +3641,8 @@ impl OpenCADStudio { zoom_factor: 60, cursor_size: 5, pick_box: 3, + dim_accepted_snaps: Vec::new(), + dim_accepted_snaps_owner: None, double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT, diff --git a/src/command.rs b/src/command.rs index 50f17757c..56e9d83c9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2163,6 +2163,14 @@ pub trait CadCommand: Send { } /// Returns `true` when the command needs entity picking (hit-test) instead of point picking. + /// PR2: this command creates a dimension whose measurement must be + /// compensated when its points are picked through a paper-space viewport, + /// and whose explicit object pick may land on model geometry displayed + /// inside one. See `crate::app::dim_viewport`. + fn measures_through_viewports(&self) -> bool { + false + } + fn needs_entity_pick(&self) -> bool { false } diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 9c9214041..dc9813c28 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -235,6 +235,69 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option) { + let common = entity.common_mut(); + let mut kept: Vec<(i16, XDataValue)> = pairs(&common.extended_data) + .into_iter() + .filter(|(c, _)| *c != code) + .collect(); + if let Some(v) = value { + kept.push((code, v)); + } + + let legacy = common.extended_data.get_record("ACAD_DSTYLE").is_some() + && common + .extended_data + .get_record("ACAD") + .is_some_and(|rec| { + !matches!(rec.values.first(), Some(XDataValue::String(n)) if n == "DSTYLE") + }); + let app = if legacy { "ACAD_DSTYLE" } else { "ACAD" }; + + let values = if kept.is_empty() { + None + } else { + let mut vals = Vec::new(); + if !legacy { + vals.push(XDataValue::String("DSTYLE".to_string())); + } + vals.push(XDataValue::ControlString("{".to_string())); + for (code, value) in kept { + vals.push(XDataValue::Integer16(code)); + vals.push(value); + } + vals.push(XDataValue::ControlString("}".to_string())); + Some(vals) + }; + + let mut rebuilt = ExtendedData::new(); + for record in common.extended_data.records() { + if record.application_name != app + && !(!legacy && record.application_name == "ACAD_DSTYLE") + { + rebuilt.add_record(record.clone()); + } + } + if let Some(vals) = values { + let mut record = acadrust::xdata::ExtendedDataRecord::new(app); + for v in vals { + record.add_value(v); + } + rebuilt.add_record(record); + } + rebuilt.raw_dwg_eed = common.extended_data.raw_dwg_eed.clone(); + common.extended_data = rebuilt; +} + pub fn property_real_code(field: &str) -> Option { Some(match field { "dim_arrow_size" => DIMASZ, diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 872a00232..157b6152c 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -161,6 +161,12 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::Cancel } + /// PR2: points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index b5d3bf417..b253dda77 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -482,6 +482,12 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } + /// PR2: points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 1f712ff5e..e03e2e630 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -132,6 +132,12 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } + /// PR2: points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 263fc8cac..7c203efc3 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -217,6 +217,12 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } + /// PR2: points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index ff69fcdd3..e72a84465 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -130,6 +130,12 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } + /// PR2: points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } From fcf1b96fdf7622619e5e563c8ec72b02940e8f09 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:29:21 -0500 Subject: [PATCH 07/23] Resolve dimension object picks against model geometry inside a viewport Paper-space hit testing only sees sheet entities, so DIMLINEAR / DIMALIGNED / DIMRADIUS / DIMDIAMETER / DIMANGULAR in "select object" mode found nothing when the user clicked the model geometry a viewport displays. A pick that lands inside a content viewport is now mapped into model space through the viewport frame and tested against that viewport's resident model wires - the same tessellation it draws, so the pick matches what the user sees. Ordinary block instances are descended into, with the instance transform (recursively, up to eight levels) baked into the extracted geometry, so a line inside a block measures its placed length. The picked entity is handed to the command already projected onto the sheet, so all five commands keep working entirely in paper coordinates and unchanged; the pick is recorded as a viewport snap, so the committed dimension is compensated exactly like a point-input one. A miss falls through to the ordinary paper-space pick. Also narrows mixed-space classification to the measuring points: the final click of each of these commands is the dimension-line placement, which lands on bare sheet for viewport measurements and inside a viewport rectangle for paper ones, and says nothing about what is being measured. Co-Authored-By: Claude Fable 5.1 --- src/app/dim_viewport.rs | 81 ++++++++++++++++++++++---------------- src/app/update/viewport.rs | 24 ++++++++++- 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index bf4510f94..55a84961a 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -98,7 +98,9 @@ impl OpenCADStudio { } /// The accepted snap for the `idx`-th collected point of the active - /// command, if one was recorded. + /// command, if one was recorded. The contracted accessor PR1 and PR3 read; + /// PR2 itself classifies the whole set at commit time. + #[allow(dead_code)] pub(crate) fn accepted_snap_for_point(&self, idx: usize) -> Option<&AcceptedSnap> { self.dim_accepted_snaps.get(idx) } @@ -130,13 +132,21 @@ impl OpenCADStudio { if scene.current_layout == "Model" || scene.active_viewport.is_some() { return DimensionMeasureSpace::Direct; } - let snaps = &self.dim_accepted_snaps; - if snaps.is_empty() { + // The final collected point of every dimension command in scope is the + // dimension-line / text placement click, which says nothing about what + // is being measured — it routinely lands on bare sheet even for a + // viewport measurement, and inside a viewport rectangle even for a + // plain paper one. Only the measuring points are classified. + let measuring = match self.dim_accepted_snaps.len() { + 0 | 1 => self.dim_accepted_snaps.as_slice(), + n => &self.dim_accepted_snaps[..n - 1], + }; + if measuring.is_empty() { return DimensionMeasureSpace::Direct; } let mut frame: Option = None; let mut any_free = false; - for snap in snaps { + for snap in measuring { match snap.frame { Some(f) => match frame { None => frame = Some(f), @@ -148,39 +158,13 @@ impl OpenCADStudio { } match (frame, any_free) { (None, _) => DimensionMeasureSpace::Direct, - // A dimension-line / text placement point on bare sheet is normal - // and expected, so only *measuring* points matter. The commands - // collect the measuring points first, so a trailing free point is - // the placement click; a free point before a viewport point is a - // genuine mixed-space measurement. (Some(f), false) => DimensionMeasureSpace::Viewport(f), - (Some(f), true) => { - if self.dimension_leading_points_share_viewport(f) { - DimensionMeasureSpace::Viewport(f) - } else { - DimensionMeasureSpace::Mixed - } - } + // One origin through a viewport and the other on the sheet: the + // two points are not in a common measurable space. + (Some(_), true) => DimensionMeasureSpace::Mixed, } } - /// True when every snap up to the first free one came through `frame`'s - /// viewport and no later snap came through a viewport. - fn dimension_leading_points_share_viewport(&self, frame: ViewportFrame) -> bool { - let mut seen_free = false; - for snap in &self.dim_accepted_snaps { - match snap.frame { - Some(f) => { - if seen_free || f.viewport != frame.viewport { - return false; - } - } - None => seen_free = true, - } - } - true - } - /// Apply viewport compensation to a dimension about to be committed. /// /// Returns `true` when the dimension measures through a viewport, which @@ -204,7 +188,7 @@ impl OpenCADStudio { DimensionMeasureSpace::Mixed => { self.command_line.push_warning( crate::t!( - "Mixed-space measurement is not supported: the points come from different viewports, or from a viewport and the sheet. Measuring on the sheet; viewport scale is not compensated." + "Mixed-space measurement is not supported: the extension origins come from different viewports, or from a viewport and the sheet. Measuring in paper space instead (no viewport scale compensation)." ) .as_ref(), ); @@ -234,6 +218,35 @@ impl OpenCADStudio { true } + /// Explicit "select object" pick for a dimension command, resolved through + /// a paper-space content viewport. + /// + /// Paper-space hit testing only sees sheet entities, so a click on model + /// geometry displayed inside a viewport finds nothing. This maps the + /// cursor into model space through the viewport frame, hit-tests the + /// viewport's resident model wires (descending into block instances and + /// baking the instance transform), then hands the command a copy of the + /// picked entity projected onto the sheet. The commands therefore keep + /// working entirely in paper coordinates, and the measurement is + /// compensated at commit time like any other viewport measurement. + /// + /// Returns `None` when the click is not inside a content viewport or hits + /// nothing, so the caller falls back to the ordinary paper-space pick. + pub(crate) fn try_dimension_viewport_entity_pick( + &mut self, + i: usize, + paper: DVec3, + aperture_paper: f64, + ) -> Option { + let pick = self.tabs[i] + .scene + .dimension_pick_through_viewport(paper, aperture_paper)?; + self.record_dimension_viewport_pick(i, pick.frame, pick.paper_point, pick.model_point); + let command = self.tabs[i].active_cmd.as_mut()?; + command.inject_picked_entity(pick.paper_entity); + Some(command.on_entity_pick(pick.entity_handle, pick.paper_point)) + } + /// The DIMLFAC the pending dimension would inherit *without* a viewport /// override: its own existing override if it has one, otherwise its named /// dimension style's value. diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 6290e08ea..bd2708751 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3824,7 +3824,29 @@ impl OpenCADStudio { command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); } - let result = if self.tabs[i] + // PR2: a dimension "select object" pick that lands inside a paper + // content viewport is resolved against the model geometry that + // viewport displays. Paper hit testing only sees sheet entities, + // so this runs first and falls through when it finds nothing. + let dimension_viewport_pick = if self.tabs[i].active_cmd.as_ref().is_some_and( + |command| command.needs_entity_pick() && command.measures_through_viewports(), + ) { + let aperture_paper = { + let camera = self.tabs[i].scene.camera.borrow(); + let per_px = if bounds.height > 0.0 { + camera.ortho_size() as f64 * 2.0 / bounds.height as f64 + } else { + 0.0 + }; + crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px + }; + self.try_dimension_viewport_entity_pick(i, pick_wcs, aperture_paper) + } else { + None + }; + let result = if let Some(result) = dimension_viewport_pick { + Some(result) + } else if self.tabs[i] .active_cmd .as_ref() .map(|c| c.needs_structure_point_pick()) From 773365a1b9ae617a5952ab6bfebf754f52f26660 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:35:50 -0500 Subject: [PATCH 08/23] Keep the persisted representation and the seam tests consistent Colocated unit tests pin the persisted representation: a 1:10 viewport with no user factor writes DIMLFAC -10, a 10-unit paper span then formats as 100, and the same dimension read back in model space does not scale. A positive user factor folds into the magnitude, and a drawing-wide negative DIMLFAC that already carries the compensation writes no override at all. Another test pins the viewport model->paper affine against ViewportFrame's own mapping, so object-pick projection and snap projection cannot drift. Also clears the recorded snaps when a multi-dimension command commits. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 1 + src/scene/viewport_dim_seam.rs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 133a7fd2a..2eed7c236 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2254,6 +2254,7 @@ impl OpenCADStudio { } } CmdResult::CommitDimensionsAndExit(dimensions) => { + self.clear_dimension_accepted_snaps(); let association_mode = self.tabs[i] .scene .document diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index 30a6ec35f..a89059394 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -409,9 +409,7 @@ mod tests { DVec3::new(940.5, 2113.25, 0.0), ] { let expected = f.model_to_paper(model); - let actual = t.transform_point(&acadrust::types::Vector3::new( - model.x, model.y, model.z, - )); + let actual = t.apply(acadrust::types::Vector3::new(model.x, model.y, model.z)); assert!( (actual.x - expected.x).abs() < 1e-9 && (actual.y - expected.y).abs() < 1e-9, "{actual:?} != {expected:?}" From 848f789018ff30dd4069b5a097b789cd7be70982 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:36:44 -0500 Subject: [PATCH 09/23] Stamp the layout owner so compensated text survives every commit path The negative DIMLFAC convention is resolved from the dimension's owner block, but the entity only gets one when `add_entity_to_layout` commits it. With DIMASSOC = 0 the dimension is exploded to its pieces first, and the baked text would then have read as a model-space dimension and dropped the compensation. Stamp the layout block - the same handle the add assigns - as soon as the override is written, so the entity is self-describing whichever path consumes it. Co-Authored-By: Claude Fable 5.1 --- src/app/dim_viewport.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index 55a84961a..632ed364d 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -209,6 +209,16 @@ impl OpenCADStudio { if let Some(value) = MeasurementScale::viewport_dimlfac_override(style_dimlfac, compensation) { + // The negative DIMLFAC convention is resolved from the dimension's + // owner block, and the commit path (or, with DIMASSOC = 0, the + // explode path) reads the entity before `add_entity_to_layout` + // assigns one. Stamping the layout block here — the same handle + // the add would set — keeps the entity self-describing, so the + // text is compensated whichever path consumes it. + let layout_block = self.tabs[i].scene.current_layout_block_handle_pub(); + if layout_block.is_valid() { + entity.common_mut().owner_handle = layout_block; + } dim_override::set_on_entity( entity, dim_override::DIMLFAC, From 43dea4149806112f5d62496b8fd00aa944ea57a4 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:45:59 -0500 Subject: [PATCH 10/23] Use viewport snap frame and accepted snaps for dimension measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes PR2's two seams now that PR1 (viewport snapping) is underneath. * `Scene::viewport_frame` is PR1's camera-derived implementation. The viewport-record version is gone; PR1's returns `None` for oblique / perspective viewports, which is what we want — every entry point in `viewport_dim_seam` then declines rather than guessing a planar mapping. `viewport_dim_seam` is now just the dimension object-pick path, and its module doc says so. * There is one accepted-snap store: PR1's `OpenCADStudio::accepted_snaps`. PR2's parallel `dim_accepted_snaps` (and its command-name ownership tracking, which PR1's clear-on-command-start/end already does) is deleted, along with the `record_dimension_accepted_snap` seam. `accepted_snap_for_point` reads PR1's list. Two things layer on top: - the explicit object pick is not a point step, so it still records its own snaps, now through `push_accepted_snap` into PR1's list, and now carrying the picked entity handle + block path as a `SnapSourceRef` so PR3 can associate it; - a typed coordinate carries no snap, so `measure_snap` re-attaches the content viewport under a snap that is free *and* source-less. That keeps `DIMLINEAR` with typed coordinates inside a 1:10 viewport measuring the model, without ever reinterpreting a real paper-sheet snap that happens to sit over a viewport. * One association gate, not two. `dimension_association_allowed` is the single predicate, derived from the same `dimension_measure_space` classification that decides whether to compensate the measurement, so the two can never disagree. `infer_dimension_sources_guarded` delegates to it, and the commit path's "commit unassociated when measured through a viewport" condition is now that predicate rather than a second, independently-computed bool. It also covers the explicit-pick and explicit-source-list branches, which the old inference-only guard did not. Because the classification ignores the trailing dimension-line placement click, an ordinary paper dimension whose placement click lands inside a viewport rectangle keeps its association. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 35 ++---- src/app/dim_viewport.rs | 189 ++++++++++++++++++-------------- src/app/mod.rs | 10 +- src/app/update/viewport_snap.rs | 57 ++++++---- src/scene/viewport_dim_seam.rs | 56 +++------- 5 files changed, 170 insertions(+), 177 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 2eed7c236..35259107d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -454,17 +454,6 @@ impl OpenCADStudio { } } if matches!(&input, StepInput::Point(_)) { self.refresh_command_point_pick_context(i); } - // PR2: remember which viewport (if any) each dimension point was - // picked through, before the command turns it into geometry. - if let StepInput::Point(p) = &input { - if self.tabs[i] - .active_cmd - .as_ref() - .is_some_and(|command| command.measures_through_viewports()) - { - self.record_dimension_accepted_snap(i, *p); - } - } let ctrl = self.ctrl_down; let shift = self.shift_down; let result: Option = { @@ -2117,15 +2106,17 @@ impl OpenCADStudio { acadrust::entities::Dimension::Ordinate(_) ) ); - // PR2: a dimension placed on the sheet but measuring model - // geometry through a viewport carries the compensation as a - // negative DIMLFAC override. It is deliberately *not* - // associated: its definition points are projected paper - // coordinates, so inferring a paper-space association would - // bind it to geometry that is not there. PR3 records the real - // dimension -> viewport -> entity chain. - let measured_through_viewport = - self.apply_viewport_dimension_measurement(i, &mut entity); + // A dimension placed on the sheet but measuring model geometry + // through a viewport carries the compensation as a negative + // DIMLFAC override. + self.apply_viewport_dimension_measurement(i, &mut entity); + // ...and is deliberately *not* associated in paper space: its + // definition points are projected paper coordinates, so a + // paper-space association would bind it to geometry that is + // not there. PR3 records the real dimension -> viewport -> + // entity chain instead. One gate, shared with + // `infer_dimension_sources_guarded`. + let association_allowed = self.dimension_association_allowed(i); let inherited_dimension = if preserve_base_style { match &entity { acadrust::EntityType::Dimension(dimension) => Some(( @@ -2192,7 +2183,7 @@ impl OpenCADStudio { entity, preserve_base_style, ) { - if association_mode == 2 && !measured_through_viewport { + if association_mode == 2 && association_allowed { let mut changes = vec![ (handle, crate::scene::ChangeKind::Modified), ]; @@ -2236,7 +2227,6 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].snap_result = None; - self.clear_dimension_accepted_snaps(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); } @@ -2254,7 +2244,6 @@ impl OpenCADStudio { } } CmdResult::CommitDimensionsAndExit(dimensions) => { - self.clear_dimension_accepted_snaps(); let association_mode = self.tabs[i] .scene .document diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index 632ed364d..fbd35cce7 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -5,29 +5,42 @@ //! *report* the model measurement. Because a viewport frame is a similarity //! (uniform scale plus in-plane twist), the model measurement of any linear, //! aligned or radial span is exactly the raw paper measurement times the -//! viewport compensation `1 / scale` — projecting the model span onto the +//! viewport compensation `1 / scale` -- projecting the model span onto the //! rotated paper axis and compensating is the same number. So no geometry is //! rewritten: the compensation is persisted once, as a negative DIMLFAC //! override on the dimension (see //! [`MeasurementScale::viewport_dimlfac_override`]), and applied once, by the //! linear formatter. //! -//! Angular dimensions are never compensated — a similarity preserves angles. +//! Angular dimensions are never compensated -- a similarity preserves angles. //! -//! ## PR1 seam -//! [`OpenCADStudio::record_dimension_accepted_snap`] is the one function PR1 -//! replaces. PR1 records a real [`AcceptedSnap`] per accepted point (with the -//! snapped source and the frame in effect at snap time); this branch derives -//! the same information from the cursor's paper position and the content -//! viewport under it. Everything downstream consumes `AcceptedSnap` only. +//! ## Where the snaps come from +//! The per-point [`AcceptedSnap`] list is PR1's +//! ([`OpenCADStudio::accepted_snaps`]): the click handler records the real +//! snap for every interactive pick, and `apply_step_input` records a free +//! snap for typed / dynamic-input / headless points. There is exactly one +//! source of truth. Two things are layered on top here: +//! +//! * the explicit object-pick path ([`Scene::dimension_pick_through_viewport`]) +//! is not a point step at all, so it records its own snaps +//! ([`OpenCADStudio::record_dimension_viewport_pick`]); +//! * a *typed* coordinate carries no snap, so PR1 records it as a free point +//! with no viewport. [`OpenCADStudio::measure_snap`] re-attaches the +//! content viewport under such a point, which is what makes +//! `DIMLINEAR` + typed coordinates inside a 1:10 viewport measure the model. +//! It is deliberately limited to snaps that are free *and* source-less, so a +//! genuine paper-sheet snap that happens to sit over a viewport is never +//! reinterpreted. use acadrust::entities::Dimension; +use acadrust::types::Handle; use acadrust::xdata::XDataValue; use acadrust::EntityType; use glam::DVec3; +use crate::command::DimensionAssociationSource; use crate::entities::dim_override; -use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; +use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, SnapSourceRef, ViewportFrame}; use super::OpenCADStudio; @@ -46,101 +59,106 @@ pub(crate) enum DimensionMeasureSpace { } impl OpenCADStudio { - // ───────────────────────── PR1 SEAM: BEGIN ────────────────────────── - - /// Record the snap accepted for one collected point of the active - /// command. + /// Record the accepted snaps produced by the explicit object-pick path (a + /// dimension "select object" pick resolved through a content viewport). /// - /// PR1 replaces the body with the real snap result (which knows the - /// snapped entity, the block path and the frame at snap time). Until then - /// the viewport is resolved from the paper position of the accepted point, - /// which is correct for every point that lands inside a content viewport - /// and yields a free snap everywhere else. - pub(crate) fn record_dimension_accepted_snap(&mut self, i: usize, point: DVec3) { - self.sync_dimension_snap_owner(i); - let snap = match self.tabs[i].scene.viewport_frame_at_paper_point(point) { - Some(frame) => AcceptedSnap { - paper_point: point, - model_point: frame.paper_to_model(point), - viewport: Some(frame.viewport), - frame: Some(frame), - source: None, - }, - None => AcceptedSnap::free(point), - }; - self.dim_accepted_snaps.push(snap); - } - - // ────────────────────────── PR1 SEAM: END ─────────────────────────── - - /// Record an accepted snap produced by the explicit object-pick path (a - /// pick resolved through a content viewport). Independent of PR1. + /// An object pick is not a point step, so PR1's click handler never sees + /// it. One pick supplies *both* extension origins, so it is recorded + /// twice: that keeps the list index-parallel with the definition points + /// the command derives from the entity, and lets the "every measuring + /// point came through one viewport" classification see a complete set. pub(crate) fn record_dimension_viewport_pick( &mut self, - i: usize, frame: ViewportFrame, paper_point: DVec3, model_point: DVec3, + entity: Handle, + block_path: Vec, ) { - self.sync_dimension_snap_owner(i); - // An object pick supplies both extension origins at once; record it - // twice so the "every measuring point came through one viewport" - // test sees a complete set. for _ in 0..2 { - self.dim_accepted_snaps.push(AcceptedSnap { + self.push_accepted_snap(AcceptedSnap { paper_point, model_point, viewport: Some(frame.viewport), frame: Some(frame), - source: None, + source: Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(entity), + block_path: block_path.clone(), + // An object pick acquires the whole curve, not one + // feature; the per-slot feature point is derived from the + // committed dimension's own definition points. + snap_type: crate::snap::SnapType::Nearest, + }), }); } } /// The accepted snap for the `idx`-th collected point of the active - /// command, if one was recorded. The contracted accessor PR1 and PR3 read; - /// PR2 itself classifies the whole set at commit time. + /// command, with the typed-coordinate viewport fallback applied. + /// + /// Reads PR1's list -- there is no second store. #[allow(dead_code)] - pub(crate) fn accepted_snap_for_point(&self, idx: usize) -> Option<&AcceptedSnap> { - self.dim_accepted_snaps.get(idx) + pub(crate) fn accepted_snap_for_point(&self, i: usize, idx: usize) -> Option { + self.accepted_snaps().get(idx).map(|snap| self.measure_snap(i, snap)) } - /// Drop recorded snaps when the active command changes, so one command - /// never sees another's points. - fn sync_dimension_snap_owner(&mut self, i: usize) { - let name = self.tabs[i] - .active_cmd - .as_ref() - .map(|command| command.name().to_string()); - if self.dim_accepted_snaps_owner != name { - self.dim_accepted_snaps.clear(); - self.dim_accepted_snaps_owner = name; + /// One accepted snap as *measurement* sees it. + /// + /// Identical to the recorded snap except that a point which carries no + /// snap at all (a typed coordinate, a dynamic-input point, a headless + /// script point) and lands inside a content viewport is re-attached to + /// that viewport, so typing `100,50` inside a 1:10 viewport measures the + /// model just like clicking there would. A snap that landed on real + /// paper-sheet geometry keeps `viewport: None` and is never reinterpreted. + pub(crate) fn measure_snap(&self, i: usize, snap: &AcceptedSnap) -> AcceptedSnap { + if snap.frame.is_some() || snap.source.is_some() { + return snap.clone(); + } + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" || scene.active_viewport.is_some() { + return snap.clone(); + } + match scene.viewport_frame_at_paper_point(snap.paper_point) { + Some(frame) => AcceptedSnap { + paper_point: snap.paper_point, + model_point: frame.paper_to_model(snap.paper_point), + viewport: Some(frame.viewport), + frame: Some(frame), + source: None, + }, + None => snap.clone(), } } - pub(crate) fn clear_dimension_accepted_snaps(&mut self) { - self.dim_accepted_snaps.clear(); - self.dim_accepted_snaps_owner = None; + /// The accepted snaps that say what the pending dimension *measures*, in + /// collection order. + /// + /// The final collected point of every dimension command in scope is the + /// dimension-line / text placement click, which says nothing about what is + /// being measured -- it routinely lands on bare sheet even for a viewport + /// measurement, and inside a viewport rectangle even for a plain paper + /// one. It is dropped here, which is also why association inference is + /// guarded on this set rather than on every recorded snap. + pub(crate) fn dimension_measuring_snaps(&self, i: usize) -> Vec { + let all = self.accepted_snaps(); + let measuring = match all.len() { + 0 | 1 => all, + n => &all[..n - 1], + }; + measuring.iter().map(|snap| self.measure_snap(i, snap)).collect() } /// Classify how the pending dimension should be measured from the snaps /// recorded for it. - fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { + pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { let scene = &self.tabs[i].scene; // Only a dimension being created *on the sheet* can measure through a // viewport. Inside MSPACE the command already works in model space. if scene.current_layout == "Model" || scene.active_viewport.is_some() { return DimensionMeasureSpace::Direct; } - // The final collected point of every dimension command in scope is the - // dimension-line / text placement click, which says nothing about what - // is being measured — it routinely lands on bare sheet even for a - // viewport measurement, and inside a viewport rectangle even for a - // plain paper one. Only the measuring points are classified. - let measuring = match self.dim_accepted_snaps.len() { - 0 | 1 => self.dim_accepted_snaps.as_slice(), - n => &self.dim_accepted_snaps[..n - 1], - }; + let measuring = self.dimension_measuring_snaps(i); + let measuring = measuring.as_slice(); if measuring.is_empty() { return DimensionMeasureSpace::Direct; } @@ -167,24 +185,23 @@ impl OpenCADStudio { /// Apply viewport compensation to a dimension about to be committed. /// - /// Returns `true` when the dimension measures through a viewport, which - /// tells the caller not to infer a paper-space association from the - /// projected definition points (PR3 creates the real viewport - /// associations). + /// Whether the dimension may also take a paper-space association is a + /// separate question with the same answer source: + /// [`OpenCADStudio::dimension_association_allowed`]. pub(crate) fn apply_viewport_dimension_measurement( &mut self, i: usize, entity: &mut EntityType, - ) -> bool { + ) { let EntityType::Dimension(dimension) = entity else { - return false; + return; }; let is_angular = matches!( dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) ); let frame = match self.dimension_measure_space(i) { - DimensionMeasureSpace::Direct => return false, + DimensionMeasureSpace::Direct => return, DimensionMeasureSpace::Mixed => { self.command_line.push_warning( crate::t!( @@ -192,16 +209,15 @@ impl OpenCADStudio { ) .as_ref(), ); - return false; + return; } DimensionMeasureSpace::Viewport(frame) => frame, }; // A similarity preserves angles, so an angular dimension reads the - // model angle already. It still measured through a viewport, so the - // caller must skip association inference. + // model angle already and needs no override. if is_angular { - return true; + return; } let compensation = frame.paper_to_model_length_factor(); @@ -225,7 +241,6 @@ impl OpenCADStudio { Some(XDataValue::Real(value)), ); } - true } /// Explicit "select object" pick for a dimension command, resolved through @@ -251,7 +266,13 @@ impl OpenCADStudio { let pick = self.tabs[i] .scene .dimension_pick_through_viewport(paper, aperture_paper)?; - self.record_dimension_viewport_pick(i, pick.frame, pick.paper_point, pick.model_point); + self.record_dimension_viewport_pick( + pick.frame, + pick.paper_point, + pick.model_point, + pick.entity_handle, + pick.block_path.clone(), + ); let command = self.tabs[i].active_cmd.as_mut()?; command.inject_picked_entity(pick.paper_entity); Some(command.on_entity_pick(pick.entity_handle, pick.paper_point)) diff --git a/src/app/mod.rs b/src/app/mod.rs index 7a204e85e..c68bdfa0d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,7 +9,7 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; -mod dim_viewport; +pub(crate) mod dim_viewport; mod document; mod drafting_settings; pub(crate) mod expr_eval; @@ -509,12 +509,6 @@ pub(super) struct OpenCADStudio { cursor_size: i32, /// Selection-box size setting (PICKBOX, 0..=50). pick_box: i32, - /// PR2: accepted snaps for the points the active dimension command has - /// collected, in collection order. Transient command state — the committed - /// result is an ordinary dimension entity, so undo/redo needs nothing here. - pub(crate) dim_accepted_snaps: Vec, - /// Name of the command `dim_accepted_snaps` belongs to. - pub(crate) dim_accepted_snaps_owner: Option, /// Use REFEDIT rather than BEDIT when double-clicking an attribute-free block. double_click_block_refedit: bool, /// Open ATTEDIT when double-clicking a block with attributes. @@ -3641,8 +3635,6 @@ impl OpenCADStudio { zoom_factor: 60, cursor_size: 5, pick_box: 3, - dim_accepted_snaps: Vec::new(), - dim_accepted_snaps_owner: None, double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT, diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 898c55570..0265b36c7 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -46,43 +46,51 @@ impl OpenCADStudio { self.accepted_snaps.last() } - /// `true` when any point the active command collected came through a - /// layout viewport. Dimension association inference (which compares a - /// dimension's own definition points against geometry in the *current* - /// space) must not run in that case: the definition points are paper - /// coordinates that correspond to no paper-sheet geometry, and the model - /// geometry they really describe lives at completely different - /// coordinates. PR3 replaces this guard with real viewport-aware - /// associations built from `AcceptedSnap::source`. - pub(crate) fn accepted_snaps_through_viewport(&self) -> bool { - self.accepted_snaps.iter().any(AcceptedSnap::through_viewport) - } - - /// `Scene::infer_dimension_sources`, suppressed when the dimension's points - /// were picked through a layout viewport (PR1). + /// `Scene::infer_dimension_sources`, suppressed when the dimension being + /// created measures through a layout viewport. /// /// Inference works by looking for geometry near the dimension's own /// definition points, in whatever space it is handed. A paper-space /// dimension measured through a viewport has PAPER definition points that /// happen to sit over projected model geometry, so unguarded inference /// would either associate it with unrelated paper-sheet geometry at those - /// coordinates, or match model geometry using paper coordinates. Neither is - /// correct, and a wrong association is worse than none. Returning an empty - /// source list makes `attach_dimension_association` a no-op. + /// coordinates, or match model geometry using paper coordinates. Neither + /// is correct, and a wrong association is worse than none. Returning an + /// empty source list makes `attach_dimension_association` a no-op. /// - /// Deliberately minimal: PR3 replaces this with a real viewport-aware - /// association built from `AcceptedSnap::source`. + /// The classification is + /// [`OpenCADStudio::dimension_measure_space`] — the *same* one that + /// decides whether to compensate the measurement, so the two can never + /// disagree. In particular it ignores the trailing dimension-line + /// placement click, which routinely lands inside a viewport rectangle for + /// an ordinary paper-space dimension and must not suppress its + /// association. pub(crate) fn infer_dimension_sources_guarded( &self, tab: usize, dimension: Handle, ) -> Vec> { - if self.accepted_snaps_through_viewport() { + if !self.dimension_association_allowed(tab) { return Vec::new(); } self.tabs[tab].scene.infer_dimension_sources(dimension) } + /// The single gate on creating a paper-space association for the + /// dimension the active command is committing. + /// + /// `false` exactly when the dimension measures through (or partly through) + /// a layout viewport, whatever supplied its sources — inference, + /// an explicit object pick, or an explicit source list. Everything in the + /// commit path that used to decide this for itself now asks here, so the + /// measurement rule and the association rule cannot drift apart. + pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool { + matches!( + self.dimension_measure_space(tab), + crate::app::dim_viewport::DimensionMeasureSpace::Direct + ) + } + /// Forget the accepted snaps of a finished / abandoned command. pub(crate) fn clear_accepted_snaps(&mut self) { self.accepted_snaps.clear(); @@ -107,6 +115,15 @@ impl OpenCADStudio { } None => AcceptedSnap::free(committed), }; + self.push_accepted_snap(accepted); + } + + /// Append an already-built accepted snap, keeping the retention cap. + /// + /// Used by paths that are not point steps (the dimension object pick + /// resolved through a viewport) and therefore never reach the click + /// handler. + pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) { if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS { self.accepted_snaps.remove(0); } diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index a89059394..575aaf505 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -1,19 +1,20 @@ -//! PR2 seams onto the paper-space viewport world. +//! Object-pick resolution for paper-space dimensions taken through a layout +//! viewport. //! -//! Everything in this file exists so that viewport-aware dimension -//! measurement works *without* PR1 (viewport snapping) in the tree. Each seam -//! is small and self-contained; the merge notes say which ones PR1 replaces. +//! Paper-space hit testing only sees the sheet's own entities, so a dimension +//! command's explicit "select object" pick that lands inside a content +//! viewport finds nothing. [`Scene::dimension_pick_through_viewport`] maps the +//! click into model space through the viewport's +//! [`crate::scene::viewport_ref::ViewportFrame`], hit-tests the viewport's +//! resident model wires (descending into block instances and baking the +//! instance transform), and hands back a copy of the picked entity already +//! projected onto the sheet. //! -//! ## Seams -//! * [`Scene::viewport_frame`] — **replaced by PR1.** PR1 owns construction of -//! [`ViewportFrame`] from the live viewport camera. This implementation -//! derives the frame straight from the DXF viewport record, which is correct -//! for plan (non-oblique) content viewports. -//! * [`Scene::content_viewport_at_paper_point`] / -//! [`Scene::viewport_frame_at_paper_point`] — PR1 supersedes these with its -//! snap-time viewport resolution; harmless to keep. -//! * [`Scene::dimension_pick_through_viewport`] — PR2 owns this permanently. -//! It is the explicit-object-pick path and does not depend on PR1. +//! The frame itself is built by [`Scene::viewport_frame`] (see +//! `crate::scene::mspace`), which derives it from the *live viewport camera* +//! so it always agrees with the pixels the renderer drew. It returns `None` +//! for oblique / perspective viewports, and every entry point here then +//! declines the pick rather than guessing a planar mapping. use super::*; @@ -43,33 +44,6 @@ pub struct ViewportDimPick { } impl Scene { - // ───────────────────────── PR1 SEAM: BEGIN ────────────────────────── - // Replace the body of `viewport_frame` with PR1's implementation on - // merge; the signature is the shared contract and must not change. - - /// Planar paper↔model mapping for one layout viewport. - /// - /// Derived from the viewport record rather than the render camera: - /// `paper_center = vp.center`, `model_target = vp.view_target`, - /// `scale = vp_effective_scale(...)`, `twist = vp.twist_angle`. - pub fn viewport_frame(&self, viewport: Handle) -> Option { - let Some(EntityType::Viewport(vp)) = self.document.get_entity(viewport) else { - return None; - }; - let scale = vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); - if !scale.is_finite() || scale.abs() < 1e-12 { - return None; - } - Some(ViewportFrame { - viewport, - paper_center: DVec2::new(vp.center.x, vp.center.y), - model_target: DVec2::new(vp.view_target.x, vp.view_target.y), - scale, - twist: vp.twist_angle, - locked: vp.status.locked, - }) - } - // ────────────────────────── PR1 SEAM: END ─────────────────────────── /// The smallest *on* content viewport of the current layout whose paper /// rectangle contains `paper`. `None` on the Model tab, while editing From 417e8e3676e56623def5c4ec00d5ee8d87fc4e49 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:31:57 -0500 Subject: [PATCH 11/23] Add a viewport frame seam for dimension association resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR3 has to map a resolved model point onto the sheet, which needs the layout viewport's paper<->model mapping. PR1 (feat/viewport-snap) owns `Scene::viewport_frame` and is not visible from this branch, so this adds the minimal stand-in the shared contract in `viewport_ref.rs` describes: paper_center from the viewport rectangle's centre, model_target from the saved view target, scale from `vp_effective_scale`, twist from the saved view twist. It is fenced off in one clearly marked block with merge instructions. PR1's version is camera-derived, because it must agree with what is actually drawn — including the auto-fit rescue `camera_for_viewport` applies to stale saved views. Nothing else in PR3 reads viewport fields directly, so replacing this one function is the whole merge. Co-Authored-By: Claude Fable 5.1 --- src/scene/viewport_ref.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index 7c64f4e29..66c92d565 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -328,6 +328,43 @@ impl MeasurementScale { } } +// ───────────────────────────────────────────────────────────────────────── +// MERGE SEAM — PR1 (feat/viewport-snap) owns `Scene::viewport_frame`. +// +// PR3 needs a frame to map a resolved model point onto the sheet, but PR1 is +// not visible from this branch. This is the minimal stand-in agreed in the +// shared contract: paper_center = vp.center, model_target = vp.view_target, +// scale = `vp_effective_scale`, twist = vp.twist_angle. PR1's version is +// camera-derived (it must agree with what is actually drawn, including the +// auto-fit rescue in `Scene::camera_for_viewport` for stale saved views). +// +// To merge: delete this whole block. Nothing else in PR3 reads viewport +// fields directly — every call goes through `Scene::viewport_frame`. +// ───────────────────────────────────────────────────────────────────────── +impl crate::scene::Scene { + /// The planar paper<->model mapping of layout viewport `handle`. + /// + /// `None` when the handle is not a viewport, or when its scale is + /// degenerate (a zero-height view cannot map anything). + pub fn viewport_frame(&self, handle: Handle) -> Option { + let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity(handle) else { + return None; + }; + let scale = crate::scene::vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); + if !scale.is_finite() || scale.abs() < 1e-12 { + return None; + } + Some(ViewportFrame { + viewport: handle, + paper_center: DVec2::new(vp.center.x, vp.center.y), + model_target: DVec2::new(vp.view_target.x, vp.view_target.y), + scale, + twist: vp.twist_angle, + locked: vp.status.locked, + }) + } +} + #[cfg(test)] mod tests { use super::*; From 64c534f511ffe76931b48377138038766e77c082 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:32:34 -0500 Subject: [PATCH 12/23] Resolve dimension references through viewport and block chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `AssocDimensionReference` addresses a feature through a chain of handles — dimension -> [viewport] -> [insert ...] -> entity -> feature — stored in `xrefs` (AutoCAD's `mainObjectIds`). The existing resolver read only `xrefs[0]`. That is right for a model-space dimension on top-level geometry and wrong for everything else: a paper-space dimension measuring through a layout viewport has the VIEWPORT first, so resolution stopped on a viewport entity, found no curve and gave up. Nine of the ten association objects in the 115448 regression drawing are that shape. `dimension_assoc_chain` walks the chain positionally, which is all the file gives us: a leading VIEWPORT is the trans-space hop, INSERTs after it are the block-instance path (with their composed transform), and the first handle that is neither is the geometry. A chain of nothing but INSERTs ends on the innermost one, which is how a block reference's own insertion point is dimensioned. A handle that is no longer in the document comes back as `ChainError::Missing` rather than as a silent failure, so the caller can tell a broken link from an unsupported one. The module also evaluates the osnap feature itself, in the entity's own coordinates: centre, midpoint, quadrant, node/insertion, nearest, perpendicular, tangent (closed form on a circle or arc; a Newton refinement of (C(u) - P) . C'(u) = 0 on a spline, seeded from the stored parameter — the shape of the 115448 tangent-to-spline reference), and intersection / apparent intersection against the second object chain. Types we do not evaluate return None and keep their stored data, so an unsupported reference is left unresolved instead of being replaced by a guess. `Transform` has no inverse of its own and a block path is always affine, so the 3x3 cofactor inverse lives here too. All arithmetic is f64. Co-Authored-By: Claude Fable 5.1 --- src/scene/dimension_assoc_chain.rs | 635 +++++++++++++++++++++++++++++ src/scene/mod.rs | 1 + 2 files changed, 636 insertions(+) create mode 100644 src/scene/dimension_assoc_chain.rs diff --git a/src/scene/dimension_assoc_chain.rs b/src/scene/dimension_assoc_chain.rs new file mode 100644 index 000000000..ce83ac307 --- /dev/null +++ b/src/scene/dimension_assoc_chain.rs @@ -0,0 +1,635 @@ +//! Full reference chains for associative dimensions. +//! +//! An `AssocDimensionReference` addresses a *feature* — an osnap point on a +//! piece of geometry — through a chain of handles: +//! +//! ```text +//! dimension -> [viewport] -> [insert ...] -> entity -> feature +//! ``` +//! +//! `AssocDimensionReference::xrefs` is that chain, outermost first. AutoCAD +//! calls the same list `mainObjectIds`; `intersection_objects` +//! (`intersectObjectIds`) is the second chain an INTERSECTION / APPARENT +//! INTERSECTION osnap needs. +//! +//! The pre-PR3 resolver only ever read `xrefs[0]`, which is correct for a +//! model-space dimension on top-level geometry and wrong for everything else: +//! a paper-space dimension measuring through a layout viewport stores the +//! VIEWPORT as `xrefs[0]`, so the resolver stopped at a viewport entity, found +//! no curve on it and gave up. Nine of the ten association objects in the +//! 115448 regression drawing are of that shape. +//! +//! This module owns: +//! * [`walk_chain`] — turn `xrefs` into a viewport, a block-instance path +//! with its accumulated transform, and the innermost entity. +//! * [`feature_point`] — evaluate the osnap feature on that entity, in the +//! entity's own coordinates, then lift it through the block path into +//! model space. +//! +//! Mapping model -> paper through the viewport frame is deliberately *not* +//! done here; the caller owns it, because only the caller knows whether the +//! association is `trans_space` and which [`ViewportFrame`] is current. +//! +//! All arithmetic is `f64`. +//! +//! [`ViewportFrame`]: super::viewport_ref::ViewportFrame + +use acadrust::objects::AssocDimensionReference; +use acadrust::types::{Handle, Matrix4, Transform, Vector3}; +use acadrust::{CadDocument, EntityType}; +use cadkernel::geom2d::{closest_point, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN}; +use cadkernel::space::Plane; + +use crate::entities::curve::entity_curve; + +/// `AcDb::OsnapMode`, the value stored in +/// [`AssocDimensionReference::osnap_type`]. +/// +/// These are the AutoCAD ObjectARX codes, not our own [`crate::snap::SnapType`] +/// discriminants. The pre-PR3 writer already emitted `END` for generic points +/// and `NEAR` for a point taken at a parameter on a circle, so those two keep +/// meaning exactly what they used to. +#[allow(dead_code)] // The full code table is kept so a reader can see what a +// stored `osnap_type` means, including the modes we pass through untouched. +pub(crate) mod osnap { + pub const NONE: u8 = 0; + pub const END: u8 = 1; + pub const MID: u8 = 2; + pub const CEN: u8 = 3; + pub const NODE: u8 = 4; + pub const QUAD: u8 = 5; + pub const INTERSEC: u8 = 6; + pub const INS: u8 = 7; + pub const PERP: u8 = 8; + pub const TAN: u8 = 9; + pub const NEAR: u8 = 10; + pub const APPARENT_INT: u8 = 11; + pub const PARA: u8 = 12; + pub const START_POINT: u8 = 13; +} + +/// Translate a live snap mode into the `AcDb::OsnapMode` we persist. +/// +/// Modes with no ObjectARX equivalent (grid, object pick) map to +/// [`osnap::NONE`]; a reference carrying `NONE` resolves by marker/parameter +/// alone, exactly like the pre-PR3 records. +pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 { + use crate::snap::SnapType as S; + match snap { + S::Endpoint => osnap::END, + S::Midpoint => osnap::MID, + S::Center => osnap::CEN, + S::Node => osnap::NODE, + S::Quadrant => osnap::QUAD, + S::Intersection => osnap::INTERSEC, + S::Insertion => osnap::INS, + S::Perpendicular => osnap::PERP, + S::Tangent => osnap::TAN, + S::Nearest | S::Extension => osnap::NEAR, + S::ApparentIntersection => osnap::APPARENT_INT, + S::Parallel => osnap::PARA, + S::Grid | S::ObjectPick => osnap::NONE, + } +} + +/// A resolved `xrefs` chain. +#[derive(Clone, Debug)] +pub(crate) struct ReferenceChain { + /// The layout viewport the reference is seen through, when the chain + /// starts with one. `None` for a plain model-space reference. + pub viewport: Option, + /// INSERT handles from the outermost instance down to the instance that + /// directly contains [`Self::entity`]. Empty for top-level geometry. + pub block_path: Vec, + /// The innermost entity — the one that owns the feature. + pub entity: Handle, + /// Block-path transform: entity-local coordinates -> model coordinates. + /// Identity when `block_path` is empty. + pub transform: Transform, +} + +/// Why a chain could not be walked. The data is never discarded on a failure: +/// the caller keeps the reference exactly as read and leaves the dimension +/// showing its last valid appearance. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChainError { + /// `xrefs` was empty — nothing to resolve. + Empty, + /// A handle in the chain names an object that is not in the document + /// (erased source; restored by an undo). + Missing(Handle), +} + +/// Walk `xrefs` into a [`ReferenceChain`]. +/// +/// The chain is read positionally rather than by a stored kind, because that +/// is all the file gives us: a leading VIEWPORT is the trans-space hop, any +/// INSERTs after it are the block-instance path, and the first handle that is +/// neither is the geometry. A chain that is nothing but INSERTs (dimensioning +/// a block reference's insertion point) ends on the innermost INSERT. +pub(crate) fn walk_chain( + document: &CadDocument, + xrefs: &[Handle], +) -> Result { + let mut viewport = None; + let mut block_path: Vec = Vec::new(); + let mut entity = None; + let mut transform = Transform::identity(); + + for (index, handle) in xrefs.iter().copied().enumerate() { + if handle.is_null() { + continue; + } + let Some(node) = document.get_entity(handle) else { + return Err(ChainError::Missing(handle)); + }; + match node { + // Only a *leading* viewport is the trans-space hop. A viewport + // deeper in the chain would be geometry being dimensioned (the + // paper-space rectangle itself), so it falls through to `entity`. + EntityType::Viewport(_) if index == 0 && viewport.is_none() => { + viewport = Some(handle); + } + EntityType::Insert(insert) => { + block_path.push(handle); + transform = + transform.compose(&crate::scene::render_graph::insert_transform(document, insert)); + } + _ => { + entity = Some(handle); + break; + } + } + } + + let entity = match entity { + Some(entity) => entity, + // Nothing but INSERTs: the innermost one is the geometry, and it is + // no longer part of the path leading to it. + None => match block_path.pop() { + Some(innermost) => { + transform = block_transform(document, &block_path); + innermost + } + None => return Err(ChainError::Empty), + }, + }; + Ok(ReferenceChain { + viewport, + block_path, + entity, + transform, + }) +} + +/// Accumulated entity-local -> model transform for an ordered INSERT path. +pub(crate) fn block_transform(document: &CadDocument, block_path: &[Handle]) -> Transform { + let mut transform = Transform::identity(); + for handle in block_path { + if let Some(EntityType::Insert(insert)) = document.get_entity(*handle) { + transform = + transform.compose(&crate::scene::render_graph::insert_transform(document, insert)); + } + } + transform +} + +/// Affine inverse of `transform`. +/// +/// `acadrust::types::Matrix4` has no inverse of its own, and a block path only +/// ever produces affine matrices (translate / rotate / scale), so this inverts +/// the 3x3 linear part by cofactors and back-substitutes the translation. A +/// degenerate (zero-scale) insert returns `None` rather than exploding. +pub(crate) fn invert(transform: &Transform) -> Option { + let m = &transform.matrix.m; + let a = [ + [m[0][0], m[0][1], m[0][2]], + [m[1][0], m[1][1], m[1][2]], + [m[2][0], m[2][1], m[2][2]], + ]; + let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) + - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]); + if !det.is_finite() || det.abs() < 1e-18 { + return None; + } + let inv_det = 1.0 / det; + let mut inverse = [[0.0f64; 3]; 3]; + for row in 0..3 { + for col in 0..3 { + // Cofactor of (col, row) — transposed, which is the adjugate. + let (r0, r1) = ((col + 1) % 3, (col + 2) % 3); + let (c0, c1) = ((row + 1) % 3, (row + 2) % 3); + inverse[row][col] = (a[r0][c0] * a[r1][c1] - a[r0][c1] * a[r1][c0]) * inv_det; + } + } + let t = [m[0][3], m[1][3], m[2][3]]; + let translation = [ + -(inverse[0][0] * t[0] + inverse[0][1] * t[1] + inverse[0][2] * t[2]), + -(inverse[1][0] * t[0] + inverse[1][1] * t[1] + inverse[1][2] * t[2]), + -(inverse[2][0] * t[0] + inverse[2][1] * t[1] + inverse[2][2] * t[2]), + ]; + Some(Transform { + matrix: Matrix4 { + m: [ + [inverse[0][0], inverse[0][1], inverse[0][2], translation[0]], + [inverse[1][0], inverse[1][1], inverse[1][2], translation[1]], + [inverse[2][0], inverse[2][1], inverse[2][2], translation[2]], + [0.0, 0.0, 0.0, 1.0], + ], + }, + }) +} + +// ── Feature evaluation ──────────────────────────────────────────────────── + +fn vector3(point: [f64; 3]) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> [f64; 3] { + [point.x, point.y, point.z] +} + +fn distance_squared(a: Vector3, b: Vector3) -> f64 { + let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z); + dx * dx + dy * dy + dz * dz +} + +fn lift(plane: &Plane, uv: [f64; 2]) -> Vector3 { + vector3(plane.point_at(uv)) +} + +/// Everything a feature evaluation may need beyond the reference itself. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct FeatureContext { + /// The stored osnap point, already expressed in *entity-local* + /// coordinates. Used as the disambiguating hint for every feature that has + /// more than one candidate (quadrant, intersection, tangent, nearest). + pub hint: Option, + /// The point the feature is measured *from*, in entity-local coordinates. + /// PERPENDICULAR and TANGENT are two-point features: the foot / touch + /// point only exists relative to an external point, which for a dimension + /// is the other definition point. `None` falls back to `hint`. + pub from: Option, +} + +/// Evaluate the osnap feature `reference` names on `entity`, in the entity's +/// own coordinates. +/// +/// Returns `None` — never a guess — when the osnap type is one we do not +/// evaluate, or when the geometry no longer supports it. The caller keeps the +/// reference data intact and leaves the dimension where it was. +pub(crate) fn feature_point( + document: &CadDocument, + entity: &EntityType, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + match reference.osnap_type { + osnap::CEN => center_point(entity, reference), + osnap::MID => mid_point(entity, reference), + osnap::QUAD => quadrant_point(entity, context.hint?), + osnap::NODE | osnap::INS => node_point(entity), + osnap::INTERSEC | osnap::APPARENT_INT => intersection_point( + document, + entity, + reference, + context.hint?, + reference.osnap_type == osnap::APPARENT_INT, + ), + osnap::PERP => perpendicular_point(entity, context.from.or(context.hint)?), + osnap::TAN => tangent_point(entity, reference, context), + osnap::NEAR => nearest_point(entity, reference, context.hint), + // END / START_POINT / PARA / NONE all address a named vertex or a + // stored curve parameter, which is what the legacy marker convention + // already encodes. The caller keeps that path. + _ => None, + } +} + +/// Whether [`feature_point`] knows how to evaluate this osnap type. A type we +/// do not handle keeps its stored data and stays unresolved. +pub(crate) fn evaluates(osnap_type: u8) -> bool { + matches!( + osnap_type, + osnap::CEN + | osnap::MID + | osnap::QUAD + | osnap::NODE + | osnap::INS + | osnap::INTERSEC + | osnap::APPARENT_INT + | osnap::PERP + | osnap::TAN + | osnap::NEAR + ) +} + +/// Whether this osnap type needs the *other* definition point to evaluate. +pub(crate) fn needs_from_point(osnap_type: u8) -> bool { + matches!(osnap_type, osnap::PERP | osnap::TAN) +} + +fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option { + match entity { + EntityType::Circle(circle) => Some(circle.center_wcs()), + EntityType::Arc(arc) => Some(arc.center_wcs()), + EntityType::Ellipse(ellipse) => Some(ellipse.center), + _ => { + // A polyline's bulge segment: `main_gs_marker` names the segment. + let planar = entity_curve(entity)?; + let KernelCurve::Polyline(polyline) = &planar.curve else { + return None; + }; + let segment = reference.main_gs_marker.max(0) as usize; + let arc = polyline.segment_arc(segment)?; + Some(lift(&planar.plane, arc.center)) + } + } +} + +fn mid_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option { + let planar = entity_curve(entity)?; + match &planar.curve { + // For a polyline the marker names which segment's midpoint it is; + // without one, the midpoint of the whole run. + KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => { + let segments = planar.curve.segments(); + let segment = segments.get(reference.main_gs_marker as usize)?; + Some(lift(&planar.plane, segment.point_at(0.5))) + } + _ => Some(lift(&planar.plane, planar.curve.point_at(0.5))), + } +} + +fn quadrant_point(entity: &EntityType, hint: Vector3) -> Option { + let planar = entity_curve(entity)?; + let (centre, radius) = match &planar.curve { + KernelCurve::Circle(circle) => (circle.centre, circle.radius), + KernelCurve::Arc(arc) => (arc.centre, arc.radius), + _ => return None, + }; + // Four candidates on the curve's own plane; the stored point picks one, so + // a rotated source keeps the quadrant the user actually snapped to. + (0..4) + .map(|index| { + let angle = std::f64::consts::FRAC_PI_2 * index as f64; + lift( + &planar.plane, + [ + centre[0] + radius * angle.cos(), + centre[1] + radius * angle.sin(), + ], + ) + }) + .min_by(|first, second| { + distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint)) + }) +} + +fn node_point(entity: &EntityType) -> Option { + match entity { + EntityType::Point(point) => Some(point.location), + EntityType::Insert(insert) => Some(insert.insert_point), + EntityType::Text(text) => Some(text.insertion_point), + EntityType::MText(text) => Some(text.insertion_point), + EntityType::Shape(shape) => Some(shape.insertion_point), + _ => None, + } +} + +fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option { + let planar = entity_curve(entity)?; + let uv = planar.plane.project(dpoint(from))?; + Some(lift(&planar.plane, closest_point(&planar.curve, uv).point)) +} + +fn nearest_point( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Option, +) -> Option { + let planar = entity_curve(entity)?; + // Circles and arcs carry an *angle* in `osnap_distance` — that is the + // convention the pre-PR3 writer established and imported files share it. + // Every other curve carries the kernel's normalised 0..1 parameter. + match &planar.curve { + KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity { + EntityType::Circle(circle) => { + Some(circle.point_at_angle_wcs(reference.osnap_distance)) + } + EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), + _ => None, + }, + _ => { + let parameter = reference.osnap_distance; + if parameter.is_finite() && (0.0..=1.0).contains(¶meter) { + return Some(lift(&planar.plane, planar.curve.point_at(parameter))); + } + // No usable parameter: fall back to the point on the curve nearest + // the stored osnap point, which is what NEAR means anyway. + let uv = planar.plane.project(dpoint(hint?))?; + Some(lift(&planar.plane, closest_point(&planar.curve, uv).point)) + } + } +} + +// ── Tangent ─────────────────────────────────────────────────────────────── + +/// Tangent point on `entity` for the touch line through `context.from`. +/// +/// Circles and arcs have a closed form. A spline does not, so the stored +/// parameter (`osnap_distance`) seeds a Newton refinement of the tangency +/// condition `(C(u) - P) · C'(u) = 0`; with no usable parameter the seed is +/// the point on the spline nearest the stored osnap point. That is what makes +/// the 115448 drawing's tangent-to-spline reference survive an edit to the +/// spline instead of silently freezing at its imported location. +fn tangent_point( + entity: &EntityType, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + if let EntityType::Spline(spline) = entity { + return spline_tangent_point(spline, reference, context); + } + let planar = entity_curve(entity)?; + let (centre, radius) = match &planar.curve { + KernelCurve::Circle(circle) => (circle.centre, circle.radius), + KernelCurve::Arc(arc) => (arc.centre, arc.radius), + _ => { + // Generic curve: no closed form and no spline evaluator. Hold the + // stored parameter if there is one, else leave it unresolved. + return nearest_point(entity, reference, context.hint); + } + }; + let from = context.from.or(context.hint)?; + let uv = planar.plane.project(dpoint(from))?; + let (dx, dy) = (uv[0] - centre[0], uv[1] - centre[1]); + let distance = (dx * dx + dy * dy).sqrt(); + if !distance.is_finite() || distance <= radius + 1e-12 || radius <= 1e-12 { + return None; + } + // Two touch points, symmetric about the centre-to-`from` line. + let base = dy.atan2(dx); + let offset = (radius / distance).clamp(-1.0, 1.0).acos(); + let candidates = [base + offset, base - offset].map(|angle| { + lift( + &planar.plane, + [ + centre[0] + radius * angle.cos(), + centre[1] + radius * angle.sin(), + ], + ) + }); + let hint = context.hint.unwrap_or(candidates[0]); + candidates + .into_iter() + .min_by(|first, second| { + distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint)) + }) +} + +fn spline_tangent_point( + spline: &acadrust::entities::Spline, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + let curve = crate::entities::spline::nurbs3(spline)?; + let (start, end) = curve.domain(); + let stored = reference.osnap_distance; + let mut u = if stored.is_finite() && stored > start - 1e-9 && stored < end + 1e-9 { + stored.clamp(start, end) + } else { + let hint = context.hint.or(context.from)?; + curve.parameter_at(dpoint(hint)).clamp(start, end) + }; + let Some(from) = context.from.or(context.hint) else { + // No external point: the stored parameter is the whole answer. + return Some(vector3(curve.point_at_knot(u))); + }; + let p = dpoint(from); + // Newton on f(u) = (C(u) - P) · C'(u). f'(u) is approximated by a central + // difference of f, which converges in a handful of steps from a seed that + // is already on the right lobe and never leaves the domain. + let f = |u: f64| { + let c = curve.point_at_knot(u); + let d = curve.derivative_at_knot(u); + (c[0] - p[0]) * d[0] + (c[1] - p[1]) * d[1] + (c[2] - p[2]) * d[2] + }; + let span = (end - start).abs().max(1e-12); + let step = span * 1e-6; + for _ in 0..24 { + let value = f(u); + if value.abs() <= span * 1e-12 { + break; + } + let slope = (f((u + step).min(end)) - f((u - step).max(start))) / (2.0 * step); + if !slope.is_finite() || slope.abs() < 1e-18 { + break; + } + let next = (u - value / slope).clamp(start, end); + if !next.is_finite() || (next - u).abs() <= span * 1e-14 { + u = next; + break; + } + u = next; + } + Some(vector3(curve.point_at_knot(u))) +} + +// ── Intersection ────────────────────────────────────────────────────────── + +/// Intersection of `entity` with the reference's second object chain. +/// +/// Both curves are tessellated on the first curve's plane and crossed +/// pairwise; the crossing nearest the stored osnap point wins, so a pair of +/// curves that meet more than once keeps the corner the user picked. With +/// `apparent` the segment parameters are not clamped, which recovers the +/// crossing of the two *extended* nearest segments — the best-effort AutoCAD +/// calls an apparent intersection in a plan view. +fn intersection_point( + document: &CadDocument, + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Vector3, + apparent: bool, +) -> Option { + let other = walk_chain(document, &reference.intersection_objects).ok()?; + let other_entity = document.get_entity(other.entity)?; + let first = entity_curve(entity)?; + let second = entity_curve(other_entity)?; + // The first curve's plane is the working frame. The second curve is + // evaluated through its *own* block path, so each of its points is lifted + // to model space and then projected back onto that frame before crossing. + let a = first.curve.tessellate(DEFAULT_SEGMENTS_PER_RADIAN); + let b = second + .curve + .tessellate(DEFAULT_SEGMENTS_PER_RADIAN) + .into_iter() + .map(|uv| { + let world = other.transform.apply(lift(&second.plane, uv)); + first.plane.project(dpoint(world)).unwrap_or(uv) + }) + .collect::>(); + let hint_uv = first.plane.project(dpoint(hint))?; + let mut best: Option<([f64; 2], f64)> = None; + for pair_a in a.windows(2) { + for pair_b in b.windows(2) { + let Some(point) = segment_cross(pair_a[0], pair_a[1], pair_b[0], pair_b[1], false) + else { + continue; + }; + let error = (point[0] - hint_uv[0]).powi(2) + (point[1] - hint_uv[1]).powi(2); + if best.is_none_or(|(_, previous)| error < previous) { + best = Some((point, error)); + } + } + } + if best.is_none() && apparent { + // Nothing actually crosses: take the two segments nearest the stored + // point and cross their infinite extensions. + let nearest = |run: &[[f64; 2]]| -> Option<([f64; 2], [f64; 2])> { + run.windows(2) + .map(|pair| { + let mid = [ + (pair[0][0] + pair[1][0]) * 0.5, + (pair[0][1] + pair[1][1]) * 0.5, + ]; + let error = + (mid[0] - hint_uv[0]).powi(2) + (mid[1] - hint_uv[1]).powi(2); + ((pair[0], pair[1]), error) + }) + .min_by(|first, second| first.1.total_cmp(&second.1)) + .map(|(pair, _)| pair) + }; + let (a0, a1) = nearest(&a)?; + let (b0, b1) = nearest(&b)?; + best = segment_cross(a0, a1, b0, b1, true).map(|point| (point, 0.0)); + } + best.map(|(point, _)| lift(&first.plane, point)) +} + +/// Crossing of segments `a0->a1` and `b0->b1`. With `extend` the parameters +/// are not clamped, giving the crossing of the two infinite lines. +fn segment_cross( + a0: [f64; 2], + a1: [f64; 2], + b0: [f64; 2], + b1: [f64; 2], + extend: bool, +) -> Option<[f64; 2]> { + let d = [a1[0] - a0[0], a1[1] - a0[1]]; + let e = [b1[0] - b0[0], b1[1] - b0[1]]; + let denominator = d[0] * e[1] - d[1] * e[0]; + if !denominator.is_finite() || denominator.abs() < 1e-18 { + return None; + } + let delta = [b0[0] - a0[0], b0[1] - a0[1]]; + let t = (delta[0] * e[1] - delta[1] * e[0]) / denominator; + let s = (delta[0] * d[1] - delta[1] * d[0]) / denominator; + if !extend && !((-1e-9..=1.0 + 1e-9).contains(&t) && (-1e-9..=1.0 + 1e-9).contains(&s)) { + return None; + } + Some([a0[0] + d[0] * t, a0[1] + d[1] * t]) +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index db832be0c..91d4d847a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -30,6 +30,7 @@ mod boundary; mod camera_ops; pub(crate) mod centerline; pub(crate) mod dimension_assoc; +pub(crate) mod dimension_assoc_chain; pub(crate) mod centermark; mod dwg_native_constraints; mod entity; From 2697b60d218ea4e49f1cb54b61eee3186b5f0fe3 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:32:34 -0500 Subject: [PATCH 13/23] Create, resolve and refresh associations through viewports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This wires the chain walker into the association system proper, and adds the entry point PR2 calls. Resolution. `resolve_reference_chain` walks the chain, evaluates the osnap feature in the entity's own coordinates, lifts it to model space through the block path, and — when the chain goes through a viewport — projects it onto the sheet with that viewport's `ViewportFrame`. Both points are kept: the dimension holds paper points, the value it displays is a model length. `ChainMap` is the one place that conversion lives, including a `map_radial` that carries a radial source's plane into the dimension's space so the radius/diameter/arc paths need no viewport special-casing of their own. Which space a stored osnap point was written in is not recoverable from the record — our own writer used model/WCS, AutoCAD's trans-space records use the dimension's space, and both turn up in the same drawing — so both readings are mapped down to the entity and the one that lands on the geometry wins. It is only ever a hint that disambiguates candidates, never the answer. Creation. `Scene::attach_viewport_dimension_association` takes one `AcceptedSnap` per definition point and writes the whole chain: viewport handle, block path, entity, osnap type, sub-entity marker and parameter, plus `trans_space` on the association. Every link becomes a reactor target, not just the innermost entity, so a change anywhere along a chain reaches the refresh through the ordinary dependency index. Refresh. `refresh_associative_dimensions` now matches a changed handle anywhere in a chain (including the second chain of an intersection), so a viewport change reaches the dimensions drawn through it. Perpendicular and tangent references resolve in a second pass, measured from the other definition point. A dimension carries its sheet annotation with it: the dimension-line standoff and a user-dragged text position move by the centroid shift of the points that actually moved, so a one-ended edit keeps the dimension line centred instead of collapsing it. For a trans-space linear or aligned dimension the displayed value is recomputed from the model points and multiplied only by the user's DIMLFAC (`MeasurementScale::model_factor`) — the viewport compensation is inherent in having measured in model space, and applying it again is the double-scaling this three-PR effort exists to stop. Broken references. Brokenness is derived, never written: a reference whose source was erased simply stops resolving, the dimension keeps its last valid appearance, and the record is left byte-for-byte as it was. An undo that restores the source restores the link with no extra bookkeeping. `Scene::dimension_association_status` reports it per slot. `dimension_is_associative` now walks the chain too, instead of accepting any surviving handle in `xrefs` — which was true even for a trans-space reference whose actual geometry had gone, because the viewport was still there. Undo. Creating an association journals the new object and every reactor list it touches; refreshing a dimension journals the dimension's before-image. Both were previously invisible to the delta recorder. Co-Authored-By: Claude Fable 5.1 --- src/scene/dimension_assoc.rs | 783 ++++++++++++++++++++++++++++++++++- src/scene/mod.rs | 4 + 2 files changed, 764 insertions(+), 23 deletions(-) diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 8069e6eea..db07b30e5 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -3,7 +3,7 @@ use acadrust::objects::{ AssocDimensionAssociation, AssocDimensionReference, AssociativeData, AssociativeObject, ObjectType, }; -use acadrust::types::{Handle, Vector3}; +use acadrust::types::{Handle, Transform, Vector3}; use acadrust::EntityType; use cadkernel::geom2d::{ angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, @@ -14,6 +14,10 @@ use std::f64::consts::TAU; use crate::command::DimensionAssociationSource; +use super::dimension_assoc_chain::{ + self as chain, ChainError, FeatureContext, ReferenceChain, +}; +use super::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; use super::{ChangeKind, Scene}; pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4; @@ -100,6 +104,13 @@ impl RadialSourceGeometry { type DPoint = [f64; 3]; +/// The shared-contract vector (`glam`) as the codec's vector. `AcceptedSnap` +/// speaks `glam` because the snap engine does; everything stored in a +/// drawing speaks `acadrust`. +fn dvec3(point: glam::DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + fn vector3(point: DPoint) -> Vector3 { Vector3::new(point[0], point[1], point[2]) } @@ -351,9 +362,237 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { .map(|(index, _)| index as i32) } -fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { - let source = *reference.xrefs.first()?; - let entity = scene.document.get_entity(source)?; +// ── Chain-aware reference resolution ────────────────────────────────────── + +/// How a resolved feature point travels from the entity it belongs to up into +/// the space the dimension is drawn in. +/// +/// `transform` is the block-instance path (entity-local -> model). +/// `frame` is the layout viewport the chain looks through, when it has one; +/// with a frame the dimension lives on the sheet and every point it holds is +/// a *paper* point, while the value it displays is a *model* length. Keeping +/// both directions in one place is what stops the viewport compensation from +/// being applied twice (see [`MeasurementScale`]). +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChainMap { + pub transform: Transform, + pub frame: Option, +} + +impl ChainMap { + fn identity() -> Self { + Self { + transform: Transform::identity(), + frame: None, + } + } + + /// Entity-local -> model. + pub(crate) fn to_model(&self, point: Vector3) -> Vector3 { + self.transform.apply(point) + } + + /// Model -> the dimension's own space (paper through a viewport). + pub(crate) fn model_to_space(&self, point: Vector3) -> Vector3 { + match &self.frame { + Some(frame) => { + let mapped = + frame.model_to_paper(glam::DVec3::new(point.x, point.y, point.z)); + Vector3::new(mapped.x, mapped.y, mapped.z) + } + None => point, + } + } + + /// The dimension's own space -> model. + pub(crate) fn space_to_model(&self, point: Vector3) -> Vector3 { + match &self.frame { + Some(frame) => { + let mapped = + frame.paper_to_model(glam::DVec3::new(point.x, point.y, point.z)); + Vector3::new(mapped.x, mapped.y, mapped.z) + } + None => point, + } + } + + /// Entity-local -> the dimension's own space. + pub(crate) fn to_space(&self, point: Vector3) -> Vector3 { + self.model_to_space(self.to_model(point)) + } + + /// Entity-local -> the dimension's own space, for a direction (the linear + /// part only: block rotation/scale, then the viewport's twist and scale). + fn direction_to_space(&self, direction: Vector3) -> Vector3 { + let direction = self.transform.apply_rotation(direction); + match &self.frame { + Some(frame) => { + let xy = frame.model_to_paper_dir(glam::DVec2::new(direction.x, direction.y)); + Vector3::new(xy.x, xy.y, direction.z) + } + None => direction, + } + } + + /// Carry a plane into the dimension's space. + /// + /// A plane is an origin and two axes, and `Plane::point_at` is affine in + /// them, so mapping the origin by the full transform and each axis by its + /// linear part maps every point *on* the plane correctly — including under + /// a non-uniformly scaled block, where the axes simply stop being + /// orthonormal. + fn map_plane(&self, plane: Plane) -> Plane { + Plane::from_axes( + dpoint(self.to_space(vector3(plane.origin))), + dpoint(self.direction_to_space(vector3(plane.x_axis))), + dpoint(self.direction_to_space(vector3(plane.y_axis))), + ) + } + + /// Carry a radial source into the dimension's space. + /// + /// Only the plane moves: the centre, radius and angles are coordinates + /// *within* the plane, so they stay as they are and every point the + /// geometry produces comes out in the dimension's space already. + fn map_radial(&self, radial: RadialSourceGeometry) -> RadialSourceGeometry { + RadialSourceGeometry { + plane: self.map_plane(radial.plane), + ..radial + } + } +} + +/// One reference resolved all the way down the chain. +#[derive(Clone, Copy, Debug)] +pub struct ResolvedReference { + /// The feature point in model space, through the block-instance path. + pub model: Vector3, + /// The same point in the dimension's own space — projected onto the sheet + /// when the chain goes through a viewport, otherwise equal to `model`. + pub space: Vector3, + /// The viewport the chain looks through, if any. + pub viewport: Option, +} + +/// Why a reference did not produce a point. The reference's stored data is +/// never modified on a failure — a dimension whose source went away keeps its +/// last valid appearance, and an undo that restores the source restores the +/// link with no extra bookkeeping. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReferenceStatus { + /// Resolved to a live feature point. + Resolved, + /// A handle in the chain is no longer in the document. + Broken(Handle), + /// The chain is intact but the feature could not be evaluated — an osnap + /// type we do not support, or geometry that no longer has the feature. + Unresolved, +} + +fn chain_map(scene: &Scene, walked: &ReferenceChain) -> ChainMap { + ChainMap { + transform: walked.transform, + frame: walked.viewport.and_then(|handle| scene.viewport_frame(handle)), + } +} + +/// How far `point` (entity-local) is from `entity`'s geometry. Used only to +/// choose between two readings of a stored osnap point, so an approximate +/// answer for exotic entities is fine. +fn hint_error(entity: &EntityType, point: Vector3) -> f64 { + if let Some(distance) = source_distance_squared(entity, point) { + if distance.is_finite() { + return distance; + } + } + if let Some(planar) = crate::entities::curve::entity_curve(entity) { + if let Some(uv) = planar.plane.project(dpoint(point)) { + let near = closest_point(&planar.curve, uv); + let world = planar.plane.point_at(near.point); + return point_distance_squared(vector3(world), point); + } + } + f64::INFINITY +} + +/// The stored osnap point, brought into the entity's own coordinates. +/// +/// Which space the file wrote it in is not recoverable from the record: our +/// own pre-PR3 writer stored a model/WCS point, AutoCAD's trans-space records +/// hold the point in the space the dimension is drawn in, and both appear in +/// the same drawing. Rather than guess, both readings are mapped down to the +/// entity and the one that actually lands on the geometry wins. The point is +/// only ever a *hint* — it disambiguates candidates, it is never the answer. +fn local_hint(scene: &Scene, map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option { + let _ = scene; + let inverse = chain::invert(&map.transform)?; + let mut candidates = vec![inverse.apply(stored)]; + if map.frame.is_some() { + candidates.push(inverse.apply(map.space_to_model(stored))); + } + candidates + .into_iter() + .filter(|point| point.x.is_finite() && point.y.is_finite() && point.z.is_finite()) + .min_by(|first, second| hint_error(entity, *first).total_cmp(&hint_error(entity, *second))) +} + +/// Resolve one reference through its full chain. +/// +/// `from_space` is the *other* definition point of the same dimension, in the +/// dimension's own space. PERPENDICULAR and TANGENT have no meaning without +/// it, so the caller resolves the independent references first and comes back +/// for these. +pub fn resolve_reference_chain( + scene: &Scene, + reference: &AssocDimensionReference, + from_space: Option, +) -> Result { + let walked = match chain::walk_chain(&scene.document, &reference.xrefs) { + Ok(walked) => walked, + Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), + Err(ChainError::Empty) => return Err(ReferenceStatus::Unresolved), + }; + let Some(entity) = scene.document.get_entity(walked.entity) else { + return Err(ReferenceStatus::Broken(walked.entity)); + }; + let map = chain_map(scene, &walked); + let inverse = chain::invert(&map.transform); + + let mut local = None; + if chain::evaluates(reference.osnap_type) { + let hint = local_hint(scene, &map, entity, reference.osnap_point); + let from = from_space + .zip(inverse) + .map(|(point, inverse)| inverse.apply(map.space_to_model(point))); + local = chain::feature_point( + &scene.document, + entity, + reference, + FeatureContext { hint, from }, + ); + } + // Every reference — including one whose osnap type we do not evaluate — + // falls back to the marker/parameter convention, which is what the + // pre-PR3 writer and plain END references use. + let local = match local.or_else(|| resolve_local_by_marker(entity, reference)) { + Some(local) => local, + None => return Err(ReferenceStatus::Unresolved), + }; + let model = map.to_model(local); + Ok(ResolvedReference { + model, + space: map.model_to_space(model), + viewport: walked.viewport, + }) +} + +/// The pre-PR3 marker/parameter resolution, unchanged in behaviour but now +/// evaluated in the *entity's own* coordinates rather than assumed to be in +/// model space. Lifting through the block path is the caller's job. +fn resolve_local_by_marker( + entity: &EntityType, + reference: &AssocDimensionReference, +) -> Option { if reference.main_gs_marker == ARC_DIMENSION_POINT_MARKER { let radial = radial_source_for_marker(entity, 0)?; let sweep = positive_sweep(radial.start_angle, radial.end_angle); @@ -548,6 +787,13 @@ fn remap_plane_offset( ])) } +/// Whether `dimension` still has at least one reference whose whole chain +/// resolves to a live entity. +/// +/// The chain matters: an imported trans-space reference has the VIEWPORT as +/// its first handle, so "some handle in `xrefs` exists" was true even for a +/// reference whose actual source had been erased. Walking the chain asks the +/// question that was meant — is the geometry still there. pub(crate) fn dimension_is_associative( document: &acadrust::CadDocument, dimension: Handle, @@ -562,10 +808,7 @@ pub(crate) fn dimension_is_associative( association.dimension == dimension && association.associativity != 0 && association.references.iter().flatten().any(|reference| { - reference - .xrefs - .iter() - .any(|source| document.get_entity(*source).is_some()) + chain::walk_chain(document, &reference.xrefs).is_ok() }) }) } @@ -845,6 +1088,35 @@ impl Scene { references: [Vec; 4], associativity: i32, sources: Vec>, + ) { + self.store_dimension_association_full( + dimension, + references, + associativity, + false, + sources.into_iter().flatten().map(|s| s.handle).collect(), + ); + } + + /// Create the DIMASSOC object for `dimension` and wire its reactors. + /// + /// `reactor_targets` is every handle the association depends on — + /// including the viewport and each INSERT on a block path, not just the + /// innermost entity — so a change to any link in the chain reaches + /// [`Scene::refresh_associative_dimensions`] through the normal + /// dependency index. + /// + /// The new object and every entity whose reactor list is touched are + /// journalled into the open undo recording, so creating an associative + /// dimension is a single undoable step rather than something that survives + /// its own undo. + fn store_dimension_association_full( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + trans_space: bool, + reactor_targets: Vec, ) { let association_handle = self.document.allocate_handle(); let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); @@ -852,27 +1124,320 @@ impl Scene { object.reactors.push(dimension); object.data = AssociativeData::DimensionAssociation(AssocDimensionAssociation { associativity, + trans_space, dimension, references, ..AssocDimensionAssociation::default() }); + // A brand-new object: its before-image is "absent", so undo erases it. + self.record_undo_object_before(association_handle, None); self.document .objects .insert(association_handle, ObjectType::Associative(object)); - let mut reactor_targets = vec![dimension]; - reactor_targets.extend(sources.into_iter().flatten().map(|source| source.handle)); + let mut reactor_targets = reactor_targets; + reactor_targets.push(dimension); + reactor_targets.retain(|handle| !handle.is_null()); reactor_targets.sort_by_key(|handle| handle.value()); reactor_targets.dedup(); for handle in reactor_targets { - if let Some(entity) = self.document.get_entity_mut(handle) { - if !entity.common().reactors.contains(&association_handle) { + if self + .document + .get_entity(handle) + .is_some_and(|entity| !entity.common().reactors.contains(&association_handle)) + { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if let Some(entity) = self.document.get_entity_mut(handle) { entity.common_mut().reactors.push(association_handle); } } } } + /// Persist the association for a dimension whose points were picked + /// through [`AcceptedSnap`]s — the entry point PR2 calls once it has + /// created a paper-space dimension that measures model geometry. + /// + /// `snaps` is positional: one slot per definition point, in the same order + /// [`dimension_reference_points`] returns them (linear/aligned: first, + /// second; angular: see that function). A `None` slot, or a snap that + /// landed on empty space, leaves that slot non-associative. + /// + /// Each reference records the whole chain — viewport, block-instance path, + /// entity — plus the osnap type and the sub-entity marker / parameter, so + /// the feature survives an edit to the source rather than being + /// re-inferred by proximity. + pub fn attach_viewport_dimension_association( + &mut self, + dimension: Handle, + snaps: &[Option], + ) { + let Some(EntityType::Dimension(_)) = self.document.get_entity(dimension) else { + return; + }; + let mut references: [Vec; 4] = + std::array::from_fn(|_| Vec::new()); + let mut associativity = 0; + let mut trans_space = false; + let mut reactor_targets = Vec::new(); + + for (index, snap) in snaps.iter().enumerate().take(references.len()) { + let Some(snap) = snap else { continue }; + let Some(source) = snap.source.as_ref() else { + continue; + }; + let entity_handle = source.source.handle; + let Some(entity) = self.document.get_entity(entity_handle) else { + continue; + }; + // dimension -> [viewport] -> [insert ...] -> entity + let mut xrefs = Vec::new(); + if let Some(viewport) = snap.viewport { + xrefs.push(viewport); + trans_space = true; + } + xrefs.extend(source.block_path.iter().copied()); + xrefs.push(entity_handle); + reactor_targets.extend(xrefs.iter().copied()); + + let osnap_type = chain::osnap_type_for(source.snap_type); + // An explicitly-acquired sub-entity wins; otherwise fall back to + // the same marker inference model-space creation already uses, in + // the entity's own coordinates. + let (marker, parameter) = match source.source.marker { + Some(marker) => (marker, source.source.parameter), + None => { + let local = Self::point_in_entity_space( + &self.document, + &source.block_path, + dvec3(snap.model_point), + ); + source_reference(entity, local).unwrap_or((0, 0.0)) + } + }; + associativity |= 1 << index; + references[index].push(AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type, + xrefs, + main_subent_type: 1, + main_gs_marker: marker, + osnap_distance: parameter, + // Stored in MODEL space. `local_hint` reads either convention, + // so an imported paper-space point still resolves. + osnap_point: dvec3(snap.model_point), + ..AssocDimensionReference::default() + }); + } + if associativity == 0 { + return; + } + self.store_dimension_association_full( + dimension, + references, + associativity, + trans_space, + reactor_targets, + ); + } + + /// A model-space point brought down into the coordinates of the entity at + /// the end of `block_path`. Identity for top-level geometry. + fn point_in_entity_space( + document: &acadrust::CadDocument, + block_path: &[Handle], + point: Vector3, + ) -> Vector3 { + if block_path.is_empty() { + return point; + } + chain::invert(&chain::block_transform(document, block_path)) + .map(|inverse| inverse.apply(point)) + .unwrap_or(point) + } + + /// Per-slot health of a dimension's association, for the UI and for tests. + /// + /// Nothing here is persisted: brokenness is *derived* from whether the + /// chain still resolves. That is what makes undo restore a link for free — + /// the erased source comes back and the same query starts answering + /// [`ReferenceStatus::Resolved`] again, with the reference record itself + /// never having been rewritten. + pub fn dimension_association_status( + &self, + dimension: Handle, + ) -> Vec<(usize, ReferenceStatus)> { + let Some(association) = self.dimension_association(dimension) else { + return Vec::new(); + }; + association + .references + .iter() + .enumerate() + .filter_map(|(index, slot)| { + let reference = slot.first()?; + Some(( + index, + match resolve_reference_chain(self, reference, None) { + Ok(_) => ReferenceStatus::Resolved, + Err(status) => status, + }, + )) + }) + .collect() + } + + /// Every entity `dimension` depends on: the viewport it looks through, + /// each INSERT on the block path, and the source geometry itself — for + /// each reference chain, plus the second chain of an intersection. + /// + /// Layer visibility and viewport clipping are deliberately not consulted: + /// hiding a layer or clipping a viewport changes what a *new* snap may + /// acquire (PR1's concern), never what an existing association points at. + pub fn dimension_association_sources(&self, dimension: Handle) -> Vec { + let Some(association) = self.dimension_association(dimension) else { + return Vec::new(); + }; + let mut sources = Vec::new(); + for reference in association.references.iter().flatten() { + for xrefs in [&reference.xrefs, &reference.intersection_objects] { + let Ok(walked) = chain::walk_chain(&self.document, xrefs) else { + continue; + }; + sources.extend(walked.viewport); + sources.extend(walked.block_path.iter().copied()); + sources.push(walked.entity); + } + } + sources.sort_by_key(|handle| handle.value()); + sources.dedup(); + sources + } + + /// The DIMASSOC payload attached to `dimension`, if there is one. + pub fn dimension_association( + &self, + dimension: Handle, + ) -> Option<&AssocDimensionAssociation> { + self.document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + (association.dimension == dimension).then_some(association) + }) + } + + /// Duplicate dimension associations across a COPY/MIRROR/ARRAY handle map. + /// + /// A copied dimension only keeps an association when *every* handle in + /// every chain was copied too — the viewport, each INSERT on the path and + /// the source entity. Then the whole chain is remapped onto the copies, so + /// the copy measures the copied geometry. Copying a dimension on its own + /// leaves it with no association at all, which is exactly what the model + /// space path did before this: an all-or-nothing rule, never a copy that + /// silently drives off the original's geometry. + pub(crate) fn copy_dimension_associations( + &mut self, + handle_map: &rustc_hash::FxHashMap, + ) { + if handle_map.is_empty() { + return; + } + let copies: Vec<_> = self + .document + .objects + .values() + .filter_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + let dimension = *handle_map.get(&association.dimension)?; + let complete = association.references.iter().flatten().all(|reference| { + reference + .xrefs + .iter() + .chain(reference.intersection_objects.iter()) + .all(|handle| handle.is_null() || handle_map.contains_key(handle)) + }); + if !complete { + return None; + } + let remap = |handles: &Vec| -> Vec { + handles + .iter() + .map(|handle| handle_map.get(handle).copied().unwrap_or(*handle)) + .collect() + }; + let references = std::array::from_fn(|index| { + association.references[index] + .iter() + .map(|reference| AssocDimensionReference { + xrefs: remap(&reference.xrefs), + intersection_objects: remap(&reference.intersection_objects), + ..reference.clone() + }) + .collect::>() + }); + Some(( + dimension, + references, + association.associativity, + association.trans_space, + )) + }) + .collect(); + for (dimension, references, associativity, trans_space) in copies { + let reactor_targets: Vec = { + let references: &[Vec; 4] = &references; + references + .iter() + .flatten() + .flat_map(|reference| { + reference + .xrefs + .iter() + .chain(reference.intersection_objects.iter()) + .copied() + }) + .collect() + }; + self.store_dimension_association_full( + dimension, + references, + associativity, + trans_space, + reactor_targets, + ); + } + } + + /// Report a layout viewport as changed so the dimensions drawn through it + /// are regenerated. + /// + /// A viewport move / pan / zoom / twist changes where model geometry lands + /// on the sheet without touching that geometry, so nothing in the ordinary + /// entity-change path would notice. Every reference that looks through a + /// viewport carries that viewport's handle as `xrefs[0]`, so announcing the + /// viewport itself is all that is needed — the existing dependency lookup + /// in `refresh_associative_dimensions` finds the associations from there. + pub fn notify_viewport_changed(&mut self, viewport: Handle) { + if matches!( + self.document.get_entity(viewport), + Some(EntityType::Viewport(_)) + ) { + self.bump_entities(&[(viewport, ChangeKind::Modified)]); + } + } + pub(crate) fn infer_dimension_sources( &self, dimension: Handle, @@ -962,31 +1527,100 @@ impl Scene { let AssociativeData::DimensionAssociation(association) = &object.data else { return None; }; + // Any link in a chain counts: the source entity, an INSERT on + // the block path, or the viewport the reference looks through + // (that last one is how a pan / zoom / twist of a layout + // viewport reaches the dimensions drawn through it). association .references .iter() .flatten() - .any(|reference| reference.xrefs.iter().any(|handle| changed.contains(handle))) + .any(|reference| { + reference + .xrefs + .iter() + .chain(reference.intersection_objects.iter()) + .any(|handle| changed.contains(handle)) + }) .then_some(association.clone()) }) .collect(); let mut refreshed = Vec::new(); for association in associations { - let resolved: [Option; 4] = std::array::from_fn(|index| { - association.references[index] - .first() - .and_then(|reference| resolve_reference(self, reference)) + // Two passes. PERPENDICULAR and TANGENT are measured *from* the + // other definition point, so the independent references have to + // land first; a dimension with two dependent references keeps its + // old points as the from-points, which is stable because they were + // valid the last time round. + let mut resolved: [Option; 4] = std::array::from_fn(|index| { + let reference = association.references[index].first()?; + if chain::needs_from_point(reference.osnap_type) { + return None; + } + resolve_reference_chain(self, reference, None).ok() }); + let previous = self + .document + .get_entity(association.dimension) + .and_then(|entity| match entity { + EntityType::Dimension(dimension) => { + Some(dimension_reference_points(dimension)) + } + _ => None, + }) + .unwrap_or_default(); + for index in 0..resolved.len() { + let Some(reference) = association.references[index].first() else { + continue; + }; + if !chain::needs_from_point(reference.osnap_type) { + continue; + } + // The other point: freshly resolved where possible, else the + // one the dimension is currently drawn with. + let from = (0..resolved.len()) + .filter(|other| *other != index) + .find_map(|other| { + resolved[other] + .map(|value| value.space) + .or_else(|| previous.get(other).copied()) + }); + resolved[index] = resolve_reference_chain(self, reference, from).ok(); + } + // The map of the first live reference governs how the whole + // dimension is placed and measured — all of a dimension's points + // are in one space. + let map = resolved + .iter() + .flatten() + .next() + .and_then(|value| value.viewport) + .and_then(|handle| self.viewport_frame(handle)) + .map(|frame| ChainMap { + transform: Transform::identity(), + frame: Some(frame), + }) + .unwrap_or_else(ChainMap::identity); + let model_points: [Option; 4] = + std::array::from_fn(|index| resolved[index].map(|value| value.model)); + let resolved: [Option; 4] = + std::array::from_fn(|index| resolved[index].map(|value| value.space)); + let radial_source = association.references[0].first().and_then(|reference| { - let source = *reference.xrefs.first()?; - let entity = self.document.get_entity(source)?; + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let entity = self.document.get_entity(walked.entity)?; let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; - Some((radial, reference.osnap_distance)) + // Mapped once, here: everything the match arms below build out + // of it then lands in the dimension's own space. + Some(( + chain_map(self, &walked).map_radial(radial), + reference.osnap_distance, + )) }); let arc_source = association.references[0].first().and_then(|reference| { - let source = *reference.xrefs.first()?; - let entity = self.document.get_entity(source)?; + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let entity = self.document.get_entity(walked.entity)?; let segment = match reference.main_gs_marker { -3 => 0, POLYLINE_ARC_CENTER_MARKER => { @@ -994,11 +1628,32 @@ impl Scene { } _ => return None, }; - radial_source_for_marker(entity, segment) + let radial = radial_source_for_marker(entity, segment)?; + Some(chain_map(self, &walked).map_radial(radial)) }); if radial_source.is_none() && resolved.iter().all(Option::is_none) { continue; } + if self.is_recording_undo() { + let before = self.document.get_entity_arc(association.dimension); + self.record_undo_before(association.dimension, before); + } + // The user's DIMLFAC override travels with the dimension (PR2 + // persists it when it creates a viewport dimension); split it from + // the viewport compensation so the compensation is applied exactly + // once, on the model length, and never again on the paper one. + let scale = MeasurementScale::from_dimlfac( + self.document + .get_entity(association.dimension) + .and_then(|entity| { + crate::entities::dim_override::real( + &entity.common().extended_data, + crate::entities::dim_override::DIMLFAC, + ) + }) + .unwrap_or(0.0), + map.frame.as_ref(), + ); let Some(EntityType::Dimension(dimension)) = self.document.get_entity_mut(association.dimension) else { @@ -1006,22 +1661,45 @@ impl Scene { }; match dimension { Dimension::Linear(linear) => { + // The sheet annotation the user placed — the dimension + // line's standoff and, when they dragged it, the text — is + // held relative to the measured points. Carry it by the + // centroid shift so an edit to the source keeps the + // drafting the user did instead of collapsing it. + let carry = centroid_shift( + [Some(linear.first_point), Some(linear.second_point)], + [resolved[0], resolved[1]], + ); if let Some(point) = resolved[0] { linear.first_point = point; } if let Some(point) = resolved[1] { linear.second_point = point; } + linear.definition_point = linear.definition_point + carry; linear.base.definition_point = linear.definition_point; + if linear.base.text_user_positioned { + linear.base.text_middle_point = linear.base.text_middle_point + carry; + linear.base.insertion_point = linear.base.insertion_point + carry; + } } Dimension::Aligned(aligned) => { + let carry = centroid_shift( + [Some(aligned.first_point), Some(aligned.second_point)], + [resolved[0], resolved[1]], + ); if let Some(point) = resolved[0] { aligned.first_point = point; } if let Some(point) = resolved[1] { aligned.second_point = point; } + aligned.definition_point = aligned.definition_point + carry; aligned.base.definition_point = aligned.definition_point; + if aligned.base.text_user_positioned { + aligned.base.text_middle_point = aligned.base.text_middle_point + carry; + aligned.base.insertion_point = aligned.base.insertion_point + carry; + } } Dimension::Angular3Pt(angular) => { if let Some(point) = resolved[0] { @@ -1219,6 +1897,32 @@ impl Scene { Dimension::Linear(linear) => linear_measurement(linear), _ => dimension.measurement(), }; + // A dimension drawn on the sheet through a viewport has just been + // rebuilt from *paper* points, so measuring those gives the paper + // length. The number it shows is the model one: take it from the + // model points the same references resolved to, and apply only the + // user's DIMLFAC on top (`model_factor`) — the viewport + // compensation is already inherent in having measured in model + // space, and applying it again is the double-scaling this whole + // three-PR effort exists to stop. + let measurement = match (map.frame.as_ref(), &*dimension) { + (Some(_), Dimension::Linear(_) | Dimension::Aligned(_)) => { + match (model_points[0], model_points[1]) { + (Some(first), Some(second)) => { + let raw = match &*dimension { + // Rotated linear measures only along its axis. + Dimension::Linear(linear) => { + axis_distance(first, second, linear.rotation) + } + _ => first.distance(&second), + }; + raw * scale.model_factor() + } + _ => measurement, + } + } + _ => measurement, + }; dimension.base_mut().actual_measurement = measurement; refreshed.push((association.dimension, ChangeKind::Modified)); } @@ -1226,6 +1930,39 @@ impl Scene { } } +/// Distance between two points measured along the direction `rotation`. +/// +/// The rotated-linear rule, evaluated in whatever space the two points are in +/// — the same formula [`linear_measurement`] applies on the sheet, reused for +/// the model points a trans-space dimension measures. +fn axis_distance(first: Vector3, second: Vector3, rotation: f64) -> f64 { + let axis = [rotation.cos(), rotation.sin()]; + ((second.x - first.x) * axis[0] + (second.y - first.y) * axis[1]).abs() +} + +/// How far the midpoint of a dimension's measured points moved. +/// +/// Used to carry the sheet annotation — the dimension line's standoff and a +/// user-dragged text position — along with the geometry. Slots that did not +/// resolve keep their old point, so a one-ended edit shifts the annotation by +/// half the movement, which is what keeps the dimension line centred. +fn centroid_shift(before: [Option; 2], after: [Option; 2]) -> Vector3 { + let mut delta = Vector3::new(0.0, 0.0, 0.0); + let mut count = 0.0; + for index in 0..2 { + if let (Some(old), Some(new)) = (before[index], after[index]) { + delta = delta + (new - old); + count += 1.0; + } + } + if count == 0.0 { + return Vector3::new(0.0, 0.0, 0.0); + } + // Averaged over the slots that actually moved, not over both, so a + // dimension with one broken reference still tracks the live one. + Vector3::new(delta.x / count, delta.y / count, delta.z / count) +} + fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { let plane = plane_from_normal(linear.first_point, linear.base.normal); let first = plane.project(dpoint(linear.first_point)).unwrap_or([0.0; 2]); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 91d4d847a..452e6f822 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -31,6 +31,10 @@ mod camera_ops; pub(crate) mod centerline; pub(crate) mod dimension_assoc; pub(crate) mod dimension_assoc_chain; +// The association surface PR2, the UI and the tests address: create an +// association from accepted snaps, ask whether its references still +// resolve, and resolve one by hand. +pub use dimension_assoc::{ReferenceStatus, ResolvedReference}; pub(crate) mod centermark; mod dwg_native_constraints; mod entity; From e8246031fdfe02922bb247334f8e5abd5012d07c Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:32:56 -0500 Subject: [PATCH 14/23] Remap dimension associations across a copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COPY, MIRROR and ARRAY duplicated a dimension's baked block but left the copy with no association at all. Now a copy keeps one, on one condition: every handle in every chain — the viewport, each INSERT on the block path, the source entity, and an intersection's second chain — must have been copied too. Then the whole chain is remapped onto the copies, so the copy measures the copied geometry. The condition is all-or-nothing on purpose. A partially-remapped chain would leave a copy quietly driven by the original's geometry, which is worse than no association; and copying a dimension on its own still produces an unassociated dimension, which is exactly what the model space path did before. It is the same rule `copy_complete_groups` uses for groups, applied to a longer chain. Co-Authored-By: Claude Fable 5.1 --- src/scene/modify.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 0de4ad2e7..2983cdfc9 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -744,6 +744,11 @@ impl Scene { // Complete group copies record their new Group objects and dictionary // entry as targeted object deltas inside copy_complete_groups. self.copy_complete_groups(&handle_map); + // A dimension copied together with everything it measures (its + // viewport, its block path and its source) keeps an association, with + // every handle remapped onto the copies. Copied on its own it keeps + // none — see `copy_dimension_associations`. + self.copy_dimension_associations(&handle_map); // The copies are new handles (natural memo misses, tessellated fresh) // and reference only already-cached blocks — no block defn changes. // Report them as additions so derived caches patch in exactly the copies. From f80130560edd32a0a9181d3ad5a1b83fe936d61a Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:32:57 -0500 Subject: [PATCH 15/23] Regenerate viewport dimensions when the viewport view changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panning, zooming, twisting or rescaling a layout viewport moves model geometry across the sheet without touching that geometry, so nothing in the ordinary entity-change path noticed and the dimensions drawn through the viewport stayed where they were — detached from what they measure. `Scene::notify_viewport_changed` announces the viewport itself as a modified entity. That is all it takes: every trans-space reference carries the viewport handle as the first link of its chain, so the dependency lookup in `refresh_associative_dimensions` already finds the associations from there, and the refresh re-places and re-measures them. Called from each mutator that changes the paper<->model mapping: pan, zoom, fit-to-bounds, view normalisation on entering MSPACE, the view-direction/twist snaps, the viewcube camera path, and both annotation-scale setters. Co-Authored-By: Claude Fable 5.1 --- src/scene/camera_ops.rs | 3 +++ src/scene/mod.rs | 8 ++++++++ src/scene/mspace.rs | 23 +++++++++++++++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 4c0118256..e17ebfd7f 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -567,6 +567,9 @@ impl Scene { viewport.view_target.x = center.x; viewport.view_target.y = center.y; viewport.view_target.z = center.z; + // Panning a viewport moves model geometry across the sheet; the + // dimensions drawn through it follow. + self.notify_viewport_changed(handle); } else { self.camera.borrow_mut().target = center; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 452e6f822..bcb966d38 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5296,6 +5296,10 @@ impl Scene { self.document .set_viewport_annotation_scale(viewport, scale_handle); + // Viewport scale changed: the dimensions drawn on the sheet + // through it measure the same model geometry at a new paper + // size and have to be re-placed. + self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); @@ -5349,6 +5353,10 @@ impl Scene { vp.view_height = vp.height / factor; self.document .set_viewport_annotation_scale(viewport, scale_handle); + // Viewport scale changed: the dimensions drawn on the sheet + // through it measure the same model geometry at a new paper + // size and have to be re-placed. + self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); true diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index dc16c99f4..97814e4c7 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -259,6 +259,9 @@ impl Scene { vp.view_height = eff_h; } } + // A viewport's view changed, so every dimension drawn on the + // sheet through it has to be re-placed and re-measured. + self.notify_viewport_changed(vp_handle); } /// Fit model-space bounds into the active floating viewport without moving @@ -297,6 +300,7 @@ impl Scene { viewport.custom_scale = viewport.height / viewport.view_height; } } + self.notify_viewport_changed(viewport_handle); self.camera_generation += 1; true } @@ -348,6 +352,9 @@ impl Scene { vp.view_target.y += model_delta.y as f64; vp.view_target.z += model_delta.z as f64; } + // A viewport's view changed, so every dimension drawn on the + // sheet through it has to be re-placed and re-measured. + self.notify_viewport_changed(vp_handle); } /// Zoom the active viewport's model-space view by `steps` notches. @@ -397,6 +404,9 @@ impl Scene { } } } + // A viewport's view changed, so every dimension drawn on the + // sheet through it has to be re-placed and re-measured. + self.notify_viewport_changed(vp_handle); } /// Orbit the active viewport's view direction by the given screen-pixel delta. @@ -474,6 +484,9 @@ impl Scene { vp.view_direction.z = dir.z as f64; vp.twist_angle = twist; } + // Twisting a viewport rotates model geometry on the sheet; the + // dimensions drawn through it follow. + self.notify_viewport_changed(vp_handle); } /// Mutate the active viewport's camera through a closure, then re-encode the @@ -502,6 +515,7 @@ impl Scene { .normalize_or(glam::Vec3::Y); let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up)); let twist = -roll as f64; + let mut changed = false; if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { if vp.status.locked { return false; @@ -514,9 +528,14 @@ impl Scene { tmp.projection == view::camera::Projection::Perspective; vp.lens_length = (12.0 / (tmp.fov_y * 0.5).tan().max(1e-6)) as f64; - return true; + changed = true; } - false + if changed { + // The view (and with it the sheet placement of everything drawn + // through this viewport) moved. + self.notify_viewport_changed(vp_handle); + } + changed } /// Render mode of the active paper-space viewport, or `None` when no From f2c9e90c3435e98aabc8a71f60df6afc6111c755 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:32:57 -0500 Subject: [PATCH 16/23] Add a synthetic viewport-association fixture A hand-written DXF small enough to read: one layout viewport at a 1:2 paper-per-model scale, one line, circle, arc, bulged lightweight polyline, spline and block instance in model space, and one associative aligned dimension per feature drawn on the sheet through the viewport. Each association exercises a different part of the chain and feature resolution: endpoints on a line, quadrants on a circle, endpoints on an arc, an endpoint and a bulge segment on a polyline, a tangent-to-spline reference (the 115448 shape), a viewport -> insert -> line chain through a block instance, and an intersection with a second object chain. Every association is marked trans_space, so all of them fail on the pre-PR3 resolver and all of them are expected to resolve now. The expected values are checkable by hand: the viewport maps model (x, y) to paper (150 + (x - 150)/2, 100 + (y - 150)/2), so a 100-unit model length is 50 paper units and must still display as 100. Verification tests come in the next pass; the fixture is committed now so they have something to read. Co-Authored-By: Claude Fable 5.1 --- .../dim_assoc/viewport_associations.dxf | 1634 +++++++++++++++++ 1 file changed, 1634 insertions(+) create mode 100644 tests/fixtures/dim_assoc/viewport_associations.dxf diff --git a/tests/fixtures/dim_assoc/viewport_associations.dxf b/tests/fixtures/dim_assoc/viewport_associations.dxf new file mode 100644 index 000000000..69eee3ed5 --- /dev/null +++ b/tests/fixtures/dim_assoc/viewport_associations.dxf @@ -0,0 +1,1634 @@ +0 +SECTION +2 +HEADER +9 +$ACADVER +1 +AC1021 +9 +$HANDSEED +5 +FFFF +9 +$INSBASE +10 +0.0 +20 +0.0 +30 +0.0 +9 +$DIMASSOC +280 +2 +9 +$DIMLFAC +40 +1.0 +0 +ENDSEC +0 +SECTION +2 +TABLES +0 +TABLE +2 +LTYPE +5 +5 +100 +AcDbSymbolTable +70 +1 +0 +LTYPE +5 +14 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord +2 +ByLayer +70 +0 +3 + +72 +65 +73 +0 +40 +0.0 +0 +ENDTAB +0 +TABLE +2 +LAYER +5 +2 +100 +AcDbSymbolTable +70 +1 +0 +LAYER +5 +10 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord +2 +0 +70 +0 +62 +7 +6 +ByLayer +370 +-3 +390 +F +0 +ENDTAB +0 +TABLE +2 +STYLE +5 +3 +100 +AcDbSymbolTable +70 +1 +0 +STYLE +5 +13 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord +2 +Standard +70 +0 +40 +0.0 +41 +1.0 +50 +0.0 +71 +0 +42 +2.5 +3 +txt +4 + +0 +ENDTAB +0 +TABLE +2 +DIMSTYLE +5 +A +100 +AcDbSymbolTable +70 +1 +0 +DIMSTYLE +105 +11 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord +2 +Standard +70 +0 +340 +13 +0 +ENDTAB +0 +TABLE +2 +APPID +5 +9 +100 +AcDbSymbolTable +70 +1 +0 +APPID +5 +12 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord +2 +ACAD +70 +0 +0 +ENDTAB +0 +TABLE +2 +BLOCK_RECORD +5 +1 +100 +AcDbSymbolTable +70 +3 +0 +BLOCK_RECORD +5 +1F +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +*Model_Space +70 +0 +280 +1 +281 +0 +0 +BLOCK_RECORD +5 +20 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +*Paper_Space +70 +0 +280 +1 +281 +0 +0 +BLOCK_RECORD +5 +21 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +TESTBLK +70 +0 +280 +1 +281 +0 +0 +ENDTAB +0 +ENDSEC +0 +SECTION +2 +BLOCKS +0 +BLOCK +5 +101F +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +*Model_Space +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +*Model_Space +1 + +0 +ENDBLK +5 +201F +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +BLOCK +5 +1020 +330 +20 +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +*Paper_Space +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +*Paper_Space +1 + +0 +ENDBLK +5 +2020 +330 +20 +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +BLOCK +5 +1021 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +TESTBLK +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +TESTBLK +1 + +0 +LINE +5 +106 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +0.0 +20 +0.0 +30 +0.0 +11 +40.0 +21 +0.0 +31 +0.0 +0 +ENDBLK +5 +2021 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +LINE +5 +100 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +0.0 +20 +0.0 +30 +0.0 +11 +100.0 +21 +0.0 +31 +0.0 +0 +LINE +5 +107 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +60.0 +20 +-30.0 +30 +0.0 +11 +60.0 +21 +30.0 +31 +0.0 +0 +CIRCLE +5 +101 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbCircle +10 +200.0 +20 +0.0 +30 +0.0 +40 +25.0 +0 +ARC +5 +102 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbCircle +10 +300.0 +20 +0.0 +30 +0.0 +40 +25.0 +100 +AcDbArc +50 +0.0 +51 +90.0 +0 +LWPOLYLINE +5 +103 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbPolyline +90 +3 +70 +0 +10 +0.0 +20 +100.0 +42 +0.5 +10 +100.0 +20 +100.0 +10 +100.0 +20 +200.0 +0 +SPLINE +5 +104 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbSpline +210 +0.0 +220 +0.0 +230 +1.0 +70 +8 +71 +3 +72 +8 +73 +4 +74 +0 +40 +0.0 +40 +0.0 +40 +0.0 +40 +0.0 +40 +1.0 +40 +1.0 +40 +1.0 +40 +1.0 +10 +0.0 +20 +300.0 +30 +0.0 +10 +40.0 +20 +360.0 +30 +0.0 +10 +80.0 +20 +240.0 +30 +0.0 +10 +120.0 +20 +300.0 +30 +0.0 +0 +INSERT +5 +105 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockReference +2 +TESTBLK +10 +400.0 +20 +0.0 +30 +0.0 +41 +2.0 +42 +2.0 +43 +1.0 +50 +0.0 +0 +VIEWPORT +5 +200 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbViewport +10 +150.0 +20 +100.0 +30 +0.0 +40 +200.0 +41 +150.0 +68 +2 +69 +2 +100 +AcDbViewport +12 +0.0 +22 +0.0 +13 +0.0 +23 +0.0 +14 +1.0 +24 +1.0 +15 +1.0 +25 +1.0 +16 +0.0 +26 +0.0 +36 +1.0 +17 +150.0 +27 +150.0 +37 +0.0 +42 +50.0 +43 +0.0 +44 +0.0 +45 +300.0 +50 +0.0 +51 +0.0 +72 +100 +90 +32864 +281 +0 +71 +1 +74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 +79 +0 +146 +0.0 +170 +0 +61 +5 +292 +0 +282 +1 +141 +0.0 +142 +0.0 +63 +250 +421 +3355443 +0 +DIMENSION +5 +201 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +20.0 +30 +0.0 +11 +80.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +100.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +25.0 +33 +0.0 +14 +125.0 +24 +25.0 +34 +0.0 +50 +0.0 +0 +DIMENSION +5 +202 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +180.0 +20 +20.0 +30 +0.0 +11 +180.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +50.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +162.5 +23 +25.0 +33 +0.0 +14 +187.5 +24 +25.0 +34 +0.0 +50 +0.0 +0 +DIMENSION +5 +203 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +250.0 +20 +20.0 +30 +0.0 +11 +250.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +35.355339 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +237.5 +23 +25.0 +33 +0.0 +14 +225.0 +24 +37.5 +34 +0.0 +50 +0.0 +0 +DIMENSION +5 +204 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +140.0 +30 +0.0 +11 +80.0 +21 +145.0 +31 +0.0 +70 +1 +71 +5 +42 +141.421356 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +75.0 +33 +0.0 +14 +125.0 +24 +125.0 +34 +0.0 +50 +0.0 +0 +DIMENSION +5 +205 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +190.0 +30 +0.0 +11 +80.0 +21 +195.0 +31 +0.0 +70 +1 +71 +5 +42 +120.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +175.0 +33 +0.0 +14 +135.0 +24 +175.0 +34 +0.0 +50 +0.0 +0 +DIMENSION +5 +206 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +330.0 +20 +20.0 +30 +0.0 +11 +330.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +80.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +275.0 +23 +25.0 +33 +0.0 +14 +315.0 +24 +25.0 +34 +0.0 +50 +0.0 +0 +DIMENSION +5 +207 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +35.0 +30 +0.0 +11 +80.0 +21 +40.0 +31 +0.0 +70 +1 +71 +5 +42 +60.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +25.0 +33 +0.0 +14 +105.0 +24 +25.0 +34 +0.0 +50 +0.0 +0 +ENDSEC +0 +SECTION +2 +OBJECTS +0 +DICTIONARY +5 +C +330 +0 +100 +AcDbDictionary +281 +1 +3 +ACAD_LAYOUT +350 +D +0 +DICTIONARY +5 +D +330 +C +100 +AcDbDictionary +281 +1 +0 +DIMASSOC +5 +300 +102 +{ACAD_REACTORS +330 +201 +102 +} +330 +C +100 +AcDbDimAssoc +330 +201 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +1 +40 +0.0 +10 +100.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +301 +102 +{ACAD_REACTORS +330 +202 +102 +} +330 +C +100 +AcDbDimAssoc +330 +202 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +5 +331 +200 +331 +101 +73 +1 +91 +0 +40 +0.0 +10 +175.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +5 +331 +200 +331 +101 +73 +1 +91 +0 +40 +0.0 +10 +225.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +302 +102 +{ACAD_REACTORS +330 +203 +102 +} +330 +C +100 +AcDbDimAssoc +330 +203 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +102 +73 +1 +91 +0 +40 +0.0 +10 +325.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +102 +73 +1 +91 +1 +40 +0.0 +10 +300.0 +20 +25.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +303 +102 +{ACAD_REACTORS +330 +204 +102 +} +330 +C +100 +AcDbDimAssoc +330 +204 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +103 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +100.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +103 +73 +1 +91 +2 +40 +0.0 +10 +100.0 +20 +200.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +304 +102 +{ACAD_REACTORS +330 +205 +102 +} +330 +C +100 +AcDbDimAssoc +330 +205 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +104 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +300.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +9 +331 +200 +331 +104 +73 +1 +91 +-2 +40 +1.0 +10 +120.0 +20 +300.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +305 +102 +{ACAD_REACTORS +330 +206 +102 +} +330 +C +100 +AcDbDimAssoc +330 +206 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +105 +331 +106 +73 +1 +91 +0 +40 +0.0 +10 +400.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +105 +331 +106 +73 +1 +91 +1 +40 +0.0 +10 +480.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +306 +102 +{ACAD_REACTORS +330 +207 +102 +} +330 +C +100 +AcDbDimAssoc +330 +207 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +6 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +60.0 +20 +0.0 +30 +0.0 +332 +200 +332 +107 +74 +1 +92 +0 +75 +0 +0 +ENDSEC +0 +EOF From be7ba3d68e4c454e19674627e772fe336f9a2263 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:52:00 -0500 Subject: [PATCH 17/23] Record viewport associations for new dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes PR3's `viewport_frame` seam in `viewport_ref.rs`; the frame is PR1's camera-derived one everywhere. The five dimension commands (DIMLINEAR / DIMALIGNED / DIMRADIUS / DIMDIAMETER / DIMANGULAR) now record a real association when they measure through a viewport. The commit path's one association gate (`dimension_association_allowed`, added when PR2 was stacked) gains an else-branch: where PR2 committed the dimension unassociated and PR1's inference guard returned an empty source list, `Scene::attach_viewport_ dimension_association` now stores the whole chain — dimension -> viewport -> block path -> entity -> feature — from the AcceptedSnaps PR1 captured, in definition-point slot order. * `Scene::dimension_association_slot_points` names that order, including the radial dimensions `dimension_reference_points` does not cover. * `OpenCADStudio::viewport_dimension_snaps` builds the slot array. A snap whose paper point is no longer the definition point it fills has its model point recomputed from that definition point through the frame: the object pick records one click for both extension origins, and a rotated DIMLINEAR projects its origins onto the measuring axis. * Radial dimensions take their own path inside `attach_viewport_dimension_association`. Their single reference is an osnap type 10 carrying the radial marker (which circle / which polyline arc segment) and the angle around it; `source_reference` would hand back the generic curve-parameter marker -2, which `radial_source_for_marker` cannot find. One measurement rule, documented in `viewport_ref::measurement_rule`: `actual_measurement` is always the RAW measurement in the dimension's own space (paper, for a viewport dimension), and the viewport compensation lives only in the magnitude of the negative DIMLFAC override, applied once by the formatter. PR2's persistence already followed it; PR3's refresh did not — it substituted the model distance times `model_factor()`, which made a refreshed dimension read `user_lfac` times too much versus an identical freshly-created one. Refresh now writes the paper measurement (a debug assert pins resolved paper points to the model points through the frame), and `axis_distance` goes with it. Block sub-entity snaps: verified that `block_cache` stamps the top-level INSERT's handle onto every expanded and clipped sub-wire, so `wire_source` can only ever report the INSERT — nested inserts included. `OpenCADStudio::resolve_source_block_path` now descends from that INSERT at accept time (using the object-pick path's existing `resolve_measurable_entity`, so arbitrary depth, not just one level) and fills `SnapSourceRef.block_path` with the real inner entity. Where the descent cannot identify one — a non-planar-pickable type such as text, hatch, spline or solid, or nesting past eight levels — the source stays the INSERT with an empty path, which is coarser but never wrong. Both the limitation and where it bites are documented on `Scene::resolve_block_snap_source` and `SnapResult::source`. Co-Authored-By: Claude Fable 5.1 --- src/app/command_driver.rs | 10 +- src/app/dim_viewport.rs | 77 +++++++++++++ src/app/update/viewport.rs | 1 + src/app/update/viewport_snap.rs | 36 +++++- src/scene/dimension_assoc.rs | 192 +++++++++++++++++++++++--------- src/scene/viewport_dim_seam.rs | 33 ++++++ src/scene/viewport_ref.rs | 68 ++++++----- src/snap.rs | 14 ++- 8 files changed, 336 insertions(+), 95 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 35259107d..5a6c7c7e3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -376,7 +376,7 @@ impl OpenCADStudio { // accepted-snap list must stay index-parallel with the points the // command collects (PR1). Interactive picks record themselves in // the click handler and never reach here. - self.record_accepted_snap(None, None, *point); + self.record_accepted_snap(i, None, None, *point); } if default_start { let StepInput::Point(point) = &input else { @@ -2220,6 +2220,14 @@ impl OpenCADStudio { changes.sort_by_key(|(handle, _)| handle.value()); changes.dedup_by_key(|(handle, _)| handle.value()); self.tabs[i].scene.bump_entities(&changes); + } else if association_mode == 2 { + // Measured through a layout viewport: a paper-space + // association would bind projected coordinates to + // geometry that is not on the sheet. Record the real + // chain instead — dimension -> viewport -> block + // path -> entity -> feature — from the AcceptedSnaps + // PR1 captured, in definition-point slot order. + self.attach_viewport_dimension_association(i, handle); } } pending diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index fbd35cce7..87e01c036 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -183,6 +183,83 @@ impl OpenCADStudio { } } + /// The slot array [`Scene::attach_viewport_dimension_association`] wants, + /// built from the snaps PR1 recorded for the command that just committed + /// `dimension`. + /// + /// Slot `k` describes the feature at + /// `Scene::dimension_association_slot_points()[k]`, so the measuring snaps + /// are taken in collection order and truncated / padded to that length. + /// + /// A snap whose paper point is not (still) the definition point it would + /// fill has its `model_point` recomputed from that definition point + /// through the frame. Two cases need it: + /// + /// * the explicit object pick, which records one click twice — once per + /// extension origin — so neither recorded point is the endpoint the + /// command derived from the entity; + /// * a point the command moved after the snap (a rotated DIMLINEAR + /// projects its origins onto the measuring axis). + /// + /// The source identity is kept in both cases: it is the entity that was + /// acquired, and the feature marker is re-derived from the corrected + /// point. + pub(crate) fn viewport_dimension_snaps( + &self, + i: usize, + dimension: Handle, + ) -> Vec> { + let slots = self.tabs[i].scene.dimension_association_slot_points(dimension); + if slots.is_empty() { + return Vec::new(); + } + let measuring = self.dimension_measuring_snaps(i); + slots + .iter() + .enumerate() + .map(|(index, slot)| { + let snap = measuring.get(index)?.clone(); + let frame = snap.frame?; + let slot = DVec3::new(slot.x, slot.y, slot.z); + if snap.paper_point.truncate().distance_squared(slot.truncate()) <= 1e-12 { + return Some(snap); + } + Some(AcceptedSnap { + paper_point: slot, + model_point: frame.paper_to_model(slot), + ..snap + }) + }) + .collect() + } + + /// Record the viewport association for a dimension that has just been + /// committed while measuring through a layout viewport. + /// + /// Announces the whole chain — the viewport, every INSERT on the block + /// path and the source entity — so the dependency index picks the new + /// association up immediately. + pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) { + let snaps = self.viewport_dimension_snaps(i, dimension); + if snaps.iter().flatten().all(|snap| snap.source.is_none()) { + return; + } + self.tabs[i] + .scene + .attach_viewport_dimension_association(dimension, &snaps); + let mut changes = vec![(dimension, crate::scene::ChangeKind::Modified)]; + changes.extend( + self.tabs[i] + .scene + .dimension_association_sources(dimension) + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)), + ); + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } + /// Apply viewport compensation to a dimension about to be committed. /// /// Whether the dimension may also take a paper-space association is a diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index bd2708751..ad174b640 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4173,6 +4173,7 @@ impl OpenCADStudio { // `world_pt` exactly as before. let pending = self.pending_click_snap.take(); self.record_accepted_snap( + i, pending.map(|(hit, _)| hit), pending.and_then(|(_, frame)| frame), world_pt, diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 0265b36c7..4882d2104 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -102,11 +102,12 @@ impl OpenCADStudio { /// after ortho / polar / axis-lock / dynamic-input resolution. pub(crate) fn record_accepted_snap( &mut self, + tab: usize, snap: Option, frame: Option, committed: glam::DVec3, ) { - let accepted = match snap { + let mut accepted = match snap { // A viewport snap keeps its frame only when the hit really came // through it, so a paper-sheet snap never claims a model point. Some(hit) => { @@ -115,9 +116,42 @@ impl OpenCADStudio { } None => AcceptedSnap::free(committed), }; + self.resolve_source_block_path(tab, &mut accepted); self.push_accepted_snap(accepted); } + /// Replace a snap source that is a block INSTANCE with the entity inside + /// it that actually owns the snapped feature, plus the INSERT path. + /// + /// The snap engine can only report what the wire carries, and the + /// renderer stamps every wire of an expanded block with the *top-level + /// INSERT's* handle — nested inserts included. `SnapSourceRef` promises + /// the innermost entity in `source.handle` and the instance chain in + /// `block_path`, so the descent happens here, once, at accept time (not + /// per pointer move). + /// + /// When the descent cannot identify the inner entity the source stays the + /// INSERT with an empty path. See + /// [`crate::scene::Scene::resolve_block_snap_source`] for exactly when + /// that happens; the resulting association is coarser (it tracks the whole + /// block instance) but never wrong. + fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) { + let Some(source) = accepted.source.as_mut() else { + return; + }; + if !source.block_path.is_empty() { + return; + } + if let Some((entity, path)) = self.tabs[tab] + .scene + .resolve_block_snap_source(source.source.handle, accepted.model_point) + { + source.source = + crate::command::DimensionAssociationSource::inferred(entity); + source.block_path = path; + } + } + /// Append an already-built accepted snap, keeping the retention cap. /// /// Used by paths that are not point steps (the dimension object pick diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index db07b30e5..2cc612bb4 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -17,7 +17,7 @@ use crate::command::DimensionAssociationSource; use super::dimension_assoc_chain::{ self as chain, ChainError, FeatureContext, ReferenceChain, }; -use super::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; +use super::viewport_ref::{AcceptedSnap, ViewportFrame}; use super::{ChangeKind, Scene}; pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4; @@ -372,7 +372,7 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { /// with a frame the dimension lives on the sheet and every point it holds is /// a *paper* point, while the value it displays is a *model* length. Keeping /// both directions in one place is what stops the viewport compensation from -/// being applied twice (see [`MeasurementScale`]). +/// being applied twice (see `viewport_ref::measurement_rule`). #[derive(Clone, Copy, Debug)] pub(crate) struct ChainMap { pub transform: Transform, @@ -1175,9 +1175,25 @@ impl Scene { dimension: Handle, snaps: &[Option], ) { - let Some(EntityType::Dimension(_)) = self.document.get_entity(dimension) else { + let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) + else { return; }; + // A radial dimension has ONE reference, and it is an + // `AcDbOsnapPointRef` of osnap type 10 carrying the *radial* marker + // (which circle / which polyline arc segment) and the angle around it, + // not a vertex marker. `source_reference` would return the generic + // curve-parameter marker `-2`, which `radial_source_for_marker` cannot + // find, so radial goes down its own path — the same one + // `attach_dimension_association_sources` uses for model space. + let radial_dimension = matches!( + dimension_entity, + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ); + if radial_dimension { + self.attach_viewport_radial_association(dimension, snaps); + return; + } let mut references: [Vec; 4] = std::array::from_fn(|_| Vec::new()); let mut associativity = 0; @@ -1244,6 +1260,96 @@ impl Scene { ); } + /// The radial half of [`Scene::attach_viewport_dimension_association`]. + /// + /// The first snap that carries a source wins: a radius / diameter + /// dimension measures one circle or one polyline arc segment, and the + /// command only ever acquires one. + fn attach_viewport_radial_association( + &mut self, + dimension: Handle, + snaps: &[Option], + ) { + let Some(snap) = snaps + .iter() + .flatten() + .find(|snap| snap.source.is_some()) + .cloned() + else { + return; + }; + let source = snap.source.as_ref().expect("filtered above"); + let entity_handle = source.source.handle; + let Some(entity) = self.document.get_entity(entity_handle) else { + return; + }; + // The acquired point in the source entity's own coordinates, so the + // radial geometry and the angle are both computed where the entity + // lives. + let local = Self::point_in_entity_space( + &self.document, + &source.block_path, + dvec3(snap.model_point), + ); + let radial = match source.source.marker { + Some(marker) => radial_source_for_marker(entity, marker), + None => radial_source_at(entity, local), + }; + let Some(radial) = radial else { return }; + let angle = if source.source.marker.is_some() && source.source.parameter.is_finite() { + source.source.parameter + } else { + radial.angle_at(dpoint(local)) + }; + let mut xrefs = Vec::new(); + let mut trans_space = false; + if let Some(viewport) = snap.viewport { + xrefs.push(viewport); + trans_space = true; + } + xrefs.extend(source.block_path.iter().copied()); + xrefs.push(entity_handle); + let reactor_targets = xrefs.clone(); + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs, + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: dvec3(snap.model_point), + ..AssocDimensionReference::default() + }; + self.store_dimension_association_full( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + trans_space, + reactor_targets, + ); + } + + /// The definition points an association fills, slot by slot, in the same + /// order [`Scene::attach_viewport_dimension_association`] consumes its + /// `snaps` argument. + /// + /// Callers use it to size and sanity-check the slot array they build from + /// the accepted snaps: slot `k` of that array must describe the feature + /// sitting at `dimension_association_slot_points()[k]`. + pub(crate) fn dimension_association_slot_points(&self, dimension: Handle) -> Vec { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { + return Vec::new(); + }; + match entity { + // A radial dimension's single reference describes the point where + // the leader meets the circle. + Dimension::Radius(radius) => vec![radius.definition_point], + Dimension::Diameter(diameter) => vec![diameter.angle_vertex], + Dimension::LargeRadial(radial) => vec![radial.chord_point], + other => dimension_reference_points(other), + } + } + /// A model-space point brought down into the coordinates of the entity at /// the end of `block_path`. Identity for top-level geometry. fn point_in_entity_space( @@ -1638,22 +1744,6 @@ impl Scene { let before = self.document.get_entity_arc(association.dimension); self.record_undo_before(association.dimension, before); } - // The user's DIMLFAC override travels with the dimension (PR2 - // persists it when it creates a viewport dimension); split it from - // the viewport compensation so the compensation is applied exactly - // once, on the model length, and never again on the paper one. - let scale = MeasurementScale::from_dimlfac( - self.document - .get_entity(association.dimension) - .and_then(|entity| { - crate::entities::dim_override::real( - &entity.common().extended_data, - crate::entities::dim_override::DIMLFAC, - ) - }) - .unwrap_or(0.0), - map.frame.as_ref(), - ); let Some(EntityType::Dimension(dimension)) = self.document.get_entity_mut(association.dimension) else { @@ -1897,32 +1987,34 @@ impl Scene { Dimension::Linear(linear) => linear_measurement(linear), _ => dimension.measurement(), }; - // A dimension drawn on the sheet through a viewport has just been - // rebuilt from *paper* points, so measuring those gives the paper - // length. The number it shows is the model one: take it from the - // model points the same references resolved to, and apply only the - // user's DIMLFAC on top (`model_factor`) — the viewport - // compensation is already inherent in having measured in model - // space, and applying it again is the double-scaling this whole - // three-PR effort exists to stop. - let measurement = match (map.frame.as_ref(), &*dimension) { - (Some(_), Dimension::Linear(_) | Dimension::Aligned(_)) => { - match (model_points[0], model_points[1]) { - (Some(first), Some(second)) => { - let raw = match &*dimension { - // Rotated linear measures only along its axis. - Dimension::Linear(linear) => { - axis_distance(first, second, linear.rotation) - } - _ => first.distance(&second), - }; - raw * scale.model_factor() - } - _ => measurement, - } - } - _ => measurement, - }; + // `actual_measurement` stays RAW, in the dimension's own space — + // paper, for a dimension drawn on the sheet through a viewport. + // The viewport compensation is carried by the negative DIMLFAC + // override and applied once, by the formatter. See + // `viewport_ref::measurement_rule`: substituting the model + // distance here (or multiplying by `MeasurementScale::model_factor`) + // would make a refreshed dimension read a different number from an + // identical freshly-created one. + // + // The similarity property of a viewport frame is what makes this + // exact: paper_span * (1 / scale) is the model span, for a rotated + // linear projection just as much as for a free distance. + debug_assert!( + map.frame.is_none() + || model_points + .iter() + .zip(resolved.iter()) + .all(|(model, space)| match (model, space, map.frame.as_ref()) { + (Some(model), Some(space), Some(frame)) => { + let projected = + frame.model_to_paper(glam::DVec3::new(model.x, model.y, model.z)); + (projected.x - space.x).abs() < 1e-6 + && (projected.y - space.y).abs() < 1e-6 + } + _ => true, + }), + "resolved paper points must be the model points through the frame", + ); dimension.base_mut().actual_measurement = measurement; refreshed.push((association.dimension, ChangeKind::Modified)); } @@ -1930,16 +2022,6 @@ impl Scene { } } -/// Distance between two points measured along the direction `rotation`. -/// -/// The rotated-linear rule, evaluated in whatever space the two points are in -/// — the same formula [`linear_measurement`] applies on the sheet, reused for -/// the model points a trans-space dimension measures. -fn axis_distance(first: Vector3, second: Vector3, rotation: f64) -> f64 { - let axis = [rotation.cos(), rotation.sin()]; - ((second.x - first.x) * axis[0] + (second.y - first.y) * axis[1]).abs() -} - /// How far the midpoint of a dimension's measured points moved. /// /// Used to carry the sheet annotation — the dimension line's standoff and a diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index 575aaf505..0447bd7c8 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -110,6 +110,39 @@ impl Scene { }) } + /// Resolve a snapped INSERT handle into the innermost entity that owns the + /// geometry, plus the INSERT path down to it. + /// + /// The renderer names every wire of an expanded block with the **top-level + /// INSERT's** handle (see `block_cache`), including wires belonging to + /// nested inserts, so a snap inside a block reports the INSERT and nothing + /// finer. This walks the block definition from that INSERT looking for the + /// nearest measurable entity to `model_point`, which recovers the real + /// source handle and the instance path for the association chain. + /// + /// Returns `None` — and the caller then keeps the INSERT itself as the + /// source, which still gives a correct, if coarse, association — when: + /// * `top` is not an INSERT (the common case: `Some((top, vec![]))`); + /// * the geometry is a type [`planar_pick_distance`] does not handle + /// (text, hatch, spline, solid, 3-D geometry); + /// * the nesting is deeper than eight levels. + /// + /// Depth is not limited to one level: [`Scene::descend_block_instance`] + /// recurses, and the returned path lists every INSERT from the outermost + /// down to the one that directly contains the entity. + pub fn resolve_block_snap_source( + &self, + top: Handle, + model_point: DVec3, + ) -> Option<(Handle, Vec)> { + if !matches!(self.document.get_entity(top), Some(EntityType::Insert(_))) { + return None; + } + let (entity, path) = self.resolve_measurable_entity(top, model_point)?; + let handle = entity.as_entity().handle(); + handle.is_valid().then_some((handle, path)) + } + /// Nearest resident model wire of `viewport` to `model_point`, within /// `aperture` model units. Wires are the same tessellation the viewport /// draws, so this matches what the user sees. diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index 66c92d565..38d06529e 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -328,42 +328,38 @@ impl MeasurementScale { } } -// ───────────────────────────────────────────────────────────────────────── -// MERGE SEAM — PR1 (feat/viewport-snap) owns `Scene::viewport_frame`. -// -// PR3 needs a frame to map a resolved model point onto the sheet, but PR1 is -// not visible from this branch. This is the minimal stand-in agreed in the -// shared contract: paper_center = vp.center, model_target = vp.view_target, -// scale = `vp_effective_scale`, twist = vp.twist_angle. PR1's version is -// camera-derived (it must agree with what is actually drawn, including the -// auto-fit rescue in `Scene::camera_for_viewport` for stale saved views). -// -// To merge: delete this whole block. Nothing else in PR3 reads viewport -// fields directly — every call goes through `Scene::viewport_frame`. -// ───────────────────────────────────────────────────────────────────────── -impl crate::scene::Scene { - /// The planar paper<->model mapping of layout viewport `handle`. - /// - /// `None` when the handle is not a viewport, or when its scale is - /// degenerate (a zero-height view cannot map anything). - pub fn viewport_frame(&self, handle: Handle) -> Option { - let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity(handle) else { - return None; - }; - let scale = crate::scene::vp_effective_scale(vp.custom_scale, vp.view_height, vp.height); - if !scale.is_finite() || scale.abs() < 1e-12 { - return None; - } - Some(ViewportFrame { - viewport: handle, - paper_center: DVec2::new(vp.center.x, vp.center.y), - model_target: DVec2::new(vp.view_target.x, vp.view_target.y), - scale, - twist: vp.twist_angle, - locked: vp.status.locked, - }) - } -} +/// # Where the viewport compensation lives, once +/// +/// A dimension that sits on a paper layout but measures model geometry seen +/// through a viewport has **paper** definition points and displays a **model** +/// number. Exactly one rule governs that, everywhere: +/// +/// > `Dimension::actual_measurement` (DXF group 42) is always the **raw** +/// > geometric measurement in the dimension's *own* space — paper, for a +/// > viewport dimension. The viewport compensation lives **only** in the +/// > magnitude of the negative DIMLFAC override +/// > ([`MeasurementScale::viewport_dimlfac_override`]), and is applied +/// > **once**, at format time, by the linear formatter. +/// +/// So for a 1:10 viewport and a user DIMLFAC of 1: a 100-unit model line is +/// 10 paper units, `actual_measurement` is `10`, the override is `-10`, +/// [`MeasurementScale::user_lfac_for_space`] resolves that to `10` for a +/// paper-space dimension, and the text reads `100`. +/// +/// The consequence for association refresh +/// (`Scene::refresh_associative_dimensions`) is that after re-placing a +/// viewport dimension's definition points on the sheet it must write the +/// **paper** measurement back — it must *not* substitute the model distance, +/// and it must *not* multiply by [`MeasurementScale::model_factor`]. Doing +/// either makes a refreshed dimension read a different number from an +/// identical freshly-created one (`user_lfac` too many times). This is the +/// rule that makes `attach_viewport_dimension_association` + refresh agree +/// with creation by construction, and it is why `actual_measurement` stays +/// raw: that is also what the DXF format specifies and what export writes. +/// +/// Angular dimensions are exempt on both counts — a similarity preserves +/// angles, so there is no compensation and no override. +pub mod measurement_rule {} #[cfg(test)] mod tests { diff --git a/src/snap.rs b/src/snap.rs index b388e0f96..0a515081f 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -93,8 +93,15 @@ pub struct SnapResult { /// query, never by the engine itself. (PR1) pub viewport: Option, /// Owning entity of the geometry the snap landed on, when the engine could - /// attribute the feature to a wire. Block sub-entities report the top-level - /// INSERT handle; the block path is resolved later by + /// attribute the feature to a wire. + /// + /// This is whatever the *wire* carries, which for anything drawn from a + /// block is the **top-level INSERT's** handle — nested inserts included, + /// because `block_cache` stamps the host insert's name onto every + /// expanded and clipped sub-wire. Resolving that to the inner entity plus + /// the instance path needs the document and the accepted model point, so + /// it happens once at accept time, in + /// `OpenCADStudio::resolve_source_block_path`, which fills /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. (PR1) pub source: Option, } @@ -108,6 +115,9 @@ impl SnapResult { /// Entity handle carried by a wire (`WireModel::name` is the decimal handle /// value). Returns `None` for preview / interim wires with a symbolic name. +/// +/// For block geometry the value is the top-level INSERT handle, never the +/// inner entity: see [`SnapResult::source`]. #[inline] pub(crate) fn wire_source(wire: &WireModel) -> Option { wire.name From 2ee378285035e4f826ee7ff2f3c1c7c0934e18ef Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 14:13:19 -0500 Subject: [PATCH 18/23] Fix viewport dimension acquisition and preserve measurement scaling --- docs/viewport-dimensions.md | 48 ++++ src/app/command_driver.rs | 17 +- src/app/dim_viewport.rs | 380 ++++++++++----------------- src/app/mod.rs | 2 + src/app/update/viewport.rs | 49 ++-- src/app/update/viewport_snap.rs | 68 ++--- src/app/viewport_dimension_tests.rs | 379 ++++++++++++++++++++++++++ src/command.rs | 8 + src/entities/dimension.rs | 24 +- src/modules/annotate/aligned_dim.rs | 11 + src/modules/annotate/angular_dim.rs | 14 + src/modules/annotate/diameter_dim.rs | 10 + src/modules/annotate/linear_dim.rs | 11 + src/modules/annotate/radius_dim.rs | 10 + src/scene/mspace.rs | 17 +- src/scene/viewport_dim_seam.rs | 242 +++++++++-------- src/scene/viewport_ref.rs | 173 +++++------- src/snap.rs | 71 ++--- 18 files changed, 957 insertions(+), 577 deletions(-) create mode 100644 docs/viewport-dimensions.md create mode 100644 src/app/viewport_dimension_tests.rs diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md new file mode 100644 index 000000000..09f85208c --- /dev/null +++ b/docs/viewport-dimensions.md @@ -0,0 +1,48 @@ +# Dimensions through paper-space viewports + +Paper-space point commands can snap to displayed model geometry through a planar, +orthographic layout viewport. The acquired result retains the original model +coordinate, its paper coordinate, the viewport frame, and feature identity. +Ordinary selection continues to operate on the active drawing space. + +`DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER` use this +acquisition context. The supported object-pick modes also query viewport geometry. +Snapping and object picking share viewport draw order, visibility, and clipping +rules. Object picking uses the resident interaction index and applies block +instance transforms. Nonuniformly transformed circles and circular arcs are +declined as radial object picks because their displayed geometry is elliptical. + +## Input and measurement rules + +- Free and typed sheet points measure paper units, including points inside a + viewport rectangle. +- Measuring points must all belong to the same space and, when applicable, the + same viewport. An incompatible pick leaves the command at its current step. +- Dimension-line and text placement do not change the measurement's space. +- Paper geometry takes priority over viewport geometry at an object pick. +- The final dimension style is applied before computing measurement scaling. + Length dimensions keep paper-space definition points and a negative `DIMLFAC` + override containing the effective user factor multiplied by `1 / viewport scale`. + Angular measurements do not use this length factor. +- `DIMASSOC=0` uses the same compensated value before exploding the annotation. + +## Persistent measurement data + +`OCS_VIEWPORT_MEASUREMENT` XData stores three values: version `1` (integer16), +the positive user factor (real), and viewport compensation (real). This keeps the +user's unit conversion separate from viewport scale. The ordinary `DIMLFAC` +override remains sufficient for displaying the saved measurement in other CAD +readers. The data survives DXF/DWG saves and undo/redo. + +The dependent association change uses this metadata when viewport scale changes. +This creation change alone creates nonassociative viewport dimensions and leaves +the drawing's `DIMASSOC` setting unchanged. Direct model/paper associations retain +their existing behavior. + +## Validation + +`src/app/viewport_dimension_tests.rs` exercises snap-engine acquisition, command +commits for all five commands, object picks, input retries, active positive and +negative style factors, exploded dimensions, undo/redo, DXF/DWG persistence, +clipping, overlapping viewports, pan, scale, and twist. These are headless tests; +they do not establish visual acceptance in the desktop UI. diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d8f355ed9..6cc436e5f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -388,7 +388,9 @@ impl OpenCADStudio { // accepted-snap list must stay index-parallel with the points the // command collects (PR1). Interactive picks record themselves in // the click handler and never reach here. - self.record_accepted_snap(None, None, *point); + if !self.record_accepted_snap(i, None, None, *point) { + return Task::none(); + } } if default_start { let StepInput::Point(point) = &input else { @@ -402,6 +404,7 @@ impl OpenCADStudio { self.push_ucs_to_cmd(i); } if let StepInput::EntityPick(handle, point) = &input { + if !self.dimension_acquisition_allowed(i, None) { return Task::none(); } let solid_pick = matches!( self.tabs[i].scene.document.get_entity(*handle), Some( @@ -474,6 +477,7 @@ impl OpenCADStudio { } let ctrl = self.ctrl_down; let shift = self.shift_down; + let picked_handle = if let StepInput::EntityPick(h, _) = &input { Some(*h) } else { None }; let result: Option = { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { return Task::none(); @@ -492,6 +496,10 @@ impl OpenCADStudio { StepInput::Escape => Some(cmd.on_escape()), } }; + if let Some(handle) = picked_handle { + self.record_dimension_entity_points(i, None, handle, Vec::new()); + } + self.sync_dimension_snaps(i); match result { Some(r) => self.apply_cmd_result(r), None => Task::none(), @@ -2357,7 +2365,12 @@ impl OpenCADStudio { // A dimension placed on the sheet but measuring model geometry // through a viewport carries the compensation as a negative // DIMLFAC override. - self.apply_viewport_dimension_measurement(i, &mut entity); + if !preserve_base_style { + crate::scene::creation_style::apply_current_creation_styles(&self.tabs[i].scene.document, &mut entity); + } + if !self.apply_viewport_dimension_measurement(i, &mut entity) { + return Task::none(); + } // ...and is deliberately *not* associated in paper space: its // definition points are projected paper coordinates, so a // paper-space association would bind it to geometry that is diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index fbd35cce7..577237e57 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -1,262 +1,162 @@ -//! Viewport-aware dimension measurement (PR2). -//! -//! A dimension created in a paper-space layout while measuring geometry seen -//! through a content viewport must *place* itself in paper coordinates but -//! *report* the model measurement. Because a viewport frame is a similarity -//! (uniform scale plus in-plane twist), the model measurement of any linear, -//! aligned or radial span is exactly the raw paper measurement times the -//! viewport compensation `1 / scale` -- projecting the model span onto the -//! rotated paper axis and compensating is the same number. So no geometry is -//! rewritten: the compensation is persisted once, as a negative DIMLFAC -//! override on the dimension (see -//! [`MeasurementScale::viewport_dimlfac_override`]), and applied once, by the -//! linear formatter. -//! -//! Angular dimensions are never compensated -- a similarity preserves angles. -//! -//! ## Where the snaps come from -//! The per-point [`AcceptedSnap`] list is PR1's -//! ([`OpenCADStudio::accepted_snaps`]): the click handler records the real -//! snap for every interactive pick, and `apply_step_input` records a free -//! snap for typed / dynamic-input / headless points. There is exactly one -//! source of truth. Two things are layered on top here: -//! -//! * the explicit object-pick path ([`Scene::dimension_pick_through_viewport`]) -//! is not a point step at all, so it records its own snaps -//! ([`OpenCADStudio::record_dimension_viewport_pick`]); -//! * a *typed* coordinate carries no snap, so PR1 records it as a free point -//! with no viewport. [`OpenCADStudio::measure_snap`] re-attaches the -//! content viewport under such a point, which is what makes -//! `DIMLINEAR` + typed coordinates inside a 1:10 viewport measure the model. -//! It is deliberately limited to snaps that are free *and* source-less, so a -//! genuine paper-sheet snap that happens to sit over a viewport is never -//! reinterpreted. - -use acadrust::entities::Dimension; -use acadrust::types::Handle; -use acadrust::xdata::XDataValue; -use acadrust::EntityType; -use glam::DVec3; +//! Paper-space dimension acquisition and measurement. Only acquired geometry +//! carries viewport compensation; free and typed sheet coordinates stay on the sheet. +use super::OpenCADStudio; use crate::command::DimensionAssociationSource; use crate::entities::dim_override; use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, SnapSourceRef, ViewportFrame}; +use acadrust::entities::Dimension; +use acadrust::types::Handle; +use acadrust::EntityType; +use glam::DVec3; -use super::OpenCADStudio; - -/// How a pending dimension should be measured. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum DimensionMeasureSpace { - /// Plain measurement in the space the definition points live in. Direct, - /// Every measuring point came through one viewport; compensate by its - /// scale. Viewport(ViewportFrame), - /// The points came from different viewports, or from a viewport and the - /// bare sheet. Falls back to `Direct` with a warning — never a silently - /// wrong number. Mixed, } impl OpenCADStudio { - /// Record the accepted snaps produced by the explicit object-pick path (a - /// dimension "select object" pick resolved through a content viewport). - /// - /// An object pick is not a point step, so PR1's click handler never sees - /// it. One pick supplies *both* extension origins, so it is recorded - /// twice: that keeps the list index-parallel with the definition points - /// the command derives from the entity, and lets the "every measuring - /// point came through one viewport" classification see a complete set. - pub(crate) fn record_dimension_viewport_pick( + pub(crate) fn finish_command_click(&mut self, i: usize) { + let mut selection = self.tabs[i].scene.selection.borrow_mut(); + selection.left_down = false; + selection.left_press_pos = None; + selection.left_press_time = None; + selection.left_dragging = false; + } + + #[allow(dead_code)] // Consumed by the dependent association change. + pub(crate) fn dimension_measuring_snaps(&self, _i: usize) -> Vec { + self.accepted_snaps().to_vec() + } + + pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" || scene.active_viewport.is_some() { + return DimensionMeasureSpace::Direct; + } + let mut frame: Option = None; + let mut direct = false; + for snap in self.accepted_snaps() { + match snap.frame { + Some(f) => match frame { + None => frame = Some(f), + Some(previous) if previous.viewport == f.viewport => {} + Some(_) => return DimensionMeasureSpace::Mixed, + }, + None => direct = true, + } + } + match (frame, direct) { + (None, _) => DimensionMeasureSpace::Direct, + (Some(f), false) => DimensionMeasureSpace::Viewport(f), + _ => DimensionMeasureSpace::Mixed, + } + } + + /// Reject an incompatible measuring input before it advances the command. + pub(crate) fn dimension_acquisition_allowed( &mut self, - frame: ViewportFrame, - paper_point: DVec3, - model_point: DVec3, + i: usize, + viewport: Option, + ) -> bool { + if !self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.measures_through_viewports()) + { + return true; + } + if self.accepted_snaps().iter().any(|s| s.viewport != viewport) { + self.command_line.push_error(crate::t!("Pick geometry from the same viewport, or use paper-space points for the whole dimension.").as_ref()); + return false; + } + true + } + + /// A command can reject a degenerate point without advancing its step. + pub(crate) fn sync_dimension_snaps(&mut self, i: usize) { + if let Some(cmd) = self.tabs[i] + .active_cmd + .as_ref() + .filter(|c| c.measures_through_viewports()) + { + self.accepted_snaps + .truncate(cmd.dimension_acquired_points().len()); + } + } + + /// Record each definition point actually acquired by an object pick. + /// Arcs may supply three angular slots, lines two, and radial picks one. + pub(crate) fn record_dimension_entity_points( + &mut self, + i: usize, + frame: Option, entity: Handle, block_path: Vec, ) { - for _ in 0..2 { + let Some(cmd) = self.tabs[i] + .active_cmd + .as_ref() + .filter(|c| c.measures_through_viewports()) + else { + return; + }; + let points = cmd.dimension_acquired_points(); + for paper_point in points.into_iter().skip(self.accepted_snaps.len()) { self.push_accepted_snap(AcceptedSnap { paper_point, - model_point, - viewport: Some(frame.viewport), - frame: Some(frame), + model_point: frame.map_or(paper_point, |f| f.paper_to_model(paper_point)), + viewport: frame.map(|f| f.viewport), + frame, source: Some(SnapSourceRef { source: DimensionAssociationSource::inferred(entity), block_path: block_path.clone(), - // An object pick acquires the whole curve, not one - // feature; the per-slot feature point is derived from the - // committed dimension's own definition points. - snap_type: crate::snap::SnapType::Nearest, + snap_type: crate::snap::SnapType::ObjectPick, + intersection: None, }), }); } } - /// The accepted snap for the `idx`-th collected point of the active - /// command, with the typed-coordinate viewport fallback applied. - /// - /// Reads PR1's list -- there is no second store. - #[allow(dead_code)] - pub(crate) fn accepted_snap_for_point(&self, i: usize, idx: usize) -> Option { - self.accepted_snaps().get(idx).map(|snap| self.measure_snap(i, snap)) - } - - /// One accepted snap as *measurement* sees it. - /// - /// Identical to the recorded snap except that a point which carries no - /// snap at all (a typed coordinate, a dynamic-input point, a headless - /// script point) and lands inside a content viewport is re-attached to - /// that viewport, so typing `100,50` inside a 1:10 viewport measures the - /// model just like clicking there would. A snap that landed on real - /// paper-sheet geometry keeps `viewport: None` and is never reinterpreted. - pub(crate) fn measure_snap(&self, i: usize, snap: &AcceptedSnap) -> AcceptedSnap { - if snap.frame.is_some() || snap.source.is_some() { - return snap.clone(); - } - let scene = &self.tabs[i].scene; - if scene.current_layout == "Model" || scene.active_viewport.is_some() { - return snap.clone(); - } - match scene.viewport_frame_at_paper_point(snap.paper_point) { - Some(frame) => AcceptedSnap { - paper_point: snap.paper_point, - model_point: frame.paper_to_model(snap.paper_point), - viewport: Some(frame.viewport), - frame: Some(frame), - source: None, - }, - None => snap.clone(), - } - } - - /// The accepted snaps that say what the pending dimension *measures*, in - /// collection order. - /// - /// The final collected point of every dimension command in scope is the - /// dimension-line / text placement click, which says nothing about what is - /// being measured -- it routinely lands on bare sheet even for a viewport - /// measurement, and inside a viewport rectangle even for a plain paper - /// one. It is dropped here, which is also why association inference is - /// guarded on this set rather than on every recorded snap. - pub(crate) fn dimension_measuring_snaps(&self, i: usize) -> Vec { - let all = self.accepted_snaps(); - let measuring = match all.len() { - 0 | 1 => all, - n => &all[..n - 1], - }; - measuring.iter().map(|snap| self.measure_snap(i, snap)).collect() - } - - /// Classify how the pending dimension should be measured from the snaps - /// recorded for it. - pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { - let scene = &self.tabs[i].scene; - // Only a dimension being created *on the sheet* can measure through a - // viewport. Inside MSPACE the command already works in model space. - if scene.current_layout == "Model" || scene.active_viewport.is_some() { - return DimensionMeasureSpace::Direct; - } - let measuring = self.dimension_measuring_snaps(i); - let measuring = measuring.as_slice(); - if measuring.is_empty() { - return DimensionMeasureSpace::Direct; - } - let mut frame: Option = None; - let mut any_free = false; - for snap in measuring { - match snap.frame { - Some(f) => match frame { - None => frame = Some(f), - Some(existing) if existing.viewport == f.viewport => {} - Some(_) => return DimensionMeasureSpace::Mixed, - }, - None => any_free = true, - } - } - match (frame, any_free) { - (None, _) => DimensionMeasureSpace::Direct, - (Some(f), false) => DimensionMeasureSpace::Viewport(f), - // One origin through a viewport and the other on the sheet: the - // two points are not in a common measurable space. - (Some(_), true) => DimensionMeasureSpace::Mixed, - } - } - - /// Apply viewport compensation to a dimension about to be committed. - /// - /// Whether the dimension may also take a paper-space association is a - /// separate question with the same answer source: - /// [`OpenCADStudio::dimension_association_allowed`]. pub(crate) fn apply_viewport_dimension_measurement( &mut self, i: usize, entity: &mut EntityType, - ) { + ) -> bool { let EntityType::Dimension(dimension) = entity else { - return; + return true; }; - let is_angular = matches!( + let angular = matches!( dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) ); let frame = match self.dimension_measure_space(i) { - DimensionMeasureSpace::Direct => return, + DimensionMeasureSpace::Direct => return true, DimensionMeasureSpace::Mixed => { - self.command_line.push_warning( - crate::t!( - "Mixed-space measurement is not supported: the extension origins come from different viewports, or from a viewport and the sheet. Measuring in paper space instead (no viewport scale compensation)." - ) - .as_ref(), - ); - return; + self.command_line.push_error(crate::t!("Pick geometry from the same viewport, or use paper-space points for the whole dimension.").as_ref()); + return false; } - DimensionMeasureSpace::Viewport(frame) => frame, + DimensionMeasureSpace::Viewport(f) => f, }; - - // A similarity preserves angles, so an angular dimension reads the - // model angle already and needs no override. - if is_angular { - return; + if angular { + return true; } - - let compensation = frame.paper_to_model_length_factor(); - let style_dimlfac = self.pending_dimension_style_dimlfac(i, entity); - if let Some(value) = - MeasurementScale::viewport_dimlfac_override(style_dimlfac, compensation) - { - // The negative DIMLFAC convention is resolved from the dimension's - // owner block, and the commit path (or, with DIMASSOC = 0, the - // explode path) reads the entity before `add_entity_to_layout` - // assigns one. Stamping the layout block here — the same handle - // the add would set — keeps the entity self-describing, so the - // text is compensated whichever path consumes it. - let layout_block = self.tabs[i].scene.current_layout_block_handle_pub(); - if layout_block.is_valid() { - entity.common_mut().owner_handle = layout_block; - } - dim_override::set_on_entity( - entity, - dim_override::DIMLFAC, - Some(XDataValue::Real(value)), - ); + let dimlfac = self.pending_dimension_style_dimlfac(i, entity); + let scale = MeasurementScale { + user_lfac: MeasurementScale::user_lfac_for_space(dimlfac, true), + viewport_compensation: frame.paper_to_model_length_factor(), + }; + if !scale.paper_factor().is_finite() || scale.paper_factor() <= 0.0 { + return false; } + // Owner is needed even by DIMASSOC=0's explode path, before insertion. + entity.common_mut().owner_handle = self.tabs[i].scene.current_layout_block_handle_pub(); + scale.write_to_entity(entity); + true } - /// Explicit "select object" pick for a dimension command, resolved through - /// a paper-space content viewport. - /// - /// Paper-space hit testing only sees sheet entities, so a click on model - /// geometry displayed inside a viewport finds nothing. This maps the - /// cursor into model space through the viewport frame, hit-tests the - /// viewport's resident model wires (descending into block instances and - /// baking the instance transform), then hands the command a copy of the - /// picked entity projected onto the sheet. The commands therefore keep - /// working entirely in paper coordinates, and the measurement is - /// compensated at commit time like any other viewport measurement. - /// - /// Returns `None` when the click is not inside a content viewport or hits - /// nothing, so the caller falls back to the ordinary paper-space pick. pub(crate) fn try_dimension_viewport_entity_pick( &mut self, i: usize, @@ -266,42 +166,38 @@ impl OpenCADStudio { let pick = self.tabs[i] .scene .dimension_pick_through_viewport(paper, aperture_paper)?; - self.record_dimension_viewport_pick( - pick.frame, - pick.paper_point, - pick.model_point, - pick.entity_handle, - pick.block_path.clone(), - ); + if !self.dimension_acquisition_allowed(i, Some(pick.frame.viewport)) { + return Some(crate::command::CmdResult::NeedPoint); + } let command = self.tabs[i].active_cmd.as_mut()?; command.inject_picked_entity(pick.paper_entity); - Some(command.on_entity_pick(pick.entity_handle, pick.paper_point)) + let result = command.on_entity_pick(pick.entity_handle, pick.paper_point); + self.record_dimension_entity_points( + i, + Some(pick.frame), + pick.entity_handle, + pick.block_path, + ); + Some(result) } - /// The DIMLFAC the pending dimension would inherit *without* a viewport - /// override: its own existing override if it has one, otherwise its named - /// dimension style's value. fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 { let EntityType::Dimension(dimension) = entity else { return 1.0; }; - if let Some(value) = dim_override::real( + dim_override::real( &dimension.base().common.extended_data, dim_override::DIMLFAC, - ) { - return value; - } - let name = dimension.base().style_name.as_str(); - self.tabs[i] - .scene - .document - .dim_styles - .iter() - .find(|style| { - style.name.eq_ignore_ascii_case(name) - || (name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard")) - }) - .map(|style| style.dimlfac) - .unwrap_or(1.0) + ) + .or_else(|| { + self.tabs[i] + .scene + .document + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&dimension.base().style_name)) + .map(|s| s.dimlfac) + }) + .unwrap_or(1.0) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 9a5b4ebb8..77cd9f90a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,6 +11,8 @@ mod annotation_data; mod command_driver; pub(crate) mod commands; pub(crate) mod dim_viewport; +#[cfg(test)] +mod viewport_dimension_tests; mod document; mod drafting_settings; pub(crate) mod expr_eval; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7d0f11ec0..3971ec478 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2374,6 +2374,8 @@ impl OpenCADStudio { extension_dir: None, viewport: None, source: None, + secondary_source: None, + model_point: None, }); if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { cmd.set_acquisition_hint(Some(pick.label)); @@ -3853,29 +3855,7 @@ impl OpenCADStudio { command.set_ctrl(self.ctrl_down); command.set_shift(self.shift_down); } - // PR2: a dimension "select object" pick that lands inside a paper - // content viewport is resolved against the model geometry that - // viewport displays. Paper hit testing only sees sheet entities, - // so this runs first and falls through when it finds nothing. - let dimension_viewport_pick = if self.tabs[i].active_cmd.as_ref().is_some_and( - |command| command.needs_entity_pick() && command.measures_through_viewports(), - ) { - let aperture_paper = { - let camera = self.tabs[i].scene.camera.borrow(); - let per_px = if bounds.height > 0.0 { - camera.ortho_size() as f64 * 2.0 / bounds.height as f64 - } else { - 0.0 - }; - crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px - }; - self.try_dimension_viewport_entity_pick(i, pick_wcs, aperture_paper) - } else { - None - }; - let result = if let Some(result) = dimension_viewport_pick { - Some(result) - } else if self.tabs[i] + let result = if self.tabs[i] .active_cmd .as_ref() .map(|c| c.needs_structure_point_pick()) @@ -4065,6 +4045,7 @@ impl OpenCADStudio { } } + if !self.dimension_acquisition_allowed(i, None) { self.finish_command_click(i); return Task::none(); } let shift = self.shift_down; let result = self.tabs[i].active_cmd.as_mut().map(|c| { // Shift-swap state for TRIM/EXTEND (#336). @@ -4072,6 +4053,7 @@ impl OpenCADStudio { c.set_entity_pick_direction(entity_pick_direction); c.on_entity_pick(handle, entity_pick_point) }); + self.record_dimension_entity_points(i, None, handle, Vec::new()); // HATCHEDIT: after pick, inject hatch model data into the command. if self.tabs[i] .active_cmd @@ -4142,11 +4124,24 @@ impl OpenCADStudio { } } result + } else if let Some(result) = { + let dimension = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.measures_through_viewports()); + if dimension { + let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0 / (bounds.height as f64).max(1.0); + self.try_dimension_viewport_entity_pick(i, pick_wcs, crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px) + } else { None } + } { + Some(result) } else if self.tabs[i] .active_cmd .as_ref() .is_some_and(|command| command.entity_pick_accepts_points()) { + let pending = self.pending_click_snap.take(); + if !self.record_accepted_snap(i, pending.map(|(hit, _)| hit), pending.and_then(|(_, frame)| frame), pick_wcs) { + self.finish_command_click(i); + return Task::none(); + } self.refresh_command_point_pick_context(i); self.tabs[i] .active_cmd @@ -4215,17 +4210,18 @@ impl OpenCADStudio { self.tabs[i].dyn_active = 0; } } - self.last_point = Some(world_pt); // Record what this point step accepted, alongside the point // itself (PR1): paper point, model point, viewport, frame and // geometry identity. Commands that don't care keep consuming // `world_pt` exactly as before. let pending = self.pending_click_snap.take(); - self.record_accepted_snap( + if !self.record_accepted_snap( + i, pending.map(|(hit, _)| hit), pending.and_then(|(_, frame)| frame), world_pt, - ); + ) { self.finish_command_click(i); return Task::none(); } + self.last_point = Some(world_pt); // The one-shot snap override is spent by this pick — // restore the running osnap configuration (#337). self.snapper.clear_override(); @@ -4260,6 +4256,7 @@ impl OpenCADStudio { } }; + self.sync_dimension_snaps(i); if let Some(r) = result { let task = self.apply_cmd_result(r); let mut sel = self.tabs[i].scene.selection.borrow_mut(); diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 0265b36c7..35c112b85 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -102,10 +102,11 @@ impl OpenCADStudio { /// after ortho / polar / axis-lock / dynamic-input resolution. pub(crate) fn record_accepted_snap( &mut self, + tab: usize, snap: Option, frame: Option, committed: glam::DVec3, - ) { + ) -> bool { let accepted = match snap { // A viewport snap keeps its frame only when the hit really came // through it, so a paper-sheet snap never claims a model point. @@ -115,7 +116,24 @@ impl OpenCADStudio { } None => AcceptedSnap::free(committed), }; + if self.tabs[tab] + .active_cmd + .as_ref() + .is_some_and(|cmd| cmd.measures_through_viewports()) + { + if self.tabs[tab] + .active_cmd + .as_ref() + .is_some_and(|cmd| cmd.dimension_placement_pending()) + { + return true; + } + if !self.dimension_acquisition_allowed(tab, accepted.viewport) { + return false; + } + } self.push_accepted_snap(accepted); + true } /// Append an already-built accepted snap, keeping the retention cap. @@ -165,30 +183,12 @@ impl OpenCADStudio { return None; } - let handles = self.tabs[i].scene.layout_content_viewports(); - // Draw order puts the last-drawn viewport on top; probe top-most first - // so overlapping viewports resolve the same way they render. - for &handle in handles.iter().rev() { - if !self.tabs[i].scene.viewport_displays_content(handle) { - continue; - } - let Some(frame) = self.tabs[i].scene.viewport_frame(handle) else { - continue; // oblique / perspective viewport: unsupported - }; - // Clip first (rectangle + non-rectangular clip boundary), in paper - // coordinates, so a cursor over the cut-away part of a clipped - // viewport sees nothing. - if !self - .tabs[i] - .scene - .viewport_displays_paper_point(handle, cursor_paper.truncate()) - { - continue; - } - let Some((cam, rect)) = self - .tabs[i] - .scene - .viewport_edit_frame_for(handle, canvas) + for frame in self.tabs[i] + .scene + .viewport_frames_at_paper_point(cursor_paper) + { + let handle = frame.viewport; + let Some((cam, rect)) = self.tabs[i].scene.viewport_edit_frame_for(handle, canvas) else { continue; }; @@ -208,8 +208,7 @@ impl OpenCADStudio { // the viewport camera projects back onto `local`. let model_cursor = frame.paper_to_model(cursor_paper); - let wires = self - .tabs[i] + let wires = self.tabs[i] .scene .model_wires_for_viewport_arc(handle, bounds.height); let candidates = self.tabs[i].scene.interaction_candidates_near( @@ -221,14 +220,14 @@ impl OpenCADStudio { self.snapper.osnap_radius_px, ); - // Object snap only. The grid belongs to the paper sheet, and the - // rubber-band origin / tracking points are paper-space points, so - // perpendicular and extension feet would be nonsense in model - // space. Save and restore, since `Snapper` is shared state. + // The grid belongs to the sheet. Translate the construction + // anchor for perpendicular/tangent snaps into this viewport's + // model coordinates, then restore the shared snapper state. let saved_grid = self.snapper.grid_snap_on; let saved_from = self.snapper.from_point; self.snapper.grid_snap_on = false; - self.snapper.from_point = None; + self.snapper.from_point = + saved_from.map(|p| frame.paper_to_model(p.as_dvec3()).as_vec3()); let hit = self.snapper.snap( model_cursor, local, @@ -251,8 +250,7 @@ impl OpenCADStudio { // the aperture reaching past the clip. Reject those: a snap must be // a real feature the viewport actually displays. let paper = frame.model_to_paper(hit.world); - if !self - .tabs[i] + if !self.tabs[i] .scene .viewport_displays_paper_point(handle, paper.truncate()) { @@ -291,5 +289,7 @@ fn project_hit_to_paper( }), viewport: Some(viewport), source: hit.source, + secondary_source: hit.secondary_source, + model_point: Some(hit.world), } } diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs new file mode 100644 index 000000000..5ac9cb658 --- /dev/null +++ b/src/app/viewport_dimension_tests.rs @@ -0,0 +1,379 @@ +use super::OpenCADStudio; +use crate::command::{DimensionAssociationSource, StepInput}; +use crate::entities::dim_override; +use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; +use crate::scene::Scene; +use crate::snap::{SnapResult, SnapType}; +use acadrust::entities::{Circle, Dimension, Line, Viewport}; +use acadrust::types::{Handle, Vector3}; +use acadrust::{CadDocument, EntityType}; +use glam::DVec3; + +fn fixture() -> (OpenCADStudio, Handle, ViewportFrame) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let mut scene = Scene::new(); + let line = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(100.0, 0.0, 0.0), + ))); + scene.document.add_layout("Dimensions").unwrap(); + scene.set_current_layout("Dimensions".into()); + let mut viewport = Viewport::new(); + viewport.id = 2; + viewport.center = Vector3::new(50.0, 50.0, 0.0); + viewport.width = 100.0; + viewport.height = 100.0; + viewport.view_height = 1000.0; + viewport.custom_scale = 0.1; + viewport.view_direction = Vector3::UNIT_Z; + viewport.status.is_on = true; + let viewport = scene.add_entity(EntityType::Viewport(viewport)); + let frame = scene.viewport_frame(viewport).unwrap(); + scene.document.header.dimension_associativity = 2; + let i = app.active_tab; + app.tabs[i].scene = scene; + (app, line, frame) +} + +fn hit(frame: ViewportFrame, source: Handle, model: DVec3) -> SnapResult { + SnapResult { + world: frame.model_to_paper(model), + model_point: Some(model), + screen: iced::Point::ORIGIN, + snap_type: SnapType::Endpoint, + tangent_obj: None, + extension_base: None, + extension_base2: None, + extension_origin: None, + extension_dir: None, + viewport: Some(frame.viewport), + source: Some(DimensionAssociationSource::inferred(source)), + secondary_source: None, + } +} + +fn point(app: &mut OpenCADStudio, frame: ViewportFrame, source: Handle, model: DVec3) { + let i = app.active_tab; + let hit = hit(frame, source, model); + let paper = hit.world.with_z(0.0); + assert!(app.record_accepted_snap(i, Some(hit), Some(frame), paper)); + let result = app.tabs[i].active_cmd.as_mut().unwrap().on_point(paper); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); +} + +fn dimension(doc: &CadDocument) -> &Dimension { + doc.entities() + .find_map(|e| match e { + EntityType::Dimension(d) => Some(d), + _ => None, + }) + .unwrap() +} + +fn displayed(doc: &CadDocument) -> f64 { + let dim = dimension(doc); + if matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + return dim.measurement(); + } + let factor = dim_override::real(&dim.base().common.extended_data, dim_override::DIMLFAC) + .or_else(|| { + doc.dim_styles + .get(&dim.base().style_name) + .map(|s| s.dimlfac) + }) + .unwrap_or(1.0); + dim.measurement() * MeasurementScale::user_lfac_for_space(factor, true) +} + +fn finish_aligned(app: &mut OpenCADStudio, line: Handle, frame: ViewportFrame) { + let _ = app.dispatch_command("DIMALIGNED"); + point(app, frame, line, DVec3::ZERO); + point(app, frame, line, DVec3::X * 100.0); + let _ = app.feed_command(StepInput::Point(DVec3::new(250.0, 200.0, 0.0))); +} + +#[test] +fn viewport_dimension_projected_snap_keeps_identity_and_elevation() { + let (_, line, frame) = fixture(); + let model = DVec3::new(100.0, 0.0, 35.0); + let hit = hit(frame, line, model); + let accepted = + AcceptedSnap::from_snap(&hit, Some(frame)).with_paper_point(hit.world.with_z(0.0)); + assert_eq!(accepted.model_point, model); + assert_eq!(accepted.source.as_ref().unwrap().source.handle, line); + let moved = accepted.with_paper_point(hit.world + DVec3::X); + assert!(moved.source.is_none()); + assert!((moved.model_point - frame.paper_to_model(moved.paper_point)).length() < 1e-9); +} + +#[test] +fn viewport_dimension_creation_preserves_active_units_and_roundtrips() { + for factor in [25.4, -25.4] { + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + let mut style = acadrust::tables::DimStyle::new("Millimetres"); + style.dimlfac = factor; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "Millimetres".into(); + finish_aligned(&mut app, line, frame); + let doc = &app.tabs[i].scene.document; + assert_eq!(dimension(doc).base().style_name, "Millimetres"); + assert!((displayed(doc) - 2540.0).abs() < 1e-5, "{}", displayed(doc)); + let data = MeasurementScale::read(&dimension(doc).base().common.extended_data).unwrap(); + assert_eq!(data.user_lfac, 25.4); + assert_eq!(doc.header.dimension_associativity, 2); + for ext in ["dxf", "dwg"] { + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let loaded = crate::io::load_bytes(&format!("viewport.{ext}"), bytes).unwrap(); + assert!( + (displayed(&loaded) - 2540.0).abs() < 1e-4, + "{ext}: {}", + displayed(&loaded) + ); + assert_eq!( + MeasurementScale::read(&dimension(&loaded).base().common.extended_data), + Some(data) + ); + } + app.undo_steps(1); + assert!(!app.tabs[i] + .scene + .document + .entities() + .any(|e| matches!(e, EntityType::Dimension(_)))); + app.redo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 2540.0).abs() < 1e-5); + } +} + +#[test] +fn viewport_dimension_mixed_and_degenerate_picks_allow_retry() { + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + point(&mut app, frame, line, DVec3::ZERO); + assert_eq!(app.accepted_snaps().len(), 1); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 50.0, 0.0))); + assert_eq!(app.accepted_snaps().len(), 1); + assert_eq!( + app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .dimension_acquired_points() + .len(), + 1 + ); + let other = ViewportFrame { + viewport: Handle::new(0xFFFF), + ..frame + }; + assert!(!app.record_accepted_snap( + i, + Some(hit(other, line, DVec3::X * 100.0)), + Some(other), + DVec3::new(60.0, 50.0, 0.0) + )); + point(&mut app, frame, line, DVec3::X * 100.0); + let _ = app.feed_command(StepInput::Point(DVec3::new(250.0, 200.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_typed_sheet_points_keep_paper_units() { + let (mut app, _, _) = fixture(); + let result = app.automation_op(r#"{"op":"run","cmd":"DIMALIGNED 50,50 60,50 60,55"}"#); + assert_eq!(result["ok"], true); + let doc = &app.tabs[app.active_tab].scene.document; + assert!((displayed(doc) - 10.0).abs() < 1e-9); + assert!(MeasurementScale::read(&dimension(doc).base().common.extended_data).is_none()); +} + +#[test] +fn viewport_dimension_linear_aligned_and_radial_object_picks() { + for (name, expected) in [ + ("DIMLINEAR", 100.0), + ("DIMALIGNED", 100.0), + ("DIMRADIUS", 100.0), + ("DIMDIAMETER", 200.0), + ] { + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let radial = name == "DIMRADIUS" || name == "DIMDIAMETER"; + let model = if radial { + let mut circle = Circle::new(); + circle.center = Vector3::new(0.0, 200.0, 0.0); + circle.radius = 100.0; + app.tabs[i].scene.set_current_layout("Model".into()); + app.tabs[i].scene.add_entity(EntityType::Circle(circle)); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + DVec3::new(100.0, 200.0, 0.0) + } else { + DVec3::new(50.0, 0.0, 0.0) + }; + let _ = app.dispatch_command(name); + if !radial { + let _ = app.feed_command(StepInput::Enter); + } + let result = app + .try_dimension_viewport_entity_pick(i, frame.model_to_paper(model), 0.5) + .unwrap(); + let _ = app.apply_cmd_result(result); + assert_eq!( + app.accepted_snaps().len(), + if radial { 1 } else { 2 }, + "{name}" + ); + let _ = app.feed_command(StepInput::Point(DVec3::new(55.0, 80.0, 0.0))); + assert!( + (displayed(&app.tabs[i].scene.document) - expected).abs() < 1e-4, + "{name}: {}", + displayed(&app.tabs[i].scene.document) + ); + } +} + +#[test] +fn viewport_dimension_angular_preserves_angle_without_length_factor() { + let (mut app, line, frame) = fixture(); + let _ = app.dispatch_command("DIMANGULAR"); + let _ = app.feed_command(StepInput::Enter); + point(&mut app, frame, line, DVec3::ZERO); + point(&mut app, frame, line, DVec3::X * 100.0); + point(&mut app, frame, line, DVec3::Y * 100.0); + let _ = app.feed_command(StepInput::Point(DVec3::new(70.0, 70.0, 0.0))); + let doc = &app.tabs[app.active_tab].scene.document; + assert!((dimension(doc).measurement() - 90.0).abs() < 1e-5); + assert!(MeasurementScale::read(&dimension(doc).base().common.extended_data).is_none()); +} + +#[test] +fn viewport_dimension_snap_query_matches_pan_scale_and_twist() { + for (height, twist, target) in [ + (1000.0, 0.0, Vector3::ZERO), + (500.0, 0.45, Vector3::new(80.0, 20.0, 0.0)), + ] { + let (mut app, line, initial) = fixture(); + let i = app.active_tab; + let Some(EntityType::Viewport(vp)) = + app.tabs[i].scene.document.get_entity_mut(initial.viewport) + else { + panic!() + }; + vp.view_height = height; + vp.custom_scale = vp.height / height; + vp.twist_angle = twist; + vp.view_target = target; + let frame = app.tabs[i].scene.viewport_frame(initial.viewport).unwrap(); + let model = DVec3::X * 100.0; + let paper = frame.model_to_paper(model); + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + let screen = { + let cam = app.tabs[i].scene.camera.borrow(); + let ndc = cam + .view_proj_rte(bounds) + .project_point3((paper - cam.eye()).as_vec3()); + iced::Point::new((ndc.x + 1.0) * 500.0, (1.0 - ndc.y) * 500.0) + }; + app.snapper.snap_enabled = true; + let (hit, acquired_frame) = app + .paper_viewport_snap(i, screen, (1000.0, 1000.0), paper) + .unwrap(); + assert_eq!(hit.source.unwrap().handle, line); + assert!((hit.world - paper).length() < 1e-5, "{hit:?}"); + let accepted = AcceptedSnap::from_snap(&hit, Some(acquired_frame)); + assert!((accepted.model_point - model).length() < 1e-6); + } +} + +#[test] +fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { + use acadrust::entities::LwPolyline; + use acadrust::types::Vector2; + let (mut app, _, first) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut overlay = scene.document.get_entity(first.viewport).unwrap().clone(); + if let EntityType::Viewport(vp) = &mut overlay { + vp.id = 3; + vp.width = 200.0; + } + overlay.common_mut().handle = Handle::NULL; + let top = scene.add_entity(overlay); + let paper = DVec3::new(55.0, 50.0, 0.0); + assert_eq!( + scene.viewport_frame_at_paper_point(paper).unwrap().viewport, + top + ); + assert_eq!( + scene + .dimension_pick_through_viewport(paper, 0.2) + .unwrap() + .frame + .viewport, + top + ); + let mut clip = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(100.0, 0.0), + Vector2::new(0.0, 100.0), + ]); + clip.is_closed = true; + let clip = scene.add_entity(EntityType::LwPolyline(clip)); + if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(top) { + vp.clip_boundary_handle = clip; + } + assert_eq!( + scene.viewport_frame_at_paper_point(paper).unwrap().viewport, + first.viewport + ); + assert_eq!( + scene + .dimension_pick_through_viewport(paper, 0.2) + .unwrap() + .frame + .viewport, + first.viewport + ); + if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(first.viewport) { + vp.status.is_on = false; + } + assert!(scene.viewport_frame_at_paper_point(paper).is_none()); + assert!(scene.dimension_pick_through_viewport(paper, 0.2).is_none()); +} + +#[test] +fn viewport_dimension_exploded_commit_uses_compensated_text_and_undo() { + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.document.header.dimension_associativity = 0; + finish_aligned(&mut app, line, frame); + let doc = &app.tabs[i].scene.document; + assert!(!doc + .entities() + .any(|e| matches!(e, EntityType::Dimension(_)))); + let texts: Vec<_> = doc + .entities() + .filter_map(|e| match e { + EntityType::Text(t) => Some(t.value.clone()), + EntityType::MText(t) => Some(t.value.clone()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t.contains("100")), "{texts:?}"); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document.header.dimension_associativity, 0); + app.redo_steps(1); + assert!(app.tabs[i] + .scene + .document + .entities() + .any(|e| matches!(e, EntityType::Text(_) | EntityType::MText(_)))); +} diff --git a/src/command.rs b/src/command.rs index a8a18632b..a248db896 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2254,6 +2254,14 @@ pub trait CadCommand: Send { false } + /// Definition points acquired so far, in the command's working space. + /// Used to retain references only for accepted measuring inputs. + fn dimension_acquired_points(&self) -> Vec { Vec::new() } + + /// The next point places annotation rather than acquiring geometry. + fn dimension_placement_pending(&self) -> bool { false } + + fn needs_entity_pick(&self) -> bool { false } diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 572f09dca..44ee7556a 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -1638,25 +1638,11 @@ pub(crate) fn dimension_in_paper_space(dimension: &Dimension, document: &CadDocu if !owner.is_valid() { return false; } - let model_block = document - .block_records - .get("*Model_Space") - .map(|record| record.handle) - .filter(|handle| handle.is_valid()) - .or_else(|| { - document.objects.values().find_map(|object| match object { - acadrust::objects::ObjectType::Layout(layout) - if layout.name == "Model" && !layout.block_record.is_null() => - { - Some(layout.block_record) - } - _ => None, - }) - }); - match model_block { - Some(model) => owner != model, - None => false, - } + document.objects.values().any(|object| matches!(object, + acadrust::objects::ObjectType::Layout(layout) + if layout.name != "Model" && layout.block_record == owner + )) + } /// The value a dimension's text reports for its measurement: the raw diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 157b6152c..9cde3e7f2 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -167,6 +167,17 @@ impl CadCommand for AlignedDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::First => vec![], Step::Second(p) => vec![p], + Step::DimLine { p1, p2 } => vec![p1, p2], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimLine { .. }) + } + fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index b253dda77..e9bdb0aea 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -488,6 +488,20 @@ impl CadCommand for AngularDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::Vertex => vec![], Step::FirstRay(p) => vec![p], + Step::SecondRay { vertex, first } | Step::CircleSecondRay { vertex, first, .. } => vec![vertex, first], + Step::SecondLine { first_start, first_end, .. } => vec![first_start, first_end], + Step::ArcPoint3 { vertex, first, second } => vec![vertex, first, second], + Step::ArcPoint2 { first_start, first_end, second_start, second_end } => vec![first_start, first_end, second_start, second_end], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. }) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index e03e2e630..b128d4dde 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -138,6 +138,16 @@ impl CadCommand for DiameterDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::SelectObject => vec![], Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimLine(_)) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 7c203efc3..e8f55c32f 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -223,6 +223,17 @@ impl CadCommand for LinearDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::FirstPoint => vec![], Step::SecondPoint(p) => vec![p], + Step::DimensionLine { first, second } => vec![first, second], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimensionLine { .. }) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index e72a84465..214933a8e 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -136,6 +136,16 @@ impl CadCommand for RadiusDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::SelectObject => vec![], Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimLine(_)) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index dc16c99f4..11f516329 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -160,6 +160,18 @@ impl Scene { }) } + /// Visible, supported content viewports at a sheet point, topmost first. + /// Shared by snapping and explicit dimension object picking. + pub fn viewport_frames_at_paper_point(&self, paper: glam::DVec3) -> Vec { + if self.current_layout == "Model" || self.active_viewport.is_some() { return Vec::new(); } + self.layout_content_viewports().iter().rev().filter_map(|&handle| { + if !self.viewport_displays_content(handle) || !self.viewport_displays_paper_point(handle, paper.truncate()) { + return None; + } + self.viewport_frame(handle) + }).collect() + } + /// Does `paper` lie inside the viewport's displayed area? /// /// Always bounded by the viewport's paper rectangle; a viewport with a @@ -184,9 +196,8 @@ impl Scene { } let poly = self.clip_boundary_polygon(vp.clip_boundary_handle, vp.center.z as f32); if poly.len() < 3 { - // A clip entity we cannot tessellate: fall back to the rectangle - // rather than refusing every snap in the viewport. - return true; + // An unsupported boundary cannot establish snap eligibility. + return false; } point_in_polygon_xy(paper, &poly) } diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index 575aaf505..1954d240a 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -21,7 +21,9 @@ use super::*; use crate::command::EntityTransform; use crate::scene::viewport_ref::ViewportFrame; use acadrust::types::{Matrix4, Transform}; -use glam::{DVec2, DVec3}; +#[cfg(test)] +use glam::DVec2; +use glam::DVec3; /// A model entity resolved by clicking inside a paper-space content viewport. #[derive(Clone, Debug)] @@ -44,36 +46,16 @@ pub struct ViewportDimPick { } impl Scene { - - /// The smallest *on* content viewport of the current layout whose paper - /// rectangle contains `paper`. `None` on the Model tab, while editing - /// inside a viewport (MSPACE), or when the point is on bare sheet. pub fn content_viewport_at_paper_point(&self, paper: DVec3) -> Option { - if self.current_layout == "Model" || self.active_viewport.is_some() { - return None; - } - let (_, _, handles) = self.paper_viewport_handles(); - handles - .iter() - .filter_map(|handle| { - let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { - return None; - }; - if !vp.status.is_on || vp.width <= 0.0 || vp.height <= 0.0 { - return None; - } - let dx = (paper.x - vp.center.x).abs(); - let dy = (paper.y - vp.center.y).abs(); - (dx <= vp.width * 0.5 && dy <= vp.height * 0.5) - .then(|| (*handle, vp.width * vp.height)) - }) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(handle, _)| handle) + self.viewport_frames_at_paper_point(paper) + .first() + .map(|f| f.viewport) } - /// [`Scene::content_viewport_at_paper_point`] resolved to a frame. pub fn viewport_frame_at_paper_point(&self, paper: DVec3) -> Option { - self.viewport_frame(self.content_viewport_at_paper_point(paper)?) + self.viewport_frames_at_paper_point(paper) + .into_iter() + .next() } /// Resolve an explicit dimension object pick made on the sheet but landing @@ -86,28 +68,42 @@ impl Scene { paper: DVec3, aperture_paper: f64, ) -> Option { - let frame = self.viewport_frame_at_paper_point(paper)?; - let model_point = frame.paper_to_model(paper); - let aperture_model = - (aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9); + for frame in self.viewport_frames_at_paper_point(paper) { + let model_point = frame.paper_to_model(paper); + let aperture_model = + (aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9); - let top = self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model)?; - let (entity, block_path) = self.resolve_measurable_entity(top, model_point)?; + let Some(top) = + self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model) + else { + continue; + }; + let Some((entity, block_path)) = + self.resolve_measurable_entity(top, model_point, Some(frame.viewport)) + else { + continue; + }; - let mut paper_entity = entity; - crate::scene::view::dispatch::apply_transform( - &mut paper_entity, - &EntityTransform::Affine(viewport_model_to_paper_transform(&frame)), - ); - let entity_handle = paper_entity.as_entity().handle(); - Some(ViewportDimPick { - frame, - entity_handle: if entity_handle.is_valid() { entity_handle } else { top }, - block_path, - paper_entity, - paper_point: paper, - model_point, - }) + let mut paper_entity = entity; + crate::scene::view::dispatch::apply_transform( + &mut paper_entity, + &EntityTransform::Affine(viewport_model_to_paper_transform(&frame)), + ); + let entity_handle = paper_entity.as_entity().handle(); + return Some(ViewportDimPick { + frame, + entity_handle: if entity_handle.is_valid() { + entity_handle + } else { + top + }, + block_path, + paper_entity, + paper_point: paper, + model_point, + }); + } + None } /// Nearest resident model wire of `viewport` to `model_point`, within @@ -119,7 +115,23 @@ impl Scene { model_point: DVec3, aperture: f64, ) -> Option { - let wires = self.model_wires_for_viewport_arc(viewport, 0.0); + let camera = self.camera_for_viewport(viewport)?; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + let radius_px = + (aperture / (2.0 * camera.ortho_size() as f64) * bounds.height as f64) as f32; + let wires = self.interaction_pick_candidates_near( + self.model_wires_for_viewport_arc(viewport, 0.0), + model_point, + camera.view_proj_rte(bounds), + camera.eye(), + bounds, + radius_px.max(1.0), + ); let mut best: Option<(f64, Handle)> = None; for wire in wires.iter() { if !wire.display_visible { @@ -133,8 +145,17 @@ impl Scene { .as_ref() .map(|inst| DVec3::from_array(inst.translation)) .unwrap_or(DVec3::ZERO); - let distance = wire_polyline_distance(wire, offset, model_point); - if let Some(distance) = distance { + let distance = wire_polyline_nearest(wire, offset, model_point); + if let Some((distance, nearest)) = distance { + let Some(frame) = self.viewport_frame(viewport) else { + continue; + }; + if !self.viewport_displays_paper_point( + viewport, + frame.model_to_paper(nearest).truncate(), + ) { + continue; + } if distance <= aperture && best.as_ref().is_none_or(|(d, _)| distance < *d) { best = Some((distance, handle)); } @@ -146,10 +167,11 @@ impl Scene { /// Turn a picked handle into a measurable planar entity in model (WCS) /// coordinates, descending through block instances and baking the /// instance transform into the returned clone. - fn resolve_measurable_entity( + pub(crate) fn resolve_measurable_entity( &self, handle: Handle, model_point: DVec3, + viewport: Option, ) -> Option<(EntityType, Vec)> { let entity = self.document.get_entity(handle)?; match entity { @@ -161,6 +183,7 @@ impl Scene { model_point, &mut path, 0, + viewport, )?; Some((found.0, found.1)) } @@ -180,6 +203,7 @@ impl Scene { model_point: DVec3, path: &mut Vec, depth: usize, + viewport: Option, ) -> Option<(EntityType, Vec)> { const MAX_DEPTH: usize = 8; if depth > MAX_DEPTH { @@ -199,6 +223,14 @@ impl Scene { let Some(child_entity) = self.document.get_entity(child) else { continue; }; + let common = child_entity.common(); + let layer = self.document.layers.get(&common.layer); + let frozen = viewport.and_then(|h| self.document.get_entity(h)).is_some_and(|entity| { + matches!(entity, EntityType::Viewport(vp) if layer.is_some_and(|l| vp.frozen_layers.contains(&l.handle))) + }); + if common.invisible || layer.is_some_and(|l| l.flags.off || l.flags.frozen) || frozen { + continue; + } if matches!(child_entity, EntityType::Insert(_)) { let mut nested = Vec::new(); if let Some((entity, mut nested_path)) = self.descend_block_instance( @@ -207,6 +239,7 @@ impl Scene { model_point, &mut nested, depth + 1, + viewport, ) { if let Some(distance) = planar_pick_distance(&entity, model_point) { if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { @@ -218,6 +251,22 @@ impl Scene { } continue; } + // A nonuniform block transform turns circular geometry into an + // ellipse. The entity transform API retains the circle type, so + // decline this object pick instead of manufacturing a radius. + let curved = matches!(child_entity, EntityType::Circle(_) | EntityType::Arc(_)) + || matches!(child_entity, EntityType::LwPolyline(p) if p.vertices.iter().any(|v| v.bulge != 0.0)); + let m = &combined.matrix.m; + let x = DVec3::new(m[0][0], m[1][0], m[2][0]); + let y = DVec3::new(m[0][1], m[1][1], m[2][1]); + let magnitude = x.length_squared().max(y.length_squared()); + if curved + && (magnitude < 1e-24 + || (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-10 + || x.dot(y).abs() > magnitude * 1e-10) + { + continue; + } let mut placed = child_entity.clone(); crate::scene::view::dispatch::apply_transform( &mut placed, @@ -259,11 +308,11 @@ pub fn viewport_model_to_paper_transform(frame: &ViewportFrame) -> Transform { /// Distance from `point` to a wire's polyline, in XY. `None` when the wire has /// no usable segment. -fn wire_polyline_distance( +fn wire_polyline_nearest( wire: &crate::scene::WireModel, offset: DVec3, point: DVec3, -) -> Option { +) -> Option<(f64, DVec3)> { let count = wire.points.len(); if count == 0 { return None; @@ -278,6 +327,7 @@ fn wire_polyline_distance( ) + offset }; let mut best = f64::INFINITY; + let mut best_point = DVec3::ZERO; let mut previous: Option = None; for i in 0..count { let current = at(i); @@ -285,77 +335,41 @@ fn wire_polyline_distance( previous = None; continue; } - match previous { - Some(start) => best = best.min(segment_distance_xy(start, current, point)), - None => best = best.min((current.truncate() - point.truncate()).length()), + let nearest = previous.map_or(current, |start| { + let delta = current - start; + let len2 = delta.truncate().length_squared(); + let t = if len2 > 1e-24 { + ((point - start).truncate().dot(delta.truncate()) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + start + delta * t + }); + let distance = (nearest - point).truncate().length(); + if distance < best { + best = distance; + best_point = nearest; } previous = Some(current); } - best.is_finite().then_some(best) -} - -fn segment_distance_xy(a: DVec3, b: DVec3, p: DVec3) -> f64 { - let ab = b.truncate() - a.truncate(); - let ap = p.truncate() - a.truncate(); - let len2 = ab.length_squared(); - if len2 < 1e-24 { - return ap.length(); - } - let t = (ap.dot(ab) / len2).clamp(0.0, 1.0); - (ap - ab * t).length() + best.is_finite().then_some((best, best_point)) } /// Distance from `point` to the planar geometry object-pick dimensioning /// supports. `None` for entity types that cannot be dimensioned by picking. pub fn planar_pick_distance(entity: &EntityType, point: DVec3) -> Option { - let p = point.truncate(); - match entity { - EntityType::Line(line) => Some(segment_distance_xy( - DVec3::new(line.start.x, line.start.y, line.start.z), - DVec3::new(line.end.x, line.end.y, line.end.z), - point, - )), - EntityType::Circle(circle) => { - let c = DVec2::new(circle.center.x, circle.center.y); - Some(((p - c).length() - circle.radius).abs()) - } - EntityType::Arc(arc) => { - let c = DVec2::new(arc.center.x, arc.center.y); - Some(((p - c).length() - arc.radius).abs()) - } - EntityType::LwPolyline(polyline) => polyline_distance( - polyline - .vertices - .iter() - .map(|v| DVec3::new(v.location.x, v.location.y, polyline.elevation)), - polyline.is_closed, - point, - ), - EntityType::Polyline2D(polyline) => polyline_distance( - polyline - .vertices - .iter() - .map(|v| DVec3::new(v.location.x, v.location.y, polyline.elevation)), - polyline.is_closed(), - point, - ), - _ => None, - } -} - -fn polyline_distance( - points: impl IntoIterator, - closed: bool, - point: DVec3, -) -> Option { - let points: Vec = points.into_iter().collect(); - if points.len() < 2 { + let planar = crate::entities::curve::entity_curve(entity)?; + let normal = DVec3::from_array(planar.plane.normal()?); + if normal.cross(DVec3::Z).length_squared() > 1e-12 { return None; } - let count = if closed { points.len() } else { points.len() - 1 }; - (0..count) - .map(|i| segment_distance_xy(points[i], points[(i + 1) % points.len()], point)) - .reduce(f64::min) + let uv = planar.plane.project(point.to_array())?; + let nearest = cadkernel::geom2d::closest_point(&planar.curve, uv); + Some( + (DVec3::from_array(planar.plane.point_at(nearest.point)) - point) + .truncate() + .length(), + ) } #[cfg(test)] diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index 7c64f4e29..3ce7dfae9 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -1,20 +1,4 @@ -//! Shared interfaces for paper-space snapping through layout viewports and -//! for viewport-aware dimension measurement. -//! -//! Three PRs build on this module concurrently: -//! * PR1 (viewport snap) constructs [`ViewportFrame`] from the live viewport -//! camera and fills [`SnapSourceRef`] / [`AcceptedSnap`] when a snap is -//! accepted in paper space. -//! * PR2 (dimension measurement) consumes [`AcceptedSnap`] and uses -//! [`MeasurementScale`] to split the user's DIMLFAC from viewport -//! compensation so the compensation is applied exactly once. -//! * PR3 (associations) turns [`SnapSourceRef`] into persistent -//! `AssocDimensionReference` chains (dimension -> viewport -> block path -> -//! entity -> feature) and resolves them back. -//! -//! Anything here is a contract between those branches. Extend by adding -//! fields/methods; do not rename or remove what exists without updating the -//! sibling branches. +//! Coordinates and feature identity shared by viewport acquisition and dimensions. use acadrust::types::Handle; use glam::{DMat3, DVec2, DVec3}; @@ -27,8 +11,7 @@ use crate::snap::SnapType; /// scale (paper units per model unit) and in-plane rotation (`twist`, /// radians, counter-clockwise, applied to model geometry before scaling). /// -/// PR1 owns construction (`Scene::viewport_frame`); everyone else only reads -/// it. All arithmetic is `f64`. +/// Constructed by `Scene::viewport_frame`. All arithmetic is `f64`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ViewportFrame { /// Viewport entity handle. @@ -105,6 +88,8 @@ pub struct SnapSourceRef { pub source: DimensionAssociationSource, pub block_path: Vec, pub snap_type: SnapType, + /// The other entity/instance path for a true intersection. + pub intersection: Option>, } /// A snap accepted by a point-input command, with enough information for @@ -149,31 +134,29 @@ impl AcceptedSnap { self.viewport.is_some() } - /// Build an accepted snap from a [`crate::snap::SnapResult`]. - /// - /// * `frame` `Some`: the snap ran against MODEL geometry through that - /// viewport, so `snap.world` is a model point and the paper point is its - /// projection onto the sheet. - /// * `frame` `None`: the snap ran in the current space directly, so paper - /// and model point coincide. - /// - /// PR1 fills `source` from `SnapResult::source` (the owning entity handle - /// the snap engine attributed the feature to); `block_path` stays empty - /// until PR3 resolves block instance chains. + /// `snap.world` is always in the command's current space. A projected + /// viewport hit also retains its original model coordinate explicitly. pub fn from_snap(snap: &crate::snap::SnapResult, frame: Option) -> Self { - let (paper_point, model_point) = match &frame { - Some(frame) => (frame.model_to_paper(snap.world), snap.world), - None => (snap.world, snap.world), + let frame = frame.filter(|frame| snap.viewport == Some(frame.viewport)); + let source_ref = |source| SnapSourceRef { + source, + block_path: Vec::new(), + snap_type: snap.snap_type, + intersection: None, }; Self { - paper_point, - model_point, + paper_point: snap.world, + model_point: frame.map_or(snap.world, |frame| { + snap.model_point + .unwrap_or_else(|| frame.paper_to_model(snap.world)) + }), viewport: frame.map(|f| f.viewport), frame, source: snap.source.map(|source| SnapSourceRef { - source, - block_path: Vec::new(), - snap_type: snap.snap_type, + intersection: snap + .secondary_source + .map(|source| Box::new(source_ref(source))), + ..source_ref(source) }), } } @@ -222,6 +205,53 @@ pub struct MeasurementScale { } impl MeasurementScale { + /// OCS bookkeeping keeps user scaling separate from the changing viewport + /// scale. Standard DIMLFAC still carries the full factor for other readers. + pub const APP_ID: &'static str = "OCS_VIEWPORT_MEASUREMENT"; + + pub fn read(data: &acadrust::xdata::ExtendedData) -> Option { + use acadrust::xdata::XDataValue; + let record = data.get_record(Self::APP_ID)?; + let [XDataValue::Integer16(1), XDataValue::Real(user), XDataValue::Real(compensation)] = + record.values.as_slice() + else { + return None; + }; + if !user.is_finite() || *user <= 0.0 || !compensation.is_finite() || *compensation <= 0.0 { + return None; + } + Some(Self { + user_lfac: *user, + viewport_compensation: *compensation, + }) + } + + pub fn write_to_entity(self, entity: &mut acadrust::EntityType) { + use acadrust::xdata::{ExtendedData, ExtendedDataRecord, XDataValue}; + crate::entities::dim_override::set_on_entity( + entity, + crate::entities::dim_override::DIMLFAC, + Some(XDataValue::Real(-self.paper_factor())), + ); + let common = entity.common_mut(); + let mut data = ExtendedData::new(); + for record in common + .extended_data + .records() + .iter() + .filter(|r| r.application_name != Self::APP_ID) + { + data.add_record(record.clone()); + } + let mut record = ExtendedDataRecord::new(Self::APP_ID); + record.add_value(XDataValue::Integer16(1)); + record.add_value(XDataValue::Real(self.user_lfac)); + record.add_value(XDataValue::Real(self.viewport_compensation)); + data.add_record(record); + // Parsed records are authoritative after changing measurement data. + common.extended_data = data; + } + pub const IDENTITY: Self = Self { user_lfac: 1.0, viewport_compensation: 1.0, @@ -272,8 +302,7 @@ impl MeasurementScale { /// /// Rendering an *existing* dimension only knows whether the entity lives /// in a paper-space layout; the viewport compensation is already baked - /// into the persisted magnitude (see - /// [`MeasurementScale::viewport_dimlfac_override`]). This is the same rule + /// into the persisted magnitude. This is the same rule /// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()` /// replaced by `paper_space`. pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 { @@ -287,45 +316,6 @@ impl MeasurementScale { 1.0 } } - - /// The persisted representation of viewport compensation, PR2's single - /// source of truth. - /// - /// A paper-space dimension that measures model geometry through a viewport - /// keeps its definition points in **paper** coordinates and carries a - /// **negative DIMLFAC override** whose magnitude is - /// `user_lfac * viewport_compensation`. Because a viewport frame is a - /// similarity (uniform scale + in-plane twist), the raw paper measurement - /// times that magnitude is exactly the model measurement times the user's - /// linear factor — so the compensation is applied exactly once, at - /// formatting time, and round-trips through DXF/DWG unchanged (AutoCAD - /// applies |DIMLFAC| to paper-space dimensions by the same rule). - /// - /// Returns `None` when no override is needed: - /// * the compensation is 1:1, or - /// * the drawing/style DIMLFAC is already negative with exactly this - /// magnitude (the drawing-wide DIMLFAC is set for this purpose). - /// - /// `style_dimlfac` is the effective DIMLFAC the dimension would inherit - /// *without* an override. - pub fn viewport_dimlfac_override(style_dimlfac: f64, compensation: f64) -> Option { - if !compensation.is_finite() || compensation.abs() < 1e-12 { - return None; - } - if (compensation - 1.0).abs() < 1e-12 { - return None; - } - if style_dimlfac < 0.0 - && ((-style_dimlfac) - compensation).abs() <= 1e-9 * compensation.abs().max(1.0) - { - return None; - } - // A positive style DIMLFAC is the user's own multiplier and must be - // preserved; a negative one of a *different* magnitude was meant for - // another viewport scale, so the user multiplier there is 1.0. - let user = if style_dimlfac > 0.0 { style_dimlfac } else { 1.0 }; - Some(-(user * compensation)) - } } #[cfg(test)] @@ -392,29 +382,4 @@ mod tests { ); } } - - #[test] - fn override_encodes_compensation_once() { - // 1:10 viewport, no user factor -> -10, and a 10-unit paper span then - // formats as 100. - let ovr = MeasurementScale::viewport_dimlfac_override(1.0, 10.0).unwrap(); - assert!((ovr + 10.0).abs() < 1e-12); - let lfac = MeasurementScale::user_lfac_for_space(ovr, true); - assert!((10.0 * lfac - 100.0).abs() < 1e-9); - // Same dimension read back in model space must not scale. - assert_eq!(MeasurementScale::user_lfac_for_space(ovr, false), 1.0); - } - - #[test] - fn override_preserves_user_multiplier_and_skips_redundant_cases() { - assert_eq!(MeasurementScale::viewport_dimlfac_override(1.0, 1.0), None); - // Drawing-wide negative DIMLFAC already carries this compensation. - assert_eq!(MeasurementScale::viewport_dimlfac_override(-10.0, 10.0), None); - // Positive user factor is folded in. - let ovr = MeasurementScale::viewport_dimlfac_override(25.4, 10.0).unwrap(); - assert!((ovr + 254.0).abs() < 1e-9); - // Negative but for a different scale -> user multiplier is 1.0. - let ovr = MeasurementScale::viewport_dimlfac_override(-2.0, 10.0).unwrap(); - assert!((ovr + 10.0).abs() < 1e-12); - } } diff --git a/src/snap.rs b/src/snap.rs index b388e0f96..75bcf5f20 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -97,6 +97,10 @@ pub struct SnapResult { /// INSERT handle; the block path is resolved later by /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. (PR1) pub source: Option, + /// Second real object at an intersection, when both objects are known. + pub secondary_source: Option, + /// Original model point when `world` has been projected onto a sheet. + pub model_point: Option, } impl SnapResult { @@ -984,6 +988,8 @@ impl Snapper { extension_dir: None, viewport: None, source: None, + secondary_source: None, + model_point: None, }) } @@ -1119,6 +1125,8 @@ impl Snapper { extension_dir: None, viewport: None, source: None, + secondary_source: None, + model_point: None, }); best_rank = snap_tier(SnapType::Grid); best_sub = snap_priority(SnapType::Grid); @@ -1216,7 +1224,7 @@ impl Snapper { let mut try_pt = |world: glam::DVec3, snap_type: SnapType, - src: Option| { + src: Option, secondary: Option| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1245,6 +1253,8 @@ impl Snapper { extension_dir: None, viewport: None, source: src, + secondary_source: secondary, + model_point: None, }); } }; @@ -1262,7 +1272,7 @@ impl Snapper { SnapHint::Endpoint => SnapType::Endpoint, }; if self.is_on(snap_type) { - try_pt(world, snap_type, src); + try_pt(world, snap_type, src, None); } }; if let Some(points) = wires.snap_points() { @@ -1298,8 +1308,7 @@ impl Snapper { try_pt( DVec3::from_array(point), SnapType::Endpoint, - wire_source(wire), - ); + wire_source(wire), None); } // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains @@ -1313,14 +1322,13 @@ impl Snapper { continue; } if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint, wire_source(wire)); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, wire_source(wire), None); } if wire.points.len() > 1 { try_pt( wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint, - wire_source(wire), - ); + wire_source(wire), None); } } } else { @@ -1328,7 +1336,7 @@ impl Snapper { let src = wire_source(wire); if !wire.key_vertices.is_empty() { for &point in &wire.key_vertices { - try_pt(DVec3::from_array(point), SnapType::Endpoint, src); + try_pt(DVec3::from_array(point), SnapType::Endpoint, src, None); } } else { let closed = wire @@ -1337,14 +1345,13 @@ impl Snapper { .any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); if !closed { if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint, src); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, src, None); } if wire.points.len() > 1 { try_pt( wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint, - src, - ); + src, None); } } } @@ -1371,7 +1378,7 @@ impl Snapper { let a = DVec3::from_array(a); let b = DVec3::from_array(b); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint, wire_source(wire)); + try_pt((a + b) * 0.5, SnapType::Midpoint, wire_source(wire), None); } } } else { @@ -1381,7 +1388,7 @@ impl Snapper { let a = DVec3::from_array(segment[0]); let b = DVec3::from_array(segment[1]); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint, src); + try_pt((a + b) * 0.5, SnapType::Midpoint, src, None); } } } @@ -1395,8 +1402,7 @@ impl Snapper { try_pt( nearest_on_segment(cursor_world, seg.a, seg.b), SnapType::Nearest, - wires.source_wire(seg.wire).and_then(wire_source), - ); + wires.source_wire(seg.wire).and_then(wire_source), None); } } else { for wire in in_range_wires.iter() { @@ -1404,7 +1410,7 @@ impl Snapper { for i in 0..wire.points.len().saturating_sub(1) { let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); - try_pt(p, SnapType::Nearest, src); + try_pt(p, SnapType::Nearest, src, None); } } } @@ -1420,8 +1426,7 @@ impl Snapper { try_pt( foot, SnapType::Perpendicular, - wires.source_wire(seg.wire).and_then(wire_source), - ); + wires.source_wire(seg.wire).and_then(wire_source), None); } } } else { @@ -1429,7 +1434,7 @@ impl Snapper { let src = wire_source(wire); for i in 0..wire.points.len().saturating_sub(1) { if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) { - try_pt(foot, SnapType::Perpendicular, src); + try_pt(foot, SnapType::Perpendicular, src, None); } } } @@ -1450,8 +1455,7 @@ impl Snapper { try_pt( point, SnapType::Intersection, - wires.source_wire(segment.wire).and_then(wire_source), - ); + wires.source_wire(segment.wire).and_then(wire_source), None); } } } @@ -1466,7 +1470,7 @@ impl Snapper { wp_f64(wire, index + 1), ) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection, src); + try_pt(point, SnapType::Intersection, src, None); } } } @@ -1552,7 +1556,7 @@ impl Snapper { // first. PR3 resolves the true pair. let src = wire_source(wire_i); for point in points { - try_pt(point, SnapType::Intersection, src); + try_pt(point, SnapType::Intersection, src, wire_source(wire_j)); } resolved_pairs.insert(pair_key(wire_i, wire_j)); } @@ -1586,8 +1590,7 @@ impl Snapper { try_pt( pt, SnapType::Intersection, - wires.source_wire(a.wire).and_then(wire_source), - ); + wires.source_wire(a.wire).and_then(wire_source), wires.source_wire(b.wire).and_then(wire_source)); if exact_cursor { break 'intersection_sweep; } @@ -1609,7 +1612,7 @@ impl Snapper { if let Some(pts) = exact_curve_intersections(wire_i, wire_j) { let src = wire_source(wire_i); for pt in pts { - try_pt(pt, SnapType::Intersection, src); + try_pt(pt, SnapType::Intersection, src, wire_source(wire_j)); } continue; } @@ -1633,7 +1636,7 @@ impl Snapper { continue; } if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection, wire_source(wire_i)); + try_pt(pt, SnapType::Intersection, wire_source(wire_i), wire_source(wire_j)); } } } @@ -1659,7 +1662,7 @@ impl Snapper { bounds, self.osnap_radius_px, ) { - try_pt(ext, SnapType::Extension, None); + try_pt(ext, SnapType::Extension, None, None); } } } @@ -1706,7 +1709,7 @@ impl Snapper { // feet (which sit closer to the cursor on their own lines), // or the cursor would snap to a line instead of the crossing. let pt = glam::DVec3::new(a0.x + t1 * d1.x, a0.y + t1 * d1.y, a0.z); - try_pt(pt, SnapType::Intersection, None); + try_pt(pt, SnapType::Intersection, None, None); } } } @@ -1759,8 +1762,7 @@ impl Snapper { try_pt( segment_a.a + ta as f64 * (segment_a.b - segment_a.a), SnapType::ApparentIntersection, - wires.source_wire(segment_a.wire).and_then(wire_source), - ); + wires.source_wire(segment_a.wire).and_then(wire_source), wires.source_wire(segment_b.wire).and_then(wire_source)); if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { break 'apparent_sweep; } @@ -1800,8 +1802,7 @@ impl Snapper { try_pt( wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection, - wire_source(wire_i), - ); + wire_source(wire_i), wire_source(wire_j)); } } } @@ -2024,6 +2025,8 @@ impl Snapper { extension_dir: None, viewport: None, source: wire_source(wire), + secondary_source: None, + model_point: None, }); } } @@ -2088,6 +2091,8 @@ impl Snapper { extension_dir: None, viewport: None, source: wire_source(wire), + secondary_source: None, + model_point: None, }); } }; From 3274d6d0fd60212584be9ef71b2c44fa2da4202d Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 14:51:58 -0500 Subject: [PATCH 19/23] Validate viewport object picks and cover angular arc acquisition --- docs/viewport-dimensions.md | 2 ++ src/app/viewport_dimension_tests.rs | 46 ++++++++++++++++++++++++++++- src/scene/viewport_dim_seam.rs | 6 ++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md index 09f85208c..119b2361a 100644 --- a/docs/viewport-dimensions.md +++ b/docs/viewport-dimensions.md @@ -11,6 +11,8 @@ Snapping and object picking share viewport draw order, visibility, and clipping rules. Object picking uses the resident interaction index and applies block instance transforms. Nonuniformly transformed circles and circular arcs are declined as radial object picks because their displayed geometry is elliptical. +Array inserts without an addressable instance index are also declined; resolved +block geometry must remain within the original pick aperture. ## Input and measurement rules diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs index 5ac9cb658..3b47db064 100644 --- a/src/app/viewport_dimension_tests.rs +++ b/src/app/viewport_dimension_tests.rs @@ -239,11 +239,20 @@ fn viewport_dimension_linear_aligned_and_radial_object_picks() { #[test] fn viewport_dimension_angular_preserves_angle_without_length_factor() { let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let vertical = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(0.0, 100.0, 0.0), + ))); + app.tabs[i].scene.set_current_layout("Dimensions".into()); let _ = app.dispatch_command("DIMANGULAR"); let _ = app.feed_command(StepInput::Enter); point(&mut app, frame, line, DVec3::ZERO); point(&mut app, frame, line, DVec3::X * 100.0); - point(&mut app, frame, line, DVec3::Y * 100.0); + point(&mut app, frame, vertical, DVec3::Y * 100.0); let _ = app.feed_command(StepInput::Point(DVec3::new(70.0, 70.0, 0.0))); let doc = &app.tabs[app.active_tab].scene.document; assert!((dimension(doc).measurement() - 90.0).abs() < 1e-5); @@ -377,3 +386,38 @@ fn viewport_dimension_exploded_commit_uses_compensated_text_and_undo() { .entities() .any(|e| matches!(e, EntityType::Text(_) | EntityType::MText(_)))); } + +#[test] +fn viewport_dimension_angular_object_picks_measure_arc_features() { + use acadrust::entities::{Arc, LwPolyline}; + use acadrust::types::Vector2; + for bulged in [false, true] { + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let entity = if bulged { + let mut poly = + LwPolyline::from_points(vec![Vector2::new(100.0, 200.0), Vector2::new(0.0, 300.0)]); + poly.vertices[0].bulge = (std::f64::consts::PI / 8.0).tan(); + EntityType::LwPolyline(poly) + } else { + let mut arc = Arc::new(); + arc.center = Vector3::new(0.0, 200.0, 0.0); + arc.radius = 100.0; + arc.start_angle = 0.0; + arc.end_angle = std::f64::consts::FRAC_PI_2; + EntityType::Arc(arc) + }; + app.tabs[i].scene.add_entity(entity); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMANGULAR"); + let model = DVec3::new(100.0 / 2.0_f64.sqrt(), 200.0 + 100.0 / 2.0_f64.sqrt(), 0.0); + let result = app + .try_dimension_viewport_entity_pick(i, frame.model_to_paper(model), 0.5) + .unwrap(); + let _ = app.apply_cmd_result(result); + assert_eq!(app.accepted_snaps().len(), 3); + let _ = app.feed_command(StepInput::Point(DVec3::new(65.0, 85.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); + } +} diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index 1954d240a..5f4ca3ce4 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -84,6 +84,9 @@ impl Scene { continue; }; + if !planar_pick_distance(&entity, model_point).is_some_and(|d| d <= aperture_model) { + continue; + } let mut paper_entity = entity; crate::scene::view::dispatch::apply_transform( &mut paper_entity, @@ -212,6 +215,9 @@ impl Scene { let EntityType::Insert(insert) = &insert_entity else { return None; }; + if insert.row_count > 1 || insert.column_count > 1 { + return None; + } let insert_handle = insert.common.handle; let local = crate::scene::render_graph::insert_transform(&self.document, insert); let combined = local.then(&outer); From 64ce0b2e8c1486ec89a2df57896b1f8de8088779 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 14:53:45 -0500 Subject: [PATCH 20/23] Resolve viewport dimension features and preserve valid measurements on refresh --- docs/viewport-dimensions.md | 58 +- src/app/dim_viewport.rs | 31 +- src/app/properties.rs | 18 + src/app/update/viewport_snap.rs | 84 +- src/app/viewport_dimension_tests.rs | 720 +++++++++++++++++- src/scene/dimension_assoc.rs | 429 +++++++++-- src/scene/dimension_assoc_chain.rs | 441 ++++++----- src/scene/entity.rs | 2 +- src/scene/viewport_dim_seam.rs | 101 ++- tests/fixtures/dim_assoc/README.md | 16 + .../dim_assoc/viewport_associations.dxf | 124 ++- 11 files changed, 1680 insertions(+), 344 deletions(-) create mode 100644 tests/fixtures/dim_assoc/README.md diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md index 09f85208c..7525a9abc 100644 --- a/docs/viewport-dimensions.md +++ b/docs/viewport-dimensions.md @@ -34,10 +34,46 @@ user's unit conversion separate from viewport scale. The ordinary `DIMLFAC` override remains sufficient for displaying the saved measurement in other CAD readers. The data survives DXF/DWG saves and undo/redo. -The dependent association change uses this metadata when viewport scale changes. -This creation change alone creates nonassociative viewport dimensions and leaves -the drawing's `DIMASSOC` setting unchanged. Direct model/paper associations retain -their existing behavior. +With `DIMASSOC=2`, acquired features attach a dimension to its viewport, each +block instance, and the source entity. Intersections retain both source chains. +Source edits and viewport changes update the definition points and recompute +compensation while preserving explicit user-positioned sheet text and dimension +style overrides. A successful update invalidates the saved picture block so the +new geometry and number are rendered and exported. Undo restores both the +reference data and the previous picture. + +Properties reports whether the dimension is associated, partially associated, +unresolved, broken, or nonassociative. A broken or unsupported required reference +preserves the entire dimension's last valid appearance and its original records. +Creating another dimension does not announce geometry changes to its sources. + +## Imported references and support limits + +The resolver distinguishes the application's vertex/parameter convention from +imported edge references. Imported start/end and midpoint modes use their edge +marker and parameter together. The original regression drawing's first-edge +start/end pair now reproduces its `0.128` measurement. An imported polyline +endpoint beyond the first edge requires a usable stored point to validate the +marker; zero/sentinel data cannot establish that identity. Ambiguous imports +remain unresolved. Imported viewport scale is updated only when existing OCS +metadata or `ACAD_DIMASSOC_CALC_DIMLFAC` separates compensation from user scaling. + +Unsupported cases retain their saved data and appearance: + +- Perspective/oblique viewport projections and malformed/missing reference chains. +- Ambiguous intersections within one batched block; they are not attached to a + guessed pair of children. Intersections between separate identified chains work. +- Array inserts without an addressable instance index, nesting beyond eight levels, + and radial measurements of nonuniformly transformed circular geometry. +- Imported quadrant references whose moved geometry no longer validates the stored + point; newly acquired circle/arc quadrants keep their source-local angle. +- Tangency with no verified solution, two dependent perpendicular/tangent references + requiring a coupled solve, unsupported snap modes, and imported measurement data that does not identify the old viewport compensation. + +The osnap codes and reference fields follow the +[Autodesk DIMASSOC reference](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-C0B96256-A911-4B4D-85E6-EB4AF2C91E27.htm). +The restricted imported-marker interpretation above is validated by fixtures; +it is not a claim that every exporter uses an interchangeable marker scheme. ## Validation @@ -46,3 +82,17 @@ commits for all five commands, object picks, input retries, active positive and negative style factors, exploded dimensions, undo/redo, DXF/DWG persistence, clipping, overlapping viewports, pan, scale, and twist. These are headless tests; they do not establish visual acceptance in the desktop UI. + +Association tests also exercise regeneration, nearest and quadrant parameters, +angular arc/polyline object picks, spline tangency, +perpendicularity under nonuniform block scale, nested paths, intersection source +edits, preserved overrides, erased sources, copy detachment, saved picture +invalidation, and history. The checked-in DXF fixture is exercised directly. +The original private drawing can be checked separately: + +```powershell +$env:OPENCAD_VIEWPORT_REGRESSION_DXF = 'C:\path\to\115448.dxf' +cargo test --locked --offline --lib viewport_dimension_original -- --ignored --nocapture +``` + +Do not treat passing headless checks as completed desktop visual acceptance. diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index 3bea9c53a..a80b151c4 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -208,7 +208,9 @@ impl OpenCADStudio { i: usize, dimension: Handle, ) -> Vec> { - let slots = self.tabs[i].scene.dimension_association_slot_points(dimension); + let slots = self.tabs[i] + .scene + .dimension_association_slot_points(dimension); if slots.is_empty() { return Vec::new(); } @@ -220,7 +222,12 @@ impl OpenCADStudio { let snap = measuring.get(index)?.clone(); let frame = snap.frame?; let slot = DVec3::new(slot.x, slot.y, slot.z); - if snap.paper_point.truncate().distance_squared(slot.truncate()) <= 1e-12 { + if snap + .paper_point + .truncate() + .distance_squared(slot.truncate()) + <= 1e-12 + { return Some(snap); } Some(AcceptedSnap { @@ -235,9 +242,8 @@ impl OpenCADStudio { /// Record the viewport association for a dimension that has just been /// committed while measuring through a layout viewport. /// - /// Announces the whole chain — the viewport, every INSERT on the block - /// path and the source entity — so the dependency index picks the new - /// association up immediately. + /// The stored reference chains establish dependencies. Only the new + /// dimension needs a render update; its sources have not changed. pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) { let snaps = self.viewport_dimension_snaps(i, dimension); if snaps.iter().flatten().all(|snap| snap.source.is_none()) { @@ -246,17 +252,8 @@ impl OpenCADStudio { self.tabs[i] .scene .attach_viewport_dimension_association(dimension, &snaps); - let mut changes = vec![(dimension, crate::scene::ChangeKind::Modified)]; - changes.extend( - self.tabs[i] - .scene - .dimension_association_sources(dimension) - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)), - ); - changes.sort_by_key(|(handle, _)| handle.value()); - changes.dedup_by_key(|(handle, _)| handle.value()); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i] + .scene + .bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]); } - } diff --git a/src/app/properties.rs b/src/app/properties.rs index a942c5ca6..6a6081a37 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1277,6 +1277,24 @@ impl OpenCADStudio { &self.tabs[i].scene.document, d.base().common.handle, ); + let statuses = self.tabs[i].scene.dimension_association_status(d.base().common.handle); + let slots = self.tabs[i].scene.dimension_association_slot_points(d.base().common.handle).len(); + let status = if statuses.is_empty() { + t!("Nonassociative") + } else if statuses.iter().any(|(_, status)| matches!(status, crate::scene::ReferenceStatus::Broken(_))) { + t!("Broken reference") + } else if statuses.iter().any(|(_, status)| matches!(status, crate::scene::ReferenceStatus::Unresolved)) { + t!("Unresolved reference") + } else if statuses.len() < slots { + t!("Partially associated") + } else { + t!("Associated") + }; + general.props.push(crate::scene::model::object::Property { + label: t!("Association status").into_owned(), + field: "association_status", + value: crate::scene::model::object::PropValue::ReadOnly(status.into_owned()), + }); general.props.push(crate::scene::model::object::Property { label: t!("Associative").into_owned(), field: "associative", diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 977a28415..4a638f6e3 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -137,35 +137,73 @@ impl OpenCADStudio { true } - /// Replace a snap source that is a block INSTANCE with the entity inside - /// it that actually owns the snapped feature, plus the INSERT path. - /// - /// The snap engine can only report what the wire carries, and the - /// renderer stamps every wire of an expanded block with the *top-level - /// INSERT's* handle — nested inserts included. `SnapSourceRef` promises - /// the innermost entity in `source.handle` and the instance chain in - /// `block_path`, so the descent happens here, once, at accept time (not - /// per pointer move). - /// - /// When the descent cannot identify the inner entity the source stays the - /// INSERT with an empty path. See - /// [`crate::scene::Scene::resolve_block_snap_source`] for exactly when - /// that happens; the resulting association is coarser (it tracks the whole - /// block instance) but never wrong. + /// Resolve the instance path once, when a feature is accepted. Failure + /// drops source identity so a failed descent cannot attach to the insert's + /// origin. Ambiguous intersections within one batched block remain unlinked. fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) { + let resolve = |source: &mut crate::scene::viewport_ref::SnapSourceRef| { + if !source.block_path.is_empty() { + return true; + } + if !matches!( + self.tabs[tab] + .scene + .document + .get_entity(source.source.handle), + Some(acadrust::EntityType::Insert(_)) + ) { + return true; + } + if source.snap_type == SnapType::Insertion { + let Some(acadrust::EntityType::Insert(insert)) = self.tabs[tab] + .scene + .document + .get_entity(source.source.handle) + else { + return false; + }; + let origin = glam::DVec3::new( + insert.insert_point.x, + insert.insert_point.y, + insert.insert_point.z, + ); + return origin.distance(accepted.model_point) < 1e-5; + } + let Some((entity, path)) = self.tabs[tab].scene.resolve_measurable_feature( + source.source.handle, + accepted.model_point, + accepted.viewport, + Some(source.snap_type), + ) else { + return false; + }; + let distance = crate::scene::viewport_dim_seam::feature_pick_distance( + &entity, + accepted.model_point, + Some(source.snap_type), + ); + if !distance.is_some_and(|distance| distance < 1e-5) { + return false; + } + source.source = + crate::command::DimensionAssociationSource::inferred(entity.common().handle); + source.block_path = path; + true + }; let Some(source) = accepted.source.as_mut() else { return; }; - if !source.block_path.is_empty() { + if !resolve(source) { + accepted.source = None; return; } - if let Some((entity, path)) = self.tabs[tab] - .scene - .resolve_block_snap_source(source.source.handle, accepted.model_point) - { - source.source = - crate::command::DimensionAssociationSource::inferred(entity); - source.block_path = path; + if let Some(other) = source.intersection.as_mut() { + if !resolve(other) + || (other.source.handle == source.source.handle + && other.block_path == source.block_path) + { + source.intersection = None; + } } } diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs index 5ac9cb658..44d1aff54 100644 --- a/src/app/viewport_dimension_tests.rs +++ b/src/app/viewport_dimension_tests.rs @@ -239,11 +239,20 @@ fn viewport_dimension_linear_aligned_and_radial_object_picks() { #[test] fn viewport_dimension_angular_preserves_angle_without_length_factor() { let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let vertical = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(0.0, 100.0, 0.0), + ))); + app.tabs[i].scene.set_current_layout("Dimensions".into()); let _ = app.dispatch_command("DIMANGULAR"); let _ = app.feed_command(StepInput::Enter); point(&mut app, frame, line, DVec3::ZERO); point(&mut app, frame, line, DVec3::X * 100.0); - point(&mut app, frame, line, DVec3::Y * 100.0); + point(&mut app, frame, vertical, DVec3::Y * 100.0); let _ = app.feed_command(StepInput::Point(DVec3::new(70.0, 70.0, 0.0))); let doc = &app.tabs[app.active_tab].scene.document; assert!((dimension(doc).measurement() - 90.0).abs() < 1e-5); @@ -377,3 +386,712 @@ fn viewport_dimension_exploded_commit_uses_compensated_text_and_undo() { .entities() .any(|e| matches!(e, EntityType::Text(_) | EntityType::MText(_)))); } + +#[test] +fn viewport_dimension_association_scale_edit_picture_and_history() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert_eq!( + app.tabs[i].scene.dimension_association_status(handle).len(), + 2 + ); + let Some(EntityType::Dimension(d)) = app.tabs[i].scene.document.get_entity_mut(handle) else { + panic!() + }; + d.base_mut().text_user_positioned = true; + d.base_mut().text_middle_point = Vector3::new(250.0, 200.0, 0.0); + d.base_mut().block_name = "*SavedPicture".into(); + let pending = app.begin_undo(i, "Viewport scale", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(frame.viewport); + app.tabs[i].scene.record_undo_before(frame.viewport, before); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); + assert!((dimension(&app.tabs[i].scene.document).measurement() - 20.0).abs() < 1e-5); + assert!(dimension(&app.tabs[i].scene.document) + .base() + .block_name + .is_empty()); + assert_eq!( + dimension(&app.tabs[i].scene.document) + .base() + .text_middle_point, + Vector3::new(250.0, 200.0, 0.0) + ); + app.undo_steps(1); + assert!((dimension(&app.tabs[i].scene.document).measurement() - 10.0).abs() < 1e-5); + assert_eq!( + dimension(&app.tabs[i].scene.document).base().block_name, + "*SavedPicture" + ); + app.redo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); + let pending = app.begin_undo(i, "Edit source", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(line); + app.tabs[i].scene.record_undo_before(line, before); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(line) else { + panic!() + }; + source.end.x = 175.0; + app.tabs[i] + .scene + .bump_entities(&[(line, ChangeKind::Modified)]); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 175.0).abs() < 1e-5); + let pieces = crate::modules::draw::modify::explode::explode_entity( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &app.tabs[i].scene.document, + ); + assert!( + pieces.iter().any( + |e| matches!(e,EntityType::Text(t) if t.value.contains("175")) + || matches!(e,EntityType::MText(t) if t.value.contains("175")) + ), + "{pieces:?}" + ); + for ext in ["dxf", "dwg"] { + let doc = &app.tabs[i].scene.document; + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let mut loaded = Scene::new(); + loaded.document = crate::io::load_bytes(&format!("assoc.{ext}"), bytes).unwrap(); + loaded.notify_viewport_changed(frame.viewport); + assert!((displayed(&loaded.document) - 175.0).abs() < 1e-5, "{ext}"); + assert_eq!(loaded.dimension_association_status(handle).len(), 2); + } + app.undo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_unsupported_and_erased_sources_keep_last_picture() { + use crate::scene::dimension_assoc::{resolve_reference_chain, ReferenceStatus}; + use acadrust::objects::AssocDimensionReference; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + let reference = AssocDimensionReference { + xrefs: vec![frame.viewport, line], + osnap_type: 255, + ..Default::default() + }; + assert!(matches!( + resolve_reference_chain(&app.tabs[i].scene, &reference, None), + Err(ReferenceStatus::Unresolved) + )); + let previous = app.tabs[i] + .scene + .document + .get_entity(handle) + .unwrap() + .clone(); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.status.perspective = true; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert_eq!( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &previous + ); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Unresolved)); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.status.perspective = false; + let pending = app.begin_undo(i, "Erase source", 1, false).unwrap(); + app.tabs[i].scene.erase_entities(&[line]); + app.commit_undo_delta(i, pending); + assert_eq!( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &previous + ); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Broken(line))); + app.undo_steps(1); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); +} + +#[test] +fn viewport_dimension_intersection_tracks_both_entities() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let other = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(60.0, -50.0, 0.0), + Vector3::new(60.0, 50.0, 0.0), + ))); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut intersection = hit(frame, line, DVec3::X * 60.0); + intersection.snap_type = SnapType::Intersection; + intersection.secondary_source = Some(DimensionAssociationSource::inferred(other)); + assert!(app.record_accepted_snap(i, Some(intersection), Some(frame), intersection.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(intersection.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(80.0, 80.0, 0.0))); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!(app.tabs[i] + .scene + .dimension_association_sources(handle) + .contains(&other)); + assert!((displayed(&app.tabs[i].scene.document) - 60.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(other) else { + panic!() + }; + source.start.x = 80.0; + source.end.x = 80.0; + app.tabs[i] + .scene + .bump_entities(&[(other, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 80.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_spline_tangency_is_parallel_and_validated() { + use crate::scene::dimension_assoc_chain::{feature_point, osnap, FeatureContext}; + use acadrust::objects::AssocDimensionReference; + let mut spline = acadrust::entities::Spline::new(); + spline.degree = 2; + spline.control_points = vec![ + Vector3::ZERO, + Vector3::new(0.5, 0.0, 0.0), + Vector3::new(1.0, 1.0, 0.0), + ]; + spline.knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]; + let entity = EntityType::Spline(spline); + let reference = AssocDimensionReference { + osnap_type: osnap::TAN, + osnap_distance: 0.8, + ..Default::default() + }; + let result = feature_point( + &CadDocument::new(), + &entity, + &reference, + FeatureContext { + hint: Some(Vector3::new(1.0, 1.0, 0.0)), + from: Some(Vector3::new(0.0, -1.0, 0.0)), + }, + ) + .unwrap(); + assert!( + (result.x - 1.0).abs() < 1e-6 && (result.y - 1.0).abs() < 1e-6, + "{result:?}" + ); + assert!(feature_point( + &CadDocument::new(), + &entity, + &reference, + FeatureContext { + hint: None, + from: Some(Vector3::new(0.0, 1.0, 0.0)) + } + ) + .is_none()); +} + +#[test] +#[ignore = "Set OPENCAD_VIEWPORT_REGRESSION_DXF to the original local regression drawing"] +fn viewport_dimension_original_drawing_keeps_unchanged_measurements() { + use crate::scene::ChangeKind; + let path = std::env::var("OPENCAD_VIEWPORT_REGRESSION_DXF").expect("regression drawing path"); + let doc = acadrust::DxfReader::from_file(std::path::Path::new(&path)) + .unwrap() + .read() + .unwrap(); + let mut scene = Scene::new(); + scene.document = doc; + let dimensions: Vec<_> = scene + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Dimension(dim) => { + Some((dim.base().common.handle, dim.base().actual_measurement)) + } + _ => None, + }) + .collect(); + let mut sources: Vec<_> = dimensions + .iter() + .flat_map(|(h, _)| scene.dimension_association_sources(*h)) + .collect(); + sources.sort(); + sources.dedup(); + scene.bump_entities( + &sources + .into_iter() + .map(|h| (h, ChangeKind::Modified)) + .collect::>(), + ); + for (handle, saved) in dimensions { + if scene.dimension_association_status(handle).is_empty() { + continue; + } + let EntityType::Dimension(dim) = scene.document.get_entity(handle).unwrap() else { + panic!() + }; + let factor = dim_override::real(&dim.base().common.extended_data, dim_override::DIMLFAC) + .unwrap_or(1.0) + .abs(); + let result = dim.measurement() * factor; + println!( + "{:X}: saved={saved:.9}, current={result:.9}, status={:?}", + handle.value(), + scene.dimension_association_status(handle) + ); + assert!( + (result - saved).abs() < 1e-3, + "{:X}: {saved} -> {result}", + handle.value() + ); + } +} + +#[test] +fn viewport_dimension_fixture_regenerates_and_tracks_an_intersection() { + use crate::scene::ChangeKind; + let bytes = include_bytes!("../../tests/fixtures/dim_assoc/viewport_associations.dxf").to_vec(); + let mut scene = Scene::new(); + scene.document = crate::io::load_bytes("viewport_associations.dxf", bytes).unwrap(); + scene.notify_viewport_changed(Handle::new(0x200)); + // The cubic's tangent from (0,300) touches at u=.75, C=(90,283.125). + let EntityType::Spline(spline) = scene.document.get_entity(Handle::new(0x104)).unwrap() else { + panic!() + }; + let curve = crate::entities::spline::nurbs3(spline).unwrap(); + let c = DVec3::from_array(curve.point_at_knot(0.75)); + let tangent = DVec3::from_array(curve.derivative_at_knot(0.75)); + assert!((c - DVec3::new(0.0, 300.0, 0.0)).cross(tangent).length() < 1e-8); + let measured = |scene: &Scene, handle| { + let EntityType::Dimension(d) = scene.document.get_entity(Handle::new(handle)).unwrap() + else { + panic!() + }; + d.measurement() + * dim_override::real(&d.base().common.extended_data, dim_override::DIMLFAC) + .unwrap_or(1.0) + .abs() + }; + for (handle, expected) in [ + (0x201, 100.0), + (0x202, 50.0), + (0x203, 35.355339), + (0x204, 141.421356), + (0x205, 90.0_f64.hypot(16.875)), + (0x206, 80.0), + (0x207, 60.0), + ] { + assert!( + (measured(&scene, handle) - expected).abs() < 1e-4, + "{handle:X}: {}", + measured(&scene, handle) + ); + } + let Some(EntityType::Line(line)) = scene.document.get_entity_mut(Handle::new(0x107)) else { + panic!() + }; + line.start.x = 80.0; + line.end.x = 80.0; + scene.bump_entities(&[(Handle::new(0x107), ChangeKind::Modified)]); + assert!((measured(&scene, 0x207) - 80.0).abs() < 1e-5); + let before = scene + .document + .get_entity(Handle::new(0x205)) + .unwrap() + .clone(); + for types in [(1, 255), (8, 9)] { + let acadrust::objects::ObjectType::Associative(object) = + scene.document.objects.get_mut(&Handle::new(0x304)).unwrap() + else { + panic!() + }; + let acadrust::objects::AssociativeData::DimensionAssociation(assoc) = &mut object.data + else { + panic!() + }; + assoc.references[0][0].osnap_type = types.0; + assoc.references[1][0].osnap_type = types.1; + scene.notify_viewport_changed(Handle::new(0x200)); + assert_eq!( + scene.document.get_entity(Handle::new(0x205)).unwrap(), + &before + ); + assert!(scene + .dimension_association_status(Handle::new(0x205)) + .iter() + .any(|(_, s)| *s == crate::scene::ReferenceStatus::Unresolved)); + } +} + +#[test] +fn viewport_dimension_nearest_parameter_survives_source_stretch() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut near = hit(frame, line, DVec3::X * 75.0); + near.snap_type = SnapType::Nearest; + assert!(app.record_accepted_snap(i, Some(near), Some(frame), near.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(near.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(55.0, 80.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 75.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(line) else { + panic!() + }; + source.end.x = 200.0; + app.tabs[i] + .scene + .bump_entities(&[(line, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 150.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_nested_block_path_survives_edit_and_copy() { + use crate::scene::ChangeKind; + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + let mut inner = BlockRecord::new("Inner"); + inner.handle = scene.document.allocate_handle(); + let inner_h = inner.handle; + scene.document.block_records.add(inner).unwrap(); + let mut leaf = Line::from_points(Vector3::ZERO, Vector3::new(20.0, 0.0, 0.0)); + leaf.common.owner_handle = inner_h; + let leaf = scene.document.add_entity(EntityType::Line(leaf)).unwrap(); + let mut outer = BlockRecord::new("Outer"); + outer.handle = scene.document.allocate_handle(); + let outer_h = outer.handle; + scene.document.block_records.add(outer).unwrap(); + let mut nested = Insert::new("Inner", Vector3::new(30.0, 0.0, 0.0)); + nested.common.owner_handle = outer_h; + nested.set_x_scale(3.0); + nested.set_y_scale(3.0); + nested.rotation = 0.4; + let nested = scene + .document + .add_entity(EntityType::Insert(nested)) + .unwrap(); + scene.set_current_layout("Model".into()); + let mut root = Insert::new("Outer", Vector3::new(20.0, 50.0, 0.0)); + root.set_x_scale(2.0); + root.set_y_scale(2.0); + root.rotation = 0.3; + let root = scene.add_entity(EntityType::Insert(root)); + scene.set_current_layout("Dimensions".into()); + let transform = + crate::scene::dimension_assoc_chain::block_transform(&scene.document, &[root, nested]); + let model = |x| { + let v = transform.apply(Vector3::new(x, 0.0, 0.0)); + DVec3::new(v.x, v.y, v.z) + }; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, root, model(0.0)); + let accepted = app.accepted_snaps()[0].source.as_ref().unwrap(); + assert_eq!(accepted.source.handle, leaf); + assert_eq!(accepted.block_path, vec![root, nested]); + point(&mut app, frame, root, model(20.0)); + let _ = app.feed_command(StepInput::Point(DVec3::new(90.0, 90.0, 0.0))); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!((displayed(&app.tabs[i].scene.document) - 120.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(leaf) else { + panic!() + }; + source.end.x = 40.0; + app.tabs[i] + .scene + .bump_entities(&[(leaf, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 240.0).abs() < 1e-5); + let copied = app.tabs[i].scene.copy_entities( + &[handle], + &crate::command::EntityTransform::Translate(DVec3::new(10.0, 10.0, 0.0)), + ); + assert_eq!(copied.len(), 1); + assert!(app.tabs[i].scene.dimension_association(copied[0]).is_none()); + assert!(app.tabs[i].scene.dimension_association(handle).is_some()); +} + +#[test] +fn viewport_dimension_hidden_border_and_lock_keep_content_available() { + let (mut app, _, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut layer = acadrust::tables::Layer::new("Viewport frames"); + layer.flags.off = true; + scene.document.layers.add(layer).unwrap(); + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(frame.viewport) else { + panic!() + }; + vp.common.layer = "Viewport frames".into(); + vp.status.locked = true; + let pick = scene + .dimension_pick_through_viewport(DVec3::new(55.0, 50.0, 0.0), 0.2) + .unwrap(); + assert!(pick.frame.locked); + assert_eq!(pick.frame.viewport, frame.viewport); +} + +#[test] +fn viewport_dimension_perpendicular_uses_transformed_geometry() { + use crate::scene::dimension_assoc::{resolve_reference_chain, ReferenceStatus}; + use acadrust::entities::Insert; + use acadrust::objects::AssocDimensionReference; + use acadrust::tables::BlockRecord; + let mut scene = Scene::new(); + let mut block = BlockRecord::new("Stretched"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut source = Line::from_points(Vector3::ZERO, Vector3::new(10.0, 10.0, 0.0)); + source.common.owner_handle = owner; + let source = scene.document.add_entity(EntityType::Line(source)).unwrap(); + let mut insert = Insert::new("Stretched", Vector3::ZERO); + insert.set_x_scale(2.0); + let root = scene.add_entity(EntityType::Insert(insert)); + let reference = AssocDimensionReference { + xrefs: vec![root, source], + osnap_type: 8, + osnap_point: Vector3::new(4.0, 2.0, 0.0), + ..Default::default() + }; + let result = + resolve_reference_chain(&scene, &reference, Some(Vector3::new(0.0, 10.0, 0.0))).unwrap(); + assert!( + (result.model.x - 4.0).abs() < 1e-8 && (result.model.y - 2.0).abs() < 1e-8, + "{result:?}" + ); + let unrelated = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))); + let invalid = AssocDimensionReference { + xrefs: vec![root, unrelated], + ..reference + }; + assert!(matches!( + resolve_reference_chain(&scene, &invalid, None), + Err(ReferenceStatus::Unresolved) + )); +} + +#[test] +fn viewport_dimension_refresh_preserves_explicit_overrides() { + use acadrust::xdata::XDataValue; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + let entity = app.tabs[i].scene.document.get_entity_mut(handle).unwrap(); + dim_override::set_on_entity( + entity, + dim_override::DIMLFAC, + Some(XDataValue::Real(-254.0)), + ); + dim_override::set_on_entity(entity, dim_override::DIMTOL, Some(XDataValue::Integer16(1))); + dim_override::set_on_entity(entity, dim_override::DIMTP, Some(XDataValue::Real(0.05))); + dim_override::set_on_entity(entity, dim_override::DIMTM, Some(XDataValue::Real(0.02))); + if let EntityType::Dimension(d) = entity { + crate::entities::dimension::set_dimension_text_override(d.base_mut(), Some("L=<>".into())); + } + let expected = displayed(&app.tabs[i].scene.document); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + let d = dimension(&app.tabs[i].scene.document); + assert!( + (displayed(&app.tabs[i].scene.document) - expected).abs() < 1e-5, + "{} vs {expected}", + displayed(&app.tabs[i].scene.document) + ); + assert_eq!( + crate::entities::dimension::dimension_text_override(d.base()), + Some("L=<>") + ); + assert_eq!( + dim_override::real(&d.base().common.extended_data, dim_override::DIMTP), + Some(0.05) + ); + assert_eq!( + dim_override::real(&d.base().common.extended_data, dim_override::DIMTM), + Some(0.02) + ); +} + +#[test] +fn viewport_dimension_block_center_keeps_the_circle_source() { + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + let mut block = BlockRecord::new("Circle block"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut circle = Circle::new(); + circle.radius = 10.0; + circle.common.owner_handle = owner; + let circle = scene + .document + .add_entity(EntityType::Circle(circle)) + .unwrap(); + // This line is closer to the circle center than its circumference is. + let mut line = Line::from_points(Vector3::new(-2.0, 1.0, 0.0), Vector3::new(2.0, 1.0, 0.0)); + line.common.owner_handle = owner; + scene.document.add_entity(EntityType::Line(line)).unwrap(); + scene.set_current_layout("Model".into()); + let root = scene.add_entity(EntityType::Insert(Insert::new( + "Circle block", + Vector3::new(40.0, 20.0, 0.0), + ))); + scene.set_current_layout("Dimensions".into()); + let mut center = hit(frame, root, DVec3::new(40.0, 20.0, 0.0)); + center.snap_type = SnapType::Center; + assert!(app.record_accepted_snap(i, Some(center), Some(frame), center.world)); + let source = app + .accepted_snaps() + .last() + .unwrap() + .source + .as_ref() + .unwrap(); + assert_eq!(source.source.handle, circle); + assert_eq!(source.block_path, vec![root]); +} + +#[test] +fn viewport_dimension_angular_object_picks_follow_arc_features() { + use crate::scene::{ChangeKind, ReferenceStatus}; + use acadrust::entities::{Arc, LwPolyline}; + use acadrust::types::Vector2; + for bulged in [false, true] { + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let entity = if bulged { + let mut poly = + LwPolyline::from_points(vec![Vector2::new(100.0, 200.0), Vector2::new(0.0, 300.0)]); + poly.vertices[0].bulge = (std::f64::consts::PI / 8.0).tan(); + EntityType::LwPolyline(poly) + } else { + let mut arc = Arc::new(); + arc.center = Vector3::new(0.0, 200.0, 0.0); + arc.radius = 100.0; + arc.start_angle = 0.0; + arc.end_angle = std::f64::consts::FRAC_PI_2; + EntityType::Arc(arc) + }; + let source = app.tabs[i].scene.add_entity(entity); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMANGULAR"); + let model = DVec3::new(100.0 / 2.0_f64.sqrt(), 200.0 + 100.0 / 2.0_f64.sqrt(), 0.0); + let result = app + .try_dimension_viewport_entity_pick(i, frame.model_to_paper(model), 0.5) + .unwrap(); + let _ = app.apply_cmd_result(result); + assert_eq!(app.accepted_snaps().len(), 3); + let _ = app.feed_command(StepInput::Point(DVec3::new(65.0, 85.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); + let dim = dimension(&app.tabs[i].scene.document).base().common.handle; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); + let status = app.tabs[i].scene.dimension_association_status(dim); + assert_eq!(status.len(), 3); + assert!( + status.iter().all(|(_, s)| *s == ReferenceStatus::Resolved), + "{bulged}: {status:?}" + ); + match app.tabs[i].scene.document.get_entity_mut(source).unwrap() { + EntityType::Arc(arc) => arc.end_angle = std::f64::consts::PI / 3.0, + EntityType::LwPolyline(poly) => { + poly.vertices[0].bulge = (std::f64::consts::PI / 12.0).tan() + } + _ => unreachable!(), + } + app.tabs[i] + .scene + .bump_entities(&[(source, ChangeKind::Modified)]); + assert!( + (displayed(&app.tabs[i].scene.document) - 60.0).abs() < 1e-5, + "{bulged}: {}", + displayed(&app.tabs[i].scene.document) + ); + } +} + +#[test] +fn viewport_dimension_quadrant_keeps_its_feature_after_translation() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let mut circle = Circle::new(); + circle.radius = 100.0; + let source = app.tabs[i].scene.add_entity(EntityType::Circle(circle)); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut quadrant = hit(frame, source, DVec3::X * 100.0); + quadrant.snap_type = SnapType::Quadrant; + assert!(app.record_accepted_snap(i, Some(quadrant), Some(frame), quadrant.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(quadrant.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let EntityType::Circle(circle) = app.tabs[i].scene.document.get_entity_mut(source).unwrap() + else { + panic!() + }; + circle.center.x = 300.0; + app.tabs[i] + .scene + .bump_entities(&[(source, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 400.0).abs() < 1e-5); +} diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 9321958f8..f8ccb3cb9 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -14,10 +14,8 @@ use std::f64::consts::TAU; use crate::command::DimensionAssociationSource; -use super::dimension_assoc_chain::{ - self as chain, ChainError, FeatureContext, ReferenceChain, -}; -use super::viewport_ref::{AcceptedSnap, ViewportFrame}; +use super::dimension_assoc_chain::{self as chain, ChainError, FeatureContext, ReferenceChain}; +use super::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; use super::{ChangeKind, Scene}; pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4; @@ -404,8 +402,7 @@ impl ChainMap { pub(crate) fn model_to_space(&self, point: Vector3) -> Vector3 { match &self.frame { Some(frame) => { - let mapped = - frame.model_to_paper(glam::DVec3::new(point.x, point.y, point.z)); + let mapped = frame.model_to_paper(glam::DVec3::new(point.x, point.y, point.z)); Vector3::new(mapped.x, mapped.y, mapped.z) } None => point, @@ -416,8 +413,7 @@ impl ChainMap { pub(crate) fn space_to_model(&self, point: Vector3) -> Vector3 { match &self.frame { Some(frame) => { - let mapped = - frame.paper_to_model(glam::DVec3::new(point.x, point.y, point.z)); + let mapped = frame.paper_to_model(glam::DVec3::new(point.x, point.y, point.z)); Vector3::new(mapped.x, mapped.y, mapped.z) } None => point, @@ -462,11 +458,18 @@ impl ChainMap { /// Only the plane moves: the centre, radius and angles are coordinates /// *within* the plane, so they stay as they are and every point the /// geometry produces comes out in the dimension's space already. - fn map_radial(&self, radial: RadialSourceGeometry) -> RadialSourceGeometry { - RadialSourceGeometry { - plane: self.map_plane(radial.plane), - ..radial + fn map_radial(&self, radial: RadialSourceGeometry) -> Option { + let plane = self.map_plane(radial.plane); + let x = glam::DVec3::from_array(plane.x_axis); + let y = glam::DVec3::from_array(plane.y_axis); + let magnitude = x.length_squared().max(y.length_squared()); + if magnitude < 1e-24 + || (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-9 + || x.dot(y).abs() > magnitude * 1e-9 + { + return None; } + Some(RadialSourceGeometry { plane, ..radial }) } } @@ -500,7 +503,9 @@ pub enum ReferenceStatus { fn chain_map(scene: &Scene, walked: &ReferenceChain) -> ChainMap { ChainMap { transform: walked.transform, - frame: walked.viewport.and_then(|handle| scene.viewport_frame(handle)), + frame: walked + .viewport + .and_then(|handle| scene.viewport_frame(handle)), } } @@ -531,7 +536,12 @@ fn hint_error(entity: &EntityType, point: Vector3) -> f64 { /// the same drawing. Rather than guess, both readings are mapped down to the /// entity and the one that actually lands on the geometry wins. The point is /// only ever a *hint* — it disambiguates candidates, it is never the answer. -fn local_hint(scene: &Scene, map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option { +fn local_hint( + scene: &Scene, + map: &ChainMap, + entity: &EntityType, + stored: Vector3, +) -> Option { let _ = scene; let inverse = chain::invert(&map.transform)?; let mut candidates = vec![inverse.apply(stored)]; @@ -558,34 +568,66 @@ pub fn resolve_reference_chain( let walked = match chain::walk_chain(&scene.document, &reference.xrefs) { Ok(walked) => walked, Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), - Err(ChainError::Empty) => return Err(ReferenceStatus::Unresolved), + Err(ChainError::Empty | ChainError::Invalid) => return Err(ReferenceStatus::Unresolved), }; let Some(entity) = scene.document.get_entity(walked.entity) else { return Err(ReferenceStatus::Broken(walked.entity)); }; let map = chain_map(scene, &walked); - let inverse = chain::invert(&map.transform); - - let mut local = None; - if chain::evaluates(reference.osnap_type) { - let hint = local_hint(scene, &map, entity, reference.osnap_point); - let from = from_space - .zip(inverse) - .map(|(point, inverse)| inverse.apply(map.space_to_model(point))); - local = chain::feature_point( + if walked.viewport.is_some() && map.frame.is_none() { + return Err(ReferenceStatus::Unresolved); + } + let inverse = chain::invert(&map.transform).ok_or(ReferenceStatus::Unresolved)?; + if matches!( + reference.osnap_type, + chain::osnap::INTERSEC | chain::osnap::APPARENT_INT + ) { + match chain::walk_chain(&scene.document, &reference.intersection_objects) { + Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), + Err(_) => return Err(ReferenceStatus::Unresolved), + Ok(_) => {} + } + } + let legacy = reference.main_subent_type != 2 + && matches!( + reference.osnap_type, + chain::osnap::NONE | chain::osnap::END | chain::osnap::START_POINT + ); + let local = if reference.osnap_type == chain::osnap::PERP && !walked.block_path.is_empty() { + from_space + .and_then(|from| { + chain::perpendicular_in_model(entity, &map.transform, map.space_to_model(from)) + }) + .map(|point| inverse.apply(point)) + } else if legacy { + resolve_local_by_marker(entity, reference) + } else if chain::evaluates(reference.osnap_type) { + let hint = if reference + .osnap_point + .x + .abs() + .max(reference.osnap_point.y.abs()) + .max(reference.osnap_point.z.abs()) + < 1e40 + { + local_hint(scene, &map, entity, reference.osnap_point) + } else { + None + }; + let from = from_space.map(|point| inverse.apply(map.space_to_model(point))); + chain::feature_point( &scene.document, entity, reference, FeatureContext { hint, from }, - ); + ) + } else { + None + } + .ok_or(ReferenceStatus::Unresolved)?; + if !local.x.is_finite() || !local.y.is_finite() || !local.z.is_finite() { + return Err(ReferenceStatus::Unresolved); } - // Every reference — including one whose osnap type we do not evaluate — - // falls back to the marker/parameter convention, which is what the - // pre-PR3 writer and plain END references use. - let local = match local.or_else(|| resolve_local_by_marker(entity, reference)) { - Some(local) => local, - None => return Err(ReferenceStatus::Unresolved), - }; let model = map.to_model(local); Ok(ResolvedReference { model, @@ -798,9 +840,11 @@ pub(crate) fn dimension_is_associative( }; association.dimension == dimension && association.associativity != 0 - && association.references.iter().flatten().any(|reference| { - chain::walk_chain(document, &reference.xrefs).is_ok() - }) + && association + .references + .iter() + .flatten() + .any(|reference| chain::walk_chain(document, &reference.xrefs).is_ok()) }) } @@ -827,6 +871,15 @@ pub(crate) fn constraint_from_associative_dimension( }; (association.dimension == handle && association.associativity != 0).then_some(association) })?; + if association.trans_space + || association + .references + .iter() + .flatten() + .any(|r| r.xrefs.len() != 1) + { + return None; + } let point = |index: usize| { let reference = association.references.get(index)?.first()?; Some(ParametricRef::point( @@ -1207,8 +1260,7 @@ impl Scene { self.attach_viewport_radial_association(dimension, snaps); return; } - let mut references: [Vec; 4] = - std::array::from_fn(|_| Vec::new()); + let mut references: [Vec; 4] = std::array::from_fn(|_| Vec::new()); let mut associativity = 0; let mut trans_space = false; let mut reactor_targets = Vec::new(); @@ -1233,25 +1285,41 @@ impl Scene { reactor_targets.extend(xrefs.iter().copied()); let osnap_type = chain::osnap_type_for(source.snap_type); - // An explicitly-acquired sub-entity wins; otherwise fall back to - // the same marker inference model-space creation already uses, in - // the entity's own coordinates. - let (marker, parameter) = match source.source.marker { - Some(marker) => (marker, source.source.parameter), - None => { - let local = Self::point_in_entity_space( - &self.document, - &source.block_path, - dvec3(snap.model_point), - ); - source_reference(entity, local).unwrap_or((0, 0.0)) - } + let local = Self::point_in_entity_space( + &self.document, + &source.block_path, + dvec3(snap.model_point), + ); + let marker_parameter = source + .source + .marker + .map(|marker| (marker, source.source.parameter)) + .or_else(|| feature_reference(entity, local, source.snap_type)); + let Some((marker, parameter)) = marker_parameter else { + continue; }; + let mut intersection_objects = Vec::new(); + if matches!( + source.snap_type, + crate::snap::SnapType::Intersection | crate::snap::SnapType::ApparentIntersection + ) { + let Some(other) = source.intersection.as_ref() else { + continue; + }; + intersection_objects.extend(snap.viewport); + intersection_objects.extend(other.block_path.iter().copied()); + intersection_objects.push(other.source.handle); + if chain::walk_chain(&self.document, &intersection_objects).is_err() { + continue; + } + reactor_targets.extend(intersection_objects.iter().copied()); + } associativity |= 1 << index; references[index].push(AssocDimensionReference { class_name: "AcDbOsnapPointRef".to_string(), osnap_type, xrefs, + intersection_objects, main_subent_type: 1, main_gs_marker: marker, osnap_distance: parameter, @@ -1385,13 +1453,41 @@ impl Scene { /// the erased source comes back and the same query starts answering /// [`ReferenceStatus::Resolved`] again, with the reference record itself /// never having been rewritten. - pub fn dimension_association_status( - &self, - dimension: Handle, - ) -> Vec<(usize, ReferenceStatus)> { + pub fn dimension_association_status(&self, dimension: Handle) -> Vec<(usize, ReferenceStatus)> { let Some(association) = self.dimension_association(dimension) else { return Vec::new(); }; + let measurement_supported = !association.trans_space + || self.document.get_entity(dimension).is_some_and(|entity| { + matches!( + entity, + EntityType::Dimension(Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) + ) || persisted_measurement_scale(&self.document, entity).is_some() + }); + let measurement_supported = measurement_supported + && self.document.get_entity(dimension).is_some_and(|entity| { + if !matches!( + entity, + EntityType::Dimension( + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ) + ) { + return true; + } + association.references[0] + .first() + .and_then(|reference| { + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let source = self.document.get_entity(walked.entity)?; + chain_map(self, &walked) + .map_radial(radial_source_for_marker(source, reference.main_gs_marker)?) + }) + .is_some() + }); + let measurement_supported = measurement_supported + && association.references.iter().flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)).count() <= 1; + let points = self.dimension_association_slot_points(dimension); association .references .iter() @@ -1400,8 +1496,16 @@ impl Scene { let reference = slot.first()?; Some(( index, - match resolve_reference_chain(self, reference, None) { - Ok(_) => ReferenceStatus::Resolved, + match resolve_reference_chain( + self, + reference, + points + .iter() + .enumerate() + .find_map(|(other, point)| (other != index).then_some(*point)), + ) { + Ok(_) if measurement_supported => ReferenceStatus::Resolved, + Ok(_) => ReferenceStatus::Unresolved, Err(status) => status, }, )) @@ -1437,10 +1541,7 @@ impl Scene { } /// The DIMASSOC payload attached to `dimension`, if there is one. - pub fn dimension_association( - &self, - dimension: Handle, - ) -> Option<&AssocDimensionAssociation> { + pub fn dimension_association(&self, dimension: Handle) -> Option<&AssocDimensionAssociation> { self.document.objects.values().find_map(|object| { let ObjectType::Associative(object) = object else { return None; @@ -1557,12 +1658,8 @@ impl Scene { } } - pub(crate) fn infer_dimension_sources( - &self, - dimension: Handle, - ) -> Vec> { - let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) - else { + pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec> { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return Vec::new(); }; let radial_data = match entity { @@ -1659,11 +1756,15 @@ impl Scene { let mut refreshed = Vec::new(); for association in associations { - // Two passes. PERPENDICULAR and TANGENT are measured *from* the - // other definition point, so the independent references have to - // land first; a dimension with two dependent references keeps its - // old points as the from-points, which is stable because they were - // valid the last time round. + // Dependent snaps need a resolved independent definition point. + // Two such features require a coupled solve; old points are not + // evidence of a solution after the geometry changes. + if association.references.iter().flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)).count() > 1 + { + continue; + } + // Resolve independent references first, then perpendicular/tangent. let mut resolved: [Option; 4] = std::array::from_fn(|index| { let reference = association.references[index].first()?; if chain::needs_from_point(reference.osnap_type) { @@ -1675,9 +1776,7 @@ impl Scene { .document .get_entity(association.dimension) .and_then(|entity| match entity { - EntityType::Dimension(dimension) => { - Some(dimension_reference_points(dimension)) - } + EntityType::Dimension(dimension) => Some(dimension_reference_points(dimension)), _ => None, }) .unwrap_or_default(); @@ -1699,6 +1798,19 @@ impl Scene { }); resolved[index] = resolve_reference_chain(self, reference, from).ok(); } + if association + .references + .iter() + .enumerate() + .any(|(index, slot)| !slot.is_empty() && resolved[index].is_none()) + { + continue; + } + let viewports: std::collections::HashSet<_> = + resolved.iter().flatten().map(|r| r.viewport).collect(); + if viewports.len() > 1 { + continue; + } // The map of the first live reference governs how the whole // dimension is placed and measured — all of a dimension's points // are in one space. @@ -1725,7 +1837,7 @@ impl Scene { // Mapped once, here: everything the match arms below build out // of it then lands in the dimension's own space. Some(( - chain_map(self, &walked).map_radial(radial), + chain_map(self, &walked).map_radial(radial)?, reference.osnap_distance, )) }); @@ -1738,15 +1850,34 @@ impl Scene { _ => return None, }; let radial = radial_source_for_marker(entity, segment)?; - Some(chain_map(self, &walked).map_radial(radial)) + chain_map(self, &walked).map_radial(radial) }); if radial_source.is_none() && resolved.iter().all(Option::is_none) { continue; } + let Some(before) = self.document.get_entity(association.dimension).cloned() else { + continue; + }; + let angular = matches!( + &before, + EntityType::Dimension(Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) + ); + let measurement_scale = if let Some(frame) = map.frame.filter(|_| !angular) { + let Some(mut scale) = persisted_measurement_scale(&self.document, &before) else { + continue; + }; + scale.viewport_compensation = frame.paper_to_model_length_factor(); + Some(scale) + } else { + None + }; if self.is_recording_undo() { let before = self.document.get_entity_arc(association.dimension); self.record_undo_before(association.dimension, before); } + if measurement_scale.is_some() { + self.ensure_app_id(MeasurementScale::APP_ID); + } let Some(EntityType::Dimension(dimension)) = self.document.get_entity_mut(association.dimension) else { @@ -2005,8 +2136,8 @@ impl Scene { .zip(resolved.iter()) .all(|(model, space)| match (model, space, map.frame.as_ref()) { (Some(model), Some(space), Some(frame)) => { - let projected = - frame.model_to_paper(glam::DVec3::new(model.x, model.y, model.z)); + let projected = frame + .model_to_paper(glam::DVec3::new(model.x, model.y, model.z)); (projected.x - space.x).abs() < 1e-6 && (projected.y - space.y).abs() < 1e-6 } @@ -2015,6 +2146,30 @@ impl Scene { "resolved paper points must be the model points through the frame", ); dimension.base_mut().actual_measurement = measurement; + if map.frame.is_some() { + if let EntityType::Dimension(old) = &before { + if old.base().text_user_positioned { + dimension.base_mut().text_middle_point = old.base().text_middle_point; + dimension.base_mut().insertion_point = old.base().insertion_point; + } + } + } + let entity = self + .document + .get_entity_mut(association.dimension) + .expect("dimension exists"); + if let Some(scale) = measurement_scale { + scale.write_to_entity(entity); + } + if *entity == before { + continue; + } + // Saved anonymous blocks bypass generated dimension rendering. + // Invalidate only after a completely resolved, committed update; + // the undo snapshot above retains the previous picture and data. + if let EntityType::Dimension(dimension) = entity { + dimension.base_mut().block_name.clear(); + } refreshed.push((association.dimension, ChangeKind::Modified)); } refreshed @@ -2053,3 +2208,121 @@ fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { let axis = [linear.rotation.cos(), linear.rotation.sin()]; ((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() } + +/// Encode the feature actually acquired, in source-local coordinates. +fn feature_reference( + entity: &EntityType, + point: Vector3, + kind: crate::snap::SnapType, +) -> Option<(i32, f64)> { + use crate::snap::SnapType as S; + match kind { + S::Node | S::Insertion => Some((0, 0.0)), + S::Center if matches!(entity, EntityType::Ellipse(_)) => Some((-3, 0.0)), + S::Endpoint | S::ObjectPick | S::Center => { + if kind != S::Center { + if let Some(index) = source_points(entity) + .iter() + .position(|candidate| point_distance_squared(*candidate, point) < 1e-12) + { + return Some((index as i32, 0.0)); + } + } + if matches!(entity, EntityType::Circle(_) | EntityType::Arc(_)) { + return source_reference(entity, point); + } + let planar = crate::entities::curve::entity_curve(entity)?; + if let KernelCurve::Polyline(polyline) = &planar.curve { + for segment in 0..polyline.vertices.len() { + if let Some(arc) = polyline.segment_arc(segment) { + let center = vector3(planar.plane.point_at(arc.center)); + if point_distance_squared(center, point) < 1e-12 { + return Some((POLYLINE_ARC_CENTER_MARKER, segment as f64)); + } + } + } + } + None + } + S::Nearest | S::Extension => { + if matches!(entity, EntityType::Circle(_) | EntityType::Arc(_)) { + return source_reference(entity, point); + } + let planar = crate::entities::curve::entity_curve(entity)?; + let near = closest_point(&planar.curve, planar.plane.project(dpoint(point))?); + Some((0, near.t)) + } + S::Midpoint => { + let planar = crate::entities::curve::entity_curve(entity)?; + if let KernelCurve::Polyline(_) = &planar.curve { + let uv = planar.plane.project(dpoint(point))?; + let segments = planar.curve.segments(); + let (index, _) = segments.iter().enumerate().min_by(|(_, a), (_, b)| { + let error = |c: &KernelCurve| { + let p = closest_point(c, uv).point; + (p[0] - uv[0]).powi(2) + (p[1] - uv[1]).powi(2) + }; + error(a).total_cmp(&error(b)) + })?; + Some((index as i32, 0.5)) + } else { + Some((0, 0.5)) + } + } + S::Tangent => { + if let EntityType::Spline(spline) = entity { + return Some(( + 0, + crate::entities::spline::nurbs3(spline)?.parameter_at(dpoint(point)), + )); + } + source_reference(entity, point) + } + S::Intersection | S::ApparentIntersection => Some((0, 0.0)), + _ => source_reference(entity, point), + } +} + +fn persisted_measurement_scale( + doc: &acadrust::CadDocument, + entity: &EntityType, +) -> Option { + use crate::entities::dim_override; + use acadrust::xdata::XDataValue; + let EntityType::Dimension(dimension) = entity else { + return None; + }; + let data = &dimension.base().common.extended_data; + let effective = dim_override::real(data, dim_override::DIMLFAC) + .or_else(|| { + doc.dim_styles + .get(&dimension.base().style_name) + .map(|s| s.dimlfac) + })? + .abs(); + let effective = if effective == 0.0 { 1.0 } else { effective }; + if !effective.is_finite() { + return None; + } + if let Some(mut scale) = MeasurementScale::read(data) { + // A Properties edit writes the persisted total DIMLFAC. Preserve that + // new user choice when the next source/viewport update recomposes it. + scale.user_lfac = effective / scale.viewport_compensation; + return Some(scale); + } + let calculated = data + .get_record("ACAD_DIMASSOC_CALC_DIMLFAC")? + .values + .iter() + .find_map(|v| match v { + XDataValue::Real(v) => Some(v.abs()), + _ => None, + })?; + if !calculated.is_finite() || calculated <= 0.0 { + return None; + } + Some(MeasurementScale { + user_lfac: effective / calculated, + viewport_compensation: calculated, + }) +} diff --git a/src/scene/dimension_assoc_chain.rs b/src/scene/dimension_assoc_chain.rs index ce83ac307..2bcf31342 100644 --- a/src/scene/dimension_assoc_chain.rs +++ b/src/scene/dimension_assoc_chain.rs @@ -37,7 +37,7 @@ use acadrust::objects::AssocDimensionReference; use acadrust::types::{Handle, Matrix4, Transform, Vector3}; use acadrust::{CadDocument, EntityType}; -use cadkernel::geom2d::{closest_point, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN}; +use cadkernel::geom2d::{closest_point, Curve as KernelCurve}; use cadkernel::space::Plane; use crate::entities::curve::entity_curve; @@ -50,7 +50,7 @@ use crate::entities::curve::entity_curve; /// and `NEAR` for a point taken at a parameter on a circle, so those two keep /// meaning exactly what they used to. #[allow(dead_code)] // The full code table is kept so a reader can see what a -// stored `osnap_type` means, including the modes we pass through untouched. + // stored `osnap_type` means, including the modes we pass through untouched. pub(crate) mod osnap { pub const NONE: u8 = 0; pub const END: u8 = 1; @@ -115,6 +115,7 @@ pub(crate) struct ReferenceChain { pub(crate) enum ChainError { /// `xrefs` was empty — nothing to resolve. Empty, + Invalid, /// A handle in the chain names an object that is not in the document /// (erased source; restored by an undo). Missing(Handle), @@ -131,55 +132,54 @@ pub(crate) fn walk_chain( document: &CadDocument, xrefs: &[Handle], ) -> Result { + if xrefs.is_empty() { + return Err(ChainError::Empty); + } let mut viewport = None; - let mut block_path: Vec = Vec::new(); - let mut entity = None; + let mut block_path = Vec::new(); let mut transform = Transform::identity(); - - for (index, handle) in xrefs.iter().copied().enumerate() { + let mut owner = None; + for (index, &handle) in xrefs.iter().enumerate() { if handle.is_null() { + return Err(ChainError::Invalid); + } + let node = document + .get_entity(handle) + .ok_or(ChainError::Missing(handle))?; + if owner.is_some_and(|owner| node.common().owner_handle != owner) { + return Err(ChainError::Invalid); + } + if index == 0 && matches!(node, EntityType::Viewport(_)) { + viewport = Some(handle); + owner = document.block_records.get("*Model_Space").map(|b| b.handle); continue; } - let Some(node) = document.get_entity(handle) else { - return Err(ChainError::Missing(handle)); - }; - match node { - // Only a *leading* viewport is the trans-space hop. A viewport - // deeper in the chain would be geometry being dimensioned (the - // paper-space rectangle itself), so it falls through to `entity`. - EntityType::Viewport(_) if index == 0 && viewport.is_none() => { - viewport = Some(handle); - } - EntityType::Insert(insert) => { - block_path.push(handle); - transform = - transform.compose(&crate::scene::render_graph::insert_transform(document, insert)); - } - _ => { - entity = Some(handle); - break; - } + if index == xrefs.len() - 1 { + return Ok(ReferenceChain { + viewport, + block_path, + entity: handle, + transform, + }); } + let EntityType::Insert(insert) = node else { + return Err(ChainError::Invalid); + }; + // Arrays need an instance index that this reference format does not carry. + if insert.row_count > 1 || insert.column_count > 1 || block_path.contains(&handle) { + return Err(ChainError::Invalid); + } + let block = document + .block_records + .get(&insert.block_name) + .ok_or(ChainError::Invalid)?; + owner = Some(block.handle); + block_path.push(handle); + transform = transform.compose(&crate::scene::render_graph::insert_transform( + document, insert, + )); } - - let entity = match entity { - Some(entity) => entity, - // Nothing but INSERTs: the innermost one is the geometry, and it is - // no longer part of the path leading to it. - None => match block_path.pop() { - Some(innermost) => { - transform = block_transform(document, &block_path); - innermost - } - None => return Err(ChainError::Empty), - }, - }; - Ok(ReferenceChain { - viewport, - block_path, - entity, - transform, - }) + Err(ChainError::Empty) } /// Accumulated entity-local -> model transform for an ordered INSERT path. @@ -187,8 +187,9 @@ pub(crate) fn block_transform(document: &CadDocument, block_path: &[Handle]) -> let mut transform = Transform::identity(); for handle in block_path { if let Some(EntityType::Insert(insert)) = document.get_entity(*handle) { - transform = - transform.compose(&crate::scene::render_graph::insert_transform(document, insert)); + transform = transform.compose(&crate::scene::render_graph::insert_transform( + document, insert, + )); } } transform @@ -270,7 +271,7 @@ pub(crate) struct FeatureContext { /// The point the feature is measured *from*, in entity-local coordinates. /// PERPENDICULAR and TANGENT are two-point features: the foot / touch /// point only exists relative to an external point, which for a dimension - /// is the other definition point. `None` falls back to `hint`. + /// is the other definition point. Tangency requires this point. pub from: Option, } @@ -287,9 +288,12 @@ pub(crate) fn feature_point( context: FeatureContext, ) -> Option { match reference.osnap_type { + osnap::END | osnap::START_POINT if reference.main_subent_type == 2 => { + imported_endpoint(entity, reference, context.hint) + } osnap::CEN => center_point(entity, reference), osnap::MID => mid_point(entity, reference), - osnap::QUAD => quadrant_point(entity, context.hint?), + osnap::QUAD => quadrant_point(entity, reference, context.hint?), osnap::NODE | osnap::INS => node_point(entity), osnap::INTERSEC | osnap::APPARENT_INT => intersection_point( document, @@ -313,7 +317,9 @@ pub(crate) fn feature_point( pub(crate) fn evaluates(osnap_type: u8) -> bool { matches!( osnap_type, - osnap::CEN + osnap::END + | osnap::START_POINT + | osnap::CEN | osnap::MID | osnap::QUAD | osnap::NODE @@ -342,7 +348,13 @@ fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Opt let KernelCurve::Polyline(polyline) = &planar.curve else { return None; }; - let segment = reference.main_gs_marker.max(0) as usize; + let segment = if reference.main_subent_type == 1 + && reference.main_gs_marker == super::dimension_assoc::POLYLINE_ARC_CENTER_MARKER + { + reference.osnap_distance.round().max(0.0) as usize + } else { + segment_index(reference)? + }; let arc = polyline.segment_arc(segment)?; Some(lift(&planar.plane, arc.center)) } @@ -356,36 +368,52 @@ fn mid_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option // without one, the midpoint of the whole run. KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => { let segments = planar.curve.segments(); - let segment = segments.get(reference.main_gs_marker as usize)?; + let segment = segments.get(segment_index(reference)?)?; Some(lift(&planar.plane, segment.point_at(0.5))) } _ => Some(lift(&planar.plane, planar.curve.point_at(0.5))), } } -fn quadrant_point(entity: &EntityType, hint: Vector3) -> Option { +fn quadrant_point( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Vector3, +) -> Option { let planar = entity_curve(entity)?; let (centre, radius) = match &planar.curve { KernelCurve::Circle(circle) => (circle.centre, circle.radius), KernelCurve::Arc(arc) => (arc.centre, arc.radius), _ => return None, }; - // Four candidates on the curve's own plane; the stored point picks one, so - // a rotated source keeps the quadrant the user actually snapped to. + let valid_angle = |angle: f64| match &planar.curve { + KernelCurve::Arc(arc) => { + (angle - arc.start_angle).rem_euclid(std::f64::consts::TAU) <= arc.sweep() + 1e-9 + } + _ => true, + }; + let at_angle = |angle: f64| { + lift( + &planar.plane, + [ + centre[0] + radius * angle.cos(), + centre[1] + radius * angle.sin(), + ], + ) + }; + // New references retain the chosen source-local angle, so translation + // cannot silently select another quadrant using the old world point. + if reference.main_subent_type == 1 && reference.main_gs_marker == -2 { + let angle = reference.osnap_distance; + return (angle.is_finite() && valid_angle(angle)).then(|| at_angle(angle)); + } + // An imported point can identify an unchanged quadrant, but cannot prove + // which quadrant was intended after it moves away from the stored hint. (0..4) - .map(|index| { - let angle = std::f64::consts::FRAC_PI_2 * index as f64; - lift( - &planar.plane, - [ - centre[0] + radius * angle.cos(), - centre[1] + radius * angle.sin(), - ], - ) - }) - .min_by(|first, second| { - distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint)) - }) + .map(|index| std::f64::consts::FRAC_PI_2 * index as f64) + .filter(|angle| valid_angle(*angle)) + .map(at_angle) + .find(|candidate| distance_squared(*candidate, hint) < 1e-12) } fn node_point(entity: &EntityType) -> Option { @@ -402,7 +430,50 @@ fn node_point(entity: &EntityType) -> Option { fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option { let planar = entity_curve(entity)?; let uv = planar.plane.project(dpoint(from))?; - Some(lift(&planar.plane, closest_point(&planar.curve, uv).point)) + let near = closest_point(&planar.curve, uv); + let direction = planar.curve.tangent_at(near.t); + let delta = [near.point[0] - uv[0], near.point[1] - uv[1]]; + let residual = delta[0] * direction[0] + delta[1] * direction[1]; + if residual.abs() > 1e-8 * delta[0].hypot(delta[1]).max(1.0) { + return None; + } + Some(lift(&planar.plane, near.point)) +} + +/// Perpendicularity must be evaluated after a block's nonuniform scale. +pub(crate) fn perpendicular_in_model( + entity: &EntityType, + transform: &Transform, + from: Vector3, +) -> Option { + use glam::DVec3; + let source = entity_curve(entity)?; + let origin = transform.apply(vector3(source.plane.origin)); + let x = DVec3::from_array(dpoint( + transform.apply_rotation(vector3(source.plane.x_axis)), + )); + let y = DVec3::from_array(dpoint( + transform.apply_rotation(vector3(source.plane.y_axis)), + )); + let axis_x = x.try_normalize()?; + let normal = x.cross(y).try_normalize()?; + let axis_y = normal.cross(axis_x); + let plane = Plane::from_axes(dpoint(origin), axis_x.to_array(), axis_y.to_array()); + let curve = source.curve.transformed(&cadkernel::geom2d::Transform { + origin: [0.0, 0.0].into(), + x_axis: [x.dot(axis_x), x.dot(axis_y)].into(), + y_axis: [y.dot(axis_x), y.dot(axis_y)].into(), + })?; + let uv = plane.project(dpoint(from))?; + let near = closest_point(&curve, uv); + let tangent = curve.tangent_at(near.t); + let delta = [near.point[0] - uv[0], near.point[1] - uv[1]]; + if (delta[0] * tangent[0] + delta[1] * tangent[1]).abs() + > 1e-8 * delta[0].hypot(delta[1]).max(1.0) + { + return None; + } + Some(lift(&plane, near.point)) } fn nearest_point( @@ -416,9 +487,7 @@ fn nearest_point( // Every other curve carries the kernel's normalised 0..1 parameter. match &planar.curve { KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity { - EntityType::Circle(circle) => { - Some(circle.point_at_angle_wcs(reference.osnap_distance)) - } + EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)), EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), _ => None, }, @@ -454,39 +523,14 @@ fn tangent_point( return spline_tangent_point(spline, reference, context); } let planar = entity_curve(entity)?; - let (centre, radius) = match &planar.curve { - KernelCurve::Circle(circle) => (circle.centre, circle.radius), - KernelCurve::Arc(arc) => (arc.centre, arc.radius), - _ => { - // Generic curve: no closed form and no spline evaluator. Hold the - // stored parameter if there is one, else leave it unresolved. - return nearest_point(entity, reference, context.hint); - } - }; - let from = context.from.or(context.hint)?; + let from = context.from?; let uv = planar.plane.project(dpoint(from))?; - let (dx, dy) = (uv[0] - centre[0], uv[1] - centre[1]); - let distance = (dx * dx + dy * dy).sqrt(); - if !distance.is_finite() || distance <= radius + 1e-12 || radius <= 1e-12 { - return None; - } - // Two touch points, symmetric about the centre-to-`from` line. - let base = dy.atan2(dx); - let offset = (radius / distance).clamp(-1.0, 1.0).acos(); - let candidates = [base + offset, base - offset].map(|angle| { - lift( - &planar.plane, - [ - centre[0] + radius * angle.cos(), - centre[1] + radius * angle.sin(), - ], - ) - }); - let hint = context.hint.unwrap_or(candidates[0]); - candidates + cadkernel::geom2d::snap::tangent_from(&planar.curve, uv) .into_iter() - .min_by(|first, second| { - distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint)) + .map(|candidate| lift(&planar.plane, candidate.point)) + .min_by(|a, b| { + distance_squared(*a, context.hint.unwrap_or(from)) + .total_cmp(&distance_squared(*b, context.hint.unwrap_or(from))) }) } @@ -504,50 +548,52 @@ fn spline_tangent_point( let hint = context.hint.or(context.from)?; curve.parameter_at(dpoint(hint)).clamp(start, end) }; - let Some(from) = context.from.or(context.hint) else { - // No external point: the stored parameter is the whole answer. - return Some(vector3(curve.point_at_knot(u))); - }; - let p = dpoint(from); - // Newton on f(u) = (C(u) - P) · C'(u). f'(u) is approximated by a central - // difference of f, which converges in a handful of steps from a seed that - // is already on the right lobe and never leaves the domain. + let from = glam::DVec3::from_array(dpoint(context.from?)); + // A tangent is parallel to C(u)-P. Minimize the vector cross product, + // which also detects nonplanar curves that have no tangent from P. let f = |u: f64| { - let c = curve.point_at_knot(u); - let d = curve.derivative_at_knot(u); - (c[0] - p[0]) * d[0] + (c[1] - p[1]) * d[1] + (c[2] - p[2]) * d[2] + let c = glam::DVec3::from_array(curve.point_at_knot(u)); + let d = glam::DVec3::from_array(curve.derivative_at_knot(u)); + (c - from).cross(d) }; - let span = (end - start).abs().max(1e-12); - let step = span * 1e-6; - for _ in 0..24 { + let span = (end - start).abs(); + if !span.is_finite() || span <= 1e-15 { + return None; + } + for _ in 0..48 { let value = f(u); - if value.abs() <= span * 1e-12 { + let lo = (u - span * 1e-6).max(start); + let hi = (u + span * 1e-6).min(end); + let derivative = (f(hi) - f(lo)) / (hi - lo); + if !value.is_finite() || !derivative.is_finite() { + return None; + } + if derivative.length_squared() <= 1e-30 { break; } - let slope = (f((u + step).min(end)) - f((u - step).max(start))) / (2.0 * step); - if !slope.is_finite() || slope.abs() < 1e-18 { - break; - } - let next = (u - value / slope).clamp(start, end); - if !next.is_finite() || (next - u).abs() <= span * 1e-14 { + let next = (u - value.dot(derivative) / derivative.length_squared()).clamp(start, end); + if (next - u).abs() < span * 1e-14 { u = next; break; } u = next; } - Some(vector3(curve.point_at_knot(u))) + let c = glam::DVec3::from_array(curve.point_at_knot(u)); + let d = glam::DVec3::from_array(curve.derivative_at_knot(u)); + let scale = (c - from).length() * d.length(); + if !c.is_finite() || scale <= 1e-20 || f(u).length() > 1e-8 * scale { + return None; + } + Some(vector3(c.to_array())) } // ── Intersection ────────────────────────────────────────────────────────── /// Intersection of `entity` with the reference's second object chain. /// -/// Both curves are tessellated on the first curve's plane and crossed -/// pairwise; the crossing nearest the stored osnap point wins, so a pair of -/// curves that meet more than once keeps the corner the user picked. With -/// `apparent` the segment parameters are not clamped, which recovers the -/// crossing of the two *extended* nearest segments — the best-effort AutoCAD -/// calls an apparent intersection in a plan view. +/// Transform both curves into the first entity's local plane and use the +/// kernel's exact/converged intersection solver. Apparent intersections may +/// project noncoplanar curves; only straight lines support an extended crossing. fn intersection_point( document: &CadDocument, entity: &EntityType, @@ -555,59 +601,53 @@ fn intersection_point( hint: Vector3, apparent: bool, ) -> Option { + let main = walk_chain(document, &reference.xrefs).ok()?; let other = walk_chain(document, &reference.intersection_objects).ok()?; - let other_entity = document.get_entity(other.entity)?; + if main.viewport != other.viewport { + return None; + } + let inverse = invert(&main.transform)?; let first = entity_curve(entity)?; - let second = entity_curve(other_entity)?; - // The first curve's plane is the working frame. The second curve is - // evaluated through its *own* block path, so each of its points is lifted - // to model space and then projected back onto that frame before crossing. - let a = first.curve.tessellate(DEFAULT_SEGMENTS_PER_RADIAN); - let b = second - .curve - .tessellate(DEFAULT_SEGMENTS_PER_RADIAN) - .into_iter() - .map(|uv| { - let world = other.transform.apply(lift(&second.plane, uv)); - first.plane.project(dpoint(world)).unwrap_or(uv) - }) - .collect::>(); + let second = entity_curve(document.get_entity(other.entity)?)?; + let map = |uv| { + let local = inverse.apply(other.transform.apply(lift(&second.plane, uv))); + let projected = first.plane.project(dpoint(local))?; + if !apparent && distance_squared(local, lift(&first.plane, projected)) > 1e-12 { + return None; + } + Some(projected) + }; + let origin = glam::DVec2::from_array(map([0.0, 0.0])?); + let x = glam::DVec2::from_array(map([1.0, 0.0])?) - origin; + let y = glam::DVec2::from_array(map([0.0, 1.0])?) - origin; + let second = second.curve.transformed(&cadkernel::geom2d::Transform { + origin: origin.to_array().into(), + x_axis: x.to_array().into(), + y_axis: y.to_array().into(), + })?; let hint_uv = first.plane.project(dpoint(hint))?; - let mut best: Option<([f64; 2], f64)> = None; - for pair_a in a.windows(2) { - for pair_b in b.windows(2) { - let Some(point) = segment_cross(pair_a[0], pair_a[1], pair_b[0], pair_b[1], false) - else { - continue; - }; - let error = (point[0] - hint_uv[0]).powi(2) + (point[1] - hint_uv[1]).powi(2); - if best.is_none_or(|(_, previous)| error < previous) { - best = Some((point, error)); - } + let mut points: Vec<_> = cadkernel::geom2d::intersect( + &first.curve, + &second, + cadkernel::geom2d::Tolerance::new(1e-9), + ) + .into_iter() + .map(|hit| hit.point) + .collect(); + if apparent && points.is_empty() { + // Extended apparent intersections are meaningful for straight lines. + // Curved geometry must actually cross after projection. + if let (KernelCurve::Line(a), KernelCurve::Line(b)) = (&first.curve, &second) { + points.extend(segment_cross(a.start, a.end, b.start, b.end, true)); } } - if best.is_none() && apparent { - // Nothing actually crosses: take the two segments nearest the stored - // point and cross their infinite extensions. - let nearest = |run: &[[f64; 2]]| -> Option<([f64; 2], [f64; 2])> { - run.windows(2) - .map(|pair| { - let mid = [ - (pair[0][0] + pair[1][0]) * 0.5, - (pair[0][1] + pair[1][1]) * 0.5, - ]; - let error = - (mid[0] - hint_uv[0]).powi(2) + (mid[1] - hint_uv[1]).powi(2); - ((pair[0], pair[1]), error) - }) - .min_by(|first, second| first.1.total_cmp(&second.1)) - .map(|(pair, _)| pair) - }; - let (a0, a1) = nearest(&a)?; - let (b0, b1) = nearest(&b)?; - best = segment_cross(a0, a1, b0, b1, true).map(|point| (point, 0.0)); - } - best.map(|(point, _)| lift(&first.plane, point)) + points + .into_iter() + .min_by(|a, b| { + let error = |p: &[f64; 2]| (p[0] - hint_uv[0]).powi(2) + (p[1] - hint_uv[1]).powi(2); + error(a).total_cmp(&error(b)) + }) + .map(|point| lift(&first.plane, point)) } /// Crossing of segments `a0->a1` and `b0->b1`. With `extend` the parameters @@ -633,3 +673,48 @@ fn segment_cross( } Some([a0[0] + d[0] * t, a0[1] + d[1] * t]) } + +/// Internal markers are zero-based; imported edge markers are one-based. +fn segment_index(reference: &AssocDimensionReference) -> Option { + let marker = if reference.main_subent_type == 2 { + reference.main_gs_marker.checked_sub(1)? + } else { + reference.main_gs_marker + }; + usize::try_from(marker).ok() +} + +fn imported_endpoint( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Option, +) -> Option { + let planar = entity_curve(entity)?; + let t = match reference.osnap_distance { + v if v.abs() < 1e-9 => 0.0, + v if (v - 1.0).abs() < 1e-9 => 1.0, + _ => return None, + }; + let point = match &planar.curve { + KernelCurve::Polyline(_) => { + let segment = segment_index(reference)?; + let point = lift( + &planar.plane, + planar.curve.segments().get(segment)?.point_at(t), + ); + // Exporters can leave stale GS markers after editing a polyline. + // Beyond the unambiguous first-edge convention, require a real + // stored snap point to validate this imported endpoint. Zero and + // 2e50 sentinel points do not establish feature identity. + if segment > 0 && !hint.is_some_and(|hint| distance_squared(point, hint) < 1e-10) { + return None; + } + point + } + KernelCurve::Line(_) | KernelCurve::Arc(_) | KernelCurve::Nurbs(_) => { + lift(&planar.plane, planar.curve.point_at(t)) + } + _ => return None, + }; + Some(point) +} diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 8a5ee23f4..5497319d9 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -134,7 +134,7 @@ impl Scene { let _ = self.document.layers.add(layer); } - fn ensure_app_id(&mut self, name: &str) { + pub(super) fn ensure_app_id(&mut self, name: &str) { if name.trim().is_empty() || self.document.app_ids.contains(name) { return; } diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index 4f032a4b3..efb62e1b5 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -84,6 +84,9 @@ impl Scene { continue; }; + if !planar_pick_distance(&entity, model_point).is_some_and(|d| d <= aperture_model) { + continue; + } let mut paper_entity = entity; crate::scene::view::dispatch::apply_transform( &mut paper_entity, @@ -106,39 +109,6 @@ impl Scene { None } - /// Resolve a snapped INSERT handle into the innermost entity that owns the - /// geometry, plus the INSERT path down to it. - /// - /// The renderer names every wire of an expanded block with the **top-level - /// INSERT's** handle (see `block_cache`), including wires belonging to - /// nested inserts, so a snap inside a block reports the INSERT and nothing - /// finer. This walks the block definition from that INSERT looking for the - /// nearest measurable entity to `model_point`, which recovers the real - /// source handle and the instance path for the association chain. - /// - /// Returns `None` — and the caller then keeps the INSERT itself as the - /// source, which still gives a correct, if coarse, association — when: - /// * `top` is not an INSERT (the common case: `Some((top, vec![]))`); - /// * the geometry is a type [`planar_pick_distance`] does not handle - /// (text, hatch, spline, solid, 3-D geometry); - /// * the nesting is deeper than eight levels. - /// - /// Depth is not limited to one level: [`Scene::descend_block_instance`] - /// recurses, and the returned path lists every INSERT from the outermost - /// down to the one that directly contains the entity. - pub fn resolve_block_snap_source( - &self, - top: Handle, - model_point: DVec3, - ) -> Option<(Handle, Vec)> { - if !matches!(self.document.get_entity(top), Some(EntityType::Insert(_))) { - return None; - } - let (entity, path) = self.resolve_measurable_entity(top, model_point, None)?; - let handle = entity.as_entity().handle(); - handle.is_valid().then_some((handle, path)) - } - /// Nearest resident model wire of `viewport` to `model_point`, within /// `aperture` model units. Wires are the same tessellation the viewport /// draws, so this matches what the user sees. @@ -205,6 +175,16 @@ impl Scene { handle: Handle, model_point: DVec3, viewport: Option, + ) -> Option<(EntityType, Vec)> { + self.resolve_measurable_feature(handle, model_point, viewport, None) + } + + pub(crate) fn resolve_measurable_feature( + &self, + handle: Handle, + model_point: DVec3, + viewport: Option, + kind: Option, ) -> Option<(EntityType, Vec)> { let entity = self.document.get_entity(handle)?; match entity { @@ -217,11 +197,12 @@ impl Scene { &mut path, 0, viewport, + kind, )?; Some((found.0, found.1)) } other => { - planar_pick_distance(other, model_point)?; + feature_pick_distance(other, model_point, kind)?; Some((other.clone(), Vec::new())) } } @@ -237,6 +218,7 @@ impl Scene { path: &mut Vec, depth: usize, viewport: Option, + kind: Option, ) -> Option<(EntityType, Vec)> { const MAX_DEPTH: usize = 8; if depth > MAX_DEPTH { @@ -245,6 +227,9 @@ impl Scene { let EntityType::Insert(insert) = &insert_entity else { return None; }; + if insert.row_count > 1 || insert.column_count > 1 { + return None; + } let insert_handle = insert.common.handle; let local = crate::scene::render_graph::insert_transform(&self.document, insert); let combined = local.then(&outer); @@ -273,8 +258,9 @@ impl Scene { &mut nested, depth + 1, viewport, + kind, ) { - if let Some(distance) = planar_pick_distance(&entity, model_point) { + if let Some(distance) = feature_pick_distance(&entity, model_point, kind) { if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { let mut full = vec![insert_handle]; full.append(&mut nested_path); @@ -294,6 +280,7 @@ impl Scene { let y = DVec3::new(m[0][1], m[1][1], m[2][1]); let magnitude = x.length_squared().max(y.length_squared()); if curved + && kind != Some(crate::snap::SnapType::Center) && (magnitude < 1e-24 || (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-10 || x.dot(y).abs() > magnitude * 1e-10) @@ -305,7 +292,7 @@ impl Scene { &mut placed, &EntityTransform::Affine(combined), ); - let Some(distance) = planar_pick_distance(&placed, model_point) else { + let Some(distance) = feature_pick_distance(&placed, model_point, kind) else { continue; }; if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { @@ -438,3 +425,45 @@ mod tests { } } } + +/// Distance to the particular feature accepted by a snap, rather than to the +/// nearest curve. A circle's center is deliberately not on its circumference. +pub(crate) fn feature_pick_distance( + entity: &EntityType, + point: DVec3, + kind: Option, +) -> Option { + use crate::snap::SnapType as S; + let points: Vec = match kind { + Some(S::Center) => match entity { + EntityType::Circle(c) => vec![c.center_wcs()], + EntityType::Arc(a) => vec![a.center_wcs()], + EntityType::Ellipse(e) => vec![e.center], + _ => return None, + }, + Some(S::Endpoint) => crate::scene::dimension_assoc::source_points(entity), + Some(S::Node) => match entity { + EntityType::Point(p) => vec![p.location], + _ => return None, + }, + Some(S::Midpoint | S::Quadrant) => { + let planar = crate::entities::curve::entity_curve(entity)?; + cadkernel::geom2d::snap::characteristic_points(&planar.curve) + .into_iter() + .filter(|p| match kind { + Some(S::Midpoint) => p.kind == cadkernel::geom2d::snap::SnapKind::Midpoint, + _ => p.kind == cadkernel::geom2d::snap::SnapKind::Quadrant, + }) + .map(|p| { + let p = planar.plane.point_at(p.point); + acadrust::types::Vector3::new(p[0], p[1], p[2]) + }) + .collect() + } + _ => return planar_pick_distance(entity, point), + }; + points + .into_iter() + .map(|p| (DVec3::new(p.x, p.y, p.z) - point).truncate().length()) + .min_by(f64::total_cmp) +} diff --git a/tests/fixtures/dim_assoc/README.md b/tests/fixtures/dim_assoc/README.md new file mode 100644 index 000000000..a006911b7 --- /dev/null +++ b/tests/fixtures/dim_assoc/README.md @@ -0,0 +1,16 @@ +# Viewport association fixture + +`viewport_associations.dxf` is a synthetic paper-space fixture for line, circle, +arc, polyline, spline, block-instance, and intersection references through one +1:2 viewport. `src/app/viewport_dimension_tests.rs` loads it and exercises actual +association refresh and an edit to the second intersection source. + +Each dimension carries `DIMLFAC=-2` and `ACAD_DIMASSOC_CALC_DIMLFAC=-2`. These are +necessary for its paper definition points and saved model measurements to agree; +the original fixture omitted both factors. The spline tangent also uses the +actual touch point `(90, 283.125)` at parameter `0.75`, measured from `(0, 300)`. +The original endpoint `(120, 300)` was not tangent. Tests verify the cross product +with the spline derivative and preservation after an unsupported reference edit. + +The larger original `115448.dxf` is private and is not included. An opt-in test +uses `OPENCAD_VIEWPORT_REGRESSION_DXF` to read a local copy without modifying it. diff --git a/tests/fixtures/dim_assoc/viewport_associations.dxf b/tests/fixtures/dim_assoc/viewport_associations.dxf index 69eee3ed5..dcb198569 100644 --- a/tests/fixtures/dim_assoc/viewport_associations.dxf +++ b/tests/fixtures/dim_assoc/viewport_associations.dxf @@ -788,6 +788,22 @@ AcDbAlignedDimension 0.0 50 0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} 0 DIMENSION 5 @@ -844,6 +860,22 @@ AcDbAlignedDimension 0.0 50 0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} 0 DIMENSION 5 @@ -900,6 +932,22 @@ AcDbAlignedDimension 0.0 50 0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} 0 DIMENSION 5 @@ -956,6 +1004,22 @@ AcDbAlignedDimension 0.0 50 0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} 0 DIMENSION 5 @@ -991,7 +1055,7 @@ AcDbDimension 71 5 42 -120.0 +91.56836585306085 1 3 @@ -1005,13 +1069,29 @@ AcDbAlignedDimension 33 0.0 14 -135.0 +120.0 24 -175.0 +166.5625 34 0.0 50 0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} 0 DIMENSION 5 @@ -1068,6 +1148,22 @@ AcDbAlignedDimension 0.0 50 0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} 0 DIMENSION 5 @@ -1124,6 +1220,22 @@ AcDbAlignedDimension 0.0 50 0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} 0 ENDSEC 0 @@ -1475,11 +1587,11 @@ AcDbOsnapPointRef 91 -2 40 -1.0 +0.75 10 -120.0 +90.0 20 -300.0 +283.125 30 0.0 75 From 6de4ed5a062bea92552b87b9a2c131d8f0f137f4 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 15:56:33 -0500 Subject: [PATCH 21/23] Support paper centerline dimensions through viewports --- docs/viewport-dimension-creation.svg | 22 ++++ docs/viewport-dimensions.md | 82 +++++++------- src/app/command_driver.rs | 41 ++++--- src/app/commands/mod.rs | 2 +- src/app/dim_viewport.rs | 49 ++++----- src/app/mod.rs | 17 +-- src/app/update/viewport.rs | 56 ++++++---- src/app/update/viewport_snap.rs | 95 +++------------- src/app/viewport_dimension_tests.rs | 100 ++++++++++++++--- src/command.rs | 16 +-- src/entities/dim_override.rs | 12 +-- src/entities/dimension.rs | 16 +-- src/modules/annotate/aligned_dim.rs | 5 +- src/modules/annotate/angular_dim.rs | 28 +++-- src/modules/annotate/diameter_dim.rs | 5 +- src/modules/annotate/linear_dim.rs | 5 +- src/modules/annotate/radius_dim.rs | 5 +- src/scene/mod.rs | 2 +- src/scene/mspace.rs | 65 ++++++----- src/scene/project.rs | 5 +- ...dim_seam.rs => viewport_dimension_pick.rs} | 78 +++----------- src/scene/viewport_ref.rs | 95 ++-------------- src/snap.rs | 102 +++++++++++------- 23 files changed, 421 insertions(+), 482 deletions(-) create mode 100644 docs/viewport-dimension-creation.svg rename src/scene/{viewport_dim_seam.rs => viewport_dimension_pick.rs} (82%) diff --git a/docs/viewport-dimension-creation.svg b/docs/viewport-dimension-creation.svg new file mode 100644 index 000000000..efd1464c1 --- /dev/null +++ b/docs/viewport-dimension-creation.svg @@ -0,0 +1,22 @@ + + + + +Dimension from a paper centerline to a model endpoint + +Viewport 1:10 + + + +Model geometry + + + +50 +Brown dashed line: paper-space centerline +5 paper units between the picked points +5 × 10 = 50 model units +The viewport supplies the length scale. +Either pick order gives the same value. +Measurement schematic; dimension style unit factor = 1. + diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md index 119b2361a..2af0b7aa4 100644 --- a/docs/viewport-dimensions.md +++ b/docs/viewport-dimensions.md @@ -1,50 +1,50 @@ -# Dimensions through paper-space viewports +# Dimensions through layout viewports -Paper-space point commands can snap to displayed model geometry through a planar, -orthographic layout viewport. The acquired result retains the original model -coordinate, its paper coordinate, the viewport frame, and feature identity. -Ordinary selection continues to operate on the active drawing space. +In paper space, snap to model geometry displayed through a planar, orthographic +viewport. `DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER` +also support their usual object-pick modes through these viewports. -`DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER` use this -acquisition context. The supported object-pick modes also query viewport geometry. -Snapping and object picking share viewport draw order, visibility, and clipping -rules. Object picking uses the resident interaction index and applies block -instance transforms. Nonuniformly transformed circles and circular arcs are -declined as radial object picks because their displayed geometry is elliptical. -Array inserts without an addressable instance index are also declined; resolved -block geometry must remain within the original pick aperture. +## Measurement -## Input and measurement rules +- Model geometry supplies the viewport scale. A 100-unit model length displayed + at 1:10 measures 100, although it occupies 10 paper units. +- A paper-space centerline or free sheet point can be combined with model + geometry through one viewport, in either pick order. The paper-space span is + measured using that viewport's scale. +- Sheet-only dimensions measure paper units, even inside a viewport rectangle. +- Model picks from a second viewport are rejected without advancing the command. + Dimension-line and text placement do not change the measurement scale. +- The current dimension style's unit conversion is preserved. Angular dimensions + do not use length compensation. `DIMASSOC=0` explodes the compensated dimension. -- Free and typed sheet points measure paper units, including points inside a - viewport rectangle. -- Measuring points must all belong to the same space and, when applicable, the - same viewport. An incompatible pick leaves the command at its current step. -- Dimension-line and text placement do not change the measurement's space. -- Paper geometry takes priority over viewport geometry at an object pick. -- The final dimension style is applied before computing measurement scaling. - Length dimensions keep paper-space definition points and a negative `DIMLFAC` - override containing the effective user factor multiplied by `1 / viewport scale`. - Angular measurements do not use this length factor. -- `DIMASSOC=0` uses the same compensated value before exploding the annotation. +Snapping and object picking respect visibility, clipping, and viewport draw order. +Paper geometry has priority in object picks. Ordinary selection uses the active +drawing space. Object picks reuse the resident interaction index and resolve +nested block transforms. -## Persistent measurement data +![Measurement schematic: a five-unit sheet span measures 50 through a 1:10 viewport.](viewport-dimension-creation.svg) -`OCS_VIEWPORT_MEASUREMENT` XData stores three values: version `1` (integer16), -the positive user factor (real), and viewport compensation (real). This keeps the -user's unit conversion separate from viewport scale. The ordinary `DIMLFAC` -override remains sufficient for displaying the saved measurement in other CAD -readers. The data survives DXF/DWG saves and undo/redo. +## Saved scale -The dependent association change uses this metadata when viewport scale changes. -This creation change alone creates nonassociative viewport dimensions and leaves -the drawing's `DIMASSOC` setting unchanged. Direct model/paper associations retain -their existing behavior. +Length dimensions store paper-space definition points and a negative `DIMLFAC` +override: user unit factor multiplied by `1 / viewport scale`. +`OCS_VIEWPORT_MEASUREMENT` XData stores version `1` (integer16), the positive user +factor (real), and viewport compensation (real). Keeping the factors separate +allows compensation to change without losing the user's unit conversion. +Both the override and XData survive DXF/DWG saves and undo/redo. -## Validation +Viewport dimensions are nonassociative: later source or viewport edits do not +update them. The drawing's `DIMASSOC` setting and direct model/paper associations +keep their existing behavior. -`src/app/viewport_dimension_tests.rs` exercises snap-engine acquisition, command -commits for all five commands, object picks, input retries, active positive and -negative style factors, exploded dimensions, undo/redo, DXF/DWG persistence, -clipping, overlapping viewports, pan, scale, and twist. These are headless tests; -they do not establish visual acceptance in the desktop UI. +## Limits + +Perspective and oblique views are unsupported. Object picks decline array +instances without an addressable instance index, nesting beyond eight levels, +and radial measurements of nonuniformly scaled circles or arcs. + +## Tests + +Run `cargo test --locked --lib viewport_dimension`. Tests cover the five commands, +mixed sheet/model input, styles, clipped/overlapping viewports, object picks, +input retries, exploded dimensions, undo/redo, and DXF/DWG persistence. diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 6cc436e5f..7aa1f47fb 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -98,7 +98,7 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.last_point = None; // Points collected in the space being left are meaningless in the new - // one (PR1). + // one. self.clear_accepted_snaps(); self.pending_click_snap = None; self.snapper.from_point = None; @@ -386,7 +386,7 @@ impl OpenCADStudio { } // Typed / dynamic-input / headless points carry no snap, but the // accepted-snap list must stay index-parallel with the points the - // command collects (PR1). Interactive picks record themselves in + // command collects. Interactive picks record themselves in // the click handler and never reach here. if !self.record_accepted_snap(i, None, None, *point) { return Task::none(); @@ -404,7 +404,9 @@ impl OpenCADStudio { self.push_ucs_to_cmd(i); } if let StepInput::EntityPick(handle, point) = &input { - if !self.dimension_acquisition_allowed(i, None) { return Task::none(); } + if !self.dimension_acquisition_allowed(i, None) { + return Task::none(); + } let solid_pick = matches!( self.tabs[i].scene.document.get_entity(*handle), Some( @@ -477,7 +479,11 @@ impl OpenCADStudio { } let ctrl = self.ctrl_down; let shift = self.shift_down; - let picked_handle = if let StepInput::EntityPick(h, _) = &input { Some(*h) } else { None }; + let picked_handle = if let StepInput::EntityPick(h, _) = &input { + Some(*h) + } else { + None + }; let result: Option = { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { return Task::none(); @@ -2366,17 +2372,15 @@ impl OpenCADStudio { // through a viewport carries the compensation as a negative // DIMLFAC override. if !preserve_base_style { - crate::scene::creation_style::apply_current_creation_styles(&self.tabs[i].scene.document, &mut entity); + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); } if !self.apply_viewport_dimension_measurement(i, &mut entity) { return Task::none(); } - // ...and is deliberately *not* associated in paper space: its - // definition points are projected paper coordinates, so a - // paper-space association would bind it to geometry that is - // not there. PR3 records the real dimension -> viewport -> - // entity chain instead. One gate, shared with - // `infer_dimension_sources_guarded`. + // Projected points cannot use direct paper-space source inference. let association_allowed = self.dimension_association_allowed(i); let inherited_dimension = if preserve_base_style { match &entity { @@ -2438,20 +2442,15 @@ impl OpenCADStudio { } else { let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - if let Some(handle) = self.commit_entity_handle_with_dimension_policy( - entity, - preserve_base_style, - ) { + if let Some(handle) = + self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) + { if association_mode == 2 && association_allowed { - let mut changes = vec![ - (handle, crate::scene::ChangeKind::Modified), - ]; + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; match association { crate::command::DimensionAssociationInput::Infer(source) => { let sources: Vec<_> = source.map_or_else( - || { - self.infer_dimension_sources_guarded(i, handle) - }, + || self.infer_dimension_sources_guarded(i, handle), |source| { if single_source_dimension { vec![Some(source)] diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a8cf35bc5..7befcd464 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -121,7 +121,7 @@ impl OpenCADStudio { // is not constrained by ortho/polar relative to a previous command's endpoint. self.last_point = None; // A new command collects its own points, so the previous command's - // accepted snaps must not leak into it (PR1). + // accepted snaps must not leak into it. self.clear_accepted_snaps(); // Starting a command restarts the right-click cycle, so its first // right-click acts as Enter rather than opening the context menu. diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index 577237e57..e7760d0b8 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -1,5 +1,5 @@ -//! Paper-space dimension acquisition and measurement. Only acquired geometry -//! carries viewport compensation; free and typed sheet coordinates stay on the sheet. +//! Paper-space dimension acquisition and measurement. Acquiring model geometry +//! supplies a viewport scale; sheet-only dimensions retain paper units. use super::OpenCADStudio; use crate::command::DimensionAssociationSource; @@ -14,7 +14,7 @@ use glam::DVec3; pub(crate) enum DimensionMeasureSpace { Direct, Viewport(ViewportFrame), - Mixed, + ConflictingViewports, } impl OpenCADStudio { @@ -26,33 +26,23 @@ impl OpenCADStudio { selection.left_dragging = false; } - #[allow(dead_code)] // Consumed by the dependent association change. - pub(crate) fn dimension_measuring_snaps(&self, _i: usize) -> Vec { - self.accepted_snaps().to_vec() - } - pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { let scene = &self.tabs[i].scene; if scene.current_layout == "Model" || scene.active_viewport.is_some() { return DimensionMeasureSpace::Direct; } let mut frame: Option = None; - let mut direct = false; - for snap in self.accepted_snaps() { - match snap.frame { - Some(f) => match frame { - None => frame = Some(f), - Some(previous) if previous.viewport == f.viewport => {} - Some(_) => return DimensionMeasureSpace::Mixed, - }, - None => direct = true, + for f in self.accepted_snaps().iter().filter_map(|snap| snap.frame) { + match frame { + None => frame = Some(f), + Some(previous) if previous.viewport == f.viewport => {} + Some(_) => return DimensionMeasureSpace::ConflictingViewports, } } - match (frame, direct) { - (None, _) => DimensionMeasureSpace::Direct, - (Some(f), false) => DimensionMeasureSpace::Viewport(f), - _ => DimensionMeasureSpace::Mixed, - } + frame.map_or( + DimensionMeasureSpace::Direct, + DimensionMeasureSpace::Viewport, + ) } /// Reject an incompatible measuring input before it advances the command. @@ -68,8 +58,15 @@ impl OpenCADStudio { { return true; } - if self.accepted_snaps().iter().any(|s| s.viewport != viewport) { - self.command_line.push_error(crate::t!("Pick geometry from the same viewport, or use paper-space points for the whole dimension.").as_ref()); + // Sheet geometry and free sheet points may accompany one viewport. + // A second viewport would make the measurement scale ambiguous. + if viewport.is_some_and(|viewport| { + self.accepted_snaps() + .iter() + .filter_map(|s| s.viewport) + .any(|previous| previous != viewport) + }) { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref()); return false; } true @@ -134,8 +131,8 @@ impl OpenCADStudio { ); let frame = match self.dimension_measure_space(i) { DimensionMeasureSpace::Direct => return true, - DimensionMeasureSpace::Mixed => { - self.command_line.push_error(crate::t!("Pick geometry from the same viewport, or use paper-space points for the whole dimension.").as_ref()); + DimensionMeasureSpace::ConflictingViewports => { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref()); return false; } DimensionMeasureSpace::Viewport(f) => f, diff --git a/src/app/mod.rs b/src/app/mod.rs index 48ef49c54..56e44bb8a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -717,21 +717,12 @@ pub(super) struct OpenCADStudio { show_layout_tabs: bool, /// Last point committed by a drawing command — used as ortho/polar base. last_point: Option, - /// Viewport frame the live snap marker is currently looking through (PR1). - /// Set by the cursor-move handler when the displayed snap came from a - /// layout viewport, cleared otherwise. Display/bookkeeping only — the point - /// itself is already projected onto the sheet. + /// Viewport used by the current snap; the displayed point is in paper space. pub(crate) vp_snap_frame: Option, - /// Snaps accepted by the active command's point steps, parallel to the - /// points it collected (PR1). A command that does not care keeps using the - /// plain paper point; dimension commands (PR2) read the model point, - /// viewport and frame from here, and PR3 reads `source`. Cleared whenever a - /// command starts or ends. + /// Acquired coordinates and source identities in command-step order. + /// Cleared when the command starts or ends. accepted_snaps: Vec, - /// Snap resolved by the click currently being handled, with the viewport - /// frame it came through (PR1). Set inside the click handler's point - /// resolution so the point-accept path further down can record the - /// [`crate::scene::viewport_ref::AcceptedSnap`] without recomputing. + /// Click result retained until the command accepts its point. pending_click_snap: Option<( crate::snap::SnapResult, Option, diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f404c0a83..acbb65bdf 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2059,24 +2059,24 @@ impl OpenCADStudio { ) }; - // Paper-space snapping THROUGH layout viewports (PR1). `edit_cam` + // Paper-space snapping through layout viewports. `edit_cam` // is None here, so `cursor_world` is the paper point and the sheet // fills the canvas (pane-local == canvas pixels). The viewport hit // arrives already projected onto the sheet, so everything // downstream keeps working in paper coordinates — LINE still draws // at the projected paper point. self.vp_snap_frame = None; - if edit_cam.is_none() && !needs_entity && !is_gathering && !needs_structure + if edit_cam.is_none() + && !needs_entity + && !is_gathering + && !needs_structure && !needs_tan { if let Some((vp_hit, frame)) = self.paper_viewport_snap(i, p_full, vp_size, cursor_world) { - let merged = crate::snap::merge_snap( - self.tabs[i].snap_result, - Some(vp_hit), - p_full, - ); + let merged = + crate::snap::merge_snap(self.tabs[i].snap_result, Some(vp_hit), p_full); if merged.is_some_and(|hit| hit.viewport.is_some()) { self.vp_snap_frame = Some(frame); } @@ -3701,7 +3701,7 @@ impl OpenCADStudio { } } } - // Paper-space snapping through layout viewports (PR1). Mirrors + // Paper-space snapping through layout viewports. Mirrors // the cursor-move pass: the click recomputes snapping from // scratch, so the viewport query has to run here too. The hit // comes back already projected onto the sheet. @@ -3710,8 +3710,7 @@ impl OpenCADStudio { if let Some((vp_hit, frame)) = self.paper_viewport_snap(i, p_full, (vw, vh), raw) { - let merged = - crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full); + let merged = crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full); if merged.is_some_and(|hit| hit.viewport.is_some()) { click_frame = Some(frame); } @@ -4046,7 +4045,10 @@ impl OpenCADStudio { } } - if !self.dimension_acquisition_allowed(i, None) { self.finish_command_click(i); return Task::none(); } + if !self.dimension_acquisition_allowed(i, None) { + self.finish_command_click(i); + return Task::none(); + } let shift = self.shift_down; let result = self.tabs[i].active_cmd.as_mut().map(|c| { // Shift-swap state for TRIM/EXTEND (#336). @@ -4126,11 +4128,21 @@ impl OpenCADStudio { } result } else if let Some(result) = { - let dimension = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.measures_through_viewports()); + let dimension = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.measures_through_viewports()); if dimension { - let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0 / (bounds.height as f64).max(1.0); - self.try_dimension_viewport_entity_pick(i, pick_wcs, crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px) - } else { None } + let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0 + / (bounds.height as f64).max(1.0); + self.try_dimension_viewport_entity_pick( + i, + pick_wcs, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px, + ) + } else { + None + } } { Some(result) } else if self.tabs[i] @@ -4139,7 +4151,12 @@ impl OpenCADStudio { .is_some_and(|command| command.entity_pick_accepts_points()) { let pending = self.pending_click_snap.take(); - if !self.record_accepted_snap(i, pending.map(|(hit, _)| hit), pending.and_then(|(_, frame)| frame), pick_wcs) { + if !self.record_accepted_snap( + i, + pending.map(|(hit, _)| hit), + pending.and_then(|(_, frame)| frame), + pick_wcs, + ) { self.finish_command_click(i); return Task::none(); } @@ -4212,7 +4229,7 @@ impl OpenCADStudio { } } // Record what this point step accepted, alongside the point - // itself (PR1): paper point, model point, viewport, frame and + // itself: paper point, model point, viewport, frame and // geometry identity. Commands that don't care keep consuming // `world_pt` exactly as before. let pending = self.pending_click_snap.take(); @@ -4221,7 +4238,10 @@ impl OpenCADStudio { pending.map(|(hit, _)| hit), pending.and_then(|(_, frame)| frame), world_pt, - ) { self.finish_command_click(i); return Task::none(); } + ) { + self.finish_command_click(i); + return Task::none(); + } self.last_point = Some(world_pt); // The one-shot snap override is spent by this pick — // restore the running osnap configuration (#337). diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 35c112b85..a2a2e28c8 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -1,24 +1,7 @@ -//! Paper-space snapping THROUGH layout viewports (PR1). -//! -//! In a paper layout with no active viewport, `Scene::hit_test_wires` returns -//! only the paper sheet's own entities — viewport contents are deliberately not -//! interactive, and that must stay true for *selection*. Snapping, however, -//! should see the model geometry a viewport displays, so a paper-space LINE / -//! DIMENSION can pick a real model endpoint and land on the paper pixel where -//! it is drawn. -//! -//! This module adds that as a SEPARATE query. It never changes -//! `hit_test_wires`. For each displayed content viewport under the cursor -//! (top-most first) it runs the EXISTING snap engine against that viewport's -//! resident model wires, using the viewport's own camera and screen rectangle -//! (`Scene::viewport_edit_frame_for` — the same adapter MSPACE editing uses), -//! then maps the accepted model point back onto the sheet with -//! [`ViewportFrame::model_to_paper`]. -//! -//! Performance: no projected geometry is rebuilt per pointer event. The wire -//! set comes from `model_wires_for_viewport_arc`, which is the renderer's own -//! resident, camera-independent `Arc`, and the cursor-local broad phase reuses -//! the shared interaction index built over it. +//! Snap to displayed model geometry while a paper-space command is active. +//! Viewport queries use the renderer's resident wires and interaction index, +//! then project the accepted model point onto the sheet. Ordinary selection +//! continues to use paper-space hit testing. use iced::Point; @@ -34,37 +17,14 @@ const MAX_ACCEPTED_SNAPS: usize = 64; impl OpenCADStudio { /// Snaps accepted by the active command's point steps, oldest first, in - /// step order. Consumed by the dimension commands in PR2/PR3. - #[allow(dead_code)] + /// step order. pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] { &self.accepted_snaps } - /// The most recently accepted snap, if any. - #[allow(dead_code)] - pub(crate) fn last_accepted_snap(&self) -> Option<&AcceptedSnap> { - self.accepted_snaps.last() - } - - /// `Scene::infer_dimension_sources`, suppressed when the dimension being - /// created measures through a layout viewport. - /// - /// Inference works by looking for geometry near the dimension's own - /// definition points, in whatever space it is handed. A paper-space - /// dimension measured through a viewport has PAPER definition points that - /// happen to sit over projected model geometry, so unguarded inference - /// would either associate it with unrelated paper-sheet geometry at those - /// coordinates, or match model geometry using paper coordinates. Neither - /// is correct, and a wrong association is worse than none. Returning an - /// empty source list makes `attach_dimension_association` a no-op. - /// - /// The classification is - /// [`OpenCADStudio::dimension_measure_space`] — the *same* one that - /// decides whether to compensate the measurement, so the two can never - /// disagree. In particular it ignores the trailing dimension-line - /// placement click, which routinely lands inside a viewport rectangle for - /// an ordinary paper-space dimension and must not suppress its - /// association. + /// Infer direct sources only when the measurement uses the active space. + /// Projected model points must retain their acquired source identities; + /// proximity on the sheet could bind them to unrelated paper geometry. pub(crate) fn infer_dimension_sources_guarded( &self, tab: usize, @@ -76,14 +36,8 @@ impl OpenCADStudio { self.tabs[tab].scene.infer_dimension_sources(dimension) } - /// The single gate on creating a paper-space association for the - /// dimension the active command is committing. - /// - /// `false` exactly when the dimension measures through (or partly through) - /// a layout viewport, whatever supplied its sources — inference, - /// an explicit object pick, or an explicit source list. Everything in the - /// commit path that used to decide this for itself now asks here, so the - /// measurement rule and the association rule cannot drift apart. + /// Whether the dimension can use ordinary active-space associations. + /// Placement clicks are excluded by the measurement classification. pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool { matches!( self.dimension_measure_space(tab), @@ -136,33 +90,15 @@ impl OpenCADStudio { true } - /// Append an already-built accepted snap, keeping the retention cap. - /// - /// Used by paths that are not point steps (the dimension object pick - /// resolved through a viewport) and therefore never reach the click - /// handler. + /// Retain a bounded history for point and object picks. pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) { if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS { self.accepted_snaps.remove(0); } self.accepted_snaps.push(accepted); } - /// Snap the paper-space cursor to model geometry seen through a layout - /// viewport. - /// - /// * `i` — tab index. - /// * `cursor_canvas` — cursor in canvas pixels. - /// * `canvas` — canvas size in pixels. - /// * `cursor_paper` — the paper-space point under the cursor. - /// - /// Returns the hit expressed in PAPER coordinates (`world` projected onto - /// the sheet, `screen` in canvas pixels) together with the frame it came - /// through, so the caller can merge it with the ordinary paper-sheet snap - /// and later recover the model point. - /// - /// `None` in the Model layout, while a viewport is active (MSPACE already - /// snaps in model space), when snapping is off, or when the cursor is not - /// over a displayed viewport. + /// Query visible viewport geometry with a canvas-pixel cursor and sheet point. + /// Returns paper coordinates and canvas pixels, plus the source viewport frame. pub(in crate::app) fn paper_viewport_snap( &mut self, i: usize, @@ -245,10 +181,7 @@ impl OpenCADStudio { let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else { continue; }; - // The wire set is the whole model, not a clipped copy, so the only - // way a feature outside the visible part could be offered is via - // the aperture reaching past the clip. Reject those: a snap must be - // a real feature the viewport actually displays. + // The snap aperture can reach outside the visible clip boundary. let paper = frame.model_to_paper(hit.world); if !self.tabs[i] .scene diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs index 3b47db064..281005f03 100644 --- a/src/app/viewport_dimension_tests.rs +++ b/src/app/viewport_dimension_tests.rs @@ -149,24 +149,13 @@ fn viewport_dimension_creation_preserves_active_units_and_roundtrips() { } #[test] -fn viewport_dimension_mixed_and_degenerate_picks_allow_retry() { +fn viewport_dimension_conflicting_viewports_and_degenerate_picks_allow_retry() { let (mut app, line, frame) = fixture(); let i = app.active_tab; let _ = app.dispatch_command("DIMALIGNED"); point(&mut app, frame, line, DVec3::ZERO); point(&mut app, frame, line, DVec3::ZERO); assert_eq!(app.accepted_snaps().len(), 1); - let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 50.0, 0.0))); - assert_eq!(app.accepted_snaps().len(), 1); - assert_eq!( - app.tabs[i] - .active_cmd - .as_ref() - .unwrap() - .dimension_acquired_points() - .len(), - 1 - ); let other = ViewportFrame { viewport: Handle::new(0xFFFF), ..frame @@ -318,7 +307,12 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { let top = scene.add_entity(overlay); let paper = DVec3::new(55.0, 50.0, 0.0); assert_eq!( - scene.viewport_frame_at_paper_point(paper).unwrap().viewport, + scene + .viewport_frames_at_paper_point(paper) + .into_iter() + .next() + .unwrap() + .viewport, top ); assert_eq!( @@ -340,7 +334,12 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { vp.clip_boundary_handle = clip; } assert_eq!( - scene.viewport_frame_at_paper_point(paper).unwrap().viewport, + scene + .viewport_frames_at_paper_point(paper) + .into_iter() + .next() + .unwrap() + .viewport, first.viewport ); assert_eq!( @@ -354,7 +353,7 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(first.viewport) { vp.status.is_on = false; } - assert!(scene.viewport_frame_at_paper_point(paper).is_none()); + assert!(scene.viewport_frames_at_paper_point(paper).is_empty()); assert!(scene.dimension_pick_through_viewport(paper, 0.2).is_none()); } @@ -421,3 +420,74 @@ fn viewport_dimension_angular_object_picks_measure_arc_features() { assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); } } + +fn start_centerline_dimension( + command: &str, + paper_first: bool, + paper_kind: SnapType, + factor: f64, +) -> (OpenCADStudio, Handle, Handle, ViewportFrame) { + let (mut app, model, frame) = fixture(); + let i = app.active_tab; + let paper = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(55.0, 40.0, 0.0), + Vector3::new(55.0, 60.0, 0.0), + ))); + let mut style = acadrust::tables::DimStyle::new("CenterlineUnits"); + style.dimlfac = factor; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "CenterlineUnits".into(); + let _ = app.dispatch_command(command); + let mut paper_hit = hit(frame, paper, DVec3::new(55.0, 50.0, 0.0)); + paper_hit.world = DVec3::new(55.0, 50.0, 0.0); + paper_hit.model_point = None; + paper_hit.viewport = None; + paper_hit.snap_type = paper_kind; + let mut picks = [ + (paper_hit, None), + (hit(frame, model, DVec3::X * 100.0), Some(frame)), + ]; + if !paper_first { + picks.reverse(); + } + for (snap, context) in picks { + assert!(app.record_accepted_snap(i, Some(snap), context, snap.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(snap.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + } + (app, model, paper, frame) +} + +#[test] +fn viewport_dimension_paper_centerline_uses_viewport_units_in_either_order() { + for command in ["DIMLINEAR", "DIMALIGNED"] { + for paper_first in [true, false] { + let (mut app, _, paper, _) = + start_centerline_dimension(command, paper_first, SnapType::Midpoint, 25.4); + let paper_snap = app + .accepted_snaps() + .iter() + .find(|s| s.viewport.is_none()) + .unwrap(); + assert_eq!(paper_snap.source.as_ref().unwrap().source.handle, paper); + assert_eq!(paper_snap.paper_point, paper_snap.model_point); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let doc = &app.tabs[app.active_tab].scene.document; + assert!( + (displayed(doc) - 1270.0).abs() < 1e-3, + "{command}: {}", + displayed(doc) + ); + app.undo_steps(1); + app.redo_steps(1); + assert!((displayed(&app.tabs[app.active_tab].scene.document) - 1270.0).abs() < 1e-3); + } + } +} diff --git a/src/command.rs b/src/command.rs index a248db896..8368c9f8a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2245,23 +2245,23 @@ pub trait CadCommand: Send { CmdResult::Cancel } - /// Returns `true` when the command needs entity picking (hit-test) instead of point picking. - /// PR2: this command creates a dimension whose measurement must be - /// compensated when its points are picked through a paper-space viewport, - /// and whose explicit object pick may land on model geometry displayed - /// inside one. See `crate::app::dim_viewport`. + /// Supports acquiring dimension geometry through a paper-space viewport. fn measures_through_viewports(&self) -> bool { false } /// Definition points acquired so far, in the command's working space. /// Used to retain references only for accepted measuring inputs. - fn dimension_acquired_points(&self) -> Vec { Vec::new() } + fn dimension_acquired_points(&self) -> Vec { + Vec::new() + } /// The next point places annotation rather than acquiring geometry. - fn dimension_placement_pending(&self) -> bool { false } - + fn dimension_placement_pending(&self) -> bool { + false + } + /// Needs entity hit-testing instead of point input. fn needs_entity_pick(&self) -> bool { false } diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index dc9813c28..d9688ea5b 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -255,12 +255,9 @@ pub fn set_on_entity(entity: &mut EntityType, code: i16, value: Option) -> f64 { let raw = dimension.measurement(); - if matches!(dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + if matches!( + dimension, + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) + ) { return raw; } let lfac = style.map(|s| s.dimlfac).unwrap_or(1.0); diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 9cde3e7f2..2e2f3f133 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -161,7 +161,7 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -169,7 +169,8 @@ impl CadCommand for AlignedDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::First => vec![], Step::Second(p) => vec![p], + Step::First => vec![], + Step::Second(p) => vec![p], Step::DimLine { p1, p2 } => vec![p1, p2], } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index e9bdb0aea..66fb49596 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -482,7 +482,7 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -490,11 +490,27 @@ impl CadCommand for AngularDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::Vertex => vec![], Step::FirstRay(p) => vec![p], - Step::SecondRay { vertex, first } | Step::CircleSecondRay { vertex, first, .. } => vec![vertex, first], - Step::SecondLine { first_start, first_end, .. } => vec![first_start, first_end], - Step::ArcPoint3 { vertex, first, second } => vec![vertex, first, second], - Step::ArcPoint2 { first_start, first_end, second_start, second_end } => vec![first_start, first_end, second_start, second_end], + Step::Vertex => vec![], + Step::FirstRay(p) => vec![p], + Step::SecondRay { vertex, first } | Step::CircleSecondRay { vertex, first, .. } => { + vec![vertex, first] + } + Step::SecondLine { + first_start, + first_end, + .. + } => vec![first_start, first_end], + Step::ArcPoint3 { + vertex, + first, + second, + } => vec![vertex, first, second], + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => vec![first_start, first_end, second_start, second_end], } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index b128d4dde..b41fa2839 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -132,7 +132,7 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -140,7 +140,8 @@ impl CadCommand for DiameterDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::SelectObject => vec![], Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + Step::SelectObject => vec![], + Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], } } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index e8f55c32f..7e92b0c6b 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -217,7 +217,7 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -225,7 +225,8 @@ impl CadCommand for LinearDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::FirstPoint => vec![], Step::SecondPoint(p) => vec![p], + Step::FirstPoint => vec![], + Step::SecondPoint(p) => vec![p], Step::DimensionLine { first, second } => vec![first, second], } } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 214933a8e..898ba52ca 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -130,7 +130,7 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -138,7 +138,8 @@ impl CadCommand for RadiusDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::SelectObject => vec![], Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + Step::SelectObject => vec![], + Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4137628bf..9622be181 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -41,7 +41,7 @@ mod limits; mod modify; mod mspace; pub mod viewport_ref; -pub mod viewport_dim_seam; +pub mod viewport_dimension_pick; pub mod named_parameters; mod page_setup; mod paper; diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index 11f516329..3cd822f1c 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -83,14 +83,7 @@ impl Scene { self.viewport_edit_frame_for(self.active_viewport?, canvas_px) } - /// [`viewport_edit_frame`] for an explicitly-named viewport, rather than - /// only the active (MSPACE) one. - /// - /// Paper-space snapping through viewports (PR1) needs the same camera + - /// screen rectangle pair for a viewport the user has NOT entered, so the - /// existing snap engine can run against that viewport's model wires on the - /// pixels the GPU actually drew them on. `viewport_edit_frame` is now a - /// thin wrapper over this. + /// Camera and canvas rectangle for snapping through an inactive viewport. pub fn viewport_edit_frame_for( &self, vp_handle: Handle, @@ -101,15 +94,12 @@ impl Scene { Some((cam, full)) } - /// Planar paper <-> model mapping for a layout viewport, derived from the - /// *same* camera the renderer displays its contents with - /// ([`camera_for_viewport`]) so a snap computed through the frame lands on - /// the pixels the GPU drew. - /// - /// Returns `None` for a viewport whose view is not an in-plane orthographic - /// top view (oblique / isometric / perspective viewports are unsupported by - /// the planar frame — callers fall back to no viewport snapping there). - pub fn viewport_frame(&self, vp_handle: Handle) -> Option { + /// Planar paper/model mapping derived from the viewport's display camera. + /// Oblique and perspective views have no supported planar mapping. + pub fn viewport_frame( + &self, + vp_handle: Handle, + ) -> Option { use crate::scene::viewport_ref::ViewportFrame; let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) { @@ -120,7 +110,7 @@ impl Scene { ), _ => return None, }; - if vp_height.abs() < 1e-9 { + if !paper_center.is_finite() || !vp_height.is_finite() || vp_height < 1e-9 { return None; } let cam = self.camera_for_viewport(vp_handle)?; @@ -133,7 +123,7 @@ impl Scene { // an oblique 3-D view: unsupported. let right = (cam.rotation * glam::Vec3::X).as_dvec3(); let up = (cam.rotation * glam::Vec3::Y).as_dvec3(); - if right.z.abs() > 1e-6 || up.z.abs() > 1e-6 { + if !right.is_finite() || !up.is_finite() || right.z.abs() > 1e-6 || up.z.abs() > 1e-6 { return None; } let normal = right.cross(up); @@ -142,7 +132,7 @@ impl Scene { } // Model units visible across the viewport's height -> paper per model. let model_height = cam.ortho_size() as f64 * 2.0; - if model_height.abs() < 1e-12 { + if !model_height.is_finite() || model_height < 1e-12 { return None; } let scale = vp_height / model_height; @@ -162,23 +152,28 @@ impl Scene { /// Visible, supported content viewports at a sheet point, topmost first. /// Shared by snapping and explicit dimension object picking. - pub fn viewport_frames_at_paper_point(&self, paper: glam::DVec3) -> Vec { - if self.current_layout == "Model" || self.active_viewport.is_some() { return Vec::new(); } - self.layout_content_viewports().iter().rev().filter_map(|&handle| { - if !self.viewport_displays_content(handle) || !self.viewport_displays_paper_point(handle, paper.truncate()) { - return None; - } - self.viewport_frame(handle) - }).collect() + pub fn viewport_frames_at_paper_point( + &self, + paper: glam::DVec3, + ) -> Vec { + if self.current_layout == "Model" || self.active_viewport.is_some() { + return Vec::new(); + } + self.layout_content_viewports() + .iter() + .rev() + .filter_map(|&handle| { + if !self.viewport_displays_content(handle) + || !self.viewport_displays_paper_point(handle, paper.truncate()) + { + return None; + } + self.viewport_frame(handle) + }) + .collect() } - /// Does `paper` lie inside the viewport's displayed area? - /// - /// Always bounded by the viewport's paper rectangle; a viewport with a - /// `clip_boundary_handle` (non-rectangular / clipped viewport) additionally - /// has to contain the point inside that boundary polygon. Used so a - /// paper-space snap never reports a model feature the viewport does not - /// actually show. + /// Test the viewport rectangle and optional nonrectangular clip boundary. pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool { let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else { return false; diff --git a/src/scene/project.rs b/src/scene/project.rs index f20c10e38..4d67327ac 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -674,10 +674,7 @@ pub(crate) fn clip_boundary_polygon_for_document( z: f32, ) -> Vec<[f32; 3]> { use std::f64::consts::TAU; - let Some(entity) = document - .entities() - .find(|e| e.common().handle == handle) - else { + let Some(entity) = document.get_entity(handle) else { return vec![]; }; // Circles and ellipses tessellate directly — their `to_render` returns a diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dimension_pick.rs similarity index 82% rename from src/scene/viewport_dim_seam.rs rename to src/scene/viewport_dimension_pick.rs index 5f4ca3ce4..e14cffe29 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dimension_pick.rs @@ -1,20 +1,5 @@ -//! Object-pick resolution for paper-space dimensions taken through a layout -//! viewport. -//! -//! Paper-space hit testing only sees the sheet's own entities, so a dimension -//! command's explicit "select object" pick that lands inside a content -//! viewport finds nothing. [`Scene::dimension_pick_through_viewport`] maps the -//! click into model space through the viewport's -//! [`crate::scene::viewport_ref::ViewportFrame`], hit-tests the viewport's -//! resident model wires (descending into block instances and baking the -//! instance transform), and hands back a copy of the picked entity already -//! projected onto the sheet. -//! -//! The frame itself is built by [`Scene::viewport_frame`] (see -//! `crate::scene::mspace`), which derives it from the *live viewport camera* -//! so it always agrees with the pixels the renderer drew. It returns `None` -//! for oblique / perspective viewports, and every entry point here then -//! declines the pick rather than guessing a planar mapping. +//! Pick dimension geometry through a layout viewport. Resolve block instances +//! in model space, then project the picked entity onto the paper sheet. use super::*; @@ -27,7 +12,7 @@ use glam::DVec3; /// A model entity resolved by clicking inside a paper-space content viewport. #[derive(Clone, Debug)] -pub struct ViewportDimPick { +pub struct ViewportDimensionPick { /// The viewport the click looked through. pub frame: ViewportFrame, /// Handle of the innermost model entity that owns the picked geometry. @@ -46,18 +31,6 @@ pub struct ViewportDimPick { } impl Scene { - pub fn content_viewport_at_paper_point(&self, paper: DVec3) -> Option { - self.viewport_frames_at_paper_point(paper) - .first() - .map(|f| f.viewport) - } - - pub fn viewport_frame_at_paper_point(&self, paper: DVec3) -> Option { - self.viewport_frames_at_paper_point(paper) - .into_iter() - .next() - } - /// Resolve an explicit dimension object pick made on the sheet but landing /// inside a content viewport, against the *model* geometry that viewport /// displays. @@ -67,14 +40,13 @@ impl Scene { &self, paper: DVec3, aperture_paper: f64, - ) -> Option { + ) -> Option { for frame in self.viewport_frames_at_paper_point(paper) { let model_point = frame.paper_to_model(paper); let aperture_model = (aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9); - let Some(top) = - self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model) + let Some(top) = self.nearest_model_wire_handle(&frame, model_point, aperture_model) else { continue; }; @@ -93,7 +65,7 @@ impl Scene { &EntityTransform::Affine(viewport_model_to_paper_transform(&frame)), ); let entity_handle = paper_entity.as_entity().handle(); - return Some(ViewportDimPick { + return Some(ViewportDimensionPick { frame, entity_handle: if entity_handle.is_valid() { entity_handle @@ -114,10 +86,11 @@ impl Scene { /// draws, so this matches what the user sees. fn nearest_model_wire_handle( &self, - viewport: Handle, + frame: &ViewportFrame, model_point: DVec3, aperture: f64, ) -> Option { + let viewport = frame.viewport; let camera = self.camera_for_viewport(viewport)?; let bounds = iced::Rectangle { x: 0.0, @@ -150,9 +123,6 @@ impl Scene { .unwrap_or(DVec3::ZERO); let distance = wire_polyline_nearest(wire, offset, model_point); if let Some((distance, nearest)) = distance { - let Some(frame) = self.viewport_frame(viewport) else { - continue; - }; if !self.viewport_displays_paper_point( viewport, frame.model_to_paper(nearest).truncate(), @@ -178,17 +148,8 @@ impl Scene { ) -> Option<(EntityType, Vec)> { let entity = self.document.get_entity(handle)?; match entity { - EntityType::Insert(_) => { - let mut path = Vec::new(); - let found = self.descend_block_instance( - entity.clone(), - Transform::identity(), - model_point, - &mut path, - 0, - viewport, - )?; - Some((found.0, found.1)) + EntityType::Insert(insert) => { + self.descend_block_instance(insert, Transform::identity(), model_point, 0, viewport) } other => { planar_pick_distance(other, model_point)?; @@ -201,20 +162,16 @@ impl Scene { /// Returns the entity already transformed into WCS plus the INSERT path. fn descend_block_instance( &self, - insert_entity: EntityType, + insert: &acadrust::entities::Insert, outer: Transform, model_point: DVec3, - path: &mut Vec, depth: usize, viewport: Option, ) -> Option<(EntityType, Vec)> { const MAX_DEPTH: usize = 8; - if depth > MAX_DEPTH { + if depth >= MAX_DEPTH { return None; } - let EntityType::Insert(insert) = &insert_entity else { - return None; - }; if insert.row_count > 1 || insert.column_count > 1 { return None; } @@ -222,10 +179,9 @@ impl Scene { let local = crate::scene::render_graph::insert_transform(&self.document, insert); let combined = local.then(&outer); let block = self.document.block_records.get(&insert.block_name)?; - let children: Vec = block.entity_handles.clone(); let mut best: Option<(f64, EntityType, Vec)> = None; - for child in children { + for &child in &block.entity_handles { let Some(child_entity) = self.document.get_entity(child) else { continue; }; @@ -237,13 +193,11 @@ impl Scene { if common.invisible || layer.is_some_and(|l| l.flags.off || l.flags.frozen) || frozen { continue; } - if matches!(child_entity, EntityType::Insert(_)) { - let mut nested = Vec::new(); + if let EntityType::Insert(child_insert) = child_entity { if let Some((entity, mut nested_path)) = self.descend_block_instance( - child_entity.clone(), + child_insert, combined, model_point, - &mut nested, depth + 1, viewport, ) { @@ -286,8 +240,6 @@ impl Scene { } } let (_, entity, found_path) = best?; - path.clear(); - path.extend(found_path.iter().copied()); Some((entity, found_path)) } } diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index 3ce7dfae9..327764b6d 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -187,17 +187,8 @@ impl AcceptedSnap { } } -/// The two independent factors that turn a raw measured distance into the -/// number a linear/aligned/radial dimension displays. -/// -/// * `user_lfac` is the drawing/style's DIMLFAC as the user intends it -/// (always positive here; the sign convention of DXF DIMLFAC is resolved by -/// [`MeasurementScale::from_dimlfac`]). -/// * `viewport_compensation` undoes the viewport's paper-per-model scale for -/// a dimension placed in paper space but measuring model geometry. It is -/// `1.0` for model-space dimensions and for paper-only dimensions. -/// -/// Angular dimensions never use either factor. +/// Length dimensions store raw distances in their owning space. The displayed +/// value uses `user_lfac * viewport_compensation`; angular dimensions are exempt. #[derive(Clone, Copy, Debug, PartialEq)] pub struct MeasurementScale { pub user_lfac: f64, @@ -252,59 +243,13 @@ impl MeasurementScale { common.extended_data = data; } - pub const IDENTITY: Self = Self { - user_lfac: 1.0, - viewport_compensation: 1.0, - }; - - /// Resolve the DXF DIMLFAC convention: - /// * `dimlfac > 0`: applies to every dimension. - /// * `dimlfac < 0`: `|dimlfac|` applies only to dimensions created in - /// paper space that measure model geometry through a viewport; model - /// space dimensions use `1.0`. - /// * `dimlfac == 0`: treated as `1.0`. - /// - /// `viewport` supplies the compensation when the dimension measures - /// through a viewport; pass `None` for model-space or paper-only - /// dimensions. - pub fn from_dimlfac(dimlfac: f64, viewport: Option<&ViewportFrame>) -> Self { - let through_viewport = viewport.is_some(); - let user_lfac = if dimlfac == 0.0 { - 1.0 - } else if dimlfac > 0.0 { - dimlfac - } else if through_viewport { - -dimlfac - } else { - 1.0 - }; - let viewport_compensation = viewport - .map(ViewportFrame::paper_to_model_length_factor) - .unwrap_or(1.0); - Self { - user_lfac, - viewport_compensation, - } - } - /// Combined factor applied to a distance measured on the paper sheet. pub fn paper_factor(&self) -> f64 { self.user_lfac * self.viewport_compensation } - /// Factor applied to a distance already measured in model space (the - /// viewport compensation must not be applied again). - pub fn model_factor(&self) -> f64 { - self.user_lfac - } - - /// The DXF DIMLFAC sign convention, resolved without a [`ViewportFrame`]. - /// - /// Rendering an *existing* dimension only knows whether the entity lives - /// in a paper-space layout; the viewport compensation is already baked - /// into the persisted magnitude. This is the same rule - /// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()` - /// replaced by `paper_space`. + /// Positive DIMLFAC applies everywhere; negative DIMLFAC applies only on + /// paper. Zero means one. Saved viewport compensation is already in DIMLFAC. pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 { if dimlfac == 0.0 { 1.0 @@ -352,34 +297,10 @@ mod tests { } #[test] - fn negative_dimlfac_only_applies_through_viewports() { - let f = frame(0.5, 0.0); - let through = MeasurementScale::from_dimlfac(-2.0, Some(&f)); - assert_eq!(through.user_lfac, 2.0); - assert!((through.viewport_compensation - 2.0).abs() < 1e-12); - assert!((through.paper_factor() - 4.0).abs() < 1e-12); - assert!((through.model_factor() - 2.0).abs() < 1e-12); - - let model = MeasurementScale::from_dimlfac(-2.0, None); - assert_eq!(model, MeasurementScale::IDENTITY); - - let positive = MeasurementScale::from_dimlfac(3.0, None); - assert_eq!(positive.user_lfac, 3.0); - assert_eq!(positive.viewport_compensation, 1.0); - } - - #[test] - fn user_lfac_for_space_matches_from_dimlfac() { - let f = frame(0.5, 0.0); - for lfac in [-2.0, 0.0, 1.0, 3.5] { - assert_eq!( - MeasurementScale::user_lfac_for_space(lfac, true), - MeasurementScale::from_dimlfac(lfac, Some(&f)).user_lfac - ); - assert_eq!( - MeasurementScale::user_lfac_for_space(lfac, false), - MeasurementScale::from_dimlfac(lfac, None).user_lfac - ); + fn dimlfac_sign_depends_on_dimension_space() { + for (factor, paper, model) in [(-2.0, 2.0, 1.0), (0.0, 1.0, 1.0), (3.5, 3.5, 3.5)] { + assert_eq!(MeasurementScale::user_lfac_for_space(factor, true), paper); + assert_eq!(MeasurementScale::user_lfac_for_space(factor, false), model); } } } diff --git a/src/snap.rs b/src/snap.rs index 75bcf5f20..fa837110b 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -9,9 +9,9 @@ use iced::time::Instant; use iced::{Point, Rectangle}; use cadkernel::geom2d::Curve; +use acadrust::types::Handle; use crate::command::{DimensionAssociationSource, TangentObject}; -use acadrust::types::Handle; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; @@ -90,12 +90,12 @@ pub struct SnapResult { /// Layout viewport this snap was seen *through*, when the snap ran against /// model geometry displayed by a paper-space viewport. `None` for ordinary /// model-space / paper-sheet snaps. Filled by the paper-space viewport snap - /// query, never by the engine itself. (PR1) + /// query, never by the engine itself. pub viewport: Option, /// Owning entity of the geometry the snap landed on, when the engine could /// attribute the feature to a wire. Block sub-entities report the top-level /// INSERT handle; the block path is resolved later by - /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. (PR1) + /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. pub source: Option, /// Second real object at an intersection, when both objects are known. pub secondary_source: Option, @@ -986,10 +986,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, - viewport: None, - source: None, - secondary_source: None, - model_point: None, + viewport: None, + source: None, + secondary_source: None, + model_point: None, }) } @@ -1224,7 +1224,8 @@ impl Snapper { let mut try_pt = |world: glam::DVec3, snap_type: SnapType, - src: Option, secondary: Option| { + src: Option, + secondary: Option| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1251,10 +1252,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, - viewport: None, - source: src, - secondary_source: secondary, - model_point: None, + viewport: None, + source: src, + secondary_source: secondary, + model_point: None, }); } }; @@ -1308,7 +1309,9 @@ impl Snapper { try_pt( DVec3::from_array(point), SnapType::Endpoint, - wire_source(wire), None); + wire_source(wire), + None, + ); } // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains @@ -1328,7 +1331,9 @@ impl Snapper { try_pt( wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint, - wire_source(wire), None); + wire_source(wire), + None, + ); } } } else { @@ -1351,7 +1356,9 @@ impl Snapper { try_pt( wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint, - src, None); + src, + None, + ); } } } @@ -1402,7 +1409,9 @@ impl Snapper { try_pt( nearest_on_segment(cursor_world, seg.a, seg.b), SnapType::Nearest, - wires.source_wire(seg.wire).and_then(wire_source), None); + wires.source_wire(seg.wire).and_then(wire_source), + None, + ); } } else { for wire in in_range_wires.iter() { @@ -1426,7 +1435,9 @@ impl Snapper { try_pt( foot, SnapType::Perpendicular, - wires.source_wire(seg.wire).and_then(wire_source), None); + wires.source_wire(seg.wire).and_then(wire_source), + None, + ); } } } else { @@ -1455,7 +1466,9 @@ impl Snapper { try_pt( point, SnapType::Intersection, - wires.source_wire(segment.wire).and_then(wire_source), None); + wires.source_wire(segment.wire).and_then(wire_source), + None, + ); } } } @@ -1553,7 +1566,7 @@ impl Snapper { for &wire_j in &local_wires[idx + 1..] { if let Some(points) = exact_curve_intersections(wire_i, wire_j) { // Two wires meet here; attribute the feature to the - // first. PR3 resolves the true pair. + // first. Association resolution requires both source paths. let src = wire_source(wire_i); for point in points { try_pt(point, SnapType::Intersection, src, wire_source(wire_j)); @@ -1590,7 +1603,9 @@ impl Snapper { try_pt( pt, SnapType::Intersection, - wires.source_wire(a.wire).and_then(wire_source), wires.source_wire(b.wire).and_then(wire_source)); + wires.source_wire(a.wire).and_then(wire_source), + wires.source_wire(b.wire).and_then(wire_source), + ); if exact_cursor { break 'intersection_sweep; } @@ -1636,7 +1651,12 @@ impl Snapper { continue; } if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection, wire_source(wire_i), wire_source(wire_j)); + try_pt( + pt, + SnapType::Intersection, + wire_source(wire_i), + wire_source(wire_j), + ); } } } @@ -1762,7 +1782,9 @@ impl Snapper { try_pt( segment_a.a + ta as f64 * (segment_a.b - segment_a.a), SnapType::ApparentIntersection, - wires.source_wire(segment_a.wire).and_then(wire_source), wires.source_wire(segment_b.wire).and_then(wire_source)); + wires.source_wire(segment_a.wire).and_then(wire_source), + wires.source_wire(segment_b.wire).and_then(wire_source), + ); if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { break 'apparent_sweep; } @@ -1802,7 +1824,9 @@ impl Snapper { try_pt( wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection, - wire_source(wire_i), wire_source(wire_j)); + wire_source(wire_i), + wire_source(wire_j), + ); } } } @@ -2023,10 +2047,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, - viewport: None, - source: wire_source(wire), - secondary_source: None, - model_point: None, + viewport: None, + source: wire_source(wire), + secondary_source: None, + model_point: None, }); } } @@ -2196,19 +2220,13 @@ fn snap_tier(t: SnapType) -> u8 { } } -/// Candidate ordering: tier first, then cursor distance, and only when two -/// candidates are effectively equidistant (coincident features) the classic -/// sub-priority — so an Endpoint still beats an Intersection sitting on the -/// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. -/// Choose between two independently-computed snap results using the SAME -/// priority-then-distance rule the engine applies internally -/// ([`snap_better`]): a higher-priority snap inside the aperture wins even -/// when a lower-priority one is nearer, and distance only breaks ties. -/// -/// Used to merge the ordinary paper-sheet snap with the viewport snap taken -/// through a layout viewport (PR1). Both `screen` fields must be expressed in -/// the same pixel frame. -pub fn merge_snap(a: Option, b: Option, cursor: Point) -> Option { +/// Merge paper and viewport snaps using the engine's ordering. +/// Both screen positions must use canvas pixels. +pub fn merge_snap( + a: Option, + b: Option, + cursor: Point, +) -> Option { match (a, b) { (Some(a), Some(b)) => { let (at, asub) = (snap_tier(a.snap_type), snap_priority(a.snap_type)); @@ -2225,6 +2243,10 @@ pub fn merge_snap(a: Option, b: Option, cursor: Point) - } } +/// Candidate ordering: tier first, then cursor distance, and only when two +/// candidates are effectively equidistant (coincident features) the classic +/// sub-priority — so an Endpoint still beats an Intersection sitting on the +/// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { let (bt, bd2, bsub) = best; if tier != bt { From 5424a19482fe9512b321d57ea4c3ee600a5ab4e8 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 16:00:11 -0500 Subject: [PATCH 22/23] Track mixed paper and viewport dimension references --- docs/viewport-dimension-association.svg | 19 ++ docs/viewport-dimension-creation.svg | 22 ++ docs/viewport-dimensions.md | 140 ++++----- src/app/command_driver.rs | 48 ++- src/app/commands/mod.rs | 2 +- src/app/dim_viewport.rs | 55 ++-- src/app/mod.rs | 17 +- src/app/properties.rs | 21 +- src/app/update/viewport.rs | 56 ++-- src/app/update/viewport_snap.rs | 97 +----- src/app/viewport_dimension_tests.rs | 283 ++++++++++++++++-- src/command.rs | 16 +- src/entities/dim_override.rs | 12 +- src/entities/dimension.rs | 16 +- src/modules/annotate/aligned_dim.rs | 5 +- src/modules/annotate/angular_dim.rs | 28 +- src/modules/annotate/diameter_dim.rs | 5 +- src/modules/annotate/linear_dim.rs | 5 +- src/modules/annotate/radius_dim.rs | 5 +- src/scene/dimension_assoc.rs | 274 ++++++++--------- src/scene/dimension_assoc_chain.rs | 135 ++------- src/scene/mod.rs | 2 +- src/scene/mspace.rs | 65 ++-- src/scene/project.rs | 5 +- ...dim_seam.rs => viewport_dimension_pick.rs} | 86 ++---- src/scene/viewport_ref.rs | 128 +------- src/snap.rs | 102 ++++--- tests/fixtures/dim_assoc/README.md | 20 +- 28 files changed, 823 insertions(+), 846 deletions(-) create mode 100644 docs/viewport-dimension-association.svg create mode 100644 docs/viewport-dimension-creation.svg rename src/scene/{viewport_dim_seam.rs => viewport_dimension_pick.rs} (84%) diff --git a/docs/viewport-dimension-association.svg b/docs/viewport-dimension-association.svg new file mode 100644 index 000000000..8a7a06428 --- /dev/null +++ b/docs/viewport-dimension-association.svg @@ -0,0 +1,19 @@ + + + + +The dimension follows its paper-space centerline + + +BeforeCenterline moved 1 paper unit right + + + + + + + +5040 +The model endpoint stays fixed. The dimension updates from 50 to 40. +Association schematic; viewport 1:10, dimension style unit factor = 1, DIMASSOC = 2. + diff --git a/docs/viewport-dimension-creation.svg b/docs/viewport-dimension-creation.svg new file mode 100644 index 000000000..efd1464c1 --- /dev/null +++ b/docs/viewport-dimension-creation.svg @@ -0,0 +1,22 @@ + + + + +Dimension from a paper centerline to a model endpoint + +Viewport 1:10 + + + +Model geometry + + + +50 +Brown dashed line: paper-space centerline +5 paper units between the picked points +5 × 10 = 50 model units +The viewport supplies the length scale. +Either pick order gives the same value. +Measurement schematic; dimension style unit factor = 1. + diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md index fe313cd1d..2190bdeb2 100644 --- a/docs/viewport-dimensions.md +++ b/docs/viewport-dimensions.md @@ -1,100 +1,74 @@ -# Dimensions through paper-space viewports +# Dimensions through layout viewports -Paper-space point commands can snap to displayed model geometry through a planar, -orthographic layout viewport. The acquired result retains the original model -coordinate, its paper coordinate, the viewport frame, and feature identity. -Ordinary selection continues to operate on the active drawing space. +In paper space, snap to model geometry displayed through a planar, orthographic +viewport. `DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER` +also support their usual object-pick modes through these viewports. -`DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER` use this -acquisition context. The supported object-pick modes also query viewport geometry. -Snapping and object picking share viewport draw order, visibility, and clipping -rules. Object picking uses the resident interaction index and applies block -instance transforms. Nonuniformly transformed circles and circular arcs are -declined as radial object picks because their displayed geometry is elliptical. -Array inserts without an addressable instance index are also declined; resolved -block geometry must remain within the original pick aperture. +## Measurement -## Input and measurement rules +- Model geometry supplies the viewport scale. A 100-unit model length displayed + at 1:10 measures 100, although it occupies 10 paper units. +- A paper-space centerline or free sheet point can be combined with model + geometry through one viewport, in either pick order. The paper-space span is + measured using that viewport's scale. +- Sheet-only dimensions measure paper units, even inside a viewport rectangle. +- Model picks from a second viewport are rejected without advancing the command. + Dimension-line and text placement do not change the measurement scale. +- The current dimension style's unit conversion is preserved. Angular dimensions + do not use length compensation. `DIMASSOC=0` explodes the compensated dimension. -- Free and typed sheet points measure paper units, including points inside a - viewport rectangle. -- Measuring points must all belong to the same space and, when applicable, the - same viewport. An incompatible pick leaves the command at its current step. -- Dimension-line and text placement do not change the measurement's space. -- Paper geometry takes priority over viewport geometry at an object pick. -- The final dimension style is applied before computing measurement scaling. - Length dimensions keep paper-space definition points and a negative `DIMLFAC` - override containing the effective user factor multiplied by `1 / viewport scale`. - Angular measurements do not use this length factor. -- `DIMASSOC=0` uses the same compensated value before exploding the annotation. +Snapping and object picking respect visibility, clipping, and viewport draw order. +Paper geometry has priority in object picks. Ordinary selection uses the active +drawing space. Object picks reuse the resident interaction index and resolve +nested block transforms. -## Persistent measurement data +![Measurement schematic: a five-unit sheet span measures 50 through a 1:10 viewport.](viewport-dimension-creation.svg) -`OCS_VIEWPORT_MEASUREMENT` XData stores three values: version `1` (integer16), -the positive user factor (real), and viewport compensation (real). This keeps the -user's unit conversion separate from viewport scale. The ordinary `DIMLFAC` -override remains sufficient for displaying the saved measurement in other CAD -readers. The data survives DXF/DWG saves and undo/redo. +## Saved scale -With `DIMASSOC=2`, acquired features attach a dimension to its viewport, each -block instance, and the source entity. Intersections retain both source chains. -Source edits and viewport changes update the definition points and recompute -compensation while preserving explicit user-positioned sheet text and dimension -style overrides. A successful update invalidates the saved picture block so the -new geometry and number are rendered and exported. Undo restores both the -reference data and the previous picture. +Length dimensions store paper-space definition points and a negative `DIMLFAC` +override: user unit factor multiplied by `1 / viewport scale`. +`OCS_VIEWPORT_MEASUREMENT` XData stores version `1` (integer16), the positive user +factor (real), and viewport compensation (real). Keeping the factors separate +allows compensation to change without losing the user's unit conversion. +Both the override and XData survive DXF/DWG saves and undo/redo. -Properties reports whether the dimension is associated, partially associated, -unresolved, broken, or nonassociative. A broken or unsupported required reference -preserves the entire dimension's last valid appearance and its original records. -Creating another dimension does not announce geometry changes to its sources. +## Associations -## Imported references and support limits +With `DIMASSOC=2`, acquired features retain their viewport, block-instance path, +source entity, and snap feature. Intersections retain both source chains. +Source edits and viewport changes update the dimension and its compensation, +preserving style overrides and user-positioned text on the sheet. -The resolver distinguishes the application's vertex/parameter convention from -imported edge references. Imported start/end and midpoint modes use their edge -marker and parameter together. The original regression drawing's first-edge -start/end pair now reproduces its `0.128` measurement. An imported polyline -endpoint beyond the first edge requires a usable stored point to validate the -marker; zero/sentinel data cannot establish that identity. Ambiguous imports -remain unresolved. Imported viewport scale is updated only when existing OCS -metadata or `ACAD_DIMASSOC_CALC_DIMLFAC` separates compensation from user scaling. +A sheet centerline stays on the sheet; a model endpoint follows its viewport. +Moving the viewport therefore changes the distance between them. Undo/redo and +DXF/DWG saves preserve the references. -Unsupported cases retain their saved data and appearance: +Properties shows association status. If a required reference is broken or cannot +be resolved, the entire dimension keeps its last valid geometry and reference +records. A successful refresh regenerates its displayed and exported geometry. -- Perspective/oblique viewport projections and malformed/missing reference chains. -- Ambiguous intersections within one batched block; they are not attached to a - guessed pair of children. Intersections between separate identified chains work. -- Array inserts without an addressable instance index, nesting beyond eight levels, - and radial measurements of nonuniformly transformed circular geometry. -- Imported quadrant references whose moved geometry no longer validates the stored - point; newly acquired circle/arc quadrants keep their source-local angle. -- Tangency with no verified solution, two dependent perpendicular/tangent references - requiring a coupled solve, unsupported snap modes, and imported measurement data that does not identify the old viewport compensation. +![Association schematic: moving the sheet centerline changes the dimension from 50 to 40.](viewport-dimension-association.svg) -The osnap codes and reference fields follow the -[Autodesk DIMASSOC reference](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-C0B96256-A911-4B4D-85E6-EB4AF2C91E27.htm). -The restricted imported-marker interpretation above is validated by fixtures; -it is not a claim that every exporter uses an interchangeable marker scheme. +## Limits -## Validation +Unsupported cases preserve saved dimensions: -`src/app/viewport_dimension_tests.rs` exercises snap-engine acquisition, command -commits for all five commands, object picks, input retries, active positive and -negative style factors, exploded dimensions, undo/redo, DXF/DWG persistence, -clipping, overlapping viewports, pan, scale, and twist. These are headless tests; -they do not establish visual acceptance in the desktop UI. +- Perspective or oblique views; missing or malformed reference chains. +- Array instances without an addressable instance index, nesting beyond eight + levels, and radial measurements of nonuniformly scaled circles or arcs. +- Intersections without two identifiable source chains inside a batched block. +- Ambiguous imported endpoint, midpoint, or quadrant markers. A stored point can + validate a feature; it cannot establish an otherwise unknown identity. +- Tangency without a verified solution, or two perpendicular/tangent references + requiring a coupled solve. +- Imported viewport scaling without OCS scale metadata or + `ACAD_DIMASSOC_CALC_DIMLFAC` identifying the saved compensation. -Association tests also exercise regeneration, nearest and quadrant parameters, -angular arc/polyline object picks, spline tangency, -perpendicularity under nonuniform block scale, nested paths, intersection source -edits, preserved overrides, erased sources, copy detachment, saved picture -invalidation, and history. The checked-in DXF fixture is exercised directly. -The original private drawing can be checked separately: +## Tests -```powershell -$env:OPENCAD_VIEWPORT_REGRESSION_DXF = 'C:\path\to\115448.dxf' -cargo test --locked --offline --lib viewport_dimension_original -- --ignored --nocapture -``` - -Do not treat passing headless checks as completed desktop visual acceptance. +Run `cargo test --locked --lib viewport_dimension`. Tests cover the five commands, +mixed sheet/model input, styles, clipping, object picks, source/viewport edits, +snap features, nested paths, intersections, preserved overrides, undo/redo, and +DXF/DWG persistence. The synthetic association fixture is in +`tests/fixtures/dim_assoc/`. diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index cd78df641..8580c8eef 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -98,7 +98,7 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.last_point = None; // Points collected in the space being left are meaningless in the new - // one (PR1). + // one. self.clear_accepted_snaps(); self.pending_click_snap = None; self.snapper.from_point = None; @@ -386,7 +386,7 @@ impl OpenCADStudio { } // Typed / dynamic-input / headless points carry no snap, but the // accepted-snap list must stay index-parallel with the points the - // command collects (PR1). Interactive picks record themselves in + // command collects. Interactive picks record themselves in // the click handler and never reach here. if !self.record_accepted_snap(i, None, None, *point) { return Task::none(); @@ -404,7 +404,9 @@ impl OpenCADStudio { self.push_ucs_to_cmd(i); } if let StepInput::EntityPick(handle, point) = &input { - if !self.dimension_acquisition_allowed(i, None) { return Task::none(); } + if !self.dimension_acquisition_allowed(i, None) { + return Task::none(); + } let solid_pick = matches!( self.tabs[i].scene.document.get_entity(*handle), Some( @@ -477,7 +479,11 @@ impl OpenCADStudio { } let ctrl = self.ctrl_down; let shift = self.shift_down; - let picked_handle = if let StepInput::EntityPick(h, _) = &input { Some(*h) } else { None }; + let picked_handle = if let StepInput::EntityPick(h, _) = &input { + Some(*h) + } else { + None + }; let result: Option = { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { return Task::none(); @@ -2366,17 +2372,15 @@ impl OpenCADStudio { // through a viewport carries the compensation as a negative // DIMLFAC override. if !preserve_base_style { - crate::scene::creation_style::apply_current_creation_styles(&self.tabs[i].scene.document, &mut entity); + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); } if !self.apply_viewport_dimension_measurement(i, &mut entity) { return Task::none(); } - // ...and is deliberately *not* associated in paper space: its - // definition points are projected paper coordinates, so a - // paper-space association would bind it to geometry that is - // not there. PR3 records the real dimension -> viewport -> - // entity chain instead. One gate, shared with - // `infer_dimension_sources_guarded`. + // Projected points cannot use direct paper-space source inference. let association_allowed = self.dimension_association_allowed(i); let inherited_dimension = if preserve_base_style { match &entity { @@ -2438,20 +2442,15 @@ impl OpenCADStudio { } else { let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - if let Some(handle) = self.commit_entity_handle_with_dimension_policy( - entity, - preserve_base_style, - ) { + if let Some(handle) = + self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) + { if association_mode == 2 && association_allowed { - let mut changes = vec![ - (handle, crate::scene::ChangeKind::Modified), - ]; + let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; match association { crate::command::DimensionAssociationInput::Infer(source) => { let sources: Vec<_> = source.map_or_else( - || { - self.infer_dimension_sources_guarded(i, handle) - }, + || self.infer_dimension_sources_guarded(i, handle), |source| { if single_source_dimension { vec![Some(source)] @@ -2481,12 +2480,7 @@ impl OpenCADStudio { changes.dedup_by_key(|(handle, _)| handle.value()); self.tabs[i].scene.bump_entities(&changes); } else if association_mode == 2 { - // Measured through a layout viewport: a paper-space - // association would bind projected coordinates to - // geometry that is not on the sheet. Record the real - // chain instead — dimension -> viewport -> block - // path -> entity -> feature — from the AcceptedSnaps - // PR1 captured, in definition-point slot order. + // Preserve the acquired viewport and source paths. self.attach_viewport_dimension_association(i, handle); } } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a8cf35bc5..7befcd464 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -121,7 +121,7 @@ impl OpenCADStudio { // is not constrained by ortho/polar relative to a previous command's endpoint. self.last_point = None; // A new command collects its own points, so the previous command's - // accepted snaps must not leak into it (PR1). + // accepted snaps must not leak into it. self.clear_accepted_snaps(); // Starting a command restarts the right-click cycle, so its first // right-click acts as Enter rather than opening the context menu. diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index a80b151c4..841b784e1 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -1,5 +1,5 @@ -//! Paper-space dimension acquisition and measurement. Only acquired geometry -//! carries viewport compensation; free and typed sheet coordinates stay on the sheet. +//! Paper-space dimension acquisition and measurement. Acquiring model geometry +//! supplies a viewport scale; sheet-only dimensions retain paper units. use super::OpenCADStudio; use crate::command::DimensionAssociationSource; @@ -14,7 +14,7 @@ use glam::DVec3; pub(crate) enum DimensionMeasureSpace { Direct, Viewport(ViewportFrame), - Mixed, + ConflictingViewports, } impl OpenCADStudio { @@ -26,33 +26,23 @@ impl OpenCADStudio { selection.left_dragging = false; } - #[allow(dead_code)] // Consumed by the dependent association change. - pub(crate) fn dimension_measuring_snaps(&self, _i: usize) -> Vec { - self.accepted_snaps().to_vec() - } - pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { let scene = &self.tabs[i].scene; if scene.current_layout == "Model" || scene.active_viewport.is_some() { return DimensionMeasureSpace::Direct; } let mut frame: Option = None; - let mut direct = false; - for snap in self.accepted_snaps() { - match snap.frame { - Some(f) => match frame { - None => frame = Some(f), - Some(previous) if previous.viewport == f.viewport => {} - Some(_) => return DimensionMeasureSpace::Mixed, - }, - None => direct = true, + for f in self.accepted_snaps().iter().filter_map(|snap| snap.frame) { + match frame { + None => frame = Some(f), + Some(previous) if previous.viewport == f.viewport => {} + Some(_) => return DimensionMeasureSpace::ConflictingViewports, } } - match (frame, direct) { - (None, _) => DimensionMeasureSpace::Direct, - (Some(f), false) => DimensionMeasureSpace::Viewport(f), - _ => DimensionMeasureSpace::Mixed, - } + frame.map_or( + DimensionMeasureSpace::Direct, + DimensionMeasureSpace::Viewport, + ) } /// Reject an incompatible measuring input before it advances the command. @@ -68,8 +58,15 @@ impl OpenCADStudio { { return true; } - if self.accepted_snaps().iter().any(|s| s.viewport != viewport) { - self.command_line.push_error(crate::t!("Pick geometry from the same viewport, or use paper-space points for the whole dimension.").as_ref()); + // Sheet geometry and free sheet points may accompany one viewport. + // A second viewport would make the measurement scale ambiguous. + if viewport.is_some_and(|viewport| { + self.accepted_snaps() + .iter() + .filter_map(|s| s.viewport) + .any(|previous| previous != viewport) + }) { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref()); return false; } true @@ -134,8 +131,8 @@ impl OpenCADStudio { ); let frame = match self.dimension_measure_space(i) { DimensionMeasureSpace::Direct => return true, - DimensionMeasureSpace::Mixed => { - self.command_line.push_error(crate::t!("Pick geometry from the same viewport, or use paper-space points for the whole dimension.").as_ref()); + DimensionMeasureSpace::ConflictingViewports => { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref()); return false; } DimensionMeasureSpace::Viewport(f) => f, @@ -214,13 +211,13 @@ impl OpenCADStudio { if slots.is_empty() { return Vec::new(); } - let measuring = self.dimension_measuring_snaps(i); + let measuring = self.accepted_snaps(); slots .iter() .enumerate() .map(|(index, slot)| { let snap = measuring.get(index)?.clone(); - let frame = snap.frame?; + let frame = snap.frame; let slot = DVec3::new(slot.x, slot.y, slot.z); if snap .paper_point @@ -232,7 +229,7 @@ impl OpenCADStudio { } Some(AcceptedSnap { paper_point: slot, - model_point: frame.paper_to_model(slot), + model_point: frame.map_or(slot, |frame| frame.paper_to_model(slot)), ..snap }) }) diff --git a/src/app/mod.rs b/src/app/mod.rs index 48ef49c54..56e44bb8a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -717,21 +717,12 @@ pub(super) struct OpenCADStudio { show_layout_tabs: bool, /// Last point committed by a drawing command — used as ortho/polar base. last_point: Option, - /// Viewport frame the live snap marker is currently looking through (PR1). - /// Set by the cursor-move handler when the displayed snap came from a - /// layout viewport, cleared otherwise. Display/bookkeeping only — the point - /// itself is already projected onto the sheet. + /// Viewport used by the current snap; the displayed point is in paper space. pub(crate) vp_snap_frame: Option, - /// Snaps accepted by the active command's point steps, parallel to the - /// points it collected (PR1). A command that does not care keeps using the - /// plain paper point; dimension commands (PR2) read the model point, - /// viewport and frame from here, and PR3 reads `source`. Cleared whenever a - /// command starts or ends. + /// Acquired coordinates and source identities in command-step order. + /// Cleared when the command starts or ends. accepted_snaps: Vec, - /// Snap resolved by the click currently being handled, with the viewport - /// frame it came through (PR1). Set inside the click handler's point - /// resolution so the point-accept path further down can record the - /// [`crate::scene::viewport_ref::AcceptedSnap`] without recomputing. + /// Click result retained until the command accepts its point. pending_click_snap: Option<( crate::snap::SnapResult, Option, diff --git a/src/app/properties.rs b/src/app/properties.rs index 6a6081a37..e816f8e1f 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1277,13 +1277,22 @@ impl OpenCADStudio { &self.tabs[i].scene.document, d.base().common.handle, ); - let statuses = self.tabs[i].scene.dimension_association_status(d.base().common.handle); - let slots = self.tabs[i].scene.dimension_association_slot_points(d.base().common.handle).len(); + let statuses = self.tabs[i] + .scene + .dimension_association_status(d.base().common.handle); + let slots = self.tabs[i] + .scene + .dimension_association_slot_points(d.base().common.handle) + .len(); let status = if statuses.is_empty() { t!("Nonassociative") - } else if statuses.iter().any(|(_, status)| matches!(status, crate::scene::ReferenceStatus::Broken(_))) { + } else if statuses.iter().any(|(_, status)| { + matches!(status, crate::scene::ReferenceStatus::Broken(_)) + }) { t!("Broken reference") - } else if statuses.iter().any(|(_, status)| matches!(status, crate::scene::ReferenceStatus::Unresolved)) { + } else if statuses.iter().any(|(_, status)| { + matches!(status, crate::scene::ReferenceStatus::Unresolved) + }) { t!("Unresolved reference") } else if statuses.len() < slots { t!("Partially associated") @@ -1293,7 +1302,9 @@ impl OpenCADStudio { general.props.push(crate::scene::model::object::Property { label: t!("Association status").into_owned(), field: "association_status", - value: crate::scene::model::object::PropValue::ReadOnly(status.into_owned()), + value: crate::scene::model::object::PropValue::ReadOnly( + status.into_owned(), + ), }); general.props.push(crate::scene::model::object::Property { label: t!("Associative").into_owned(), diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f404c0a83..acbb65bdf 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2059,24 +2059,24 @@ impl OpenCADStudio { ) }; - // Paper-space snapping THROUGH layout viewports (PR1). `edit_cam` + // Paper-space snapping through layout viewports. `edit_cam` // is None here, so `cursor_world` is the paper point and the sheet // fills the canvas (pane-local == canvas pixels). The viewport hit // arrives already projected onto the sheet, so everything // downstream keeps working in paper coordinates — LINE still draws // at the projected paper point. self.vp_snap_frame = None; - if edit_cam.is_none() && !needs_entity && !is_gathering && !needs_structure + if edit_cam.is_none() + && !needs_entity + && !is_gathering + && !needs_structure && !needs_tan { if let Some((vp_hit, frame)) = self.paper_viewport_snap(i, p_full, vp_size, cursor_world) { - let merged = crate::snap::merge_snap( - self.tabs[i].snap_result, - Some(vp_hit), - p_full, - ); + let merged = + crate::snap::merge_snap(self.tabs[i].snap_result, Some(vp_hit), p_full); if merged.is_some_and(|hit| hit.viewport.is_some()) { self.vp_snap_frame = Some(frame); } @@ -3701,7 +3701,7 @@ impl OpenCADStudio { } } } - // Paper-space snapping through layout viewports (PR1). Mirrors + // Paper-space snapping through layout viewports. Mirrors // the cursor-move pass: the click recomputes snapping from // scratch, so the viewport query has to run here too. The hit // comes back already projected onto the sheet. @@ -3710,8 +3710,7 @@ impl OpenCADStudio { if let Some((vp_hit, frame)) = self.paper_viewport_snap(i, p_full, (vw, vh), raw) { - let merged = - crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full); + let merged = crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full); if merged.is_some_and(|hit| hit.viewport.is_some()) { click_frame = Some(frame); } @@ -4046,7 +4045,10 @@ impl OpenCADStudio { } } - if !self.dimension_acquisition_allowed(i, None) { self.finish_command_click(i); return Task::none(); } + if !self.dimension_acquisition_allowed(i, None) { + self.finish_command_click(i); + return Task::none(); + } let shift = self.shift_down; let result = self.tabs[i].active_cmd.as_mut().map(|c| { // Shift-swap state for TRIM/EXTEND (#336). @@ -4126,11 +4128,21 @@ impl OpenCADStudio { } result } else if let Some(result) = { - let dimension = self.tabs[i].active_cmd.as_ref().is_some_and(|c| c.measures_through_viewports()); + let dimension = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.measures_through_viewports()); if dimension { - let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0 / (bounds.height as f64).max(1.0); - self.try_dimension_viewport_entity_pick(i, pick_wcs, crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px) - } else { None } + let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0 + / (bounds.height as f64).max(1.0); + self.try_dimension_viewport_entity_pick( + i, + pick_wcs, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px, + ) + } else { + None + } } { Some(result) } else if self.tabs[i] @@ -4139,7 +4151,12 @@ impl OpenCADStudio { .is_some_and(|command| command.entity_pick_accepts_points()) { let pending = self.pending_click_snap.take(); - if !self.record_accepted_snap(i, pending.map(|(hit, _)| hit), pending.and_then(|(_, frame)| frame), pick_wcs) { + if !self.record_accepted_snap( + i, + pending.map(|(hit, _)| hit), + pending.and_then(|(_, frame)| frame), + pick_wcs, + ) { self.finish_command_click(i); return Task::none(); } @@ -4212,7 +4229,7 @@ impl OpenCADStudio { } } // Record what this point step accepted, alongside the point - // itself (PR1): paper point, model point, viewport, frame and + // itself: paper point, model point, viewport, frame and // geometry identity. Commands that don't care keep consuming // `world_pt` exactly as before. let pending = self.pending_click_snap.take(); @@ -4221,7 +4238,10 @@ impl OpenCADStudio { pending.map(|(hit, _)| hit), pending.and_then(|(_, frame)| frame), world_pt, - ) { self.finish_command_click(i); return Task::none(); } + ) { + self.finish_command_click(i); + return Task::none(); + } self.last_point = Some(world_pt); // The one-shot snap override is spent by this pick — // restore the running osnap configuration (#337). diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 4a638f6e3..927fd22e1 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -1,24 +1,7 @@ -//! Paper-space snapping THROUGH layout viewports (PR1). -//! -//! In a paper layout with no active viewport, `Scene::hit_test_wires` returns -//! only the paper sheet's own entities — viewport contents are deliberately not -//! interactive, and that must stay true for *selection*. Snapping, however, -//! should see the model geometry a viewport displays, so a paper-space LINE / -//! DIMENSION can pick a real model endpoint and land on the paper pixel where -//! it is drawn. -//! -//! This module adds that as a SEPARATE query. It never changes -//! `hit_test_wires`. For each displayed content viewport under the cursor -//! (top-most first) it runs the EXISTING snap engine against that viewport's -//! resident model wires, using the viewport's own camera and screen rectangle -//! (`Scene::viewport_edit_frame_for` — the same adapter MSPACE editing uses), -//! then maps the accepted model point back onto the sheet with -//! [`ViewportFrame::model_to_paper`]. -//! -//! Performance: no projected geometry is rebuilt per pointer event. The wire -//! set comes from `model_wires_for_viewport_arc`, which is the renderer's own -//! resident, camera-independent `Arc`, and the cursor-local broad phase reuses -//! the shared interaction index built over it. +//! Snap to displayed model geometry while a paper-space command is active. +//! Viewport queries use the renderer's resident wires and interaction index, +//! then project the accepted model point onto the sheet. Ordinary selection +//! continues to use paper-space hit testing. use iced::Point; @@ -34,37 +17,14 @@ const MAX_ACCEPTED_SNAPS: usize = 64; impl OpenCADStudio { /// Snaps accepted by the active command's point steps, oldest first, in - /// step order. Consumed by the dimension commands in PR2/PR3. - #[allow(dead_code)] + /// step order. pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] { &self.accepted_snaps } - /// The most recently accepted snap, if any. - #[allow(dead_code)] - pub(crate) fn last_accepted_snap(&self) -> Option<&AcceptedSnap> { - self.accepted_snaps.last() - } - - /// `Scene::infer_dimension_sources`, suppressed when the dimension being - /// created measures through a layout viewport. - /// - /// Inference works by looking for geometry near the dimension's own - /// definition points, in whatever space it is handed. A paper-space - /// dimension measured through a viewport has PAPER definition points that - /// happen to sit over projected model geometry, so unguarded inference - /// would either associate it with unrelated paper-sheet geometry at those - /// coordinates, or match model geometry using paper coordinates. Neither - /// is correct, and a wrong association is worse than none. Returning an - /// empty source list makes `attach_dimension_association` a no-op. - /// - /// The classification is - /// [`OpenCADStudio::dimension_measure_space`] — the *same* one that - /// decides whether to compensate the measurement, so the two can never - /// disagree. In particular it ignores the trailing dimension-line - /// placement click, which routinely lands inside a viewport rectangle for - /// an ordinary paper-space dimension and must not suppress its - /// association. + /// Infer direct sources only when the measurement uses the active space. + /// Projected model points must retain their acquired source identities; + /// proximity on the sheet could bind them to unrelated paper geometry. pub(crate) fn infer_dimension_sources_guarded( &self, tab: usize, @@ -76,14 +36,8 @@ impl OpenCADStudio { self.tabs[tab].scene.infer_dimension_sources(dimension) } - /// The single gate on creating a paper-space association for the - /// dimension the active command is committing. - /// - /// `false` exactly when the dimension measures through (or partly through) - /// a layout viewport, whatever supplied its sources — inference, - /// an explicit object pick, or an explicit source list. Everything in the - /// commit path that used to decide this for itself now asks here, so the - /// measurement rule and the association rule cannot drift apart. + /// Whether the dimension can use ordinary active-space associations. + /// Placement clicks are excluded by the measurement classification. pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool { matches!( self.dimension_measure_space(tab), @@ -177,7 +131,7 @@ impl OpenCADStudio { ) else { return false; }; - let distance = crate::scene::viewport_dim_seam::feature_pick_distance( + let distance = crate::scene::viewport_dimension_pick::feature_pick_distance( &entity, accepted.model_point, Some(source.snap_type), @@ -207,33 +161,15 @@ impl OpenCADStudio { } } - /// Append an already-built accepted snap, keeping the retention cap. - /// - /// Used by paths that are not point steps (the dimension object pick - /// resolved through a viewport) and therefore never reach the click - /// handler. + /// Retain a bounded history for point and object picks. pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) { if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS { self.accepted_snaps.remove(0); } self.accepted_snaps.push(accepted); } - /// Snap the paper-space cursor to model geometry seen through a layout - /// viewport. - /// - /// * `i` — tab index. - /// * `cursor_canvas` — cursor in canvas pixels. - /// * `canvas` — canvas size in pixels. - /// * `cursor_paper` — the paper-space point under the cursor. - /// - /// Returns the hit expressed in PAPER coordinates (`world` projected onto - /// the sheet, `screen` in canvas pixels) together with the frame it came - /// through, so the caller can merge it with the ordinary paper-sheet snap - /// and later recover the model point. - /// - /// `None` in the Model layout, while a viewport is active (MSPACE already - /// snaps in model space), when snapping is off, or when the cursor is not - /// over a displayed viewport. + /// Query visible viewport geometry with a canvas-pixel cursor and sheet point. + /// Returns paper coordinates and canvas pixels, plus the source viewport frame. pub(in crate::app) fn paper_viewport_snap( &mut self, i: usize, @@ -316,10 +252,7 @@ impl OpenCADStudio { let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else { continue; }; - // The wire set is the whole model, not a clipped copy, so the only - // way a feature outside the visible part could be offered is via - // the aperture reaching past the clip. Reject those: a snap must be - // a real feature the viewport actually displays. + // The snap aperture can reach outside the visible clip boundary. let paper = frame.model_to_paper(hit.world); if !self.tabs[i] .scene diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs index 44d1aff54..de448dbba 100644 --- a/src/app/viewport_dimension_tests.rs +++ b/src/app/viewport_dimension_tests.rs @@ -149,24 +149,13 @@ fn viewport_dimension_creation_preserves_active_units_and_roundtrips() { } #[test] -fn viewport_dimension_mixed_and_degenerate_picks_allow_retry() { +fn viewport_dimension_conflicting_viewports_and_degenerate_picks_allow_retry() { let (mut app, line, frame) = fixture(); let i = app.active_tab; let _ = app.dispatch_command("DIMALIGNED"); point(&mut app, frame, line, DVec3::ZERO); point(&mut app, frame, line, DVec3::ZERO); assert_eq!(app.accepted_snaps().len(), 1); - let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 50.0, 0.0))); - assert_eq!(app.accepted_snaps().len(), 1); - assert_eq!( - app.tabs[i] - .active_cmd - .as_ref() - .unwrap() - .dimension_acquired_points() - .len(), - 1 - ); let other = ViewportFrame { viewport: Handle::new(0xFFFF), ..frame @@ -318,7 +307,12 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { let top = scene.add_entity(overlay); let paper = DVec3::new(55.0, 50.0, 0.0); assert_eq!( - scene.viewport_frame_at_paper_point(paper).unwrap().viewport, + scene + .viewport_frames_at_paper_point(paper) + .into_iter() + .next() + .unwrap() + .viewport, top ); assert_eq!( @@ -340,7 +334,12 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { vp.clip_boundary_handle = clip; } assert_eq!( - scene.viewport_frame_at_paper_point(paper).unwrap().viewport, + scene + .viewport_frames_at_paper_point(paper) + .into_iter() + .next() + .unwrap() + .viewport, first.viewport ); assert_eq!( @@ -354,7 +353,7 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(first.viewport) { vp.status.is_on = false; } - assert!(scene.viewport_frame_at_paper_point(paper).is_none()); + assert!(scene.viewport_frames_at_paper_point(paper).is_empty()); assert!(scene.dimension_pick_through_viewport(paper, 0.2).is_none()); } @@ -623,8 +622,8 @@ fn viewport_dimension_spline_tangency_is_parallel_and_validated() { } #[test] -#[ignore = "Set OPENCAD_VIEWPORT_REGRESSION_DXF to the original local regression drawing"] -fn viewport_dimension_original_drawing_keeps_unchanged_measurements() { +#[ignore = "Set OPENCAD_VIEWPORT_REGRESSION_DXF to an external regression fixture"] +fn viewport_dimension_external_fixture_keeps_unchanged_measurements() { use crate::scene::ChangeKind; let path = std::env::var("OPENCAD_VIEWPORT_REGRESSION_DXF").expect("regression drawing path"); let doc = acadrust::DxfReader::from_file(std::path::Path::new(&path)) @@ -820,7 +819,9 @@ fn viewport_dimension_nested_block_path_survives_edit_and_copy() { let root = scene.add_entity(EntityType::Insert(root)); scene.set_current_layout("Dimensions".into()); let transform = - crate::scene::dimension_assoc_chain::block_transform(&scene.document, &[root, nested]); + crate::scene::dimension_assoc_chain::walk_chain(&scene.document, &[root, nested, leaf]) + .unwrap() + .transform; let model = |x| { let v = transform.apply(Vector3::new(x, 0.0, 0.0)); DVec3::new(v.x, v.y, v.z) @@ -1095,3 +1096,249 @@ fn viewport_dimension_quadrant_keeps_its_feature_after_translation() { .bump_entities(&[(source, ChangeKind::Modified)]); assert!((displayed(&app.tabs[i].scene.document) - 400.0).abs() < 1e-5); } + +fn start_centerline_dimension( + command: &str, + paper_first: bool, + paper_kind: SnapType, + factor: f64, +) -> (OpenCADStudio, Handle, Handle, ViewportFrame) { + let (mut app, model, frame) = fixture(); + let i = app.active_tab; + let paper = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(55.0, 40.0, 0.0), + Vector3::new(55.0, 60.0, 0.0), + ))); + let mut style = acadrust::tables::DimStyle::new("CenterlineUnits"); + style.dimlfac = factor; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "CenterlineUnits".into(); + let _ = app.dispatch_command(command); + let mut paper_hit = hit(frame, paper, DVec3::new(55.0, 50.0, 0.0)); + paper_hit.world = DVec3::new(55.0, 50.0, 0.0); + paper_hit.model_point = None; + paper_hit.viewport = None; + paper_hit.snap_type = paper_kind; + let mut picks = [ + (paper_hit, None), + (hit(frame, model, DVec3::X * 100.0), Some(frame)), + ]; + if !paper_first { + picks.reverse(); + } + for (snap, context) in picks { + assert!(app.record_accepted_snap(i, Some(snap), context, snap.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(snap.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + } + (app, model, paper, frame) +} + +#[test] +fn viewport_dimension_paper_centerline_uses_viewport_units_in_either_order() { + for command in ["DIMLINEAR", "DIMALIGNED"] { + for paper_first in [true, false] { + let (mut app, _, paper, _) = + start_centerline_dimension(command, paper_first, SnapType::Midpoint, 25.4); + let paper_snap = app + .accepted_snaps() + .iter() + .find(|s| s.viewport.is_none()) + .unwrap(); + assert_eq!(paper_snap.source.as_ref().unwrap().source.handle, paper); + assert_eq!(paper_snap.paper_point, paper_snap.model_point); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let doc = &app.tabs[app.active_tab].scene.document; + assert!( + (displayed(doc) - 1270.0).abs() < 1e-3, + "{command}: {}", + displayed(doc) + ); + app.undo_steps(1); + app.redo_steps(1); + assert!((displayed(&app.tabs[app.active_tab].scene.document) - 1270.0).abs() < 1e-3); + } + } +} + +#[test] +fn viewport_dimension_paper_centerline_tracks_each_owning_space() { + use crate::scene::{ChangeKind, ReferenceStatus}; + for (paper_first, kind) in [(true, SnapType::Midpoint), (false, SnapType::Perpendicular)] { + let (mut app, model, paper, frame) = + start_centerline_dimension("DIMALIGNED", paper_first, kind, 1.0); + let i = app.active_tab; + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let dim = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + let assoc = app.tabs[i].scene.dimension_association(dim).unwrap(); + assert!(assoc.trans_space); + assert!(assoc + .references + .iter() + .flatten() + .any(|r| r.xrefs == vec![paper])); + assert!(assoc + .references + .iter() + .flatten() + .any(|r| r.xrefs == vec![frame.viewport, model])); + assert!(app.tabs[i] + .scene + .dimension_association_status(dim) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); + let pending = app.begin_undo(i, "Move paper centerline", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(paper); + app.tabs[i].scene.record_undo_before(paper, before); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity_mut(paper).unwrap() + else { + panic!() + }; + line.start.x = 56.0; + line.end.x = 56.0; + app.tabs[i] + .scene + .bump_entities(&[(paper, ChangeKind::Modified)]); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 40.0).abs() < 1e-4); + app.undo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + app.redo_steps(1); + let EntityType::Viewport(vp) = app.tabs[i] + .scene + .document + .get_entity_mut(frame.viewport) + .unwrap() + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + // Model endpoint now appears at paper x=70; paper centerline stays x=56. + assert!((displayed(&app.tabs[i].scene.document) - 70.0).abs() < 1e-4); + let EntityType::Viewport(vp) = app.tabs[i] + .scene + .document + .get_entity_mut(frame.viewport) + .unwrap() + else { + panic!() + }; + vp.view_target.x = 20.0; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity_mut(model).unwrap() + else { + panic!() + }; + line.end.x = 160.0; + app.tabs[i] + .scene + .bump_entities(&[(model, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 110.0).abs() < 1e-4); + for ext in ["dxf", "dwg"] { + let doc = &app.tabs[i].scene.document; + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let mut scene = Scene::new(); + scene.document = crate::io::load_bytes(&format!("centerline.{ext}"), bytes).unwrap(); + scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&scene.document) - 110.0).abs() < 1e-4, "{ext}"); + assert!(scene + .dimension_association_status(dim) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); + } + let before = app.tabs[i].scene.document.get_entity(dim).unwrap().clone(); + let model_owner = app.tabs[i] + .scene + .document + .get_entity(model) + .unwrap() + .common() + .owner_handle; + app.tabs[i] + .scene + .document + .get_entity_mut(paper) + .unwrap() + .common_mut() + .owner_handle = model_owner; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert_eq!(app.tabs[i].scene.document.get_entity(dim).unwrap(), &before); + assert!(app.tabs[i] + .scene + .dimension_association_status(dim) + .iter() + .any(|(_, s)| *s == ReferenceStatus::Unresolved)); + } +} + +#[test] +fn viewport_dimension_invalid_paths_leave_no_partial_association() { + use crate::scene::viewport_ref::SnapSourceRef; + use acadrust::entities::{DimensionAligned, Insert}; + use acadrust::tables::BlockRecord; + + let (mut app, model, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut block = BlockRecord::new("Collapsed"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut leaf = Line::from_points(Vector3::ZERO, Vector3::UNIT_X); + leaf.common.owner_handle = owner; + let leaf = scene.document.add_entity(EntityType::Line(leaf)).unwrap(); + scene.set_current_layout("Model".into()); + let mut insert = Insert::new("Collapsed", Vector3::ZERO); + insert.set_x_scale(0.0); + insert.set_y_scale(0.0); + let root = scene.add_entity(EntityType::Insert(insert)); + scene.set_current_layout("Dimensions".into()); + let paper = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(55.0, 50.0, 0.0), + Vector3::new(55.0, 60.0, 0.0), + ))); + for (source, path) in [(model, vec![Handle::new(u64::MAX)]), (leaf, vec![root])] { + let dim = scene.add_entity(EntityType::Dimension(Dimension::Aligned( + DimensionAligned::new(Vector3::new(50.0, 50.0, 0.0), Vector3::new(55.0, 50.0, 0.0)), + ))); + let invalid = AcceptedSnap { + paper_point: DVec3::new(50.0, 50.0, 0.0), + model_point: DVec3::ZERO, + viewport: Some(frame.viewport), + frame: Some(frame), + source: Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(source), + block_path: path, + snap_type: SnapType::Endpoint, + intersection: None, + }), + }; + let mut valid = AcceptedSnap::free(DVec3::new(55.0, 50.0, 0.0)); + valid.source = Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(paper), + block_path: Vec::new(), + snap_type: SnapType::Endpoint, + intersection: None, + }); + scene.attach_viewport_dimension_association(dim, &[Some(invalid), Some(valid)]); + let association = scene.dimension_association(dim).unwrap(); + assert!(!association.trans_space); + assert!(association.references[0].is_empty()); + assert_eq!(association.references[1][0].xrefs, vec![paper]); + assert_eq!(scene.dimension_association_sources(dim), vec![paper]); + assert_eq!( + scene.dimension_association_status(dim), + vec![(1, crate::scene::ReferenceStatus::Resolved)] + ); + } +} diff --git a/src/command.rs b/src/command.rs index a248db896..8368c9f8a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2245,23 +2245,23 @@ pub trait CadCommand: Send { CmdResult::Cancel } - /// Returns `true` when the command needs entity picking (hit-test) instead of point picking. - /// PR2: this command creates a dimension whose measurement must be - /// compensated when its points are picked through a paper-space viewport, - /// and whose explicit object pick may land on model geometry displayed - /// inside one. See `crate::app::dim_viewport`. + /// Supports acquiring dimension geometry through a paper-space viewport. fn measures_through_viewports(&self) -> bool { false } /// Definition points acquired so far, in the command's working space. /// Used to retain references only for accepted measuring inputs. - fn dimension_acquired_points(&self) -> Vec { Vec::new() } + fn dimension_acquired_points(&self) -> Vec { + Vec::new() + } /// The next point places annotation rather than acquiring geometry. - fn dimension_placement_pending(&self) -> bool { false } - + fn dimension_placement_pending(&self) -> bool { + false + } + /// Needs entity hit-testing instead of point input. fn needs_entity_pick(&self) -> bool { false } diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index dc9813c28..d9688ea5b 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -255,12 +255,9 @@ pub fn set_on_entity(entity: &mut EntityType, code: i16, value: Option) -> f64 { let raw = dimension.measurement(); - if matches!(dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + if matches!( + dimension, + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) + ) { return raw; } let lfac = style.map(|s| s.dimlfac).unwrap_or(1.0); diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 9cde3e7f2..2e2f3f133 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -161,7 +161,7 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -169,7 +169,8 @@ impl CadCommand for AlignedDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::First => vec![], Step::Second(p) => vec![p], + Step::First => vec![], + Step::Second(p) => vec![p], Step::DimLine { p1, p2 } => vec![p1, p2], } } diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index e9bdb0aea..66fb49596 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -482,7 +482,7 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -490,11 +490,27 @@ impl CadCommand for AngularDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::Vertex => vec![], Step::FirstRay(p) => vec![p], - Step::SecondRay { vertex, first } | Step::CircleSecondRay { vertex, first, .. } => vec![vertex, first], - Step::SecondLine { first_start, first_end, .. } => vec![first_start, first_end], - Step::ArcPoint3 { vertex, first, second } => vec![vertex, first, second], - Step::ArcPoint2 { first_start, first_end, second_start, second_end } => vec![first_start, first_end, second_start, second_end], + Step::Vertex => vec![], + Step::FirstRay(p) => vec![p], + Step::SecondRay { vertex, first } | Step::CircleSecondRay { vertex, first, .. } => { + vec![vertex, first] + } + Step::SecondLine { + first_start, + first_end, + .. + } => vec![first_start, first_end], + Step::ArcPoint3 { + vertex, + first, + second, + } => vec![vertex, first, second], + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => vec![first_start, first_end, second_start, second_end], } } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index b128d4dde..b41fa2839 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -132,7 +132,7 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -140,7 +140,8 @@ impl CadCommand for DiameterDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::SelectObject => vec![], Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + Step::SelectObject => vec![], + Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], } } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index e8f55c32f..7e92b0c6b 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -217,7 +217,7 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -225,7 +225,8 @@ impl CadCommand for LinearDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::FirstPoint => vec![], Step::SecondPoint(p) => vec![p], + Step::FirstPoint => vec![], + Step::SecondPoint(p) => vec![p], Step::DimensionLine { first, second } => vec![first, second], } } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 214933a8e..898ba52ca 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -130,7 +130,7 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } - /// PR2: points and object picks may come through a paper-space viewport; + /// Points and object picks may come through a paper-space viewport; /// the committed dimension then reports the model measurement. fn measures_through_viewports(&self) -> bool { true @@ -138,7 +138,8 @@ impl CadCommand for RadiusDimensionCommand { fn dimension_acquired_points(&self) -> Vec { match self.step { - Step::SelectObject => vec![], Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + Step::SelectObject => vec![], + Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], } } diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index f8ccb3cb9..3eb93accc 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -370,15 +370,7 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { // ── Chain-aware reference resolution ────────────────────────────────────── -/// How a resolved feature point travels from the entity it belongs to up into -/// the space the dimension is drawn in. -/// -/// `transform` is the block-instance path (entity-local -> model). -/// `frame` is the layout viewport the chain looks through, when it has one; -/// with a frame the dimension lives on the sheet and every point it holds is -/// a *paper* point, while the value it displays is a *model* length. Keeping -/// both directions in one place is what stops the viewport compensation from -/// being applied twice (see `viewport_ref::measurement_rule`). +/// Transform an entity-local feature through its block path and optional viewport. #[derive(Clone, Copy, Debug)] pub(crate) struct ChainMap { pub transform: Transform, @@ -528,21 +520,10 @@ fn hint_error(entity: &EntityType, point: Vector3) -> f64 { f64::INFINITY } -/// The stored osnap point, brought into the entity's own coordinates. -/// -/// Which space the file wrote it in is not recoverable from the record: our -/// own pre-PR3 writer stored a model/WCS point, AutoCAD's trans-space records -/// hold the point in the space the dimension is drawn in, and both appear in -/// the same drawing. Rather than guess, both readings are mapped down to the -/// entity and the one that actually lands on the geometry wins. The point is -/// only ever a *hint* — it disambiguates candidates, it is never the answer. -fn local_hint( - scene: &Scene, - map: &ChainMap, - entity: &EntityType, - stored: Vector3, -) -> Option { - let _ = scene; +/// Interpret a stored point as a source-space or dimension-space hint. +/// Validate both interpretations against the geometry before using the hint +/// to disambiguate a feature; the stored point is never the resolved result. +fn local_hint(map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option { let inverse = chain::invert(&map.transform)?; let mut candidates = vec![inverse.apply(stored)]; if map.frame.is_some() { @@ -610,7 +591,7 @@ pub fn resolve_reference_chain( .max(reference.osnap_point.z.abs()) < 1e40 { - local_hint(scene, &map, entity, reference.osnap_point) + local_hint(&map, entity, reference.osnap_point) } else { None }; @@ -636,9 +617,8 @@ pub fn resolve_reference_chain( }) } -/// The pre-PR3 marker/parameter resolution, unchanged in behaviour but now -/// evaluated in the *entity's own* coordinates rather than assumed to be in -/// model space. Lifting through the block path is the caller's job. +/// Resolve a marker/parameter in entity-local coordinates. +/// The caller applies the block and viewport transforms. fn resolve_local_by_marker( entity: &EntityType, reference: &AssocDimensionReference, @@ -1224,8 +1204,7 @@ impl Scene { } /// Persist the association for a dimension whose points were picked - /// through [`AcceptedSnap`]s — the entry point PR2 calls once it has - /// created a paper-space dimension that measures model geometry. + /// through [`AcceptedSnap`]s, preserving each source and its owning space. /// /// `snaps` is positional: one slot per definition point, in the same order /// [`dimension_reference_points`] returns them (linear/aligned: first, @@ -1245,13 +1224,7 @@ impl Scene { else { return; }; - // A radial dimension has ONE reference, and it is an - // `AcDbOsnapPointRef` of osnap type 10 carrying the *radial* marker - // (which circle / which polyline arc segment) and the angle around it, - // not a vertex marker. `source_reference` would return the generic - // curve-parameter marker `-2`, which `radial_source_for_marker` cannot - // find, so radial goes down its own path — the same one - // `attach_dimension_association_sources` uses for model space. + // Radial references identify a circle/arc and angle, rather than a vertex. let radial_dimension = matches!( dimension_entity, Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) @@ -1278,18 +1251,17 @@ impl Scene { let mut xrefs = Vec::new(); if let Some(viewport) = snap.viewport { xrefs.push(viewport); - trans_space = true; } xrefs.extend(source.block_path.iter().copied()); xrefs.push(entity_handle); - reactor_targets.extend(xrefs.iter().copied()); - + let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else { + continue; + }; + let Some(inverse) = chain::invert(&walked.transform) else { + continue; + }; + let local = inverse.apply(dvec3(snap.model_point)); let osnap_type = chain::osnap_type_for(source.snap_type); - let local = Self::point_in_entity_space( - &self.document, - &source.block_path, - dvec3(snap.model_point), - ); let marker_parameter = source .source .marker @@ -1314,6 +1286,8 @@ impl Scene { } reactor_targets.extend(intersection_objects.iter().copied()); } + trans_space |= snap.viewport.is_some(); + reactor_targets.extend(xrefs.iter().copied()); associativity |= 1 << index; references[index].push(AssocDimensionReference { class_name: "AcDbOsnapPointRef".to_string(), @@ -1323,8 +1297,8 @@ impl Scene { main_subent_type: 1, main_gs_marker: marker, osnap_distance: parameter, - // Stored in MODEL space. `local_hint` reads either convention, - // so an imported paper-space point still resolves. + // Store the source's owning-space point: model coordinates for + // viewport features, paper coordinates for direct sheet features. osnap_point: dvec3(snap.model_point), ..AssocDimensionReference::default() }); @@ -1351,12 +1325,7 @@ impl Scene { dimension: Handle, snaps: &[Option], ) { - let Some(snap) = snaps - .iter() - .flatten() - .find(|snap| snap.source.is_some()) - .cloned() - else { + let Some(snap) = snaps.iter().flatten().find(|snap| snap.source.is_some()) else { return; }; let source = snap.source.as_ref().expect("filtered above"); @@ -1364,14 +1333,17 @@ impl Scene { let Some(entity) = self.document.get_entity(entity_handle) else { return; }; - // The acquired point in the source entity's own coordinates, so the - // radial geometry and the angle are both computed where the entity - // lives. - let local = Self::point_in_entity_space( - &self.document, - &source.block_path, - dvec3(snap.model_point), - ); + let mut xrefs = Vec::new(); + xrefs.extend(snap.viewport); + xrefs.extend(source.block_path.iter().copied()); + xrefs.push(entity_handle); + let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else { + return; + }; + let Some(inverse) = chain::invert(&walked.transform) else { + return; + }; + let local = inverse.apply(dvec3(snap.model_point)); let radial = match source.source.marker { Some(marker) => radial_source_for_marker(entity, marker), None => radial_source_at(entity, local), @@ -1382,14 +1354,7 @@ impl Scene { } else { radial.angle_at(dpoint(local)) }; - let mut xrefs = Vec::new(); - let mut trans_space = false; - if let Some(viewport) = snap.viewport { - xrefs.push(viewport); - trans_space = true; - } - xrefs.extend(source.block_path.iter().copied()); - xrefs.push(entity_handle); + let trans_space = snap.viewport.is_some(); let reactor_targets = xrefs.clone(); let reference = AssocDimensionReference { class_name: "AcDbOsnapPointRef".to_string(), @@ -1431,28 +1396,64 @@ impl Scene { } } - /// A model-space point brought down into the coordinates of the entity at - /// the end of `block_path`. Identity for top-level geometry. - fn point_in_entity_space( - document: &acadrust::CadDocument, - block_path: &[Handle], - point: Vector3, - ) -> Vector3 { - if block_path.is_empty() { - return point; + /// Select one measurement viewport while allowing direct references to + /// geometry owned by the dimension's paper layout. A model-space chain + /// without its viewport cannot be interpreted as a paper-space anchor. + fn association_measurement_frame( + &self, + association: &AssocDimensionAssociation, + ) -> Result, ()> { + let mut viewport = None; + for reference in association.references.iter().flatten() { + for handles in [&reference.xrefs, &reference.intersection_objects] { + let Some(root) = handles.first() else { + continue; + }; + if matches!( + self.document.get_entity(*root), + Some(EntityType::Viewport(_)) + ) { + if viewport.is_some_and(|previous| previous != *root) { + return Err(()); + } + viewport = Some(*root); + } + } } - chain::invert(&chain::block_transform(document, block_path)) - .map(|inverse| inverse.apply(point)) - .unwrap_or(point) + let Some(viewport) = viewport else { + return if association.trans_space { + Err(()) + } else { + Ok(None) + }; + }; + let owner = self + .document + .get_entity(association.dimension) + .ok_or(())? + .common() + .owner_handle; + for reference in association.references.iter().flatten() { + for handles in [&reference.xrefs, &reference.intersection_objects] { + let Some(root) = handles.first() else { + continue; + }; + if self + .document + .get_entity(*root) + .ok_or(())? + .common() + .owner_handle + != owner + { + return Err(()); + } + } + } + self.viewport_frame(viewport).map(Some).ok_or(()) } - /// Per-slot health of a dimension's association, for the UI and for tests. - /// - /// Nothing here is persisted: brokenness is *derived* from whether the - /// chain still resolves. That is what makes undo restore a link for free — - /// the erased source comes back and the same query starts answering - /// [`ReferenceStatus::Resolved`] again, with the reference record itself - /// never having been rewritten. + /// Resolve each reference's current status without changing its saved data. pub fn dimension_association_status(&self, dimension: Handle) -> Vec<(usize, ReferenceStatus)> { let Some(association) = self.dimension_association(dimension) else { return Vec::new(); @@ -1485,8 +1486,15 @@ impl Scene { .is_some() }); let measurement_supported = measurement_supported - && association.references.iter().flatten() - .filter(|r| chain::needs_from_point(r.osnap_type)).count() <= 1; + && association + .references + .iter() + .flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)) + .count() + <= 1; + let measurement_supported = + measurement_supported && self.association_measurement_frame(association).is_ok(); let points = self.dimension_association_slot_points(dimension); association .references @@ -1519,7 +1527,7 @@ impl Scene { /// /// Layer visibility and viewport clipping are deliberately not consulted: /// hiding a layer or clipping a viewport changes what a *new* snap may - /// acquire (PR1's concern), never what an existing association points at. + /// acquire, never what an existing association points at. pub fn dimension_association_sources(&self, dimension: Handle) -> Vec { let Some(association) = self.dimension_association(dimension) else { return Vec::new(); @@ -1553,15 +1561,8 @@ impl Scene { }) } - /// Duplicate dimension associations across a COPY/MIRROR/ARRAY handle map. - /// - /// A copied dimension only keeps an association when *every* handle in - /// every chain was copied too — the viewport, each INSERT on the path and - /// the source entity. Then the whole chain is remapped onto the copies, so - /// the copy measures the copied geometry. Copying a dimension on its own - /// leaves it with no association at all, which is exactly what the model - /// space path did before this: an all-or-nothing rule, never a copy that - /// silently drives off the original's geometry. + /// Preserve an association only when the dimension and every source-chain + /// handle were copied. Remap the complete chain to the new entities. pub(crate) fn copy_dimension_associations( &mut self, handle_map: &rustc_hash::FxHashMap, @@ -1591,7 +1592,7 @@ impl Scene { if !complete { return None; } - let remap = |handles: &Vec| -> Vec { + let remap = |handles: &[Handle]| -> Vec { handles .iter() .map(|handle| handle_map.get(handle).copied().unwrap_or(*handle)) @@ -1640,15 +1641,7 @@ impl Scene { } } - /// Report a layout viewport as changed so the dimensions drawn through it - /// are regenerated. - /// - /// A viewport move / pan / zoom / twist changes where model geometry lands - /// on the sheet without touching that geometry, so nothing in the ordinary - /// entity-change path would notice. Every reference that looks through a - /// viewport carries that viewport's handle as `xrefs[0]`, so announcing the - /// viewport itself is all that is needed — the existing dependency lookup - /// in `refresh_associative_dimensions` finds the associations from there. + /// Refresh dimensions when their viewport moves, pans, zooms, or twists. pub fn notify_viewport_changed(&mut self, viewport: Handle) { if matches!( self.document.get_entity(viewport), @@ -1750,17 +1743,25 @@ impl Scene { .chain(reference.intersection_objects.iter()) .any(|handle| changed.contains(handle)) }) - .then_some(association.clone()) + .then(|| association.clone()) }) .collect(); let mut refreshed = Vec::new(); for association in associations { + let Ok(frame) = self.association_measurement_frame(&association) else { + continue; + }; // Dependent snaps need a resolved independent definition point. // Two such features require a coupled solve; old points are not // evidence of a solution after the geometry changes. - if association.references.iter().flatten() - .filter(|r| chain::needs_from_point(r.osnap_type)).count() > 1 + if association + .references + .iter() + .flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)) + .count() + > 1 { continue; } @@ -1806,27 +1807,20 @@ impl Scene { { continue; } - let viewports: std::collections::HashSet<_> = - resolved.iter().flatten().map(|r| r.viewport).collect(); - if viewports.len() > 1 { - continue; - } - // The map of the first live reference governs how the whole - // dimension is placed and measured — all of a dimension's points - // are in one space. - let map = resolved - .iter() - .flatten() - .next() - .and_then(|value| value.viewport) - .and_then(|handle| self.viewport_frame(handle)) + // Every point is already in paper space: direct sheet references + // stay there, and model references are projected individually. + // Only the measurement factor is shared by the whole dimension. + let map = frame .map(|frame| ChainMap { transform: Transform::identity(), frame: Some(frame), }) .unwrap_or_else(ChainMap::identity); - let model_points: [Option; 4] = - std::array::from_fn(|index| resolved[index].map(|value| value.model)); + let model_points: [Option; 4] = std::array::from_fn(|index| { + resolved[index] + .filter(|value| value.viewport.is_some()) + .map(|value| value.model) + }); let resolved: [Option; 4] = std::array::from_fn(|index| resolved[index].map(|value| value.space)); @@ -2117,18 +2111,7 @@ impl Scene { Dimension::Linear(linear) => linear_measurement(linear), _ => dimension.measurement(), }; - // `actual_measurement` stays RAW, in the dimension's own space — - // paper, for a dimension drawn on the sheet through a viewport. - // The viewport compensation is carried by the negative DIMLFAC - // override and applied once, by the formatter. See - // `viewport_ref::measurement_rule`: substituting the model - // distance here (or multiplying by `MeasurementScale::model_factor`) - // would make a refreshed dimension read a different number from an - // identical freshly-created one. - // - // The similarity property of a viewport frame is what makes this - // exact: paper_span * (1 / scale) is the model span, for a rotated - // linear projection just as much as for a free distance. + // Store the raw sheet measurement; the formatter applies DIMLFAC once. debug_assert!( map.frame.is_none() || model_points @@ -2176,12 +2159,7 @@ impl Scene { } } -/// How far the midpoint of a dimension's measured points moved. -/// -/// Used to carry the sheet annotation — the dimension line's standoff and a -/// user-dragged text position — along with the geometry. Slots that did not -/// resolve keep their old point, so a one-ended edit shifts the annotation by -/// half the movement, which is what keeps the dimension line centred. +/// Average displacement of the dimension's resolved definition points. fn centroid_shift(before: [Option; 2], after: [Option; 2]) -> Vector3 { let mut delta = Vector3::new(0.0, 0.0, 0.0); let mut count = 0.0; @@ -2194,8 +2172,6 @@ fn centroid_shift(before: [Option; 2], after: [Option; 2]) -> if count == 0.0 { return Vector3::new(0.0, 0.0, 0.0); } - // Averaged over the slots that actually moved, not over both, so a - // dimension with one broken reference still tracks the live one. Vector3::new(delta.x / count, delta.y / count, delta.z / count) } diff --git a/src/scene/dimension_assoc_chain.rs b/src/scene/dimension_assoc_chain.rs index 2bcf31342..6cd56aa7c 100644 --- a/src/scene/dimension_assoc_chain.rs +++ b/src/scene/dimension_assoc_chain.rs @@ -1,38 +1,8 @@ -//! Full reference chains for associative dimensions. -//! -//! An `AssocDimensionReference` addresses a *feature* — an osnap point on a -//! piece of geometry — through a chain of handles: -//! -//! ```text -//! dimension -> [viewport] -> [insert ...] -> entity -> feature -//! ``` -//! -//! `AssocDimensionReference::xrefs` is that chain, outermost first. AutoCAD -//! calls the same list `mainObjectIds`; `intersection_objects` -//! (`intersectObjectIds`) is the second chain an INTERSECTION / APPARENT -//! INTERSECTION osnap needs. -//! -//! The pre-PR3 resolver only ever read `xrefs[0]`, which is correct for a -//! model-space dimension on top-level geometry and wrong for everything else: -//! a paper-space dimension measuring through a layout viewport stores the -//! VIEWPORT as `xrefs[0]`, so the resolver stopped at a viewport entity, found -//! no curve on it and gave up. Nine of the ten association objects in the -//! 115448 regression drawing are of that shape. -//! -//! This module owns: -//! * [`walk_chain`] — turn `xrefs` into a viewport, a block-instance path -//! with its accumulated transform, and the innermost entity. -//! * [`feature_point`] — evaluate the osnap feature on that entity, in the -//! entity's own coordinates, then lift it through the block path into -//! model space. -//! -//! Mapping model -> paper through the viewport frame is deliberately *not* -//! done here; the caller owns it, because only the caller knows whether the -//! association is `trans_space` and which [`ViewportFrame`] is current. -//! -//! All arithmetic is `f64`. -//! -//! [`ViewportFrame`]: super::viewport_ref::ViewportFrame +//! Resolve a dimension feature through `[viewport?, insert..., entity]`. +//! `xrefs` stores the primary chain; `intersection_objects` stores the second +//! intersection source. Features are evaluated in entity-local coordinates and +//! transformed through their block instances. The caller projects viewport +//! references onto the sheet. All arithmetic uses `f64`. use acadrust::objects::AssocDimensionReference; use acadrust::types::{Handle, Matrix4, Transform, Vector3}; @@ -45,12 +15,8 @@ use crate::entities::curve::entity_curve; /// `AcDb::OsnapMode`, the value stored in /// [`AssocDimensionReference::osnap_type`]. /// -/// These are the AutoCAD ObjectARX codes, not our own [`crate::snap::SnapType`] -/// discriminants. The pre-PR3 writer already emitted `END` for generic points -/// and `NEAR` for a point taken at a parameter on a circle, so those two keep -/// meaning exactly what they used to. -#[allow(dead_code)] // The full code table is kept so a reader can see what a - // stored `osnap_type` means, including the modes we pass through untouched. +/// These are ObjectARX codes, independent of [`crate::snap::SnapType`]. +#[allow(dead_code)] // Include codes preserved from unsupported imported modes. pub(crate) mod osnap { pub const NONE: u8 = 0; pub const END: u8 = 1; @@ -72,7 +38,7 @@ pub(crate) mod osnap { /// /// Modes with no ObjectARX equivalent (grid, object pick) map to /// [`osnap::NONE`]; a reference carrying `NONE` resolves by marker/parameter -/// alone, exactly like the pre-PR3 records. +/// alone. pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 { use crate::snap::SnapType as S; match snap { @@ -166,7 +132,11 @@ pub(crate) fn walk_chain( return Err(ChainError::Invalid); }; // Arrays need an instance index that this reference format does not carry. - if insert.row_count > 1 || insert.column_count > 1 || block_path.contains(&handle) { + if insert.row_count > 1 + || insert.column_count > 1 + || block_path.len() >= 8 + || block_path.contains(&handle) + { return Err(ChainError::Invalid); } let block = document @@ -182,64 +152,23 @@ pub(crate) fn walk_chain( Err(ChainError::Empty) } -/// Accumulated entity-local -> model transform for an ordered INSERT path. -pub(crate) fn block_transform(document: &CadDocument, block_path: &[Handle]) -> Transform { - let mut transform = Transform::identity(); - for handle in block_path { - if let Some(EntityType::Insert(insert)) = document.get_entity(*handle) { - transform = transform.compose(&crate::scene::render_graph::insert_transform( - document, insert, - )); - } - } - transform -} - -/// Affine inverse of `transform`. -/// -/// `acadrust::types::Matrix4` has no inverse of its own, and a block path only -/// ever produces affine matrices (translate / rotate / scale), so this inverts -/// the 3x3 linear part by cofactors and back-substitutes the translation. A -/// degenerate (zero-scale) insert returns `None` rather than exploding. +/// Invert an affine block transform, rejecting singular or nonfinite matrices. pub(crate) fn invert(transform: &Transform) -> Option { let m = &transform.matrix.m; - let a = [ - [m[0][0], m[0][1], m[0][2]], - [m[1][0], m[1][1], m[1][2]], - [m[2][0], m[2][1], m[2][2]], - ]; - let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) - + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]); - if !det.is_finite() || det.abs() < 1e-18 { + let matrix = glam::DMat4::from_cols_array_2d(&std::array::from_fn(|column| { + std::array::from_fn(|row| m[row][column]) + })); + if !matrix.is_finite() || matrix.determinant().abs() < 1e-18 { return None; } - let inv_det = 1.0 / det; - let mut inverse = [[0.0f64; 3]; 3]; - for row in 0..3 { - for col in 0..3 { - // Cofactor of (col, row) — transposed, which is the adjugate. - let (r0, r1) = ((col + 1) % 3, (col + 2) % 3); - let (c0, c1) = ((row + 1) % 3, (row + 2) % 3); - inverse[row][col] = (a[r0][c0] * a[r1][c1] - a[r0][c1] * a[r1][c0]) * inv_det; - } + let inverse = matrix.inverse(); + if !inverse.is_finite() { + return None; } - let t = [m[0][3], m[1][3], m[2][3]]; - let translation = [ - -(inverse[0][0] * t[0] + inverse[0][1] * t[1] + inverse[0][2] * t[2]), - -(inverse[1][0] * t[0] + inverse[1][1] * t[1] + inverse[1][2] * t[2]), - -(inverse[2][0] * t[0] + inverse[2][1] * t[1] + inverse[2][2] * t[2]), - ]; - Some(Transform { - matrix: Matrix4 { - m: [ - [inverse[0][0], inverse[0][1], inverse[0][2], translation[0]], - [inverse[1][0], inverse[1][1], inverse[1][2], translation[1]], - [inverse[2][0], inverse[2][1], inverse[2][2], translation[2]], - [0.0, 0.0, 0.0, 1.0], - ], - }, - }) + let columns = inverse.to_cols_array_2d(); + Some(Transform::from_matrix(Matrix4 { + m: std::array::from_fn(|row| std::array::from_fn(|column| columns[column][row])), + })) } // ── Feature evaluation ──────────────────────────────────────────────────── @@ -482,9 +411,7 @@ fn nearest_point( hint: Option, ) -> Option { let planar = entity_curve(entity)?; - // Circles and arcs carry an *angle* in `osnap_distance` — that is the - // convention the pre-PR3 writer established and imported files share it. - // Every other curve carries the kernel's normalised 0..1 parameter. + // Circles and arcs store an angle; other curves use a normalized parameter. match &planar.curve { KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity { EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)), @@ -506,14 +433,8 @@ fn nearest_point( // ── Tangent ─────────────────────────────────────────────────────────────── -/// Tangent point on `entity` for the touch line through `context.from`. -/// -/// Circles and arcs have a closed form. A spline does not, so the stored -/// parameter (`osnap_distance`) seeds a Newton refinement of the tangency -/// condition `(C(u) - P) · C'(u) = 0`; with no usable parameter the seed is -/// the point on the spline nearest the stored osnap point. That is what makes -/// the 115448 drawing's tangent-to-spline reference survive an edit to the -/// spline instead of silently freezing at its imported location. +/// Tangent point for the line through `context.from`. +/// Spline tangency solves `(C(u) - P) x C'(u) = 0` and validates the residual. fn tangent_point( entity: &EntityType, reference: &AssocDimensionReference, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 431160c3b..3ac3b5d0d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -43,7 +43,7 @@ mod limits; mod modify; mod mspace; pub mod viewport_ref; -pub mod viewport_dim_seam; +pub mod viewport_dimension_pick; pub mod named_parameters; mod page_setup; mod paper; diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index a4928e9a0..3f804f11a 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -83,14 +83,7 @@ impl Scene { self.viewport_edit_frame_for(self.active_viewport?, canvas_px) } - /// [`viewport_edit_frame`] for an explicitly-named viewport, rather than - /// only the active (MSPACE) one. - /// - /// Paper-space snapping through viewports (PR1) needs the same camera + - /// screen rectangle pair for a viewport the user has NOT entered, so the - /// existing snap engine can run against that viewport's model wires on the - /// pixels the GPU actually drew them on. `viewport_edit_frame` is now a - /// thin wrapper over this. + /// Camera and canvas rectangle for snapping through an inactive viewport. pub fn viewport_edit_frame_for( &self, vp_handle: Handle, @@ -101,15 +94,12 @@ impl Scene { Some((cam, full)) } - /// Planar paper <-> model mapping for a layout viewport, derived from the - /// *same* camera the renderer displays its contents with - /// ([`camera_for_viewport`]) so a snap computed through the frame lands on - /// the pixels the GPU drew. - /// - /// Returns `None` for a viewport whose view is not an in-plane orthographic - /// top view (oblique / isometric / perspective viewports are unsupported by - /// the planar frame — callers fall back to no viewport snapping there). - pub fn viewport_frame(&self, vp_handle: Handle) -> Option { + /// Planar paper/model mapping derived from the viewport's display camera. + /// Oblique and perspective views have no supported planar mapping. + pub fn viewport_frame( + &self, + vp_handle: Handle, + ) -> Option { use crate::scene::viewport_ref::ViewportFrame; let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) { @@ -120,7 +110,7 @@ impl Scene { ), _ => return None, }; - if vp_height.abs() < 1e-9 { + if !paper_center.is_finite() || !vp_height.is_finite() || vp_height < 1e-9 { return None; } let cam = self.camera_for_viewport(vp_handle)?; @@ -133,7 +123,7 @@ impl Scene { // an oblique 3-D view: unsupported. let right = (cam.rotation * glam::Vec3::X).as_dvec3(); let up = (cam.rotation * glam::Vec3::Y).as_dvec3(); - if right.z.abs() > 1e-6 || up.z.abs() > 1e-6 { + if !right.is_finite() || !up.is_finite() || right.z.abs() > 1e-6 || up.z.abs() > 1e-6 { return None; } let normal = right.cross(up); @@ -142,7 +132,7 @@ impl Scene { } // Model units visible across the viewport's height -> paper per model. let model_height = cam.ortho_size() as f64 * 2.0; - if model_height.abs() < 1e-12 { + if !model_height.is_finite() || model_height < 1e-12 { return None; } let scale = vp_height / model_height; @@ -162,23 +152,28 @@ impl Scene { /// Visible, supported content viewports at a sheet point, topmost first. /// Shared by snapping and explicit dimension object picking. - pub fn viewport_frames_at_paper_point(&self, paper: glam::DVec3) -> Vec { - if self.current_layout == "Model" || self.active_viewport.is_some() { return Vec::new(); } - self.layout_content_viewports().iter().rev().filter_map(|&handle| { - if !self.viewport_displays_content(handle) || !self.viewport_displays_paper_point(handle, paper.truncate()) { - return None; - } - self.viewport_frame(handle) - }).collect() + pub fn viewport_frames_at_paper_point( + &self, + paper: glam::DVec3, + ) -> Vec { + if self.current_layout == "Model" || self.active_viewport.is_some() { + return Vec::new(); + } + self.layout_content_viewports() + .iter() + .rev() + .filter_map(|&handle| { + if !self.viewport_displays_content(handle) + || !self.viewport_displays_paper_point(handle, paper.truncate()) + { + return None; + } + self.viewport_frame(handle) + }) + .collect() } - /// Does `paper` lie inside the viewport's displayed area? - /// - /// Always bounded by the viewport's paper rectangle; a viewport with a - /// `clip_boundary_handle` (non-rectangular / clipped viewport) additionally - /// has to contain the point inside that boundary polygon. Used so a - /// paper-space snap never reports a model feature the viewport does not - /// actually show. + /// Test the viewport rectangle and optional nonrectangular clip boundary. pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool { let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else { return false; diff --git a/src/scene/project.rs b/src/scene/project.rs index f20c10e38..4d67327ac 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -674,10 +674,7 @@ pub(crate) fn clip_boundary_polygon_for_document( z: f32, ) -> Vec<[f32; 3]> { use std::f64::consts::TAU; - let Some(entity) = document - .entities() - .find(|e| e.common().handle == handle) - else { + let Some(entity) = document.get_entity(handle) else { return vec![]; }; // Circles and ellipses tessellate directly — their `to_render` returns a diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dimension_pick.rs similarity index 84% rename from src/scene/viewport_dim_seam.rs rename to src/scene/viewport_dimension_pick.rs index efb62e1b5..c5d87e7d2 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dimension_pick.rs @@ -1,20 +1,5 @@ -//! Object-pick resolution for paper-space dimensions taken through a layout -//! viewport. -//! -//! Paper-space hit testing only sees the sheet's own entities, so a dimension -//! command's explicit "select object" pick that lands inside a content -//! viewport finds nothing. [`Scene::dimension_pick_through_viewport`] maps the -//! click into model space through the viewport's -//! [`crate::scene::viewport_ref::ViewportFrame`], hit-tests the viewport's -//! resident model wires (descending into block instances and baking the -//! instance transform), and hands back a copy of the picked entity already -//! projected onto the sheet. -//! -//! The frame itself is built by [`Scene::viewport_frame`] (see -//! `crate::scene::mspace`), which derives it from the *live viewport camera* -//! so it always agrees with the pixels the renderer drew. It returns `None` -//! for oblique / perspective viewports, and every entry point here then -//! declines the pick rather than guessing a planar mapping. +//! Pick dimension geometry through a layout viewport. Resolve block instances +//! in model space, then project the picked entity onto the paper sheet. use super::*; @@ -27,7 +12,7 @@ use glam::DVec3; /// A model entity resolved by clicking inside a paper-space content viewport. #[derive(Clone, Debug)] -pub struct ViewportDimPick { +pub struct ViewportDimensionPick { /// The viewport the click looked through. pub frame: ViewportFrame, /// Handle of the innermost model entity that owns the picked geometry. @@ -46,18 +31,6 @@ pub struct ViewportDimPick { } impl Scene { - pub fn content_viewport_at_paper_point(&self, paper: DVec3) -> Option { - self.viewport_frames_at_paper_point(paper) - .first() - .map(|f| f.viewport) - } - - pub fn viewport_frame_at_paper_point(&self, paper: DVec3) -> Option { - self.viewport_frames_at_paper_point(paper) - .into_iter() - .next() - } - /// Resolve an explicit dimension object pick made on the sheet but landing /// inside a content viewport, against the *model* geometry that viewport /// displays. @@ -67,14 +40,13 @@ impl Scene { &self, paper: DVec3, aperture_paper: f64, - ) -> Option { + ) -> Option { for frame in self.viewport_frames_at_paper_point(paper) { let model_point = frame.paper_to_model(paper); let aperture_model = (aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9); - let Some(top) = - self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model) + let Some(top) = self.nearest_model_wire_handle(&frame, model_point, aperture_model) else { continue; }; @@ -93,7 +65,7 @@ impl Scene { &EntityTransform::Affine(viewport_model_to_paper_transform(&frame)), ); let entity_handle = paper_entity.as_entity().handle(); - return Some(ViewportDimPick { + return Some(ViewportDimensionPick { frame, entity_handle: if entity_handle.is_valid() { entity_handle @@ -114,10 +86,11 @@ impl Scene { /// draws, so this matches what the user sees. fn nearest_model_wire_handle( &self, - viewport: Handle, + frame: &ViewportFrame, model_point: DVec3, aperture: f64, ) -> Option { + let viewport = frame.viewport; let camera = self.camera_for_viewport(viewport)?; let bounds = iced::Rectangle { x: 0.0, @@ -150,9 +123,6 @@ impl Scene { .unwrap_or(DVec3::ZERO); let distance = wire_polyline_nearest(wire, offset, model_point); if let Some((distance, nearest)) = distance { - let Some(frame) = self.viewport_frame(viewport) else { - continue; - }; if !self.viewport_displays_paper_point( viewport, frame.model_to_paper(nearest).truncate(), @@ -188,19 +158,14 @@ impl Scene { ) -> Option<(EntityType, Vec)> { let entity = self.document.get_entity(handle)?; match entity { - EntityType::Insert(_) => { - let mut path = Vec::new(); - let found = self.descend_block_instance( - entity.clone(), - Transform::identity(), - model_point, - &mut path, - 0, - viewport, - kind, - )?; - Some((found.0, found.1)) - } + EntityType::Insert(insert) => self.descend_block_instance( + insert, + Transform::identity(), + model_point, + 0, + viewport, + kind, + ), other => { feature_pick_distance(other, model_point, kind)?; Some((other.clone(), Vec::new())) @@ -212,21 +177,17 @@ impl Scene { /// Returns the entity already transformed into WCS plus the INSERT path. fn descend_block_instance( &self, - insert_entity: EntityType, + insert: &acadrust::entities::Insert, outer: Transform, model_point: DVec3, - path: &mut Vec, depth: usize, viewport: Option, kind: Option, ) -> Option<(EntityType, Vec)> { const MAX_DEPTH: usize = 8; - if depth > MAX_DEPTH { + if depth >= MAX_DEPTH { return None; } - let EntityType::Insert(insert) = &insert_entity else { - return None; - }; if insert.row_count > 1 || insert.column_count > 1 { return None; } @@ -234,10 +195,9 @@ impl Scene { let local = crate::scene::render_graph::insert_transform(&self.document, insert); let combined = local.then(&outer); let block = self.document.block_records.get(&insert.block_name)?; - let children: Vec = block.entity_handles.clone(); let mut best: Option<(f64, EntityType, Vec)> = None; - for child in children { + for &child in &block.entity_handles { let Some(child_entity) = self.document.get_entity(child) else { continue; }; @@ -249,13 +209,11 @@ impl Scene { if common.invisible || layer.is_some_and(|l| l.flags.off || l.flags.frozen) || frozen { continue; } - if matches!(child_entity, EntityType::Insert(_)) { - let mut nested = Vec::new(); + if let EntityType::Insert(child_insert) = child_entity { if let Some((entity, mut nested_path)) = self.descend_block_instance( - child_entity.clone(), + child_insert, combined, model_point, - &mut nested, depth + 1, viewport, kind, @@ -300,8 +258,6 @@ impl Scene { } } let (_, entity, found_path) = best?; - path.clear(); - path.extend(found_path.iter().copied()); Some((entity, found_path)) } } diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index 2f861d862..327764b6d 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -187,17 +187,8 @@ impl AcceptedSnap { } } -/// The two independent factors that turn a raw measured distance into the -/// number a linear/aligned/radial dimension displays. -/// -/// * `user_lfac` is the drawing/style's DIMLFAC as the user intends it -/// (always positive here; the sign convention of DXF DIMLFAC is resolved by -/// [`MeasurementScale::from_dimlfac`]). -/// * `viewport_compensation` undoes the viewport's paper-per-model scale for -/// a dimension placed in paper space but measuring model geometry. It is -/// `1.0` for model-space dimensions and for paper-only dimensions. -/// -/// Angular dimensions never use either factor. +/// Length dimensions store raw distances in their owning space. The displayed +/// value uses `user_lfac * viewport_compensation`; angular dimensions are exempt. #[derive(Clone, Copy, Debug, PartialEq)] pub struct MeasurementScale { pub user_lfac: f64, @@ -252,59 +243,13 @@ impl MeasurementScale { common.extended_data = data; } - pub const IDENTITY: Self = Self { - user_lfac: 1.0, - viewport_compensation: 1.0, - }; - - /// Resolve the DXF DIMLFAC convention: - /// * `dimlfac > 0`: applies to every dimension. - /// * `dimlfac < 0`: `|dimlfac|` applies only to dimensions created in - /// paper space that measure model geometry through a viewport; model - /// space dimensions use `1.0`. - /// * `dimlfac == 0`: treated as `1.0`. - /// - /// `viewport` supplies the compensation when the dimension measures - /// through a viewport; pass `None` for model-space or paper-only - /// dimensions. - pub fn from_dimlfac(dimlfac: f64, viewport: Option<&ViewportFrame>) -> Self { - let through_viewport = viewport.is_some(); - let user_lfac = if dimlfac == 0.0 { - 1.0 - } else if dimlfac > 0.0 { - dimlfac - } else if through_viewport { - -dimlfac - } else { - 1.0 - }; - let viewport_compensation = viewport - .map(ViewportFrame::paper_to_model_length_factor) - .unwrap_or(1.0); - Self { - user_lfac, - viewport_compensation, - } - } - /// Combined factor applied to a distance measured on the paper sheet. pub fn paper_factor(&self) -> f64 { self.user_lfac * self.viewport_compensation } - /// Factor applied to a distance already measured in model space (the - /// viewport compensation must not be applied again). - pub fn model_factor(&self) -> f64 { - self.user_lfac - } - - /// The DXF DIMLFAC sign convention, resolved without a [`ViewportFrame`]. - /// - /// Rendering an *existing* dimension only knows whether the entity lives - /// in a paper-space layout; the viewport compensation is already baked - /// into the persisted magnitude. This is the same rule - /// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()` - /// replaced by `paper_space`. + /// Positive DIMLFAC applies everywhere; negative DIMLFAC applies only on + /// paper. Zero means one. Saved viewport compensation is already in DIMLFAC. pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 { if dimlfac == 0.0 { 1.0 @@ -318,39 +263,6 @@ impl MeasurementScale { } } -/// # Where the viewport compensation lives, once -/// -/// A dimension that sits on a paper layout but measures model geometry seen -/// through a viewport has **paper** definition points and displays a **model** -/// number. Exactly one rule governs that, everywhere: -/// -/// > `Dimension::actual_measurement` (DXF group 42) is always the **raw** -/// > geometric measurement in the dimension's *own* space — paper, for a -/// > viewport dimension. The viewport compensation lives **only** in the -/// > magnitude of the negative DIMLFAC override -/// > ([`MeasurementScale::viewport_dimlfac_override`]), and is applied -/// > **once**, at format time, by the linear formatter. -/// -/// So for a 1:10 viewport and a user DIMLFAC of 1: a 100-unit model line is -/// 10 paper units, `actual_measurement` is `10`, the override is `-10`, -/// [`MeasurementScale::user_lfac_for_space`] resolves that to `10` for a -/// paper-space dimension, and the text reads `100`. -/// -/// The consequence for association refresh -/// (`Scene::refresh_associative_dimensions`) is that after re-placing a -/// viewport dimension's definition points on the sheet it must write the -/// **paper** measurement back — it must *not* substitute the model distance, -/// and it must *not* multiply by [`MeasurementScale::model_factor`]. Doing -/// either makes a refreshed dimension read a different number from an -/// identical freshly-created one (`user_lfac` too many times). This is the -/// rule that makes `attach_viewport_dimension_association` + refresh agree -/// with creation by construction, and it is why `actual_measurement` stays -/// raw: that is also what the DXF format specifies and what export writes. -/// -/// Angular dimensions are exempt on both counts — a similarity preserves -/// angles, so there is no compensation and no override. -pub mod measurement_rule {} - #[cfg(test)] mod tests { use super::*; @@ -385,34 +297,10 @@ mod tests { } #[test] - fn negative_dimlfac_only_applies_through_viewports() { - let f = frame(0.5, 0.0); - let through = MeasurementScale::from_dimlfac(-2.0, Some(&f)); - assert_eq!(through.user_lfac, 2.0); - assert!((through.viewport_compensation - 2.0).abs() < 1e-12); - assert!((through.paper_factor() - 4.0).abs() < 1e-12); - assert!((through.model_factor() - 2.0).abs() < 1e-12); - - let model = MeasurementScale::from_dimlfac(-2.0, None); - assert_eq!(model, MeasurementScale::IDENTITY); - - let positive = MeasurementScale::from_dimlfac(3.0, None); - assert_eq!(positive.user_lfac, 3.0); - assert_eq!(positive.viewport_compensation, 1.0); - } - - #[test] - fn user_lfac_for_space_matches_from_dimlfac() { - let f = frame(0.5, 0.0); - for lfac in [-2.0, 0.0, 1.0, 3.5] { - assert_eq!( - MeasurementScale::user_lfac_for_space(lfac, true), - MeasurementScale::from_dimlfac(lfac, Some(&f)).user_lfac - ); - assert_eq!( - MeasurementScale::user_lfac_for_space(lfac, false), - MeasurementScale::from_dimlfac(lfac, None).user_lfac - ); + fn dimlfac_sign_depends_on_dimension_space() { + for (factor, paper, model) in [(-2.0, 2.0, 1.0), (0.0, 1.0, 1.0), (3.5, 3.5, 3.5)] { + assert_eq!(MeasurementScale::user_lfac_for_space(factor, true), paper); + assert_eq!(MeasurementScale::user_lfac_for_space(factor, false), model); } } } diff --git a/src/snap.rs b/src/snap.rs index e8f5b3c7d..ea71b6675 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -9,9 +9,9 @@ use iced::time::Instant; use iced::{Point, Rectangle}; use cadkernel::geom2d::Curve; +use acadrust::types::Handle; use crate::command::{DimensionAssociationSource, TangentObject}; -use acadrust::types::Handle; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; @@ -90,7 +90,7 @@ pub struct SnapResult { /// Layout viewport this snap was seen *through*, when the snap ran against /// model geometry displayed by a paper-space viewport. `None` for ordinary /// model-space / paper-sheet snaps. Filled by the paper-space viewport snap - /// query, never by the engine itself. (PR1) + /// query, never by the engine itself. pub viewport: Option, /// Owning entity of the geometry the snap landed on, when the engine could /// attribute the feature to a wire. @@ -102,7 +102,7 @@ pub struct SnapResult { /// the instance path needs the document and the accepted model point, so /// it happens once at accept time, in /// `OpenCADStudio::resolve_source_block_path`, which fills - /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. (PR1) + /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. pub source: Option, /// Second real object at an intersection, when both objects are known. pub secondary_source: Option, @@ -996,10 +996,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, - viewport: None, - source: None, - secondary_source: None, - model_point: None, + viewport: None, + source: None, + secondary_source: None, + model_point: None, }) } @@ -1234,7 +1234,8 @@ impl Snapper { let mut try_pt = |world: glam::DVec3, snap_type: SnapType, - src: Option, secondary: Option| { + src: Option, + secondary: Option| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1261,10 +1262,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, - viewport: None, - source: src, - secondary_source: secondary, - model_point: None, + viewport: None, + source: src, + secondary_source: secondary, + model_point: None, }); } }; @@ -1318,7 +1319,9 @@ impl Snapper { try_pt( DVec3::from_array(point), SnapType::Endpoint, - wire_source(wire), None); + wire_source(wire), + None, + ); } // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains @@ -1338,7 +1341,9 @@ impl Snapper { try_pt( wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint, - wire_source(wire), None); + wire_source(wire), + None, + ); } } } else { @@ -1361,7 +1366,9 @@ impl Snapper { try_pt( wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint, - src, None); + src, + None, + ); } } } @@ -1412,7 +1419,9 @@ impl Snapper { try_pt( nearest_on_segment(cursor_world, seg.a, seg.b), SnapType::Nearest, - wires.source_wire(seg.wire).and_then(wire_source), None); + wires.source_wire(seg.wire).and_then(wire_source), + None, + ); } } else { for wire in in_range_wires.iter() { @@ -1436,7 +1445,9 @@ impl Snapper { try_pt( foot, SnapType::Perpendicular, - wires.source_wire(seg.wire).and_then(wire_source), None); + wires.source_wire(seg.wire).and_then(wire_source), + None, + ); } } } else { @@ -1465,7 +1476,9 @@ impl Snapper { try_pt( point, SnapType::Intersection, - wires.source_wire(segment.wire).and_then(wire_source), None); + wires.source_wire(segment.wire).and_then(wire_source), + None, + ); } } } @@ -1563,7 +1576,7 @@ impl Snapper { for &wire_j in &local_wires[idx + 1..] { if let Some(points) = exact_curve_intersections(wire_i, wire_j) { // Two wires meet here; attribute the feature to the - // first. PR3 resolves the true pair. + // first. Association resolution requires both source paths. let src = wire_source(wire_i); for point in points { try_pt(point, SnapType::Intersection, src, wire_source(wire_j)); @@ -1600,7 +1613,9 @@ impl Snapper { try_pt( pt, SnapType::Intersection, - wires.source_wire(a.wire).and_then(wire_source), wires.source_wire(b.wire).and_then(wire_source)); + wires.source_wire(a.wire).and_then(wire_source), + wires.source_wire(b.wire).and_then(wire_source), + ); if exact_cursor { break 'intersection_sweep; } @@ -1646,7 +1661,12 @@ impl Snapper { continue; } if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection, wire_source(wire_i), wire_source(wire_j)); + try_pt( + pt, + SnapType::Intersection, + wire_source(wire_i), + wire_source(wire_j), + ); } } } @@ -1772,7 +1792,9 @@ impl Snapper { try_pt( segment_a.a + ta as f64 * (segment_a.b - segment_a.a), SnapType::ApparentIntersection, - wires.source_wire(segment_a.wire).and_then(wire_source), wires.source_wire(segment_b.wire).and_then(wire_source)); + wires.source_wire(segment_a.wire).and_then(wire_source), + wires.source_wire(segment_b.wire).and_then(wire_source), + ); if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { break 'apparent_sweep; } @@ -1812,7 +1834,9 @@ impl Snapper { try_pt( wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection, - wire_source(wire_i), wire_source(wire_j)); + wire_source(wire_i), + wire_source(wire_j), + ); } } } @@ -2033,10 +2057,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, - viewport: None, - source: wire_source(wire), - secondary_source: None, - model_point: None, + viewport: None, + source: wire_source(wire), + secondary_source: None, + model_point: None, }); } } @@ -2206,19 +2230,13 @@ fn snap_tier(t: SnapType) -> u8 { } } -/// Candidate ordering: tier first, then cursor distance, and only when two -/// candidates are effectively equidistant (coincident features) the classic -/// sub-priority — so an Endpoint still beats an Intersection sitting on the -/// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. -/// Choose between two independently-computed snap results using the SAME -/// priority-then-distance rule the engine applies internally -/// ([`snap_better`]): a higher-priority snap inside the aperture wins even -/// when a lower-priority one is nearer, and distance only breaks ties. -/// -/// Used to merge the ordinary paper-sheet snap with the viewport snap taken -/// through a layout viewport (PR1). Both `screen` fields must be expressed in -/// the same pixel frame. -pub fn merge_snap(a: Option, b: Option, cursor: Point) -> Option { +/// Merge paper and viewport snaps using the engine's ordering. +/// Both screen positions must use canvas pixels. +pub fn merge_snap( + a: Option, + b: Option, + cursor: Point, +) -> Option { match (a, b) { (Some(a), Some(b)) => { let (at, asub) = (snap_tier(a.snap_type), snap_priority(a.snap_type)); @@ -2235,6 +2253,10 @@ pub fn merge_snap(a: Option, b: Option, cursor: Point) - } } +/// Candidate ordering: tier first, then cursor distance, and only when two +/// candidates are effectively equidistant (coincident features) the classic +/// sub-priority — so an Endpoint still beats an Intersection sitting on the +/// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { let (bt, bd2, bsub) = best; if tier != bt { diff --git a/tests/fixtures/dim_assoc/README.md b/tests/fixtures/dim_assoc/README.md index a006911b7..4a01c475d 100644 --- a/tests/fixtures/dim_assoc/README.md +++ b/tests/fixtures/dim_assoc/README.md @@ -1,16 +1,10 @@ # Viewport association fixture -`viewport_associations.dxf` is a synthetic paper-space fixture for line, circle, -arc, polyline, spline, block-instance, and intersection references through one -1:2 viewport. `src/app/viewport_dimension_tests.rs` loads it and exercises actual -association refresh and an edit to the second intersection source. +`viewport_associations.dxf` contains line, circle, arc, polyline, spline, +block-instance, and intersection references through a 1:2 viewport. +`src/app/viewport_dimension_tests.rs` tests refresh and intersection source edits. -Each dimension carries `DIMLFAC=-2` and `ACAD_DIMASSOC_CALC_DIMLFAC=-2`. These are -necessary for its paper definition points and saved model measurements to agree; -the original fixture omitted both factors. The spline tangent also uses the -actual touch point `(90, 283.125)` at parameter `0.75`, measured from `(0, 300)`. -The original endpoint `(120, 300)` was not tangent. Tests verify the cross product -with the spline derivative and preservation after an unsupported reference edit. - -The larger original `115448.dxf` is private and is not included. An opt-in test -uses `OPENCAD_VIEWPORT_REGRESSION_DXF` to read a local copy without modifying it. +Each dimension stores `DIMLFAC=-2` and `ACAD_DIMASSOC_CALC_DIMLFAC=-2` so its paper +definition points measure in model units. The spline tangent from `(0, 300)` +touches `(90, 283.125)` at parameter `0.75`; tests verify its tangency and preserve +the dimension if an edited reference cannot resolve. From 4816652c1f440d0c05925de0533eba4560307681 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 16:02:39 -0500 Subject: [PATCH 23/23] Remove comments that repeat association operations --- src/scene/camera_ops.rs | 2 -- src/scene/modify.rs | 4 ---- src/scene/mspace.rs | 10 ---------- src/snap.rs | 14 ++------------ 4 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index e17ebfd7f..a1db36b48 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -567,8 +567,6 @@ impl Scene { viewport.view_target.x = center.x; viewport.view_target.y = center.y; viewport.view_target.z = center.z; - // Panning a viewport moves model geometry across the sheet; the - // dimensions drawn through it follow. self.notify_viewport_changed(handle); } else { self.camera.borrow_mut().target = center; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 9f1d41959..f91764067 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -730,10 +730,6 @@ impl Scene { // Complete group copies record their new Group objects and dictionary // entry as targeted object deltas inside copy_complete_groups. self.copy_complete_groups(&handle_map); - // A dimension copied together with everything it measures (its - // viewport, its block path and its source) keeps an association, with - // every handle remapped onto the copies. Copied on its own it keeps - // none — see `copy_dimension_associations`. self.copy_dimension_associations(&handle_map); // The copies are new handles (natural memo misses, tessellated fresh) // and reference only already-cached blocks — no block defn changes. diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index 3f804f11a..ab4dd423b 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -265,8 +265,6 @@ impl Scene { vp.view_height = eff_h; } } - // A viewport's view changed, so every dimension drawn on the - // sheet through it has to be re-placed and re-measured. self.notify_viewport_changed(vp_handle); } @@ -358,8 +356,6 @@ impl Scene { vp.view_target.y += model_delta.y as f64; vp.view_target.z += model_delta.z as f64; } - // A viewport's view changed, so every dimension drawn on the - // sheet through it has to be re-placed and re-measured. self.notify_viewport_changed(vp_handle); } @@ -410,8 +406,6 @@ impl Scene { } } } - // A viewport's view changed, so every dimension drawn on the - // sheet through it has to be re-placed and re-measured. self.notify_viewport_changed(vp_handle); } @@ -490,8 +484,6 @@ impl Scene { vp.view_direction.z = dir.z as f64; vp.twist_angle = twist; } - // Twisting a viewport rotates model geometry on the sheet; the - // dimensions drawn through it follow. self.notify_viewport_changed(vp_handle); } @@ -537,8 +529,6 @@ impl Scene { changed = true; } if changed { - // The view (and with it the sheet placement of everything drawn - // through this viewport) moved. self.notify_viewport_changed(vp_handle); } changed diff --git a/src/snap.rs b/src/snap.rs index ea71b6675..fa837110b 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -93,15 +93,8 @@ pub struct SnapResult { /// query, never by the engine itself. pub viewport: Option, /// Owning entity of the geometry the snap landed on, when the engine could - /// attribute the feature to a wire. - /// - /// This is whatever the *wire* carries, which for anything drawn from a - /// block is the **top-level INSERT's** handle — nested inserts included, - /// because `block_cache` stamps the host insert's name onto every - /// expanded and clipped sub-wire. Resolving that to the inner entity plus - /// the instance path needs the document and the accepted model point, so - /// it happens once at accept time, in - /// `OpenCADStudio::resolve_source_block_path`, which fills + /// attribute the feature to a wire. Block sub-entities report the top-level + /// INSERT handle; the block path is resolved later by /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. pub source: Option, /// Second real object at an intersection, when both objects are known. @@ -119,9 +112,6 @@ impl SnapResult { /// Entity handle carried by a wire (`WireModel::name` is the decimal handle /// value). Returns `None` for preview / interim wires with a symbolic name. -/// -/// For block geometry the value is the top-level INSERT handle, never the -/// inner entity: see [`SnapResult::source`]. #[inline] pub(crate) fn wire_source(wire: &WireModel) -> Option { wire.name