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 <[email protected]>
This commit is contained in:
Christopher OverbeckandClaude Fable 5.1 committed 2026-09-15 12:40:44 -05:00
1 parent 9ee809d389
commit 30c5ccbba1
10 files changed
+395 -1

No files matched your search

+22 -1
View File
@@ -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<CmdResult> = {
@@ -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);
}
+263
View File
@@ -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<ViewportFrame> = 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)
}
}
+9
View File
@@ -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<crate::scene::viewport_ref::AcceptedSnap>,
/// Name of the command `dim_accepted_snaps` belongs to.
pub(crate) dim_accepted_snaps_owner: Option<String>,
/// 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,
+8
View File
@@ -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
}
+63
View File
@@ -235,6 +235,69 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option<XData
write_pairs(doc, handle, kept);
}
/// Set — or, with `value: None`, clear — a single override on an entity that
/// is **not yet in the document** (a command's pending commit), leaving the
/// other overrides intact.
///
/// The document-backed [`set`] additionally registers the `ACAD` APPID; that
/// APPID is present in every drawing OCS writes, and committing the entity
/// goes through the normal add path, so an uncommitted write needs no
/// document. Keeping the override on the entity means it is part of the single
/// committed object and rides the ordinary undo/redo history.
pub fn set_on_entity(entity: &mut EntityType, code: i16, value: Option<XDataValue>) {
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<i16> {
Some(match field {
"dim_arrow_size" => DIMASZ,
+6
View File
@@ -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
+6
View File
@@ -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
}
+6
View File
@@ -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
}
+6
View File
@@ -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
}
+6
View File
@@ -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
}