From 2ee378285035e4f826ee7ff2f3c1c7c0934e18ef Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 14:13:19 -0500 Subject: [PATCH] Fix viewport dimension acquisition and preserve measurement scaling --- docs/viewport-dimensions.md | 48 ++++ src/app/command_driver.rs | 17 +- src/app/dim_viewport.rs | 380 ++++++++++----------------- src/app/mod.rs | 2 + src/app/update/viewport.rs | 49 ++-- src/app/update/viewport_snap.rs | 68 ++--- src/app/viewport_dimension_tests.rs | 379 ++++++++++++++++++++++++++ src/command.rs | 8 + src/entities/dimension.rs | 24 +- src/modules/annotate/aligned_dim.rs | 11 + src/modules/annotate/angular_dim.rs | 14 + src/modules/annotate/diameter_dim.rs | 10 + src/modules/annotate/linear_dim.rs | 11 + src/modules/annotate/radius_dim.rs | 10 + src/scene/mspace.rs | 17 +- src/scene/viewport_dim_seam.rs | 242 +++++++++-------- src/scene/viewport_ref.rs | 173 +++++------- src/snap.rs | 71 ++--- 18 files changed, 957 insertions(+), 577 deletions(-) create mode 100644 docs/viewport-dimensions.md create mode 100644 src/app/viewport_dimension_tests.rs diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md new file mode 100644 index 000000000..09f85208c --- /dev/null +++ b/docs/viewport-dimensions.md @@ -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. diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d8f355ed9..6cc436e5f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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 = { 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 diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index fbd35cce7..577237e57 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -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 { + 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 = 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, + ) -> 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, entity: Handle, block_path: Vec, ) { - 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 { - 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 { - 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 = 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) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 9a5b4ebb8..77cd9f90a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 7d0f11ec0..3971ec478 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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(); diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs index 0265b36c7..35c112b85 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -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, frame: Option, 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), } } diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs new file mode 100644 index 000000000..5ac9cb658 --- /dev/null +++ b/src/app/viewport_dimension_tests.rs @@ -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(_)))); +} diff --git a/src/command.rs b/src/command.rs index a8a18632b..a248db896 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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 { 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 } diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 572f09dca..44ee7556a 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -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 diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 157b6152c..9cde3e7f2 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -167,6 +167,17 @@ impl CadCommand for AlignedDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + 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 diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index b253dda77..e9bdb0aea 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -488,6 +488,20 @@ impl CadCommand for AngularDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + 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 } diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index e03e2e630..b128d4dde 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -138,6 +138,16 @@ impl CadCommand for DiameterDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + 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 } diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 7c203efc3..e8f55c32f 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -223,6 +223,17 @@ impl CadCommand for LinearDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + 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 } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index e72a84465..214933a8e 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -136,6 +136,16 @@ impl CadCommand for RadiusDimensionCommand { true } + fn dimension_acquired_points(&self) -> Vec { + 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 } diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index dc16c99f4..11f516329 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -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 { + 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) } diff --git a/src/scene/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index 575aaf505..1954d240a 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -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 { - 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 { - 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 { - 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 { - 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, ) -> Option<(EntityType, Vec)> { 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, depth: usize, + viewport: Option, ) -> Option<(EntityType, Vec)> { 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 { +) -> 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 = 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 { - 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, - closed: bool, - point: DVec3, -) -> Option { - let points: Vec = 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)] diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index 7c64f4e29..3ce7dfae9 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -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, pub snap_type: SnapType, + /// The other entity/instance path for a true intersection. + pub intersection: Option>, } /// 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) -> 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 { + 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 { - 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); - } } diff --git a/src/snap.rs b/src/snap.rs index b388e0f96..75bcf5f20 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -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, + /// Second real object at an intersection, when both objects are known. + pub secondary_source: Option, + /// Original model point when `world` has been projected onto a sheet. + pub model_point: Option, } 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| { + src: Option, secondary: Option| { 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, }); } };