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 <noreply@anthropic.com>
This commit is contained in:
Christopher OverbeckandClaude Fable 5.1 committed 2026-09-15 12:10:28 -05:00
1 parent 37cfa06b02
commit 49fe48f89c
3 files changed
+269 -1

No files matched your search

+1 -1
View File
@@ -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<i32>,
+1
View File
@@ -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;
+267
View File
@@ -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<Handle>,
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<Handle>,
/// 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<ViewportFrame>,
/// Geometry identity, when the snap landed on an entity feature (not a
/// grid/free point).
pub source: Option<SnapSourceRef>,
}
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);
}
}