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