From 30c5ccbba1b4f1acbcf3e204a4a19b714745a928 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 12:27:05 -0500 Subject: [PATCH] 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 }