Fix viewport dimension acquisition and preserve measurement scaling

This commit is contained in:
Christopher Overbeck committed 2026-09-15 14:13:19 -05:00
1 parent 369e0c9fc5
commit 2ee3782850
18 files changed
+957 -577

No files matched your search

+48
View File
@@ -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.
+15 -2
View File
@@ -388,7 +388,9 @@ impl OpenCADStudio {
// accepted-snap list must stay index-parallel with the points the // accepted-snap list must stay index-parallel with the points the
// command collects (PR1). Interactive picks record themselves in // command collects (PR1). Interactive picks record themselves in
// the click handler and never reach here. // 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 { if default_start {
let StepInput::Point(point) = &input else { let StepInput::Point(point) = &input else {
@@ -402,6 +404,7 @@ impl OpenCADStudio {
self.push_ucs_to_cmd(i); self.push_ucs_to_cmd(i);
} }
if let StepInput::EntityPick(handle, point) = &input { if let StepInput::EntityPick(handle, point) = &input {
if !self.dimension_acquisition_allowed(i, None) { return Task::none(); }
let solid_pick = matches!( let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle), self.tabs[i].scene.document.get_entity(*handle),
Some( Some(
@@ -474,6 +477,7 @@ impl OpenCADStudio {
} }
let ctrl = self.ctrl_down; let ctrl = self.ctrl_down;
let shift = self.shift_down; let shift = self.shift_down;
let picked_handle = if let StepInput::EntityPick(h, _) = &input { Some(*h) } else { None };
let result: Option<CmdResult> = { let result: Option<CmdResult> = {
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
return Task::none(); return Task::none();
@@ -492,6 +496,10 @@ impl OpenCADStudio {
StepInput::Escape => Some(cmd.on_escape()), 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 { match result {
Some(r) => self.apply_cmd_result(r), Some(r) => self.apply_cmd_result(r),
None => Task::none(), None => Task::none(),
@@ -2357,7 +2365,12 @@ impl OpenCADStudio {
// A dimension placed on the sheet but measuring model geometry // A dimension placed on the sheet but measuring model geometry
// through a viewport carries the compensation as a negative // through a viewport carries the compensation as a negative
// DIMLFAC override. // 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 // ...and is deliberately *not* associated in paper space: its
// definition points are projected paper coordinates, so a // definition points are projected paper coordinates, so a
// paper-space association would bind it to geometry that is // paper-space association would bind it to geometry that is
+138 -242
View File
@@ -1,262 +1,162 @@
//! Viewport-aware dimension measurement (PR2). //! Paper-space dimension acquisition and measurement. Only acquired geometry
//! //! carries viewport compensation; free and typed sheet coordinates stay on the sheet.
//! 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;
use super::OpenCADStudio;
use crate::command::DimensionAssociationSource; use crate::command::DimensionAssociationSource;
use crate::entities::dim_override; use crate::entities::dim_override;
use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, SnapSourceRef, ViewportFrame}; 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)] #[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum DimensionMeasureSpace { pub(crate) enum DimensionMeasureSpace {
/// Plain measurement in the space the definition points live in.
Direct, Direct,
/// Every measuring point came through one viewport; compensate by its
/// scale.
Viewport(ViewportFrame), 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, Mixed,
} }
impl OpenCADStudio { impl OpenCADStudio {
/// Record the accepted snaps produced by the explicit object-pick path (a pub(crate) fn finish_command_click(&mut self, i: usize) {
/// dimension "select object" pick resolved through a content viewport). let mut selection = self.tabs[i].scene.selection.borrow_mut();
/// selection.left_down = false;
/// An object pick is not a point step, so PR1's click handler never sees selection.left_press_pos = None;
/// it. One pick supplies *both* extension origins, so it is recorded selection.left_press_time = None;
/// twice: that keeps the list index-parallel with the definition points selection.left_dragging = false;
/// 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( #[allow(dead_code)] // Consumed by the dependent association change.
pub(crate) fn dimension_measuring_snaps(&self, _i: usize) -> Vec<AcceptedSnap> {
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<ViewportFrame> = 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, &mut self,
frame: ViewportFrame, i: usize,
paper_point: DVec3, viewport: Option<Handle>,
model_point: DVec3, ) -> 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<ViewportFrame>,
entity: Handle, entity: Handle,
block_path: Vec<Handle>, block_path: Vec<Handle>,
) { ) {
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 { self.push_accepted_snap(AcceptedSnap {
paper_point, paper_point,
model_point, model_point: frame.map_or(paper_point, |f| f.paper_to_model(paper_point)),
viewport: Some(frame.viewport), viewport: frame.map(|f| f.viewport),
frame: Some(frame), frame,
source: Some(SnapSourceRef { source: Some(SnapSourceRef {
source: DimensionAssociationSource::inferred(entity), source: DimensionAssociationSource::inferred(entity),
block_path: block_path.clone(), block_path: block_path.clone(),
// An object pick acquires the whole curve, not one snap_type: crate::snap::SnapType::ObjectPick,
// feature; the per-slot feature point is derived from the intersection: None,
// 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, 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<AcceptedSnap> {
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<AcceptedSnap> {
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<ViewportFrame> = 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( pub(crate) fn apply_viewport_dimension_measurement(
&mut self, &mut self,
i: usize, i: usize,
entity: &mut EntityType, entity: &mut EntityType,
) { ) -> bool {
let EntityType::Dimension(dimension) = entity else { let EntityType::Dimension(dimension) = entity else {
return; return true;
}; };
let is_angular = matches!( let angular = matches!(
dimension, dimension,
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)
); );
let frame = match self.dimension_measure_space(i) { let frame = match self.dimension_measure_space(i) {
DimensionMeasureSpace::Direct => return, DimensionMeasureSpace::Direct => return true,
DimensionMeasureSpace::Mixed => { DimensionMeasureSpace::Mixed => {
self.command_line.push_warning( self.command_line.push_error(crate::t!("Pick geometry from the same viewport, or use paper-space points for the whole dimension.").as_ref());
crate::t!( return false;
"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;
} }
DimensionMeasureSpace::Viewport(frame) => frame, DimensionMeasureSpace::Viewport(f) => f,
}; };
if angular {
// A similarity preserves angles, so an angular dimension reads the return true;
// model angle already and needs no override.
if is_angular {
return;
} }
let dimlfac = self.pending_dimension_style_dimlfac(i, entity);
let compensation = frame.paper_to_model_length_factor(); let scale = MeasurementScale {
let style_dimlfac = self.pending_dimension_style_dimlfac(i, entity); user_lfac: MeasurementScale::user_lfac_for_space(dimlfac, true),
if let Some(value) = viewport_compensation: frame.paper_to_model_length_factor(),
MeasurementScale::viewport_dimlfac_override(style_dimlfac, compensation) };
{ if !scale.paper_factor().is_finite() || scale.paper_factor() <= 0.0 {
// The negative DIMLFAC convention is resolved from the dimension's return false;
// 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)),
);
} }
// 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( pub(crate) fn try_dimension_viewport_entity_pick(
&mut self, &mut self,
i: usize, i: usize,
@@ -266,42 +166,38 @@ impl OpenCADStudio {
let pick = self.tabs[i] let pick = self.tabs[i]
.scene .scene
.dimension_pick_through_viewport(paper, aperture_paper)?; .dimension_pick_through_viewport(paper, aperture_paper)?;
self.record_dimension_viewport_pick( if !self.dimension_acquisition_allowed(i, Some(pick.frame.viewport)) {
pick.frame, return Some(crate::command::CmdResult::NeedPoint);
pick.paper_point, }
pick.model_point,
pick.entity_handle,
pick.block_path.clone(),
);
let command = self.tabs[i].active_cmd.as_mut()?; let command = self.tabs[i].active_cmd.as_mut()?;
command.inject_picked_entity(pick.paper_entity); 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 { fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 {
let EntityType::Dimension(dimension) = entity else { let EntityType::Dimension(dimension) = entity else {
return 1.0; return 1.0;
}; };
if let Some(value) = dim_override::real( dim_override::real(
&dimension.base().common.extended_data, &dimension.base().common.extended_data,
dim_override::DIMLFAC, dim_override::DIMLFAC,
) { )
return value; .or_else(|| {
} self.tabs[i]
let name = dimension.base().style_name.as_str(); .scene
self.tabs[i] .document
.scene .dim_styles
.document .iter()
.dim_styles .find(|s| s.name.eq_ignore_ascii_case(&dimension.base().style_name))
.iter() .map(|s| s.dimlfac)
.find(|style| { })
style.name.eq_ignore_ascii_case(name) .unwrap_or(1.0)
|| (name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard"))
})
.map(|style| style.dimlfac)
.unwrap_or(1.0)
} }
} }
+2
View File
@@ -11,6 +11,8 @@ mod annotation_data;
mod command_driver; mod command_driver;
pub(crate) mod commands; pub(crate) mod commands;
pub(crate) mod dim_viewport; pub(crate) mod dim_viewport;
#[cfg(test)]
mod viewport_dimension_tests;
mod document; mod document;
mod drafting_settings; mod drafting_settings;
pub(crate) mod expr_eval; pub(crate) mod expr_eval;
+23 -26
View File
@@ -2374,6 +2374,8 @@ impl OpenCADStudio {
extension_dir: None, extension_dir: None,
viewport: None, viewport: None,
source: None, source: None,
secondary_source: None,
model_point: None,
}); });
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(Some(pick.label)); cmd.set_acquisition_hint(Some(pick.label));
@@ -3853,29 +3855,7 @@ impl OpenCADStudio {
command.set_ctrl(self.ctrl_down); command.set_ctrl(self.ctrl_down);
command.set_shift(self.shift_down); command.set_shift(self.shift_down);
} }
// PR2: a dimension "select object" pick that lands inside a paper let result = if self.tabs[i]
// 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 .active_cmd
.as_ref() .as_ref()
.map(|c| c.needs_structure_point_pick()) .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 shift = self.shift_down;
let result = self.tabs[i].active_cmd.as_mut().map(|c| { let result = self.tabs[i].active_cmd.as_mut().map(|c| {
// Shift-swap state for TRIM/EXTEND (#336). // Shift-swap state for TRIM/EXTEND (#336).
@@ -4072,6 +4053,7 @@ impl OpenCADStudio {
c.set_entity_pick_direction(entity_pick_direction); c.set_entity_pick_direction(entity_pick_direction);
c.on_entity_pick(handle, entity_pick_point) 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. // HATCHEDIT: after pick, inject hatch model data into the command.
if self.tabs[i] if self.tabs[i]
.active_cmd .active_cmd
@@ -4142,11 +4124,24 @@ impl OpenCADStudio {
} }
} }
result 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] } else if self.tabs[i]
.active_cmd .active_cmd
.as_ref() .as_ref()
.is_some_and(|command| command.entity_pick_accepts_points()) .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.refresh_command_point_pick_context(i);
self.tabs[i] self.tabs[i]
.active_cmd .active_cmd
@@ -4215,17 +4210,18 @@ impl OpenCADStudio {
self.tabs[i].dyn_active = 0; self.tabs[i].dyn_active = 0;
} }
} }
self.last_point = Some(world_pt);
// Record what this point step accepted, alongside the point // Record what this point step accepted, alongside the point
// itself (PR1): paper point, model point, viewport, frame and // itself (PR1): paper point, model point, viewport, frame and
// geometry identity. Commands that don't care keep consuming // geometry identity. Commands that don't care keep consuming
// `world_pt` exactly as before. // `world_pt` exactly as before.
let pending = self.pending_click_snap.take(); let pending = self.pending_click_snap.take();
self.record_accepted_snap( if !self.record_accepted_snap(
i,
pending.map(|(hit, _)| hit), pending.map(|(hit, _)| hit),
pending.and_then(|(_, frame)| frame), pending.and_then(|(_, frame)| frame),
world_pt, 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 — // The one-shot snap override is spent by this pick —
// restore the running osnap configuration (#337). // restore the running osnap configuration (#337).
self.snapper.clear_override(); self.snapper.clear_override();
@@ -4260,6 +4256,7 @@ impl OpenCADStudio {
} }
}; };
self.sync_dimension_snaps(i);
if let Some(r) = result { if let Some(r) = result {
let task = self.apply_cmd_result(r); let task = self.apply_cmd_result(r);
let mut sel = self.tabs[i].scene.selection.borrow_mut(); let mut sel = self.tabs[i].scene.selection.borrow_mut();
+34 -34
View File
@@ -102,10 +102,11 @@ impl OpenCADStudio {
/// after ortho / polar / axis-lock / dynamic-input resolution. /// after ortho / polar / axis-lock / dynamic-input resolution.
pub(crate) fn record_accepted_snap( pub(crate) fn record_accepted_snap(
&mut self, &mut self,
tab: usize,
snap: Option<SnapResult>, snap: Option<SnapResult>,
frame: Option<ViewportFrame>, frame: Option<ViewportFrame>,
committed: glam::DVec3, committed: glam::DVec3,
) { ) -> bool {
let accepted = match snap { let accepted = match snap {
// A viewport snap keeps its frame only when the hit really came // A viewport snap keeps its frame only when the hit really came
// through it, so a paper-sheet snap never claims a model point. // through it, so a paper-sheet snap never claims a model point.
@@ -115,7 +116,24 @@ impl OpenCADStudio {
} }
None => AcceptedSnap::free(committed), 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); self.push_accepted_snap(accepted);
true
} }
/// Append an already-built accepted snap, keeping the retention cap. /// Append an already-built accepted snap, keeping the retention cap.
@@ -165,30 +183,12 @@ impl OpenCADStudio {
return None; return None;
} }
let handles = self.tabs[i].scene.layout_content_viewports(); for frame in self.tabs[i]
// Draw order puts the last-drawn viewport on top; probe top-most first .scene
// so overlapping viewports resolve the same way they render. .viewport_frames_at_paper_point(cursor_paper)
for &handle in handles.iter().rev() { {
if !self.tabs[i].scene.viewport_displays_content(handle) { let handle = frame.viewport;
continue; let Some((cam, rect)) = self.tabs[i].scene.viewport_edit_frame_for(handle, canvas)
}
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 { else {
continue; continue;
}; };
@@ -208,8 +208,7 @@ impl OpenCADStudio {
// the viewport camera projects back onto `local`. // the viewport camera projects back onto `local`.
let model_cursor = frame.paper_to_model(cursor_paper); let model_cursor = frame.paper_to_model(cursor_paper);
let wires = self let wires = self.tabs[i]
.tabs[i]
.scene .scene
.model_wires_for_viewport_arc(handle, bounds.height); .model_wires_for_viewport_arc(handle, bounds.height);
let candidates = self.tabs[i].scene.interaction_candidates_near( let candidates = self.tabs[i].scene.interaction_candidates_near(
@@ -221,14 +220,14 @@ impl OpenCADStudio {
self.snapper.osnap_radius_px, self.snapper.osnap_radius_px,
); );
// Object snap only. The grid belongs to the paper sheet, and the // The grid belongs to the sheet. Translate the construction
// rubber-band origin / tracking points are paper-space points, so // anchor for perpendicular/tangent snaps into this viewport's
// perpendicular and extension feet would be nonsense in model // model coordinates, then restore the shared snapper state.
// space. Save and restore, since `Snapper` is shared state.
let saved_grid = self.snapper.grid_snap_on; let saved_grid = self.snapper.grid_snap_on;
let saved_from = self.snapper.from_point; let saved_from = self.snapper.from_point;
self.snapper.grid_snap_on = false; 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( let hit = self.snapper.snap(
model_cursor, model_cursor,
local, local,
@@ -251,8 +250,7 @@ impl OpenCADStudio {
// the aperture reaching past the clip. Reject those: a snap must be // the aperture reaching past the clip. Reject those: a snap must be
// a real feature the viewport actually displays. // a real feature the viewport actually displays.
let paper = frame.model_to_paper(hit.world); let paper = frame.model_to_paper(hit.world);
if !self if !self.tabs[i]
.tabs[i]
.scene .scene
.viewport_displays_paper_point(handle, paper.truncate()) .viewport_displays_paper_point(handle, paper.truncate())
{ {
@@ -291,5 +289,7 @@ fn project_hit_to_paper(
}), }),
viewport: Some(viewport), viewport: Some(viewport),
source: hit.source, source: hit.source,
secondary_source: hit.secondary_source,
model_point: Some(hit.world),
} }
} }
+379
View File
@@ -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(_))));
}
+8
View File
@@ -2254,6 +2254,14 @@ pub trait CadCommand: Send {
false 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<DVec3> { Vec::new() }
/// The next point places annotation rather than acquiring geometry.
fn dimension_placement_pending(&self) -> bool { false }
fn needs_entity_pick(&self) -> bool { fn needs_entity_pick(&self) -> bool {
false false
} }
+5 -19
View File
@@ -1638,25 +1638,11 @@ pub(crate) fn dimension_in_paper_space(dimension: &Dimension, document: &CadDocu
if !owner.is_valid() { if !owner.is_valid() {
return false; return false;
} }
let model_block = document document.objects.values().any(|object| matches!(object,
.block_records acadrust::objects::ObjectType::Layout(layout)
.get("*Model_Space") if layout.name != "Model" && layout.block_record == owner
.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 /// The value a dimension's text reports for its measurement: the raw
+11
View File
@@ -167,6 +167,17 @@ impl CadCommand for AlignedDimensionCommand {
true true
} }
fn dimension_acquired_points(&self) -> Vec<DVec3> {
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 { fn input_kind(&self) -> InputKind {
if self.awaiting_text { if self.awaiting_text {
InputKind::FreeText InputKind::FreeText
+14
View File
@@ -488,6 +488,20 @@ impl CadCommand for AngularDimensionCommand {
true true
} }
fn dimension_acquired_points(&self) -> Vec<DVec3> {
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 { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
} }
+10
View File
@@ -138,6 +138,16 @@ impl CadCommand for DiameterDimensionCommand {
true true
} }
fn dimension_acquired_points(&self) -> Vec<DVec3> {
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 { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
} }
+11
View File
@@ -223,6 +223,17 @@ impl CadCommand for LinearDimensionCommand {
true true
} }
fn dimension_acquired_points(&self) -> Vec<DVec3> {
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 { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
} }
+10
View File
@@ -136,6 +136,16 @@ impl CadCommand for RadiusDimensionCommand {
true true
} }
fn dimension_acquired_points(&self) -> Vec<DVec3> {
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 { fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel CmdResult::Cancel
} }
+14 -3
View File
@@ -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<crate::scene::viewport_ref::ViewportFrame> {
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? /// Does `paper` lie inside the viewport's displayed area?
/// ///
/// Always bounded by the viewport's paper rectangle; a viewport with a /// 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); let poly = self.clip_boundary_polygon(vp.clip_boundary_handle, vp.center.z as f32);
if poly.len() < 3 { if poly.len() < 3 {
// A clip entity we cannot tessellate: fall back to the rectangle // An unsupported boundary cannot establish snap eligibility.
// rather than refusing every snap in the viewport. return false;
return true;
} }
point_in_polygon_xy(paper, &poly) point_in_polygon_xy(paper, &poly)
} }
+128 -114
View File
@@ -21,7 +21,9 @@ use super::*;
use crate::command::EntityTransform; use crate::command::EntityTransform;
use crate::scene::viewport_ref::ViewportFrame; use crate::scene::viewport_ref::ViewportFrame;
use acadrust::types::{Matrix4, Transform}; 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. /// A model entity resolved by clicking inside a paper-space content viewport.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -44,36 +46,16 @@ pub struct ViewportDimPick {
} }
impl Scene { 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<Handle> { pub fn content_viewport_at_paper_point(&self, paper: DVec3) -> Option<Handle> {
if self.current_layout == "Model" || self.active_viewport.is_some() { self.viewport_frames_at_paper_point(paper)
return None; .first()
} .map(|f| f.viewport)
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<ViewportFrame> { pub fn viewport_frame_at_paper_point(&self, paper: DVec3) -> Option<ViewportFrame> {
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 /// Resolve an explicit dimension object pick made on the sheet but landing
@@ -86,28 +68,42 @@ impl Scene {
paper: DVec3, paper: DVec3,
aperture_paper: f64, aperture_paper: f64,
) -> Option<ViewportDimPick> { ) -> Option<ViewportDimPick> {
let frame = self.viewport_frame_at_paper_point(paper)?; for frame in self.viewport_frames_at_paper_point(paper) {
let model_point = frame.paper_to_model(paper); let model_point = frame.paper_to_model(paper);
let aperture_model = let aperture_model =
(aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9); (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 Some(top) =
let (entity, block_path) = self.resolve_measurable_entity(top, model_point)?; 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; let mut paper_entity = entity;
crate::scene::view::dispatch::apply_transform( crate::scene::view::dispatch::apply_transform(
&mut paper_entity, &mut paper_entity,
&EntityTransform::Affine(viewport_model_to_paper_transform(&frame)), &EntityTransform::Affine(viewport_model_to_paper_transform(&frame)),
); );
let entity_handle = paper_entity.as_entity().handle(); let entity_handle = paper_entity.as_entity().handle();
Some(ViewportDimPick { return Some(ViewportDimPick {
frame, frame,
entity_handle: if entity_handle.is_valid() { entity_handle } else { top }, entity_handle: if entity_handle.is_valid() {
block_path, entity_handle
paper_entity, } else {
paper_point: paper, top
model_point, },
}) block_path,
paper_entity,
paper_point: paper,
model_point,
});
}
None
} }
/// Nearest resident model wire of `viewport` to `model_point`, within /// Nearest resident model wire of `viewport` to `model_point`, within
@@ -119,7 +115,23 @@ impl Scene {
model_point: DVec3, model_point: DVec3,
aperture: f64, aperture: f64,
) -> Option<Handle> { ) -> Option<Handle> {
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; let mut best: Option<(f64, Handle)> = None;
for wire in wires.iter() { for wire in wires.iter() {
if !wire.display_visible { if !wire.display_visible {
@@ -133,8 +145,17 @@ impl Scene {
.as_ref() .as_ref()
.map(|inst| DVec3::from_array(inst.translation)) .map(|inst| DVec3::from_array(inst.translation))
.unwrap_or(DVec3::ZERO); .unwrap_or(DVec3::ZERO);
let distance = wire_polyline_distance(wire, offset, model_point); let distance = wire_polyline_nearest(wire, offset, model_point);
if let Some(distance) = distance { 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) { if distance <= aperture && best.as_ref().is_none_or(|(d, _)| distance < *d) {
best = Some((distance, handle)); best = Some((distance, handle));
} }
@@ -146,10 +167,11 @@ impl Scene {
/// Turn a picked handle into a measurable planar entity in model (WCS) /// Turn a picked handle into a measurable planar entity in model (WCS)
/// coordinates, descending through block instances and baking the /// coordinates, descending through block instances and baking the
/// instance transform into the returned clone. /// instance transform into the returned clone.
fn resolve_measurable_entity( pub(crate) fn resolve_measurable_entity(
&self, &self,
handle: Handle, handle: Handle,
model_point: DVec3, model_point: DVec3,
viewport: Option<Handle>,
) -> Option<(EntityType, Vec<Handle>)> { ) -> Option<(EntityType, Vec<Handle>)> {
let entity = self.document.get_entity(handle)?; let entity = self.document.get_entity(handle)?;
match entity { match entity {
@@ -161,6 +183,7 @@ impl Scene {
model_point, model_point,
&mut path, &mut path,
0, 0,
viewport,
)?; )?;
Some((found.0, found.1)) Some((found.0, found.1))
} }
@@ -180,6 +203,7 @@ impl Scene {
model_point: DVec3, model_point: DVec3,
path: &mut Vec<Handle>, path: &mut Vec<Handle>,
depth: usize, depth: usize,
viewport: Option<Handle>,
) -> Option<(EntityType, Vec<Handle>)> { ) -> Option<(EntityType, Vec<Handle>)> {
const MAX_DEPTH: usize = 8; const MAX_DEPTH: usize = 8;
if depth > MAX_DEPTH { if depth > MAX_DEPTH {
@@ -199,6 +223,14 @@ impl Scene {
let Some(child_entity) = self.document.get_entity(child) else { let Some(child_entity) = self.document.get_entity(child) else {
continue; 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(_)) { if matches!(child_entity, EntityType::Insert(_)) {
let mut nested = Vec::new(); let mut nested = Vec::new();
if let Some((entity, mut nested_path)) = self.descend_block_instance( if let Some((entity, mut nested_path)) = self.descend_block_instance(
@@ -207,6 +239,7 @@ impl Scene {
model_point, model_point,
&mut nested, &mut nested,
depth + 1, depth + 1,
viewport,
) { ) {
if let Some(distance) = planar_pick_distance(&entity, model_point) { if let Some(distance) = planar_pick_distance(&entity, model_point) {
if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
@@ -218,6 +251,22 @@ impl Scene {
} }
continue; 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(); let mut placed = child_entity.clone();
crate::scene::view::dispatch::apply_transform( crate::scene::view::dispatch::apply_transform(
&mut placed, &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 /// Distance from `point` to a wire's polyline, in XY. `None` when the wire has
/// no usable segment. /// no usable segment.
fn wire_polyline_distance( fn wire_polyline_nearest(
wire: &crate::scene::WireModel, wire: &crate::scene::WireModel,
offset: DVec3, offset: DVec3,
point: DVec3, point: DVec3,
) -> Option<f64> { ) -> Option<(f64, DVec3)> {
let count = wire.points.len(); let count = wire.points.len();
if count == 0 { if count == 0 {
return None; return None;
@@ -278,6 +327,7 @@ fn wire_polyline_distance(
) + offset ) + offset
}; };
let mut best = f64::INFINITY; let mut best = f64::INFINITY;
let mut best_point = DVec3::ZERO;
let mut previous: Option<DVec3> = None; let mut previous: Option<DVec3> = None;
for i in 0..count { for i in 0..count {
let current = at(i); let current = at(i);
@@ -285,77 +335,41 @@ fn wire_polyline_distance(
previous = None; previous = None;
continue; continue;
} }
match previous { let nearest = previous.map_or(current, |start| {
Some(start) => best = best.min(segment_distance_xy(start, current, point)), let delta = current - start;
None => best = best.min((current.truncate() - point.truncate()).length()), 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); previous = Some(current);
} }
best.is_finite().then_some(best) best.is_finite().then_some((best, best_point))
}
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 /// Distance from `point` to the planar geometry object-pick dimensioning
/// supports. `None` for entity types that cannot be dimensioned by picking. /// supports. `None` for entity types that cannot be dimensioned by picking.
pub fn planar_pick_distance(entity: &EntityType, point: DVec3) -> Option<f64> { pub fn planar_pick_distance(entity: &EntityType, point: DVec3) -> Option<f64> {
let p = point.truncate(); let planar = crate::entities::curve::entity_curve(entity)?;
match entity { let normal = DVec3::from_array(planar.plane.normal()?);
EntityType::Line(line) => Some(segment_distance_xy( if normal.cross(DVec3::Z).length_squared() > 1e-12 {
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<Item = DVec3>,
closed: bool,
point: DVec3,
) -> Option<f64> {
let points: Vec<DVec3> = points.into_iter().collect();
if points.len() < 2 {
return None; return None;
} }
let count = if closed { points.len() } else { points.len() - 1 }; let uv = planar.plane.project(point.to_array())?;
(0..count) let nearest = cadkernel::geom2d::closest_point(&planar.curve, uv);
.map(|i| segment_distance_xy(points[i], points[(i + 1) % points.len()], point)) Some(
.reduce(f64::min) (DVec3::from_array(planar.plane.point_at(nearest.point)) - point)
.truncate()
.length(),
)
} }
#[cfg(test)] #[cfg(test)]
+69 -104
View File
@@ -1,20 +1,4 @@
//! Shared interfaces for paper-space snapping through layout viewports and //! Coordinates and feature identity shared by viewport acquisition and dimensions.
//! 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 acadrust::types::Handle;
use glam::{DMat3, DVec2, DVec3}; use glam::{DMat3, DVec2, DVec3};
@@ -27,8 +11,7 @@ use crate::snap::SnapType;
/// scale (paper units per model unit) and in-plane rotation (`twist`, /// scale (paper units per model unit) and in-plane rotation (`twist`,
/// radians, counter-clockwise, applied to model geometry before scaling). /// radians, counter-clockwise, applied to model geometry before scaling).
/// ///
/// PR1 owns construction (`Scene::viewport_frame`); everyone else only reads /// Constructed by `Scene::viewport_frame`. All arithmetic is `f64`.
/// it. All arithmetic is `f64`.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewportFrame { pub struct ViewportFrame {
/// Viewport entity handle. /// Viewport entity handle.
@@ -105,6 +88,8 @@ pub struct SnapSourceRef {
pub source: DimensionAssociationSource, pub source: DimensionAssociationSource,
pub block_path: Vec<Handle>, pub block_path: Vec<Handle>,
pub snap_type: SnapType, pub snap_type: SnapType,
/// The other entity/instance path for a true intersection.
pub intersection: Option<Box<SnapSourceRef>>,
} }
/// A snap accepted by a point-input command, with enough information for /// A snap accepted by a point-input command, with enough information for
@@ -149,31 +134,29 @@ impl AcceptedSnap {
self.viewport.is_some() self.viewport.is_some()
} }
/// Build an accepted snap from a [`crate::snap::SnapResult`]. /// `snap.world` is always in the command's current space. A projected
/// /// viewport hit also retains its original model coordinate explicitly.
/// * `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<ViewportFrame>) -> Self { pub fn from_snap(snap: &crate::snap::SnapResult, frame: Option<ViewportFrame>) -> Self {
let (paper_point, model_point) = match &frame { let frame = frame.filter(|frame| snap.viewport == Some(frame.viewport));
Some(frame) => (frame.model_to_paper(snap.world), snap.world), let source_ref = |source| SnapSourceRef {
None => (snap.world, snap.world), source,
block_path: Vec::new(),
snap_type: snap.snap_type,
intersection: None,
}; };
Self { Self {
paper_point, paper_point: snap.world,
model_point, 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), viewport: frame.map(|f| f.viewport),
frame, frame,
source: snap.source.map(|source| SnapSourceRef { source: snap.source.map(|source| SnapSourceRef {
source, intersection: snap
block_path: Vec::new(), .secondary_source
snap_type: snap.snap_type, .map(|source| Box::new(source_ref(source))),
..source_ref(source)
}), }),
} }
} }
@@ -222,6 +205,53 @@ pub struct MeasurementScale {
} }
impl 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<Self> {
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 { pub const IDENTITY: Self = Self {
user_lfac: 1.0, user_lfac: 1.0,
viewport_compensation: 1.0, viewport_compensation: 1.0,
@@ -272,8 +302,7 @@ impl MeasurementScale {
/// ///
/// Rendering an *existing* dimension only knows whether the entity lives /// Rendering an *existing* dimension only knows whether the entity lives
/// in a paper-space layout; the viewport compensation is already baked /// in a paper-space layout; the viewport compensation is already baked
/// into the persisted magnitude (see /// into the persisted magnitude. This is the same rule
/// [`MeasurementScale::viewport_dimlfac_override`]). This is the same rule
/// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()` /// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()`
/// replaced by `paper_space`. /// replaced by `paper_space`.
pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 { pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 {
@@ -287,45 +316,6 @@ impl MeasurementScale {
1.0 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<f64> {
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)] #[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);
}
} }
+38 -33
View File
@@ -97,6 +97,10 @@ pub struct SnapResult {
/// INSERT handle; the block path is resolved later by /// INSERT handle; the block path is resolved later by
/// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. (PR1) /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. (PR1)
pub source: Option<DimensionAssociationSource>, pub source: Option<DimensionAssociationSource>,
/// Second real object at an intersection, when both objects are known.
pub secondary_source: Option<DimensionAssociationSource>,
/// Original model point when `world` has been projected onto a sheet.
pub model_point: Option<glam::DVec3>,
} }
impl SnapResult { impl SnapResult {
@@ -984,6 +988,8 @@ impl Snapper {
extension_dir: None, extension_dir: None,
viewport: None, viewport: None,
source: None, source: None,
secondary_source: None,
model_point: None,
}) })
} }
@@ -1119,6 +1125,8 @@ impl Snapper {
extension_dir: None, extension_dir: None,
viewport: None, viewport: None,
source: None, source: None,
secondary_source: None,
model_point: None,
}); });
best_rank = snap_tier(SnapType::Grid); best_rank = snap_tier(SnapType::Grid);
best_sub = snap_priority(SnapType::Grid); best_sub = snap_priority(SnapType::Grid);
@@ -1216,7 +1224,7 @@ impl Snapper {
let mut try_pt = |world: glam::DVec3, let mut try_pt = |world: glam::DVec3,
snap_type: SnapType, snap_type: SnapType,
src: Option<DimensionAssociationSource>| { src: Option<DimensionAssociationSource>, secondary: Option<DimensionAssociationSource>| {
let screen = world_to_screen(world, view_rot, eye, bounds); let screen = world_to_screen(world, view_rot, eye, bounds);
if !in_bounds(screen) { if !in_bounds(screen) {
return; return;
@@ -1245,6 +1253,8 @@ impl Snapper {
extension_dir: None, extension_dir: None,
viewport: None, viewport: None,
source: src, source: src,
secondary_source: secondary,
model_point: None,
}); });
} }
}; };
@@ -1262,7 +1272,7 @@ impl Snapper {
SnapHint::Endpoint => SnapType::Endpoint, SnapHint::Endpoint => SnapType::Endpoint,
}; };
if self.is_on(snap_type) { 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() { if let Some(points) = wires.snap_points() {
@@ -1298,8 +1308,7 @@ impl Snapper {
try_pt( try_pt(
DVec3::from_array(point), DVec3::from_array(point),
SnapType::Endpoint, SnapType::Endpoint,
wire_source(wire), wire_source(wire), None);
);
} }
// Tessellated open curves have no key-vertex set. Their only // Tessellated open curves have no key-vertex set. Their only
// endpoints are first/last, so testing candidate wires remains // endpoints are first/last, so testing candidate wires remains
@@ -1313,14 +1322,13 @@ impl Snapper {
continue; continue;
} }
if !wire.points.is_empty() { 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 { if wire.points.len() > 1 {
try_pt( try_pt(
wp_f64(wire, wire.points.len() - 1), wp_f64(wire, wire.points.len() - 1),
SnapType::Endpoint, SnapType::Endpoint,
wire_source(wire), wire_source(wire), None);
);
} }
} }
} else { } else {
@@ -1328,7 +1336,7 @@ impl Snapper {
let src = wire_source(wire); let src = wire_source(wire);
if !wire.key_vertices.is_empty() { if !wire.key_vertices.is_empty() {
for &point in &wire.key_vertices { 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 { } else {
let closed = wire let closed = wire
@@ -1337,14 +1345,13 @@ impl Snapper {
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); .any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
if !closed { if !closed {
if !wire.points.is_empty() { 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 { if wire.points.len() > 1 {
try_pt( try_pt(
wp_f64(wire, wire.points.len() - 1), wp_f64(wire, wire.points.len() - 1),
SnapType::Endpoint, SnapType::Endpoint,
src, src, None);
);
} }
} }
} }
@@ -1371,7 +1378,7 @@ impl Snapper {
let a = DVec3::from_array(a); let a = DVec3::from_array(a);
let b = DVec3::from_array(b); let b = DVec3::from_array(b);
if a.distance_squared(b) > 1e-12 { 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 { } else {
@@ -1381,7 +1388,7 @@ impl Snapper {
let a = DVec3::from_array(segment[0]); let a = DVec3::from_array(segment[0]);
let b = DVec3::from_array(segment[1]); let b = DVec3::from_array(segment[1]);
if a.distance_squared(b) > 1e-12 { 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( try_pt(
nearest_on_segment(cursor_world, seg.a, seg.b), nearest_on_segment(cursor_world, seg.a, seg.b),
SnapType::Nearest, SnapType::Nearest,
wires.source_wire(seg.wire).and_then(wire_source), wires.source_wire(seg.wire).and_then(wire_source), None);
);
} }
} else { } else {
for wire in in_range_wires.iter() { for wire in in_range_wires.iter() {
@@ -1404,7 +1410,7 @@ impl Snapper {
for i in 0..wire.points.len().saturating_sub(1) { for i in 0..wire.points.len().saturating_sub(1) {
let p = let p =
nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); 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( try_pt(
foot, foot,
SnapType::Perpendicular, SnapType::Perpendicular,
wires.source_wire(seg.wire).and_then(wire_source), wires.source_wire(seg.wire).and_then(wire_source), None);
);
} }
} }
} else { } else {
@@ -1429,7 +1434,7 @@ impl Snapper {
let src = wire_source(wire); let src = wire_source(wire);
for i in 0..wire.points.len().saturating_sub(1) { 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)) { 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( try_pt(
point, point,
SnapType::Intersection, 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), wp_f64(wire, index + 1),
) { ) {
if (point - origin).length_squared() > 1e-18 { 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. // first. PR3 resolves the true pair.
let src = wire_source(wire_i); let src = wire_source(wire_i);
for point in points { 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)); resolved_pairs.insert(pair_key(wire_i, wire_j));
} }
@@ -1586,8 +1590,7 @@ impl Snapper {
try_pt( try_pt(
pt, pt,
SnapType::Intersection, 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 { if exact_cursor {
break 'intersection_sweep; break 'intersection_sweep;
} }
@@ -1609,7 +1612,7 @@ impl Snapper {
if let Some(pts) = exact_curve_intersections(wire_i, wire_j) { if let Some(pts) = exact_curve_intersections(wire_i, wire_j) {
let src = wire_source(wire_i); let src = wire_source(wire_i);
for pt in pts { for pt in pts {
try_pt(pt, SnapType::Intersection, src); try_pt(pt, SnapType::Intersection, src, wire_source(wire_j));
} }
continue; continue;
} }
@@ -1633,7 +1636,7 @@ impl Snapper {
continue; continue;
} }
if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { 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, bounds,
self.osnap_radius_px, 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), // feet (which sit closer to the cursor on their own lines),
// or the cursor would snap to a line instead of the crossing. // 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); 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( try_pt(
segment_a.a + ta as f64 * (segment_a.b - segment_a.a), segment_a.a + ta as f64 * (segment_a.b - segment_a.a),
SnapType::ApparentIntersection, 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 { if dist2(apparent_screen, cursor_screen) <= f32::EPSILON {
break 'apparent_sweep; break 'apparent_sweep;
} }
@@ -1800,8 +1802,7 @@ impl Snapper {
try_pt( try_pt(
wa0 + ta as f64 * (wa1 - wa0), wa0 + ta as f64 * (wa1 - wa0),
SnapType::ApparentIntersection, SnapType::ApparentIntersection,
wire_source(wire_i), wire_source(wire_i), wire_source(wire_j));
);
} }
} }
} }
@@ -2024,6 +2025,8 @@ impl Snapper {
extension_dir: None, extension_dir: None,
viewport: None, viewport: None,
source: wire_source(wire), source: wire_source(wire),
secondary_source: None,
model_point: None,
}); });
} }
} }
@@ -2088,6 +2091,8 @@ impl Snapper {
extension_dir: None, extension_dir: None,
viewport: None, viewport: None,
source: wire_source(wire), source: wire_source(wire),
secondary_source: None,
model_point: None,
}); });
} }
}; };