From 49fe48f89c5ec7f2ca482a3ef79ff77e0aff9d91 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:10:28 -0500 Subject: [PATCH] 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); + } +}