diff --git a/Cargo.lock b/Cargo.lock index 75d2c4495..d153311ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -921,7 +921,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7ce27f2#7ce27f2980da88734dff922c169eb2b9cd5dc404" dependencies = [ "acadrust", "cavalier_contours", @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7ce27f2#7ce27f2980da88734dff922c169eb2b9cd5dc404" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 05031cca2..1c1536f7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,8 +34,8 @@ clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/docs/viewport-dimension-association.svg b/docs/viewport-dimension-association.svg new file mode 100644 index 000000000..8a7a06428 --- /dev/null +++ b/docs/viewport-dimension-association.svg @@ -0,0 +1,19 @@ + + + + +The dimension follows its paper-space centerline + + +BeforeCenterline moved 1 paper unit right + + + + + + + +5040 +The model endpoint stays fixed. The dimension updates from 50 to 40. +Association schematic; viewport 1:10, dimension style unit factor = 1, DIMASSOC = 2. + diff --git a/docs/viewport-dimension-creation.svg b/docs/viewport-dimension-creation.svg new file mode 100644 index 000000000..efd1464c1 --- /dev/null +++ b/docs/viewport-dimension-creation.svg @@ -0,0 +1,22 @@ + + + + +Dimension from a paper centerline to a model endpoint + +Viewport 1:10 + + + +Model geometry + + + +50 +Brown dashed line: paper-space centerline +5 paper units between the picked points +5 × 10 = 50 model units +The viewport supplies the length scale. +Either pick order gives the same value. +Measurement schematic; dimension style unit factor = 1. + diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md new file mode 100644 index 000000000..2190bdeb2 --- /dev/null +++ b/docs/viewport-dimensions.md @@ -0,0 +1,74 @@ +# Dimensions through layout viewports + +In paper space, snap to model geometry displayed through a planar, orthographic +viewport. `DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER` +also support their usual object-pick modes through these viewports. + +## Measurement + +- Model geometry supplies the viewport scale. A 100-unit model length displayed + at 1:10 measures 100, although it occupies 10 paper units. +- A paper-space centerline or free sheet point can be combined with model + geometry through one viewport, in either pick order. The paper-space span is + measured using that viewport's scale. +- Sheet-only dimensions measure paper units, even inside a viewport rectangle. +- Model picks from a second viewport are rejected without advancing the command. + Dimension-line and text placement do not change the measurement scale. +- The current dimension style's unit conversion is preserved. Angular dimensions + do not use length compensation. `DIMASSOC=0` explodes the compensated dimension. + +Snapping and object picking respect visibility, clipping, and viewport draw order. +Paper geometry has priority in object picks. Ordinary selection uses the active +drawing space. Object picks reuse the resident interaction index and resolve +nested block transforms. + +![Measurement schematic: a five-unit sheet span measures 50 through a 1:10 viewport.](viewport-dimension-creation.svg) + +## Saved scale + +Length dimensions store paper-space definition points and a negative `DIMLFAC` +override: user unit factor multiplied by `1 / viewport scale`. +`OCS_VIEWPORT_MEASUREMENT` XData stores version `1` (integer16), the positive user +factor (real), and viewport compensation (real). Keeping the factors separate +allows compensation to change without losing the user's unit conversion. +Both the override and XData survive DXF/DWG saves and undo/redo. + +## Associations + +With `DIMASSOC=2`, acquired features retain their viewport, block-instance path, +source entity, and snap feature. Intersections retain both source chains. +Source edits and viewport changes update the dimension and its compensation, +preserving style overrides and user-positioned text on the sheet. + +A sheet centerline stays on the sheet; a model endpoint follows its viewport. +Moving the viewport therefore changes the distance between them. Undo/redo and +DXF/DWG saves preserve the references. + +Properties shows association status. If a required reference is broken or cannot +be resolved, the entire dimension keeps its last valid geometry and reference +records. A successful refresh regenerates its displayed and exported geometry. + +![Association schematic: moving the sheet centerline changes the dimension from 50 to 40.](viewport-dimension-association.svg) + +## Limits + +Unsupported cases preserve saved dimensions: + +- Perspective or oblique views; missing or malformed reference chains. +- Array instances without an addressable instance index, nesting beyond eight + levels, and radial measurements of nonuniformly scaled circles or arcs. +- Intersections without two identifiable source chains inside a batched block. +- Ambiguous imported endpoint, midpoint, or quadrant markers. A stored point can + validate a feature; it cannot establish an otherwise unknown identity. +- Tangency without a verified solution, or two perpendicular/tangent references + requiring a coupled solve. +- Imported viewport scaling without OCS scale metadata or + `ACAD_DIMASSOC_CALC_DIMLFAC` identifying the saved compensation. + +## Tests + +Run `cargo test --locked --lib viewport_dimension`. Tests cover the five commands, +mixed sheet/model input, styles, clipping, object picks, source/viewport edits, +snap features, nested paths, intersections, preserved overrides, undo/redo, and +DXF/DWG persistence. The synthetic association fixture is in +`tests/fixtures/dim_assoc/`. diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 11ee678ff..57c36eef4 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -97,6 +97,10 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].snap_result = None; self.last_point = None; + // Points collected in the space being left are meaningless in the new + // one. + self.clear_accepted_snaps(); + self.pending_click_snap = None; self.snapper.from_point = None; self.snapper.clear_tracking(); self.otrack_active = None; @@ -417,6 +421,13 @@ impl OpenCADStudio { if !self.command_point_allowed(i, *point) { return Task::none(); } + // Typed / dynamic-input / headless points carry no snap, but the + // accepted-snap list must stay index-parallel with the points the + // command collects. Interactive picks record themselves in + // the click handler and never reach here. + if !self.record_accepted_snap(i, None, None, *point) { + return Task::none(); + } } if default_start { let StepInput::Point(point) = &input else { @@ -430,6 +441,9 @@ 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( @@ -502,6 +516,11 @@ 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(); @@ -520,6 +539,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(), @@ -1876,7 +1899,7 @@ impl OpenCADStudio { }; if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i].scene.infer_dimension_sources(handle); + let sources = self.infer_dimension_sources_guarded(i, handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2383,7 +2406,7 @@ impl OpenCADStudio { let committed = self.commit_entity_handle(entity); if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i].scene.infer_dimension_sources(handle); + let sources = self.infer_dimension_sources_guarded(i, handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2416,6 +2439,20 @@ impl OpenCADStudio { &entity, acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_)) ); + // A dimension placed on the sheet but measuring model geometry + // through a viewport carries the compensation as a negative + // DIMLFAC override. + 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(); + } + // Projected points cannot use direct paper-space source inference. + let association_allowed = self.dimension_association_allowed(i); let inherited_dimension = if preserve_base_style { match &entity { acadrust::EntityType::Dimension(dimension) => Some(( @@ -2479,12 +2516,12 @@ impl OpenCADStudio { if let Some(handle) = self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) { - if association_mode == 2 { + if association_mode == 2 && association_allowed { let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)]; match association { crate::command::DimensionAssociationInput::Infer(source) => { let sources: Vec<_> = source.map_or_else( - || self.tabs[i].scene.infer_dimension_sources(handle), + || self.infer_dimension_sources_guarded(i, handle), |source| { if single_source_dimension { vec![Some(source)] @@ -2513,6 +2550,9 @@ impl OpenCADStudio { changes.sort_by_key(|(handle, _)| handle.value()); changes.dedup_by_key(|(handle, _)| handle.value()); self.tabs[i].scene.bump_entities(&changes); + } else if association_mode == 2 { + // Preserve the acquired viewport and source paths. + self.attach_viewport_dimension_association(i, handle); } } pending @@ -2599,9 +2639,7 @@ impl OpenCADStudio { crate::command::DimensionAssociationInput::Infer(source) => { source.map_or_else( || { - self.tabs[i] - .scene - .infer_dimension_sources(handle) + self.infer_dimension_sources_guarded(i, handle) .into_iter() .map(|source| { source.map( diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a1cd82915..3a079ef6e 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -123,6 +123,9 @@ impl OpenCADStudio { // Reset the last committed point so the first click of the new command // is not constrained by ortho/polar relative to a previous command's endpoint. self.last_point = None; + // A new command collects its own points, so the previous command's + // accepted snaps must not leak into it. + self.clear_accepted_snaps(); // Starting a command restarts the right-click cycle, so its first // right-click acts as Enter rather than opening the context menu. self.tabs[i] diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs new file mode 100644 index 000000000..841b784e1 --- /dev/null +++ b/src/app/dim_viewport.rs @@ -0,0 +1,256 @@ +//! Paper-space dimension acquisition and measurement. Acquiring model geometry +//! supplies a viewport scale; sheet-only dimensions retain paper units. + +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; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum DimensionMeasureSpace { + Direct, + Viewport(ViewportFrame), + ConflictingViewports, +} + +impl OpenCADStudio { + 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; + } + + 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; + for f in self.accepted_snaps().iter().filter_map(|snap| snap.frame) { + match frame { + None => frame = Some(f), + Some(previous) if previous.viewport == f.viewport => {} + Some(_) => return DimensionMeasureSpace::ConflictingViewports, + } + } + frame.map_or( + DimensionMeasureSpace::Direct, + DimensionMeasureSpace::Viewport, + ) + } + + /// Reject an incompatible measuring input before it advances the command. + pub(crate) fn dimension_acquisition_allowed( + &mut self, + i: usize, + viewport: Option, + ) -> bool { + if !self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.measures_through_viewports()) + { + return true; + } + // Sheet geometry and free sheet points may accompany one viewport. + // A second viewport would make the measurement scale ambiguous. + if viewport.is_some_and(|viewport| { + self.accepted_snaps() + .iter() + .filter_map(|s| s.viewport) + .any(|previous| previous != viewport) + }) { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").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, + ) { + 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: 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(), + snap_type: crate::snap::SnapType::ObjectPick, + intersection: None, + }), + }); + } + } + + pub(crate) fn apply_viewport_dimension_measurement( + &mut self, + i: usize, + entity: &mut EntityType, + ) -> bool { + let EntityType::Dimension(dimension) = entity else { + return true; + }; + let angular = matches!( + dimension, + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) + ); + let frame = match self.dimension_measure_space(i) { + DimensionMeasureSpace::Direct => return true, + DimensionMeasureSpace::ConflictingViewports => { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref()); + return false; + } + DimensionMeasureSpace::Viewport(f) => f, + }; + if angular { + return true; + } + 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 + } + + pub(crate) fn try_dimension_viewport_entity_pick( + &mut self, + i: usize, + paper: DVec3, + aperture_paper: f64, + ) -> Option { + let pick = self.tabs[i] + .scene + .dimension_pick_through_viewport(paper, aperture_paper)?; + 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); + 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) + } + + fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 { + let EntityType::Dimension(dimension) = entity else { + return 1.0; + }; + dim_override::real( + &dimension.base().common.extended_data, + dim_override::DIMLFAC, + ) + .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) + } +} + +impl OpenCADStudio { + pub(crate) fn viewport_dimension_snaps( + &self, + i: usize, + dimension: Handle, + ) -> Vec> { + let slots = self.tabs[i] + .scene + .dimension_association_slot_points(dimension); + if slots.is_empty() { + return Vec::new(); + } + let measuring = self.accepted_snaps(); + slots + .iter() + .enumerate() + .map(|(index, slot)| { + let snap = measuring.get(index)?.clone(); + let frame = snap.frame; + let slot = DVec3::new(slot.x, slot.y, slot.z); + if snap + .paper_point + .truncate() + .distance_squared(slot.truncate()) + <= 1e-12 + { + return Some(snap); + } + Some(AcceptedSnap { + paper_point: slot, + model_point: frame.map_or(slot, |frame| frame.paper_to_model(slot)), + ..snap + }) + }) + .collect() + } + + /// Record the viewport association for a dimension that has just been + /// committed while measuring through a layout viewport. + /// + /// The stored reference chains establish dependencies. Only the new + /// dimension needs a render update; its sources have not changed. + pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) { + let snaps = self.viewport_dimension_snaps(i, dimension); + if snaps.iter().flatten().all(|snap| snap.source.is_none()) { + return; + } + self.tabs[i] + .scene + .attach_viewport_dimension_association(dimension, &snaps); + self.tabs[i] + .scene + .bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index acd4fcdba..2b10dfee4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,6 +10,9 @@ pub use automation::{export_headless, serve}; 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; @@ -733,6 +736,16 @@ pub(super) struct OpenCADStudio { show_layout_tabs: bool, /// Last point committed by a drawing command — used as ortho/polar base. last_point: Option, + /// Viewport used by the current snap; the displayed point is in paper space. + pub(crate) vp_snap_frame: Option, + /// Acquired coordinates and source identities in command-step order. + /// Cleared when the command starts or ends. + accepted_snaps: Vec, + /// Click result retained until the command accepts its point. + pending_click_snap: Option<( + crate::snap::SnapResult, + Option, + )>, /// Endpoint + unit exit-tangent of the most recently drawn line/arc, so /// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing /// ended. `None` once a non-line/arc entity is committed. @@ -3821,6 +3834,9 @@ impl OpenCADStudio { show_file_tabs: true, show_layout_tabs: true, last_point: None, + vp_snap_frame: None, + accepted_snaps: Vec::new(), + pending_click_snap: None, main_window: None, thumbnail_capture_clean: false, #[cfg(not(target_arch = "wasm32"))] diff --git a/src/app/properties.rs b/src/app/properties.rs index 415a10483..f7438bcc0 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1277,6 +1277,35 @@ impl OpenCADStudio { &self.tabs[i].scene.document, d.base().common.handle, ); + let statuses = self.tabs[i] + .scene + .dimension_association_status(d.base().common.handle); + let slots = self.tabs[i] + .scene + .dimension_association_slot_points(d.base().common.handle) + .len(); + let status = if statuses.is_empty() { + t!("Nonassociative") + } else if statuses.iter().any(|(_, status)| { + matches!(status, crate::scene::ReferenceStatus::Broken(_)) + }) { + t!("Broken reference") + } else if statuses.iter().any(|(_, status)| { + matches!(status, crate::scene::ReferenceStatus::Unresolved) + }) { + t!("Unresolved reference") + } else if statuses.len() < slots { + t!("Partially associated") + } else { + t!("Associated") + }; + general.props.push(crate::scene::model::object::Property { + label: t!("Association status").into_owned(), + field: "association_status", + value: crate::scene::model::object::PropValue::ReadOnly( + status.into_owned(), + ), + }); general.props.push(crate::scene::model::object::Property { label: t!("Associative").into_owned(), field: "associative", diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c33de52b9..9b4882193 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -95,6 +95,7 @@ mod file; mod style; mod util; mod viewport; +mod viewport_snap; impl OpenCADStudio { pub(in crate::app) fn reset_modal_geometry(&mut self) { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index d44a92a34..2971a4ab7 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2017,6 +2017,31 @@ impl OpenCADStudio { ) }; + // Paper-space snapping through layout viewports. `edit_cam` + // is None here, so `cursor_world` is the paper point and the sheet + // fills the canvas (pane-local == canvas pixels). The viewport hit + // arrives already projected onto the sheet, so everything + // downstream keeps working in paper coordinates — LINE still draws + // at the projected paper point. + self.vp_snap_frame = None; + if edit_cam.is_none() + && !needs_entity + && !is_gathering + && !needs_structure + && !needs_tan + { + if let Some((vp_hit, frame)) = + self.paper_viewport_snap(i, p_full, vp_size, cursor_world) + { + let merged = + crate::snap::merge_snap(self.tabs[i].snap_result, Some(vp_hit), p_full); + if merged.is_some_and(|hit| hit.viewport.is_some()) { + self.vp_snap_frame = Some(frame); + } + self.tabs[i].snap_result = merged; + } + } + let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { !command.needs_entity_pick() && !command.needs_tangent_pick() @@ -2306,6 +2331,10 @@ impl OpenCADStudio { extension_base2: None, extension_origin: None, 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)); @@ -3630,6 +3659,23 @@ impl OpenCADStudio { } } } + // Paper-space snapping through layout viewports. Mirrors + // the cursor-move pass: the click recomputes snapping from + // scratch, so the viewport query has to run here too. The hit + // comes back already projected onto the sheet. + let mut click_frame: Option = None; + if edit_cam.is_none() && !needs_entity_click && !needs_tan { + if let Some((vp_hit, frame)) = + self.paper_viewport_snap(i, p_full, (vw, vh), raw) + { + let merged = crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full); + if merged.is_some_and(|hit| hit.viewport.is_some()) { + click_frame = Some(frame); + } + snap_hit = merged; + } + } + self.pending_click_snap = snap_hit.map(|hit| (hit, click_frame)); // Snap runs in model space; the result is already model. let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); // When no UCS is active clamp to world XY; with a UCS the point is @@ -3957,6 +4003,10 @@ 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). @@ -3964,6 +4014,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 @@ -4034,11 +4085,39 @@ 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 @@ -4107,6 +4186,20 @@ impl OpenCADStudio { self.tabs[i].dyn_active = 0; } } + // Record what this point step accepted, alongside the point + // itself: 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(); + 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). @@ -4142,6 +4235,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 new file mode 100644 index 000000000..927fd22e1 --- /dev/null +++ b/src/app/update/viewport_snap.rs @@ -0,0 +1,299 @@ +//! Snap to displayed model geometry while a paper-space command is active. +//! Viewport queries use the renderer's resident wires and interaction index, +//! then project the accepted model point onto the sheet. Ordinary selection +//! continues to use paper-space hit testing. + +use iced::Point; + +use acadrust::types::Handle; + +use crate::app::OpenCADStudio; +use crate::scene::viewport_ref::{AcceptedSnap, ViewportFrame}; +use crate::snap::{SnapResult, SnapType}; + +/// Bound on the retained accepted-snap history. A command's point steps are a +/// handful at most; the cap only stops a long polyline from growing forever. +const MAX_ACCEPTED_SNAPS: usize = 64; + +impl OpenCADStudio { + /// Snaps accepted by the active command's point steps, oldest first, in + /// step order. + pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] { + &self.accepted_snaps + } + + /// Infer direct sources only when the measurement uses the active space. + /// Projected model points must retain their acquired source identities; + /// proximity on the sheet could bind them to unrelated paper geometry. + pub(crate) fn infer_dimension_sources_guarded( + &self, + tab: usize, + dimension: Handle, + ) -> Vec> { + if !self.dimension_association_allowed(tab) { + return Vec::new(); + } + self.tabs[tab].scene.infer_dimension_sources(dimension) + } + + /// Whether the dimension can use ordinary active-space associations. + /// Placement clicks are excluded by the measurement classification. + pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool { + matches!( + self.dimension_measure_space(tab), + crate::app::dim_viewport::DimensionMeasureSpace::Direct + ) + } + + /// Forget the accepted snaps of a finished / abandoned command. + pub(crate) fn clear_accepted_snaps(&mut self) { + self.accepted_snaps.clear(); + } + + /// Record the snap a point step just accepted. `snap` is the displayed snap + /// result (already in the current space's coordinates) or `None` for a free + /// / typed point; `committed` is the point the command actually received + /// 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 mut 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. + Some(hit) => { + let frame = hit.viewport.and(frame); + AcceptedSnap::from_snap(&hit, frame).with_paper_point(committed) + } + None => AcceptedSnap::free(committed), + }; + if self.tabs[tab] + .active_cmd + .as_ref() + .is_some_and(|cmd| cmd.measures_through_viewports()) + { + if self.tabs[tab] + .active_cmd + .as_ref() + .is_some_and(|cmd| cmd.dimension_placement_pending()) + { + return true; + } + if !self.dimension_acquisition_allowed(tab, accepted.viewport) { + return false; + } + } + self.resolve_source_block_path(tab, &mut accepted); + self.push_accepted_snap(accepted); + true + } + + /// Resolve the instance path once, when a feature is accepted. Failure + /// drops source identity so a failed descent cannot attach to the insert's + /// origin. Ambiguous intersections within one batched block remain unlinked. + fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) { + let resolve = |source: &mut crate::scene::viewport_ref::SnapSourceRef| { + if !source.block_path.is_empty() { + return true; + } + if !matches!( + self.tabs[tab] + .scene + .document + .get_entity(source.source.handle), + Some(acadrust::EntityType::Insert(_)) + ) { + return true; + } + if source.snap_type == SnapType::Insertion { + let Some(acadrust::EntityType::Insert(insert)) = self.tabs[tab] + .scene + .document + .get_entity(source.source.handle) + else { + return false; + }; + let origin = glam::DVec3::new( + insert.insert_point.x, + insert.insert_point.y, + insert.insert_point.z, + ); + return origin.distance(accepted.model_point) < 1e-5; + } + let Some((entity, path)) = self.tabs[tab].scene.resolve_measurable_feature( + source.source.handle, + accepted.model_point, + accepted.viewport, + Some(source.snap_type), + ) else { + return false; + }; + let distance = crate::scene::viewport_dimension_pick::feature_pick_distance( + &entity, + accepted.model_point, + Some(source.snap_type), + ); + if !distance.is_some_and(|distance| distance < 1e-5) { + return false; + } + source.source = + crate::command::DimensionAssociationSource::inferred(entity.common().handle); + source.block_path = path; + true + }; + let Some(source) = accepted.source.as_mut() else { + return; + }; + if !resolve(source) { + accepted.source = None; + return; + } + if let Some(other) = source.intersection.as_mut() { + if !resolve(other) + || (other.source.handle == source.source.handle + && other.block_path == source.block_path) + { + source.intersection = None; + } + } + } + + /// Retain a bounded history for point and object picks. + pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) { + if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS { + self.accepted_snaps.remove(0); + } + self.accepted_snaps.push(accepted); + } + /// Query visible viewport geometry with a canvas-pixel cursor and sheet point. + /// Returns paper coordinates and canvas pixels, plus the source viewport frame. + pub(in crate::app) fn paper_viewport_snap( + &mut self, + i: usize, + cursor_canvas: Point, + canvas: (f32, f32), + cursor_paper: glam::DVec3, + ) -> Option<(SnapResult, ViewportFrame)> { + if !self.snapper.snap_enabled { + return None; + } + { + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" || scene.active_viewport.is_some() { + return None; + } + } + if canvas.0 < 1.0 || canvas.1 < 1.0 { + return None; + } + + 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; + }; + if rect.width < 1.0 || rect.height < 1.0 { + continue; + } + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: rect.width, + height: rect.height, + }; + let local = Point::new(cursor_canvas.x - rect.x, cursor_canvas.y - rect.y); + let view_rot = cam.view_proj_rte(bounds); + let eye = cam.eye(); + // The cursor in MODEL space, through the frame — exactly the point + // the viewport camera projects back onto `local`. + let model_cursor = frame.paper_to_model(cursor_paper); + + let wires = self.tabs[i] + .scene + .model_wires_for_viewport_arc(handle, bounds.height); + let candidates = self.tabs[i].scene.interaction_candidates_near( + wires, + model_cursor, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + ); + + // 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 = + saved_from.map(|p| frame.paper_to_model(p.as_dvec3()).as_vec3()); + let hit = self.snapper.snap( + model_cursor, + local, + &candidates, + view_rot, + eye, + bounds, + glam::Vec3::ZERO, + (glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z), + None, + ); + self.snapper.grid_snap_on = saved_grid; + self.snapper.from_point = saved_from; + + let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else { + continue; + }; + // The snap aperture can reach outside the visible clip boundary. + let paper = frame.model_to_paper(hit.world); + if !self.tabs[i] + .scene + .viewport_displays_paper_point(handle, paper.truncate()) + { + continue; + } + return Some((project_hit_to_paper(hit, &frame, rect, handle), frame)); + } + None + } +} + +/// Re-express a snap taken in a viewport's model space as a paper-space result: +/// the point projects onto the sheet through `frame`, and the pane-local pixel +/// positions shift by the viewport's screen origin so they share the canvas +/// frame with the ordinary paper snap. +fn project_hit_to_paper( + hit: SnapResult, + frame: &ViewportFrame, + rect: iced::Rectangle, + viewport: Handle, +) -> SnapResult { + let to_canvas = |p: Point| Point::new(p.x + rect.x, p.y + rect.y); + SnapResult { + world: frame.model_to_paper(hit.world), + screen: to_canvas(hit.screen), + snap_type: hit.snap_type, + // Tangent geometry is model-space and would be consumed as paper-space + // by TTR/TTT; drop it rather than hand over a wrong circle. + tangent_obj: None, + extension_base: hit.extension_base.map(to_canvas), + extension_base2: hit.extension_base2.map(to_canvas), + extension_origin: hit.extension_origin.map(|p| frame.model_to_paper(p)), + extension_dir: hit.extension_dir.map(|d| { + let mapped = frame.model_to_paper_dir(d.truncate()); + glam::DVec3::new(mapped.x, mapped.y, d.z) + }), + 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..3d0371604 --- /dev/null +++ b/src/app/viewport_dimension_tests.rs @@ -0,0 +1,1428 @@ +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_navigation_refreshes_after_the_complete_camera_update() { + let (mut app, _, _, frame) = + start_centerline_dimension("DIMALIGNED", true, SnapType::Midpoint, 1.0); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let scene = &mut app.tabs[app.active_tab].scene; + assert!((displayed(&scene.document) - 50.0).abs() < 1e-4); + scene.active_viewport = Some(frame.viewport); + let mut camera = scene.navigation_camera(); + camera.target.x += 20.0; + camera.distance *= 0.5; + assert!(scene.apply_navigation_camera(camera)); + assert!( + (displayed(&scene.document) - 55.0).abs() < 1e-4, + "{}", + displayed(&scene.document) + ); +} + +#[test] +fn viewport_dimension_imported_paper_owner_does_not_require_layout_objects() { + let (mut app, line, frame) = fixture(); + finish_aligned(&mut app, line, frame); + let doc = &mut app.tabs[app.active_tab].scene.document; + doc.objects + .retain(|_, object| !matches!(object, acadrust::objects::ObjectType::Layout(_))); + assert!(crate::entities::dimension::dimension_in_paper_space( + dimension(doc), + doc + )); +} + +#[test] +fn viewport_dimension_curved_clip_includes_the_exact_boundary() { + let (mut app, _, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut circle = Circle::new(); + circle.center = Vector3::new(50.0, 50.0, 0.0); + circle.radius = 10.0; + let boundary = circle.point_at_angle_wcs(0.123456); + let clip = scene.add_entity(EntityType::Circle(circle)); + let EntityType::Viewport(vp) = scene.document.get_entity_mut(frame.viewport).unwrap() else { + panic!() + }; + vp.clip_boundary_handle = clip; + assert!(scene + .viewport_displays_paper_point(frame.viewport, glam::DVec2::new(boundary.x, boundary.y))); + assert!(!scene.viewport_displays_paper_point(frame.viewport, glam::DVec2::new(60.01, 50.0))); + assert!(!scene.viewport_displays_paper_point(frame.viewport, glam::DVec2::new(35.0, 60.0))); +} + +#[test] +fn viewport_dimension_apparent_intersection_extends_only_the_lines() { + use crate::scene::dimension_assoc_chain::{feature_point, osnap, FeatureContext}; + use acadrust::objects::AssocDimensionReference; + let mut scene = Scene::new(); + let first = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))); + let second = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(2.0, -1.0, 0.0), + Vector3::new(2.0, -0.5, 0.0), + ))); + let mut reference = AssocDimensionReference { + osnap_type: osnap::APPARENT_INT, + xrefs: vec![first], + intersection_objects: vec![second], + ..Default::default() + }; + let expected = Vector3::new(2.0, 0.0, 0.0); + let context = FeatureContext { + hint: Some(expected), + from: None, + }; + let entity = scene.document.get_entity(first).unwrap(); + assert_eq!( + feature_point(&scene.document, entity, &reference, context), + Some(expected) + ); + reference.osnap_type = osnap::INTERSEC; + assert!(feature_point(&scene.document, entity, &reference, context).is_none()); +} + +#[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_conflicting_viewports_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 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 i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let vertical = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(0.0, 100.0, 0.0), + ))); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + 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, vertical, 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_frames_at_paper_point(paper) + .into_iter() + .next() + .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_frames_at_paper_point(paper) + .into_iter() + .next() + .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_frames_at_paper_point(paper).is_empty()); + 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(_)))); +} + +#[test] +fn viewport_dimension_association_scale_edit_picture_and_history() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert_eq!( + app.tabs[i].scene.dimension_association_status(handle).len(), + 2 + ); + let Some(EntityType::Dimension(d)) = app.tabs[i].scene.document.get_entity_mut(handle) else { + panic!() + }; + d.base_mut().text_user_positioned = true; + d.base_mut().text_middle_point = Vector3::new(250.0, 200.0, 0.0); + d.base_mut().block_name = "*SavedPicture".into(); + let pending = app.begin_undo(i, "Viewport scale", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(frame.viewport); + app.tabs[i].scene.record_undo_before(frame.viewport, before); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); + assert!((dimension(&app.tabs[i].scene.document).measurement() - 20.0).abs() < 1e-5); + assert!(dimension(&app.tabs[i].scene.document) + .base() + .block_name + .is_empty()); + assert_eq!( + dimension(&app.tabs[i].scene.document) + .base() + .text_middle_point, + Vector3::new(250.0, 200.0, 0.0) + ); + app.undo_steps(1); + assert!((dimension(&app.tabs[i].scene.document).measurement() - 10.0).abs() < 1e-5); + assert_eq!( + dimension(&app.tabs[i].scene.document).base().block_name, + "*SavedPicture" + ); + app.redo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); + let pending = app.begin_undo(i, "Edit source", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(line); + app.tabs[i].scene.record_undo_before(line, before); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(line) else { + panic!() + }; + source.end.x = 175.0; + app.tabs[i] + .scene + .bump_entities(&[(line, ChangeKind::Modified)]); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 175.0).abs() < 1e-5); + let pieces = crate::modules::draw::modify::explode::explode_entity( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &app.tabs[i].scene.document, + ); + assert!( + pieces.iter().any( + |e| matches!(e,EntityType::Text(t) if t.value.contains("175")) + || matches!(e,EntityType::MText(t) if t.value.contains("175")) + ), + "{pieces:?}" + ); + for ext in ["dxf", "dwg"] { + let doc = &app.tabs[i].scene.document; + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let mut loaded = Scene::new(); + loaded.document = crate::io::load_bytes(&format!("assoc.{ext}"), bytes).unwrap(); + loaded.notify_viewport_changed(frame.viewport); + assert!((displayed(&loaded.document) - 175.0).abs() < 1e-5, "{ext}"); + assert_eq!(loaded.dimension_association_status(handle).len(), 2); + } + app.undo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_unsupported_and_erased_sources_keep_last_picture() { + use crate::scene::dimension_assoc::{resolve_reference_chain, ReferenceStatus}; + use acadrust::objects::AssocDimensionReference; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + let reference = AssocDimensionReference { + xrefs: vec![frame.viewport, line], + osnap_type: 255, + ..Default::default() + }; + assert!(matches!( + resolve_reference_chain(&app.tabs[i].scene, &reference, None), + Err(ReferenceStatus::Unresolved) + )); + let previous = app.tabs[i] + .scene + .document + .get_entity(handle) + .unwrap() + .clone(); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.status.perspective = true; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert_eq!( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &previous + ); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Unresolved)); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.status.perspective = false; + let pending = app.begin_undo(i, "Erase source", 1, false).unwrap(); + app.tabs[i].scene.erase_entities(&[line]); + app.commit_undo_delta(i, pending); + assert_eq!( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &previous + ); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Broken(line))); + app.undo_steps(1); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); +} + +#[test] +fn viewport_dimension_intersection_tracks_both_entities() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let other = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(60.0, -50.0, 0.0), + Vector3::new(60.0, 50.0, 0.0), + ))); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut intersection = hit(frame, line, DVec3::X * 60.0); + intersection.snap_type = SnapType::Intersection; + intersection.secondary_source = Some(DimensionAssociationSource::inferred(other)); + assert!(app.record_accepted_snap(i, Some(intersection), Some(frame), intersection.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(intersection.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(80.0, 80.0, 0.0))); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!(app.tabs[i] + .scene + .dimension_association_sources(handle) + .contains(&other)); + assert!((displayed(&app.tabs[i].scene.document) - 60.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(other) else { + panic!() + }; + source.start.x = 80.0; + source.end.x = 80.0; + app.tabs[i] + .scene + .bump_entities(&[(other, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 80.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_spline_tangency_is_parallel_and_validated() { + use crate::scene::dimension_assoc_chain::{feature_point, osnap, FeatureContext}; + use acadrust::objects::AssocDimensionReference; + let mut spline = acadrust::entities::Spline::new(); + spline.degree = 2; + spline.control_points = vec![ + Vector3::ZERO, + Vector3::new(0.5, 0.0, 0.0), + Vector3::new(1.0, 1.0, 0.0), + ]; + spline.knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]; + let entity = EntityType::Spline(spline); + let reference = AssocDimensionReference { + osnap_type: osnap::TAN, + osnap_distance: 0.8, + ..Default::default() + }; + let result = feature_point( + &CadDocument::new(), + &entity, + &reference, + FeatureContext { + hint: Some(Vector3::new(1.0, 1.0, 0.0)), + from: Some(Vector3::new(0.0, -1.0, 0.0)), + }, + ) + .unwrap(); + assert!( + (result.x - 1.0).abs() < 1e-6 && (result.y - 1.0).abs() < 1e-6, + "{result:?}" + ); + assert!(feature_point( + &CadDocument::new(), + &entity, + &reference, + FeatureContext { + hint: None, + from: Some(Vector3::new(0.0, 1.0, 0.0)) + } + ) + .is_none()); +} + +#[test] +#[ignore = "Set OPENCAD_VIEWPORT_REGRESSION_DXF to an external regression fixture"] +fn viewport_dimension_external_fixture_keeps_unchanged_measurements() { + use crate::scene::ChangeKind; + let path = std::env::var("OPENCAD_VIEWPORT_REGRESSION_DXF").expect("regression drawing path"); + let doc = acadrust::DxfReader::from_file(std::path::Path::new(&path)) + .unwrap() + .read() + .unwrap(); + let mut scene = Scene::new(); + scene.document = doc; + let dimensions: Vec<_> = scene + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Dimension(dim) => { + Some((dim.base().common.handle, dim.base().actual_measurement)) + } + _ => None, + }) + .collect(); + let mut sources: Vec<_> = dimensions + .iter() + .flat_map(|(h, _)| scene.dimension_association_sources(*h)) + .collect(); + sources.sort(); + sources.dedup(); + scene.bump_entities( + &sources + .into_iter() + .map(|h| (h, ChangeKind::Modified)) + .collect::>(), + ); + for (handle, saved) in dimensions { + if scene.dimension_association_status(handle).is_empty() { + continue; + } + let EntityType::Dimension(dim) = scene.document.get_entity(handle).unwrap() else { + panic!() + }; + let factor = dim_override::real(&dim.base().common.extended_data, dim_override::DIMLFAC) + .unwrap_or(1.0) + .abs(); + let result = dim.measurement() * factor; + println!( + "{:X}: saved={saved:.9}, current={result:.9}, status={:?}", + handle.value(), + scene.dimension_association_status(handle) + ); + assert!( + (result - saved).abs() < 1e-3, + "{:X}: {saved} -> {result}", + handle.value() + ); + } +} + +#[test] +fn viewport_dimension_fixture_regenerates_and_tracks_an_intersection() { + use crate::scene::ChangeKind; + let bytes = include_bytes!("../../tests/fixtures/dim_assoc/viewport_associations.dxf").to_vec(); + let mut scene = Scene::new(); + scene.document = crate::io::load_bytes("viewport_associations.dxf", bytes).unwrap(); + scene.notify_viewport_changed(Handle::new(0x200)); + // The cubic's tangent from (0,300) touches at u=.75, C=(90,283.125). + let EntityType::Spline(spline) = scene.document.get_entity(Handle::new(0x104)).unwrap() else { + panic!() + }; + let curve = crate::entities::spline::nurbs3(spline).unwrap(); + let c = DVec3::from_array(curve.point_at_knot(0.75)); + let tangent = DVec3::from_array(curve.derivative_at_knot(0.75)); + assert!((c - DVec3::new(0.0, 300.0, 0.0)).cross(tangent).length() < 1e-8); + let measured = |scene: &Scene, handle| { + let EntityType::Dimension(d) = scene.document.get_entity(Handle::new(handle)).unwrap() + else { + panic!() + }; + d.measurement() + * dim_override::real(&d.base().common.extended_data, dim_override::DIMLFAC) + .unwrap_or(1.0) + .abs() + }; + for (handle, expected) in [ + (0x201, 100.0), + (0x202, 50.0), + (0x203, 35.355339), + (0x204, 141.421356), + (0x205, 90.0_f64.hypot(16.875)), + (0x206, 80.0), + (0x207, 60.0), + ] { + assert!( + (measured(&scene, handle) - expected).abs() < 1e-4, + "{handle:X}: {}", + measured(&scene, handle) + ); + } + let Some(EntityType::Line(line)) = scene.document.get_entity_mut(Handle::new(0x107)) else { + panic!() + }; + line.start.x = 80.0; + line.end.x = 80.0; + scene.bump_entities(&[(Handle::new(0x107), ChangeKind::Modified)]); + assert!((measured(&scene, 0x207) - 80.0).abs() < 1e-5); + let before = scene + .document + .get_entity(Handle::new(0x205)) + .unwrap() + .clone(); + for types in [(1, 255), (8, 9)] { + let acadrust::objects::ObjectType::Associative(object) = + scene.document.objects.get_mut(&Handle::new(0x304)).unwrap() + else { + panic!() + }; + let acadrust::objects::AssociativeData::DimensionAssociation(assoc) = &mut object.data + else { + panic!() + }; + assoc.references[0][0].osnap_type = types.0; + assoc.references[1][0].osnap_type = types.1; + scene.notify_viewport_changed(Handle::new(0x200)); + assert_eq!( + scene.document.get_entity(Handle::new(0x205)).unwrap(), + &before + ); + assert!(scene + .dimension_association_status(Handle::new(0x205)) + .iter() + .any(|(_, s)| *s == crate::scene::ReferenceStatus::Unresolved)); + } +} + +#[test] +fn viewport_dimension_nearest_parameter_survives_source_stretch() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut near = hit(frame, line, DVec3::X * 75.0); + near.snap_type = SnapType::Nearest; + assert!(app.record_accepted_snap(i, Some(near), Some(frame), near.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(near.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(55.0, 80.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 75.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(line) else { + panic!() + }; + source.end.x = 200.0; + app.tabs[i] + .scene + .bump_entities(&[(line, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 150.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_nested_block_path_survives_edit_and_copy() { + use crate::scene::ChangeKind; + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + let mut inner = BlockRecord::new("Inner"); + inner.handle = scene.document.allocate_handle(); + let inner_h = inner.handle; + scene.document.block_records.add(inner).unwrap(); + let mut leaf = Line::from_points(Vector3::ZERO, Vector3::new(20.0, 0.0, 0.0)); + leaf.common.owner_handle = inner_h; + let leaf = scene.document.add_entity(EntityType::Line(leaf)).unwrap(); + let mut outer = BlockRecord::new("Outer"); + outer.handle = scene.document.allocate_handle(); + let outer_h = outer.handle; + scene.document.block_records.add(outer).unwrap(); + let mut nested = Insert::new("Inner", Vector3::new(30.0, 0.0, 0.0)); + nested.common.owner_handle = outer_h; + nested.set_x_scale(3.0); + nested.set_y_scale(3.0); + nested.rotation = 0.4; + let nested = scene + .document + .add_entity(EntityType::Insert(nested)) + .unwrap(); + scene.set_current_layout("Model".into()); + let mut root = Insert::new("Outer", Vector3::new(20.0, 50.0, 0.0)); + root.set_x_scale(2.0); + root.set_y_scale(2.0); + root.rotation = 0.3; + let root = scene.add_entity(EntityType::Insert(root)); + scene.set_current_layout("Dimensions".into()); + let transform = + crate::scene::dimension_assoc_chain::walk_chain(&scene.document, &[root, nested, leaf]) + .unwrap() + .transform; + let model = |x| { + let v = transform.apply(Vector3::new(x, 0.0, 0.0)); + DVec3::new(v.x, v.y, v.z) + }; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, root, model(0.0)); + let accepted = app.accepted_snaps()[0].source.as_ref().unwrap(); + assert_eq!(accepted.source.handle, leaf); + assert_eq!(accepted.block_path, vec![root, nested]); + point(&mut app, frame, root, model(20.0)); + let _ = app.feed_command(StepInput::Point(DVec3::new(90.0, 90.0, 0.0))); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!((displayed(&app.tabs[i].scene.document) - 120.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(leaf) else { + panic!() + }; + source.end.x = 40.0; + app.tabs[i] + .scene + .bump_entities(&[(leaf, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 240.0).abs() < 1e-5); + let copied = app.tabs[i].scene.copy_entities( + &[handle], + &crate::command::EntityTransform::Translate(DVec3::new(10.0, 10.0, 0.0)), + ); + assert_eq!(copied.len(), 1); + assert!(app.tabs[i].scene.dimension_association(copied[0]).is_none()); + assert!(app.tabs[i].scene.dimension_association(handle).is_some()); +} + +#[test] +fn viewport_dimension_hidden_border_and_lock_keep_content_available() { + let (mut app, _, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut layer = acadrust::tables::Layer::new("Viewport frames"); + layer.flags.off = true; + scene.document.layers.add(layer).unwrap(); + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(frame.viewport) else { + panic!() + }; + vp.common.layer = "Viewport frames".into(); + vp.status.locked = true; + let pick = scene + .dimension_pick_through_viewport(DVec3::new(55.0, 50.0, 0.0), 0.2) + .unwrap(); + assert!(pick.frame.locked); + assert_eq!(pick.frame.viewport, frame.viewport); +} + +#[test] +fn viewport_dimension_perpendicular_uses_transformed_geometry() { + use crate::scene::dimension_assoc::{resolve_reference_chain, ReferenceStatus}; + use acadrust::entities::Insert; + use acadrust::objects::AssocDimensionReference; + use acadrust::tables::BlockRecord; + let mut scene = Scene::new(); + let mut block = BlockRecord::new("Stretched"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut source = Line::from_points(Vector3::ZERO, Vector3::new(10.0, 10.0, 0.0)); + source.common.owner_handle = owner; + let source = scene.document.add_entity(EntityType::Line(source)).unwrap(); + let mut insert = Insert::new("Stretched", Vector3::ZERO); + insert.set_x_scale(2.0); + let root = scene.add_entity(EntityType::Insert(insert)); + let reference = AssocDimensionReference { + xrefs: vec![root, source], + osnap_type: 8, + osnap_point: Vector3::new(4.0, 2.0, 0.0), + ..Default::default() + }; + let result = + resolve_reference_chain(&scene, &reference, Some(Vector3::new(0.0, 10.0, 0.0))).unwrap(); + assert!( + (result.model.x - 4.0).abs() < 1e-8 && (result.model.y - 2.0).abs() < 1e-8, + "{result:?}" + ); + let unrelated = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))); + let invalid = AssocDimensionReference { + xrefs: vec![root, unrelated], + ..reference + }; + assert!(matches!( + resolve_reference_chain(&scene, &invalid, None), + Err(ReferenceStatus::Unresolved) + )); +} + +#[test] +fn viewport_dimension_refresh_preserves_explicit_overrides() { + use acadrust::xdata::XDataValue; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + let entity = app.tabs[i].scene.document.get_entity_mut(handle).unwrap(); + dim_override::set_on_entity( + entity, + dim_override::DIMLFAC, + Some(XDataValue::Real(-254.0)), + ); + dim_override::set_on_entity(entity, dim_override::DIMTOL, Some(XDataValue::Integer16(1))); + dim_override::set_on_entity(entity, dim_override::DIMTP, Some(XDataValue::Real(0.05))); + dim_override::set_on_entity(entity, dim_override::DIMTM, Some(XDataValue::Real(0.02))); + if let EntityType::Dimension(d) = entity { + crate::entities::dimension::set_dimension_text_override(d.base_mut(), Some("L=<>".into())); + } + let expected = displayed(&app.tabs[i].scene.document); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + let d = dimension(&app.tabs[i].scene.document); + assert!( + (displayed(&app.tabs[i].scene.document) - expected).abs() < 1e-5, + "{} vs {expected}", + displayed(&app.tabs[i].scene.document) + ); + assert_eq!( + crate::entities::dimension::dimension_text_override(d.base()), + Some("L=<>") + ); + assert_eq!( + dim_override::real(&d.base().common.extended_data, dim_override::DIMTP), + Some(0.05) + ); + assert_eq!( + dim_override::real(&d.base().common.extended_data, dim_override::DIMTM), + Some(0.02) + ); +} + +#[test] +fn viewport_dimension_block_center_keeps_the_circle_source() { + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + let mut block = BlockRecord::new("Circle block"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut circle = Circle::new(); + circle.radius = 10.0; + circle.common.owner_handle = owner; + let circle = scene + .document + .add_entity(EntityType::Circle(circle)) + .unwrap(); + // This line is closer to the circle center than its circumference is. + let mut line = Line::from_points(Vector3::new(-2.0, 1.0, 0.0), Vector3::new(2.0, 1.0, 0.0)); + line.common.owner_handle = owner; + scene.document.add_entity(EntityType::Line(line)).unwrap(); + scene.set_current_layout("Model".into()); + let root = scene.add_entity(EntityType::Insert(Insert::new( + "Circle block", + Vector3::new(40.0, 20.0, 0.0), + ))); + scene.set_current_layout("Dimensions".into()); + let mut center = hit(frame, root, DVec3::new(40.0, 20.0, 0.0)); + center.snap_type = SnapType::Center; + assert!(app.record_accepted_snap(i, Some(center), Some(frame), center.world)); + let source = app + .accepted_snaps() + .last() + .unwrap() + .source + .as_ref() + .unwrap(); + assert_eq!(source.source.handle, circle); + assert_eq!(source.block_path, vec![root]); +} + +#[test] +fn viewport_dimension_angular_object_picks_follow_arc_features() { + use crate::scene::{ChangeKind, ReferenceStatus}; + use acadrust::entities::{Arc, LwPolyline}; + use acadrust::types::Vector2; + for bulged in [false, true] { + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let entity = if bulged { + let mut poly = + LwPolyline::from_points(vec![Vector2::new(100.0, 200.0), Vector2::new(0.0, 300.0)]); + poly.vertices[0].bulge = (std::f64::consts::PI / 8.0).tan(); + EntityType::LwPolyline(poly) + } else { + let mut arc = Arc::new(); + arc.center = Vector3::new(0.0, 200.0, 0.0); + arc.radius = 100.0; + arc.start_angle = 0.0; + arc.end_angle = std::f64::consts::FRAC_PI_2; + EntityType::Arc(arc) + }; + let source = app.tabs[i].scene.add_entity(entity); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMANGULAR"); + let model = DVec3::new(100.0 / 2.0_f64.sqrt(), 200.0 + 100.0 / 2.0_f64.sqrt(), 0.0); + 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(), 3); + let _ = app.feed_command(StepInput::Point(DVec3::new(65.0, 85.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); + let dim = dimension(&app.tabs[i].scene.document).base().common.handle; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); + let status = app.tabs[i].scene.dimension_association_status(dim); + assert_eq!(status.len(), 3); + assert!( + status.iter().all(|(_, s)| *s == ReferenceStatus::Resolved), + "{bulged}: {status:?}" + ); + match app.tabs[i].scene.document.get_entity_mut(source).unwrap() { + EntityType::Arc(arc) => arc.end_angle = std::f64::consts::PI / 3.0, + EntityType::LwPolyline(poly) => { + poly.vertices[0].bulge = (std::f64::consts::PI / 12.0).tan() + } + _ => unreachable!(), + } + app.tabs[i] + .scene + .bump_entities(&[(source, ChangeKind::Modified)]); + assert!( + (displayed(&app.tabs[i].scene.document) - 60.0).abs() < 1e-5, + "{bulged}: {}", + displayed(&app.tabs[i].scene.document) + ); + } +} + +#[test] +fn viewport_dimension_quadrant_keeps_its_feature_after_translation() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let mut circle = Circle::new(); + circle.radius = 100.0; + let source = app.tabs[i].scene.add_entity(EntityType::Circle(circle)); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut quadrant = hit(frame, source, DVec3::X * 100.0); + quadrant.snap_type = SnapType::Quadrant; + assert!(app.record_accepted_snap(i, Some(quadrant), Some(frame), quadrant.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(quadrant.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let EntityType::Circle(circle) = app.tabs[i].scene.document.get_entity_mut(source).unwrap() + else { + panic!() + }; + circle.center.x = 300.0; + app.tabs[i] + .scene + .bump_entities(&[(source, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 400.0).abs() < 1e-5); +} + +fn start_centerline_dimension( + command: &str, + paper_first: bool, + paper_kind: SnapType, + factor: f64, +) -> (OpenCADStudio, Handle, Handle, ViewportFrame) { + let (mut app, model, frame) = fixture(); + let i = app.active_tab; + let paper = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(55.0, 40.0, 0.0), + Vector3::new(55.0, 60.0, 0.0), + ))); + let mut style = acadrust::tables::DimStyle::new("CenterlineUnits"); + style.dimlfac = factor; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "CenterlineUnits".into(); + let _ = app.dispatch_command(command); + let mut paper_hit = hit(frame, paper, DVec3::new(55.0, 50.0, 0.0)); + paper_hit.world = DVec3::new(55.0, 50.0, 0.0); + paper_hit.model_point = None; + paper_hit.viewport = None; + paper_hit.snap_type = paper_kind; + let mut picks = [ + (paper_hit, None), + (hit(frame, model, DVec3::X * 100.0), Some(frame)), + ]; + if !paper_first { + picks.reverse(); + } + for (snap, context) in picks { + assert!(app.record_accepted_snap(i, Some(snap), context, snap.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(snap.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + } + (app, model, paper, frame) +} + +#[test] +fn viewport_dimension_paper_centerline_uses_viewport_units_in_either_order() { + for command in ["DIMLINEAR", "DIMALIGNED"] { + for paper_first in [true, false] { + let (mut app, _, paper, _) = + start_centerline_dimension(command, paper_first, SnapType::Midpoint, 25.4); + let paper_snap = app + .accepted_snaps() + .iter() + .find(|s| s.viewport.is_none()) + .unwrap(); + assert_eq!(paper_snap.source.as_ref().unwrap().source.handle, paper); + assert_eq!(paper_snap.paper_point, paper_snap.model_point); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let doc = &app.tabs[app.active_tab].scene.document; + assert!( + (displayed(doc) - 1270.0).abs() < 1e-3, + "{command}: {}", + displayed(doc) + ); + app.undo_steps(1); + app.redo_steps(1); + assert!((displayed(&app.tabs[app.active_tab].scene.document) - 1270.0).abs() < 1e-3); + } + } +} + +#[test] +fn viewport_dimension_paper_centerline_tracks_each_owning_space() { + use crate::scene::{ChangeKind, ReferenceStatus}; + for (paper_first, kind) in [(true, SnapType::Midpoint), (false, SnapType::Perpendicular)] { + let (mut app, model, paper, frame) = + start_centerline_dimension("DIMALIGNED", paper_first, kind, 1.0); + let i = app.active_tab; + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let dim = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + let assoc = app.tabs[i].scene.dimension_association(dim).unwrap(); + assert!(assoc.trans_space); + assert!(assoc + .references + .iter() + .flatten() + .any(|r| r.xrefs == vec![paper])); + assert!(assoc + .references + .iter() + .flatten() + .any(|r| r.xrefs == vec![frame.viewport, model])); + assert!(app.tabs[i] + .scene + .dimension_association_status(dim) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); + let pending = app.begin_undo(i, "Move paper centerline", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(paper); + app.tabs[i].scene.record_undo_before(paper, before); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity_mut(paper).unwrap() + else { + panic!() + }; + line.start.x = 56.0; + line.end.x = 56.0; + app.tabs[i] + .scene + .bump_entities(&[(paper, ChangeKind::Modified)]); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 40.0).abs() < 1e-4); + app.undo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + app.redo_steps(1); + let EntityType::Viewport(vp) = app.tabs[i] + .scene + .document + .get_entity_mut(frame.viewport) + .unwrap() + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + // Model endpoint now appears at paper x=70; paper centerline stays x=56. + assert!((displayed(&app.tabs[i].scene.document) - 70.0).abs() < 1e-4); + let EntityType::Viewport(vp) = app.tabs[i] + .scene + .document + .get_entity_mut(frame.viewport) + .unwrap() + else { + panic!() + }; + vp.view_target.x = 20.0; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity_mut(model).unwrap() + else { + panic!() + }; + line.end.x = 160.0; + app.tabs[i] + .scene + .bump_entities(&[(model, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 110.0).abs() < 1e-4); + for ext in ["dxf", "dwg"] { + let doc = &app.tabs[i].scene.document; + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let mut scene = Scene::new(); + scene.document = crate::io::load_bytes(&format!("centerline.{ext}"), bytes).unwrap(); + scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&scene.document) - 110.0).abs() < 1e-4, "{ext}"); + assert!(scene + .dimension_association_status(dim) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); + } + let before = app.tabs[i].scene.document.get_entity(dim).unwrap().clone(); + let model_owner = app.tabs[i] + .scene + .document + .get_entity(model) + .unwrap() + .common() + .owner_handle; + app.tabs[i] + .scene + .document + .get_entity_mut(paper) + .unwrap() + .common_mut() + .owner_handle = model_owner; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert_eq!(app.tabs[i].scene.document.get_entity(dim).unwrap(), &before); + assert!(app.tabs[i] + .scene + .dimension_association_status(dim) + .iter() + .any(|(_, s)| *s == ReferenceStatus::Unresolved)); + } +} + +#[test] +fn viewport_dimension_invalid_paths_leave_no_partial_association() { + use crate::scene::viewport_ref::SnapSourceRef; + use acadrust::entities::{DimensionAligned, Insert}; + use acadrust::tables::BlockRecord; + + let (mut app, model, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut block = BlockRecord::new("Collapsed"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut leaf = Line::from_points(Vector3::ZERO, Vector3::UNIT_X); + leaf.common.owner_handle = owner; + let leaf = scene.document.add_entity(EntityType::Line(leaf)).unwrap(); + scene.set_current_layout("Model".into()); + let mut insert = Insert::new("Collapsed", Vector3::ZERO); + insert.set_x_scale(0.0); + insert.set_y_scale(0.0); + let root = scene.add_entity(EntityType::Insert(insert)); + scene.set_current_layout("Dimensions".into()); + let paper = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(55.0, 50.0, 0.0), + Vector3::new(55.0, 60.0, 0.0), + ))); + for (source, path) in [(model, vec![Handle::new(u64::MAX)]), (leaf, vec![root])] { + let dim = scene.add_entity(EntityType::Dimension(Dimension::Aligned( + DimensionAligned::new(Vector3::new(50.0, 50.0, 0.0), Vector3::new(55.0, 50.0, 0.0)), + ))); + let invalid = AcceptedSnap { + paper_point: DVec3::new(50.0, 50.0, 0.0), + model_point: DVec3::ZERO, + viewport: Some(frame.viewport), + frame: Some(frame), + source: Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(source), + block_path: path, + snap_type: SnapType::Endpoint, + intersection: None, + }), + }; + let mut valid = AcceptedSnap::free(DVec3::new(55.0, 50.0, 0.0)); + valid.source = Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(paper), + block_path: Vec::new(), + snap_type: SnapType::Endpoint, + intersection: None, + }); + scene.attach_viewport_dimension_association(dim, &[Some(invalid), Some(valid)]); + let association = scene.dimension_association(dim).unwrap(); + assert!(!association.trans_space); + assert!(association.references[0].is_empty()); + assert_eq!(association.references[1][0].xrefs, vec![paper]); + assert_eq!(scene.dimension_association_sources(dim), vec![paper]); + assert_eq!( + scene.dimension_association_status(dim), + vec![(1, crate::scene::ReferenceStatus::Resolved)] + ); + } +} diff --git a/src/command.rs b/src/command.rs index 25d321e8f..8368c9f8a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -183,7 +183,7 @@ pub struct SelectionEntity { } /// Association source with an optional sub-entity marker. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct DimensionAssociationSource { pub handle: Handle, pub marker: Option, @@ -2245,7 +2245,23 @@ pub trait CadCommand: Send { CmdResult::Cancel } - /// Returns `true` when the command needs entity picking (hit-test) instead of point picking. + /// Supports acquiring dimension geometry through a paper-space viewport. + fn measures_through_viewports(&self) -> bool { + 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 + } + + /// Needs entity hit-testing instead of point input. fn needs_entity_pick(&self) -> bool { false } diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 9c9214041..d9688ea5b 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -235,6 +235,65 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option) { + let common = entity.common_mut(); + let mut kept: Vec<(i16, XDataValue)> = pairs(&common.extended_data) + .into_iter() + .filter(|(c, _)| *c != code) + .collect(); + if let Some(v) = value { + kept.push((code, v)); + } + + let legacy = common.extended_data.get_record("ACAD_DSTYLE").is_some() + && common.extended_data.get_record("ACAD").is_some_and( + |rec| !matches!(rec.values.first(), Some(XDataValue::String(n)) if n == "DSTYLE"), + ); + let app = if legacy { "ACAD_DSTYLE" } else { "ACAD" }; + + let values = if kept.is_empty() { + None + } else { + let mut vals = Vec::new(); + if !legacy { + vals.push(XDataValue::String("DSTYLE".to_string())); + } + vals.push(XDataValue::ControlString("{".to_string())); + for (code, value) in kept { + vals.push(XDataValue::Integer16(code)); + vals.push(value); + } + vals.push(XDataValue::ControlString("}".to_string())); + Some(vals) + }; + + let mut rebuilt = ExtendedData::new(); + for record in common.extended_data.records() { + if record.application_name != app && !(!legacy && record.application_name == "ACAD_DSTYLE") + { + rebuilt.add_record(record.clone()); + } + } + if let Some(vals) = values { + let mut record = acadrust::xdata::ExtendedDataRecord::new(app); + for v in vals { + record.add_value(v); + } + rebuilt.add_record(record); + } + rebuilt.raw_dwg_eed = common.extended_data.raw_dwg_eed.clone(); + common.extended_data = rebuilt; +} + pub fn property_real_code(field: &str) -> Option { Some(match field { "dim_arrow_size" => DIMASZ, diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 470022408..ad3fa78db 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -1613,9 +1613,56 @@ pub(crate) fn resolved_dimension_style( style.dimtxsty = record.name.clone(); } } + + // Negative DIMLFAC applies only in paper space; preserve its sign on export. + if style.dimlfac < 0.0 { + style.dimlfac = crate::scene::viewport_ref::MeasurementScale::user_lfac_for_space( + style.dimlfac, + dimension_in_paper_space(dimension, document), + ); + } style } +/// Whether the dimension's owner is paper space, even without a Layout object. +pub(crate) fn dimension_in_paper_space(dimension: &Dimension, document: &CadDocument) -> bool { + let owner = dimension.base().common.owner_handle; + if !owner.is_valid() { + return false; + } + document.objects.values().any(|object| { + matches!(object, + acadrust::objects::ObjectType::Layout(layout) + if layout.name != "Model" && layout.block_record == owner + ) + }) || document.block_records.iter().any(|block| { + block.handle == owner && block.name.to_ascii_uppercase().starts_with("*PAPER_SPACE") + }) +} + +/// The value a dimension's text reports for its measurement: the raw +/// geometric measurement times the resolved linear factor. Angular dimensions +/// never scale. +/// +/// `actual_measurement` (DXF group 42) stays **raw** — that is what the format +/// specifies and what export writes — so this is the one helper UI surfaces +/// use when they want the number the user reads. +pub(crate) fn displayed_measurement(dimension: &Dimension, style: Option<&DimStyle>) -> f64 { + let raw = dimension.measurement(); + if matches!( + dimension, + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) + ) { + return raw; + } + let lfac = style.map(|s| s.dimlfac).unwrap_or(1.0); + if lfac.abs() < 1e-12 { + raw + } else { + raw * lfac + } +} + /// Build the linear-dimension property groups from the assigned style plus /// any entity-level DSTYLE overrides. Conditional rows remain visible but are /// read-only while their controlling option is disabled. @@ -2097,7 +2144,13 @@ pub fn style_sections( property( t!("Measurement").as_ref(), "measurement", - PropValue::ReadOnly(format!("{:.4}", dimension.measurement())), + // The value the dimension text reports (DIMLFAC applied, + // including viewport compensation) — not the raw paper + // distance, which would disagree with what is drawn. + PropValue::ReadOnly(format!( + "{:.4}", + displayed_measurement(dimension, Some(s)) + )), ), property( t!("Text override").as_ref(), @@ -2196,7 +2249,12 @@ pub fn style_sections( number( t!("Dim scale linear").as_ref(), "dim_scale_linear", - real(ov::DIMLFAC, s.dimlfac), + // The *raw* signed DIMLFAC: a negative value is the + // paper-space / viewport-compensation convention, and + // hiding its sign would make the row uneditable in + // practice. `s.dimlfac` is sign-resolved, so fall back to + // the unresolved source style instead. + real(ov::DIMLFAC, style.dimlfac), true, ), number( @@ -6403,7 +6461,14 @@ fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { }) .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0, 1.0, "")); - let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac }; + // `resolved_dimension_style` has already applied the DXF sign convention, + // so a negative value here means the caller passed an unresolved style; + // fall back to 1.0 rather than reporting a negative length. + let lfac = if lfac.abs() < 1e-12 || lfac < 0.0 { + 1.0 + } else { + lfac + }; let scaled = measurement * lfac; let use_sub_units = zin & 4 != 0 && scaled.abs() < 1.0 && sub_factor.abs() > 1e-12; // For values below one unit, DIMMZF replaces DIMLFAC; applying it after diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 872a00232..2e2f3f133 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -161,6 +161,24 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn 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 b5d3bf417..66fb49596 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -482,6 +482,42 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn 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 1f712ff5e..b41fa2839 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -132,6 +132,23 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn 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 263fc8cac..7e92b0c6b 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -217,6 +217,24 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn 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 ff69fcdd3..898ba52ca 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -130,6 +130,23 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn 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/camera_ops.rs b/src/scene/camera_ops.rs index 4c0118256..a1db36b48 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -567,6 +567,7 @@ impl Scene { viewport.view_target.x = center.x; viewport.view_target.y = center.y; viewport.view_target.z = center.z; + self.notify_viewport_changed(handle); } else { self.camera.borrow_mut().target = center; } diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index e66fef3b3..3eb93accc 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -3,7 +3,7 @@ use acadrust::objects::{ AssocDimensionAssociation, AssocDimensionReference, AssociativeData, AssociativeObject, ObjectType, }; -use acadrust::types::{Handle, Vector3}; +use acadrust::types::{Handle, Transform, Vector3}; use acadrust::EntityType; use cadkernel::geom2d::{ angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, BulgeArc, @@ -14,6 +14,8 @@ use std::f64::consts::TAU; use crate::command::DimensionAssociationSource; +use super::dimension_assoc_chain::{self as chain, ChainError, FeatureContext, ReferenceChain}; +use super::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; use super::{ChangeKind, Scene}; pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4; @@ -99,6 +101,13 @@ impl RadialSourceGeometry { type DPoint = [f64; 3]; +/// The shared-contract vector (`glam`) as the codec's vector. `AcceptedSnap` +/// speaks `glam` because the snap engine does; everything stored in a +/// drawing speaks `acadrust`. +fn dvec3(point: glam::DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + fn vector3(point: DPoint) -> Vector3 { Vector3::new(point[0], point[1], point[2]) } @@ -359,9 +368,261 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { .map(|(index, _)| index as i32) } -fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { - let source = *reference.xrefs.first()?; - let entity = scene.document.get_entity(source)?; +// ── Chain-aware reference resolution ────────────────────────────────────── + +/// Transform an entity-local feature through its block path and optional viewport. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChainMap { + pub transform: Transform, + pub frame: Option, +} + +impl ChainMap { + fn identity() -> Self { + Self { + transform: Transform::identity(), + frame: None, + } + } + + /// Entity-local -> model. + pub(crate) fn to_model(&self, point: Vector3) -> Vector3 { + self.transform.apply(point) + } + + /// Model -> the dimension's own space (paper through a viewport). + pub(crate) fn model_to_space(&self, point: Vector3) -> Vector3 { + match &self.frame { + Some(frame) => { + let mapped = frame.model_to_paper(glam::DVec3::new(point.x, point.y, point.z)); + Vector3::new(mapped.x, mapped.y, mapped.z) + } + None => point, + } + } + + /// The dimension's own space -> model. + pub(crate) fn space_to_model(&self, point: Vector3) -> Vector3 { + match &self.frame { + Some(frame) => { + let mapped = frame.paper_to_model(glam::DVec3::new(point.x, point.y, point.z)); + Vector3::new(mapped.x, mapped.y, mapped.z) + } + None => point, + } + } + + /// Entity-local -> the dimension's own space. + pub(crate) fn to_space(&self, point: Vector3) -> Vector3 { + self.model_to_space(self.to_model(point)) + } + + /// Entity-local -> the dimension's own space, for a direction (the linear + /// part only: block rotation/scale, then the viewport's twist and scale). + fn direction_to_space(&self, direction: Vector3) -> Vector3 { + let direction = self.transform.apply_rotation(direction); + match &self.frame { + Some(frame) => { + let xy = frame.model_to_paper_dir(glam::DVec2::new(direction.x, direction.y)); + Vector3::new(xy.x, xy.y, direction.z) + } + None => direction, + } + } + + /// Carry a plane into the dimension's space. + /// + /// A plane is an origin and two axes, and `Plane::point_at` is affine in + /// them, so mapping the origin by the full transform and each axis by its + /// linear part maps every point *on* the plane correctly — including under + /// a non-uniformly scaled block, where the axes simply stop being + /// orthonormal. + fn map_plane(&self, plane: Plane) -> Plane { + Plane::from_axes( + dpoint(self.to_space(vector3(plane.origin))), + dpoint(self.direction_to_space(vector3(plane.x_axis))), + dpoint(self.direction_to_space(vector3(plane.y_axis))), + ) + } + + /// Carry a radial source into the dimension's space. + /// + /// Only the plane moves: the centre, radius and angles are coordinates + /// *within* the plane, so they stay as they are and every point the + /// geometry produces comes out in the dimension's space already. + fn map_radial(&self, radial: RadialSourceGeometry) -> Option { + let plane = self.map_plane(radial.plane); + let x = glam::DVec3::from_array(plane.x_axis); + let y = glam::DVec3::from_array(plane.y_axis); + let magnitude = x.length_squared().max(y.length_squared()); + if magnitude < 1e-24 + || (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-9 + || x.dot(y).abs() > magnitude * 1e-9 + { + return None; + } + Some(RadialSourceGeometry { plane, ..radial }) + } +} + +/// One reference resolved all the way down the chain. +#[derive(Clone, Copy, Debug)] +pub struct ResolvedReference { + /// The feature point in model space, through the block-instance path. + pub model: Vector3, + /// The same point in the dimension's own space — projected onto the sheet + /// when the chain goes through a viewport, otherwise equal to `model`. + pub space: Vector3, + /// The viewport the chain looks through, if any. + pub viewport: Option, +} + +/// Why a reference did not produce a point. The reference's stored data is +/// never modified on a failure — a dimension whose source went away keeps its +/// last valid appearance, and an undo that restores the source restores the +/// link with no extra bookkeeping. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReferenceStatus { + /// Resolved to a live feature point. + Resolved, + /// A handle in the chain is no longer in the document. + Broken(Handle), + /// The chain is intact but the feature could not be evaluated — an osnap + /// type we do not support, or geometry that no longer has the feature. + Unresolved, +} + +fn chain_map(scene: &Scene, walked: &ReferenceChain) -> ChainMap { + ChainMap { + transform: walked.transform, + frame: walked + .viewport + .and_then(|handle| scene.viewport_frame(handle)), + } +} + +/// How far `point` (entity-local) is from `entity`'s geometry. Used only to +/// choose between two readings of a stored osnap point, so an approximate +/// answer for exotic entities is fine. +fn hint_error(entity: &EntityType, point: Vector3) -> f64 { + if let Some(distance) = source_distance_squared(entity, point) { + if distance.is_finite() { + return distance; + } + } + if let Some(planar) = crate::entities::curve::entity_curve(entity) { + if let Some(uv) = planar.plane.project(dpoint(point)) { + let near = closest_point(&planar.curve, uv); + let world = planar.plane.point_at(near.point); + return point_distance_squared(vector3(world), point); + } + } + f64::INFINITY +} + +/// Interpret a stored point as a source-space or dimension-space hint. +/// Validate both interpretations against the geometry before using the hint +/// to disambiguate a feature; the stored point is never the resolved result. +fn local_hint(map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option { + let inverse = chain::invert(&map.transform)?; + let mut candidates = vec![inverse.apply(stored)]; + if map.frame.is_some() { + candidates.push(inverse.apply(map.space_to_model(stored))); + } + candidates + .into_iter() + .filter(|point| point.x.is_finite() && point.y.is_finite() && point.z.is_finite()) + .min_by(|first, second| hint_error(entity, *first).total_cmp(&hint_error(entity, *second))) +} + +/// Resolve one reference through its full chain. +/// +/// `from_space` is the *other* definition point of the same dimension, in the +/// dimension's own space. PERPENDICULAR and TANGENT have no meaning without +/// it, so the caller resolves the independent references first and comes back +/// for these. +pub fn resolve_reference_chain( + scene: &Scene, + reference: &AssocDimensionReference, + from_space: Option, +) -> Result { + let walked = match chain::walk_chain(&scene.document, &reference.xrefs) { + Ok(walked) => walked, + Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), + Err(ChainError::Empty | ChainError::Invalid) => return Err(ReferenceStatus::Unresolved), + }; + let Some(entity) = scene.document.get_entity(walked.entity) else { + return Err(ReferenceStatus::Broken(walked.entity)); + }; + let map = chain_map(scene, &walked); + if walked.viewport.is_some() && map.frame.is_none() { + return Err(ReferenceStatus::Unresolved); + } + let inverse = chain::invert(&map.transform).ok_or(ReferenceStatus::Unresolved)?; + if matches!( + reference.osnap_type, + chain::osnap::INTERSEC | chain::osnap::APPARENT_INT + ) { + match chain::walk_chain(&scene.document, &reference.intersection_objects) { + Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), + Err(_) => return Err(ReferenceStatus::Unresolved), + Ok(_) => {} + } + } + let legacy = reference.main_subent_type != 2 + && matches!( + reference.osnap_type, + chain::osnap::NONE | chain::osnap::END | chain::osnap::START_POINT + ); + let local = if reference.osnap_type == chain::osnap::PERP && !walked.block_path.is_empty() { + from_space + .and_then(|from| { + chain::perpendicular_in_model(entity, &map.transform, map.space_to_model(from)) + }) + .map(|point| inverse.apply(point)) + } else if legacy { + resolve_local_by_marker(entity, reference) + } else if chain::evaluates(reference.osnap_type) { + let hint = if reference + .osnap_point + .x + .abs() + .max(reference.osnap_point.y.abs()) + .max(reference.osnap_point.z.abs()) + < 1e40 + { + local_hint(&map, entity, reference.osnap_point) + } else { + None + }; + let from = from_space.map(|point| inverse.apply(map.space_to_model(point))); + chain::feature_point( + &scene.document, + entity, + reference, + FeatureContext { hint, from }, + ) + } else { + None + } + .ok_or(ReferenceStatus::Unresolved)?; + if !local.x.is_finite() || !local.y.is_finite() || !local.z.is_finite() { + return Err(ReferenceStatus::Unresolved); + } + let model = map.to_model(local); + Ok(ResolvedReference { + model, + space: map.model_to_space(model), + viewport: walked.viewport, + }) +} + +/// Resolve a marker/parameter in entity-local coordinates. +/// The caller applies the block and viewport transforms. +fn resolve_local_by_marker( + entity: &EntityType, + reference: &AssocDimensionReference, +) -> Option { if reference.main_gs_marker == ARC_DIMENSION_POINT_MARKER { let radial = radial_source_for_marker(entity, 0)?; let sweep = positive_sweep(radial.start_angle, radial.end_angle); @@ -539,6 +800,13 @@ fn remap_plane_offset( ])) } +/// Whether `dimension` still has at least one reference whose whole chain +/// resolves to a live entity. +/// +/// The chain matters: an imported trans-space reference has the VIEWPORT as +/// its first handle, so "some handle in `xrefs` exists" was true even for a +/// reference whose actual source had been erased. Walking the chain asks the +/// question that was meant — is the geometry still there. pub(crate) fn dimension_is_associative( document: &acadrust::CadDocument, dimension: Handle, @@ -552,12 +820,11 @@ pub(crate) fn dimension_is_associative( }; association.dimension == dimension && association.associativity != 0 - && association.references.iter().flatten().any(|reference| { - reference - .xrefs - .iter() - .any(|source| document.get_entity(*source).is_some()) - }) + && association + .references + .iter() + .flatten() + .any(|reference| chain::walk_chain(document, &reference.xrefs).is_ok()) }) } @@ -584,6 +851,15 @@ pub(crate) fn constraint_from_associative_dimension( }; (association.dimension == handle && association.associativity != 0).then_some(association) })?; + if association.trans_space + || association + .references + .iter() + .flatten() + .any(|r| r.xrefs.len() != 1) + { + return None; + } let point = |index: usize| { let reference = association.references.get(index)?.first()?; Some(ParametricRef::point( @@ -858,6 +1134,35 @@ impl Scene { references: [Vec; 4], associativity: i32, sources: Vec>, + ) { + self.store_dimension_association_full( + dimension, + references, + associativity, + false, + sources.into_iter().flatten().map(|s| s.handle).collect(), + ); + } + + /// Create the DIMASSOC object for `dimension` and wire its reactors. + /// + /// `reactor_targets` is every handle the association depends on — + /// including the viewport and each INSERT on a block path, not just the + /// innermost entity — so a change to any link in the chain reaches + /// [`Scene::refresh_associative_dimensions`] through the normal + /// dependency index. + /// + /// The new object and every entity whose reactor list is touched are + /// journalled into the open undo recording, so creating an associative + /// dimension is a single undoable step rather than something that survives + /// its own undo. + fn store_dimension_association_full( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + trans_space: bool, + reactor_targets: Vec, ) { let association_handle = self.document.allocate_handle(); let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); @@ -865,27 +1170,487 @@ impl Scene { object.reactors.push(dimension); object.data = AssociativeData::DimensionAssociation(AssocDimensionAssociation { associativity, + trans_space, dimension, references, ..AssocDimensionAssociation::default() }); + // A brand-new object: its before-image is "absent", so undo erases it. + self.record_undo_object_before(association_handle, None); self.document .objects .insert(association_handle, ObjectType::Associative(object)); - let mut reactor_targets = vec![dimension]; - reactor_targets.extend(sources.into_iter().flatten().map(|source| source.handle)); + let mut reactor_targets = reactor_targets; + reactor_targets.push(dimension); + reactor_targets.retain(|handle| !handle.is_null()); reactor_targets.sort_by_key(|handle| handle.value()); reactor_targets.dedup(); for handle in reactor_targets { - if let Some(entity) = self.document.get_entity_mut(handle) { - if !entity.common().reactors.contains(&association_handle) { + if self + .document + .get_entity(handle) + .is_some_and(|entity| !entity.common().reactors.contains(&association_handle)) + { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if let Some(entity) = self.document.get_entity_mut(handle) { entity.common_mut().reactors.push(association_handle); } } } } + /// Persist the association for a dimension whose points were picked + /// through [`AcceptedSnap`]s, preserving each source and its owning space. + /// + /// `snaps` is positional: one slot per definition point, in the same order + /// [`dimension_reference_points`] returns them (linear/aligned: first, + /// second; angular: see that function). A `None` slot, or a snap that + /// landed on empty space, leaves that slot non-associative. + /// + /// Each reference records the whole chain — viewport, block-instance path, + /// entity — plus the osnap type and the sub-entity marker / parameter, so + /// the feature survives an edit to the source rather than being + /// re-inferred by proximity. + pub fn attach_viewport_dimension_association( + &mut self, + dimension: Handle, + snaps: &[Option], + ) { + let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) + else { + return; + }; + // Radial references identify a circle/arc and angle, rather than a vertex. + let radial_dimension = matches!( + dimension_entity, + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ); + if radial_dimension { + self.attach_viewport_radial_association(dimension, snaps); + return; + } + let mut references: [Vec; 4] = std::array::from_fn(|_| Vec::new()); + let mut associativity = 0; + let mut trans_space = false; + let mut reactor_targets = Vec::new(); + + for (index, snap) in snaps.iter().enumerate().take(references.len()) { + let Some(snap) = snap else { continue }; + let Some(source) = snap.source.as_ref() else { + continue; + }; + let entity_handle = source.source.handle; + let Some(entity) = self.document.get_entity(entity_handle) else { + continue; + }; + // dimension -> [viewport] -> [insert ...] -> entity + let mut xrefs = Vec::new(); + if let Some(viewport) = snap.viewport { + xrefs.push(viewport); + } + xrefs.extend(source.block_path.iter().copied()); + xrefs.push(entity_handle); + let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else { + continue; + }; + let Some(inverse) = chain::invert(&walked.transform) else { + continue; + }; + let local = inverse.apply(dvec3(snap.model_point)); + let osnap_type = chain::osnap_type_for(source.snap_type); + let marker_parameter = source + .source + .marker + .map(|marker| (marker, source.source.parameter)) + .or_else(|| feature_reference(entity, local, source.snap_type)); + let Some((marker, parameter)) = marker_parameter else { + continue; + }; + let mut intersection_objects = Vec::new(); + if matches!( + source.snap_type, + crate::snap::SnapType::Intersection | crate::snap::SnapType::ApparentIntersection + ) { + let Some(other) = source.intersection.as_ref() else { + continue; + }; + intersection_objects.extend(snap.viewport); + intersection_objects.extend(other.block_path.iter().copied()); + intersection_objects.push(other.source.handle); + if chain::walk_chain(&self.document, &intersection_objects).is_err() { + continue; + } + reactor_targets.extend(intersection_objects.iter().copied()); + } + trans_space |= snap.viewport.is_some(); + reactor_targets.extend(xrefs.iter().copied()); + associativity |= 1 << index; + references[index].push(AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type, + xrefs, + intersection_objects, + main_subent_type: 1, + main_gs_marker: marker, + osnap_distance: parameter, + // Store the source's owning-space point: model coordinates for + // viewport features, paper coordinates for direct sheet features. + osnap_point: dvec3(snap.model_point), + ..AssocDimensionReference::default() + }); + } + if associativity == 0 { + return; + } + self.store_dimension_association_full( + dimension, + references, + associativity, + trans_space, + reactor_targets, + ); + } + + /// The radial half of [`Scene::attach_viewport_dimension_association`]. + /// + /// The first snap that carries a source wins: a radius / diameter + /// dimension measures one circle or one polyline arc segment, and the + /// command only ever acquires one. + fn attach_viewport_radial_association( + &mut self, + dimension: Handle, + snaps: &[Option], + ) { + let Some(snap) = snaps.iter().flatten().find(|snap| snap.source.is_some()) else { + return; + }; + let source = snap.source.as_ref().expect("filtered above"); + let entity_handle = source.source.handle; + let Some(entity) = self.document.get_entity(entity_handle) else { + return; + }; + let mut xrefs = Vec::new(); + xrefs.extend(snap.viewport); + xrefs.extend(source.block_path.iter().copied()); + xrefs.push(entity_handle); + let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else { + return; + }; + let Some(inverse) = chain::invert(&walked.transform) else { + return; + }; + let local = inverse.apply(dvec3(snap.model_point)); + let radial = match source.source.marker { + Some(marker) => radial_source_for_marker(entity, marker), + None => radial_source_at(entity, local), + }; + let Some(radial) = radial else { return }; + let angle = if source.source.marker.is_some() && source.source.parameter.is_finite() { + source.source.parameter + } else { + radial.angle_at(dpoint(local)) + }; + let trans_space = snap.viewport.is_some(); + let reactor_targets = xrefs.clone(); + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs, + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: dvec3(snap.model_point), + ..AssocDimensionReference::default() + }; + self.store_dimension_association_full( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + trans_space, + reactor_targets, + ); + } + + /// The definition points an association fills, slot by slot, in the same + /// order [`Scene::attach_viewport_dimension_association`] consumes its + /// `snaps` argument. + /// + /// Callers use it to size and sanity-check the slot array they build from + /// the accepted snaps: slot `k` of that array must describe the feature + /// sitting at `dimension_association_slot_points()[k]`. + pub(crate) fn dimension_association_slot_points(&self, dimension: Handle) -> Vec { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { + return Vec::new(); + }; + match entity { + // A radial dimension's single reference describes the point where + // the leader meets the circle. + Dimension::Radius(radius) => vec![radius.definition_point], + Dimension::Diameter(diameter) => vec![diameter.angle_vertex], + Dimension::LargeRadial(radial) => vec![radial.chord_point], + other => dimension_reference_points(other), + } + } + + /// Select one measurement viewport while allowing direct references to + /// geometry owned by the dimension's paper layout. A model-space chain + /// without its viewport cannot be interpreted as a paper-space anchor. + fn association_measurement_frame( + &self, + association: &AssocDimensionAssociation, + ) -> Result, ()> { + let mut viewport = None; + for reference in association.references.iter().flatten() { + for handles in [&reference.xrefs, &reference.intersection_objects] { + let Some(root) = handles.first() else { + continue; + }; + if matches!( + self.document.get_entity(*root), + Some(EntityType::Viewport(_)) + ) { + if viewport.is_some_and(|previous| previous != *root) { + return Err(()); + } + viewport = Some(*root); + } + } + } + let Some(viewport) = viewport else { + return if association.trans_space { + Err(()) + } else { + Ok(None) + }; + }; + let owner = self + .document + .get_entity(association.dimension) + .ok_or(())? + .common() + .owner_handle; + for reference in association.references.iter().flatten() { + for handles in [&reference.xrefs, &reference.intersection_objects] { + let Some(root) = handles.first() else { + continue; + }; + if self + .document + .get_entity(*root) + .ok_or(())? + .common() + .owner_handle + != owner + { + return Err(()); + } + } + } + self.viewport_frame(viewport).map(Some).ok_or(()) + } + + /// Resolve each reference's current status without changing its saved data. + pub fn dimension_association_status(&self, dimension: Handle) -> Vec<(usize, ReferenceStatus)> { + let Some(association) = self.dimension_association(dimension) else { + return Vec::new(); + }; + let measurement_supported = !association.trans_space + || self.document.get_entity(dimension).is_some_and(|entity| { + matches!( + entity, + EntityType::Dimension(Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) + ) || persisted_measurement_scale(&self.document, entity).is_some() + }); + let measurement_supported = measurement_supported + && self.document.get_entity(dimension).is_some_and(|entity| { + if !matches!( + entity, + EntityType::Dimension( + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ) + ) { + return true; + } + association.references[0] + .first() + .and_then(|reference| { + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let source = self.document.get_entity(walked.entity)?; + chain_map(self, &walked) + .map_radial(radial_source_for_marker(source, reference.main_gs_marker)?) + }) + .is_some() + }); + let measurement_supported = measurement_supported + && association + .references + .iter() + .flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)) + .count() + <= 1; + let measurement_supported = + measurement_supported && self.association_measurement_frame(association).is_ok(); + let points = self.dimension_association_slot_points(dimension); + association + .references + .iter() + .enumerate() + .filter_map(|(index, slot)| { + let reference = slot.first()?; + Some(( + index, + match resolve_reference_chain( + self, + reference, + points + .iter() + .enumerate() + .find_map(|(other, point)| (other != index).then_some(*point)), + ) { + Ok(_) if measurement_supported => ReferenceStatus::Resolved, + Ok(_) => ReferenceStatus::Unresolved, + Err(status) => status, + }, + )) + }) + .collect() + } + + /// Every entity `dimension` depends on: the viewport it looks through, + /// each INSERT on the block path, and the source geometry itself — for + /// each reference chain, plus the second chain of an intersection. + /// + /// Layer visibility and viewport clipping are deliberately not consulted: + /// hiding a layer or clipping a viewport changes what a *new* snap may + /// acquire, never what an existing association points at. + pub fn dimension_association_sources(&self, dimension: Handle) -> Vec { + let Some(association) = self.dimension_association(dimension) else { + return Vec::new(); + }; + let mut sources = Vec::new(); + for reference in association.references.iter().flatten() { + for xrefs in [&reference.xrefs, &reference.intersection_objects] { + let Ok(walked) = chain::walk_chain(&self.document, xrefs) else { + continue; + }; + sources.extend(walked.viewport); + sources.extend(walked.block_path.iter().copied()); + sources.push(walked.entity); + } + } + sources.sort_by_key(|handle| handle.value()); + sources.dedup(); + sources + } + + /// The DIMASSOC payload attached to `dimension`, if there is one. + pub fn dimension_association(&self, dimension: Handle) -> Option<&AssocDimensionAssociation> { + self.document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + (association.dimension == dimension).then_some(association) + }) + } + + /// Preserve an association only when the dimension and every source-chain + /// handle were copied. Remap the complete chain to the new entities. + pub(crate) fn copy_dimension_associations( + &mut self, + handle_map: &rustc_hash::FxHashMap, + ) { + if handle_map.is_empty() { + return; + } + let copies: Vec<_> = self + .document + .objects + .values() + .filter_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + let dimension = *handle_map.get(&association.dimension)?; + let complete = association.references.iter().flatten().all(|reference| { + reference + .xrefs + .iter() + .chain(reference.intersection_objects.iter()) + .all(|handle| handle.is_null() || handle_map.contains_key(handle)) + }); + if !complete { + return None; + } + let remap = |handles: &[Handle]| -> Vec { + handles + .iter() + .map(|handle| handle_map.get(handle).copied().unwrap_or(*handle)) + .collect() + }; + let references = std::array::from_fn(|index| { + association.references[index] + .iter() + .map(|reference| AssocDimensionReference { + xrefs: remap(&reference.xrefs), + intersection_objects: remap(&reference.intersection_objects), + ..reference.clone() + }) + .collect::>() + }); + Some(( + dimension, + references, + association.associativity, + association.trans_space, + )) + }) + .collect(); + for (dimension, references, associativity, trans_space) in copies { + let reactor_targets: Vec = { + let references: &[Vec; 4] = &references; + references + .iter() + .flatten() + .flat_map(|reference| { + reference + .xrefs + .iter() + .chain(reference.intersection_objects.iter()) + .copied() + }) + .collect() + }; + self.store_dimension_association_full( + dimension, + references, + associativity, + trans_space, + reactor_targets, + ); + } + } + + /// Refresh dimensions when their viewport moves, pans, zooms, or twists. + pub fn notify_viewport_changed(&mut self, viewport: Handle) { + if matches!( + self.document.get_entity(viewport), + Some(EntityType::Viewport(_)) + ) { + self.bump_entities(&[(viewport, ChangeKind::Modified)]); + } + } + pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec> { let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return Vec::new(); @@ -963,6 +1728,10 @@ impl Scene { let AssociativeData::DimensionAssociation(association) = &object.data else { return None; }; + // Any link in a chain counts: the source entity, an INSERT on + // the block path, or the viewport the reference looks through + // (that last one is how a pan / zoom / twist of a layout + // viewport reaches the dimensions drawn through it). association .references .iter() @@ -971,38 +1740,138 @@ impl Scene { reference .xrefs .iter() + .chain(reference.intersection_objects.iter()) .any(|handle| changed.contains(handle)) }) - .then_some(association.clone()) + .then(|| association.clone()) }) .collect(); let mut refreshed = Vec::new(); for association in associations { - let resolved: [Option; 4] = std::array::from_fn(|index| { - association.references[index] - .first() - .and_then(|reference| resolve_reference(self, reference)) + let Ok(frame) = self.association_measurement_frame(&association) else { + continue; + }; + // Dependent snaps need a resolved independent definition point. + // Two such features require a coupled solve; old points are not + // evidence of a solution after the geometry changes. + if association + .references + .iter() + .flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)) + .count() + > 1 + { + continue; + } + // Resolve independent references first, then perpendicular/tangent. + let mut resolved: [Option; 4] = std::array::from_fn(|index| { + let reference = association.references[index].first()?; + if chain::needs_from_point(reference.osnap_type) { + return None; + } + resolve_reference_chain(self, reference, None).ok() }); + let previous = self + .document + .get_entity(association.dimension) + .and_then(|entity| match entity { + EntityType::Dimension(dimension) => Some(dimension_reference_points(dimension)), + _ => None, + }) + .unwrap_or_default(); + for index in 0..resolved.len() { + let Some(reference) = association.references[index].first() else { + continue; + }; + if !chain::needs_from_point(reference.osnap_type) { + continue; + } + // The other point: freshly resolved where possible, else the + // one the dimension is currently drawn with. + let from = (0..resolved.len()) + .filter(|other| *other != index) + .find_map(|other| { + resolved[other] + .map(|value| value.space) + .or_else(|| previous.get(other).copied()) + }); + resolved[index] = resolve_reference_chain(self, reference, from).ok(); + } + if association + .references + .iter() + .enumerate() + .any(|(index, slot)| !slot.is_empty() && resolved[index].is_none()) + { + continue; + } + // Every point is already in paper space: direct sheet references + // stay there, and model references are projected individually. + // Only the measurement factor is shared by the whole dimension. + let map = frame + .map(|frame| ChainMap { + transform: Transform::identity(), + frame: Some(frame), + }) + .unwrap_or_else(ChainMap::identity); + let model_points: [Option; 4] = std::array::from_fn(|index| { + resolved[index] + .filter(|value| value.viewport.is_some()) + .map(|value| value.model) + }); + let resolved: [Option; 4] = + std::array::from_fn(|index| resolved[index].map(|value| value.space)); + let radial_source = association.references[0].first().and_then(|reference| { - let source = *reference.xrefs.first()?; - let entity = self.document.get_entity(source)?; + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let entity = self.document.get_entity(walked.entity)?; let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; - Some((radial, reference.osnap_distance)) + // Mapped once, here: everything the match arms below build out + // of it then lands in the dimension's own space. + Some(( + chain_map(self, &walked).map_radial(radial)?, + reference.osnap_distance, + )) }); let arc_source = association.references[0].first().and_then(|reference| { - let source = *reference.xrefs.first()?; - let entity = self.document.get_entity(source)?; + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let entity = self.document.get_entity(walked.entity)?; let segment = match reference.main_gs_marker { -3 => 0, POLYLINE_ARC_CENTER_MARKER => reference.osnap_distance.round().max(0.0) as i32, _ => return None, }; - radial_source_for_marker(entity, segment) + let radial = radial_source_for_marker(entity, segment)?; + chain_map(self, &walked).map_radial(radial) }); if radial_source.is_none() && resolved.iter().all(Option::is_none) { continue; } + let Some(before) = self.document.get_entity(association.dimension).cloned() else { + continue; + }; + let angular = matches!( + &before, + EntityType::Dimension(Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) + ); + let measurement_scale = if let Some(frame) = map.frame.filter(|_| !angular) { + let Some(mut scale) = persisted_measurement_scale(&self.document, &before) else { + continue; + }; + scale.viewport_compensation = frame.paper_to_model_length_factor(); + Some(scale) + } else { + None + }; + if self.is_recording_undo() { + let before = self.document.get_entity_arc(association.dimension); + self.record_undo_before(association.dimension, before); + } + if measurement_scale.is_some() { + self.ensure_app_id(MeasurementScale::APP_ID); + } let Some(EntityType::Dimension(dimension)) = self.document.get_entity_mut(association.dimension) else { @@ -1010,22 +1879,45 @@ impl Scene { }; match dimension { Dimension::Linear(linear) => { + // The sheet annotation the user placed — the dimension + // line's standoff and, when they dragged it, the text — is + // held relative to the measured points. Carry it by the + // centroid shift so an edit to the source keeps the + // drafting the user did instead of collapsing it. + let carry = centroid_shift( + [Some(linear.first_point), Some(linear.second_point)], + [resolved[0], resolved[1]], + ); if let Some(point) = resolved[0] { linear.first_point = point; } if let Some(point) = resolved[1] { linear.second_point = point; } + linear.definition_point = linear.definition_point + carry; linear.base.definition_point = linear.definition_point; + if linear.base.text_user_positioned { + linear.base.text_middle_point = linear.base.text_middle_point + carry; + linear.base.insertion_point = linear.base.insertion_point + carry; + } } Dimension::Aligned(aligned) => { + let carry = centroid_shift( + [Some(aligned.first_point), Some(aligned.second_point)], + [resolved[0], resolved[1]], + ); if let Some(point) = resolved[0] { aligned.first_point = point; } if let Some(point) = resolved[1] { aligned.second_point = point; } + aligned.definition_point = aligned.definition_point + carry; aligned.base.definition_point = aligned.definition_point; + if aligned.base.text_user_positioned { + aligned.base.text_middle_point = aligned.base.text_middle_point + carry; + aligned.base.insertion_point = aligned.base.insertion_point + carry; + } } Dimension::Angular3Pt(angular) => { if let Some(point) = resolved[0] { @@ -1219,13 +2111,70 @@ impl Scene { Dimension::Linear(linear) => linear_measurement(linear), _ => dimension.measurement(), }; + // Store the raw sheet measurement; the formatter applies DIMLFAC once. + debug_assert!( + map.frame.is_none() + || model_points + .iter() + .zip(resolved.iter()) + .all(|(model, space)| match (model, space, map.frame.as_ref()) { + (Some(model), Some(space), Some(frame)) => { + let projected = frame + .model_to_paper(glam::DVec3::new(model.x, model.y, model.z)); + (projected.x - space.x).abs() < 1e-6 + && (projected.y - space.y).abs() < 1e-6 + } + _ => true, + }), + "resolved paper points must be the model points through the frame", + ); dimension.base_mut().actual_measurement = measurement; + if map.frame.is_some() { + if let EntityType::Dimension(old) = &before { + if old.base().text_user_positioned { + dimension.base_mut().text_middle_point = old.base().text_middle_point; + dimension.base_mut().insertion_point = old.base().insertion_point; + } + } + } + let entity = self + .document + .get_entity_mut(association.dimension) + .expect("dimension exists"); + if let Some(scale) = measurement_scale { + scale.write_to_entity(entity); + } + if *entity == before { + continue; + } + // Saved anonymous blocks bypass generated dimension rendering. + // Invalidate only after a completely resolved, committed update; + // the undo snapshot above retains the previous picture and data. + if let EntityType::Dimension(dimension) = entity { + dimension.base_mut().block_name.clear(); + } refreshed.push((association.dimension, ChangeKind::Modified)); } refreshed } } +/// Average displacement of the dimension's resolved definition points. +fn centroid_shift(before: [Option; 2], after: [Option; 2]) -> Vector3 { + let mut delta = Vector3::new(0.0, 0.0, 0.0); + let mut count = 0.0; + for index in 0..2 { + if let (Some(old), Some(new)) = (before[index], after[index]) { + delta = delta + (new - old); + count += 1.0; + } + } + if count == 0.0 { + return Vector3::new(0.0, 0.0, 0.0); + } + Vector3::new(delta.x / count, delta.y / count, delta.z / count) +} + fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { let plane = plane_from_normal(linear.first_point, linear.base.normal); let first = plane @@ -1235,3 +2184,121 @@ fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { let axis = [linear.rotation.cos(), linear.rotation.sin()]; ((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() } + +/// Encode the feature actually acquired, in source-local coordinates. +fn feature_reference( + entity: &EntityType, + point: Vector3, + kind: crate::snap::SnapType, +) -> Option<(i32, f64)> { + use crate::snap::SnapType as S; + match kind { + S::Node | S::Insertion => Some((0, 0.0)), + S::Center if matches!(entity, EntityType::Ellipse(_)) => Some((-3, 0.0)), + S::Endpoint | S::ObjectPick | S::Center => { + if kind != S::Center { + if let Some(index) = source_points(entity) + .iter() + .position(|candidate| point_distance_squared(*candidate, point) < 1e-12) + { + return Some((index as i32, 0.0)); + } + } + if matches!(entity, EntityType::Circle(_) | EntityType::Arc(_)) { + return source_reference(entity, point); + } + let planar = crate::entities::curve::entity_curve(entity)?; + if let KernelCurve::Polyline(polyline) = &planar.curve { + for segment in 0..polyline.vertices.len() { + if let Some(arc) = polyline.segment_arc(segment) { + let center = vector3(planar.plane.point_at(arc.center)); + if point_distance_squared(center, point) < 1e-12 { + return Some((POLYLINE_ARC_CENTER_MARKER, segment as f64)); + } + } + } + } + None + } + S::Nearest | S::Extension => { + if matches!(entity, EntityType::Circle(_) | EntityType::Arc(_)) { + return source_reference(entity, point); + } + let planar = crate::entities::curve::entity_curve(entity)?; + let near = closest_point(&planar.curve, planar.plane.project(dpoint(point))?); + Some((0, near.t)) + } + S::Midpoint => { + let planar = crate::entities::curve::entity_curve(entity)?; + if let KernelCurve::Polyline(_) = &planar.curve { + let uv = planar.plane.project(dpoint(point))?; + let segments = planar.curve.segments(); + let (index, _) = segments.iter().enumerate().min_by(|(_, a), (_, b)| { + let error = |c: &KernelCurve| { + let p = closest_point(c, uv).point; + (p[0] - uv[0]).powi(2) + (p[1] - uv[1]).powi(2) + }; + error(a).total_cmp(&error(b)) + })?; + Some((index as i32, 0.5)) + } else { + Some((0, 0.5)) + } + } + S::Tangent => { + if let EntityType::Spline(spline) = entity { + return Some(( + 0, + crate::entities::spline::nurbs3(spline)?.parameter_at(dpoint(point)), + )); + } + source_reference(entity, point) + } + S::Intersection | S::ApparentIntersection => Some((0, 0.0)), + _ => source_reference(entity, point), + } +} + +fn persisted_measurement_scale( + doc: &acadrust::CadDocument, + entity: &EntityType, +) -> Option { + use crate::entities::dim_override; + use acadrust::xdata::XDataValue; + let EntityType::Dimension(dimension) = entity else { + return None; + }; + let data = &dimension.base().common.extended_data; + let effective = dim_override::real(data, dim_override::DIMLFAC) + .or_else(|| { + doc.dim_styles + .get(&dimension.base().style_name) + .map(|s| s.dimlfac) + })? + .abs(); + let effective = if effective == 0.0 { 1.0 } else { effective }; + if !effective.is_finite() { + return None; + } + if let Some(mut scale) = MeasurementScale::read(data) { + // A Properties edit writes the persisted total DIMLFAC. Preserve that + // new user choice when the next source/viewport update recomposes it. + scale.user_lfac = effective / scale.viewport_compensation; + return Some(scale); + } + let calculated = data + .get_record("ACAD_DIMASSOC_CALC_DIMLFAC")? + .values + .iter() + .find_map(|v| match v { + XDataValue::Real(v) => Some(v.abs()), + _ => None, + })?; + if !calculated.is_finite() || calculated <= 0.0 { + return None; + } + Some(MeasurementScale { + user_lfac: effective / calculated, + viewport_compensation: calculated, + }) +} diff --git a/src/scene/dimension_assoc_chain.rs b/src/scene/dimension_assoc_chain.rs new file mode 100644 index 000000000..e0687760e --- /dev/null +++ b/src/scene/dimension_assoc_chain.rs @@ -0,0 +1,585 @@ +//! Resolve a dimension feature through `[viewport?, insert..., entity]`. +//! `xrefs` stores the primary chain; `intersection_objects` stores the second +//! intersection source. Features are evaluated in entity-local coordinates and +//! transformed through their block instances. The caller projects viewport +//! references onto the sheet. All arithmetic uses `f64`. + +use acadrust::objects::AssocDimensionReference; +use acadrust::types::{Handle, Matrix4, Transform, Vector3}; +use acadrust::{CadDocument, EntityType}; +use cadkernel::geom2d::{closest_point, Curve as KernelCurve}; +use cadkernel::space::Plane; + +use crate::entities::curve::entity_curve; + +/// `AcDb::OsnapMode`, the value stored in +/// [`AssocDimensionReference::osnap_type`]. +/// +/// These are ObjectARX codes, independent of [`crate::snap::SnapType`]. +#[allow(dead_code)] // Include codes preserved from unsupported imported modes. +pub(crate) mod osnap { + pub const NONE: u8 = 0; + pub const END: u8 = 1; + pub const MID: u8 = 2; + pub const CEN: u8 = 3; + pub const NODE: u8 = 4; + pub const QUAD: u8 = 5; + pub const INTERSEC: u8 = 6; + pub const INS: u8 = 7; + pub const PERP: u8 = 8; + pub const TAN: u8 = 9; + pub const NEAR: u8 = 10; + pub const APPARENT_INT: u8 = 11; + pub const PARA: u8 = 12; + pub const START_POINT: u8 = 13; +} + +/// Translate a live snap mode into the `AcDb::OsnapMode` we persist. +/// +/// Modes with no ObjectARX equivalent (grid, object pick) map to +/// [`osnap::NONE`]; a reference carrying `NONE` resolves by marker/parameter +/// alone. +pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 { + use crate::snap::SnapType as S; + match snap { + S::Endpoint => osnap::END, + S::Midpoint => osnap::MID, + S::Center => osnap::CEN, + S::Node => osnap::NODE, + S::Quadrant => osnap::QUAD, + S::Intersection => osnap::INTERSEC, + S::Insertion => osnap::INS, + S::Perpendicular => osnap::PERP, + S::Tangent => osnap::TAN, + S::Nearest | S::Extension => osnap::NEAR, + S::ApparentIntersection => osnap::APPARENT_INT, + S::Parallel => osnap::PARA, + S::Grid | S::ObjectPick => osnap::NONE, + } +} + +/// A resolved `xrefs` chain. +#[derive(Clone, Debug)] +pub(crate) struct ReferenceChain { + /// The layout viewport the reference is seen through, when the chain + /// starts with one. `None` for a plain model-space reference. + pub viewport: Option, + /// INSERT handles from the outermost instance down to the instance that + /// directly contains [`Self::entity`]. Empty for top-level geometry. + pub block_path: Vec, + /// The innermost entity — the one that owns the feature. + pub entity: Handle, + /// Block-path transform: entity-local coordinates -> model coordinates. + /// Identity when `block_path` is empty. + pub transform: Transform, +} + +/// Why a chain could not be walked. The data is never discarded on a failure: +/// the caller keeps the reference exactly as read and leaves the dimension +/// showing its last valid appearance. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChainError { + /// `xrefs` was empty — nothing to resolve. + Empty, + Invalid, + /// A handle in the chain names an object that is not in the document + /// (erased source; restored by an undo). + Missing(Handle), +} + +/// Walk `xrefs` into a [`ReferenceChain`]. +/// +/// The chain is read positionally rather than by a stored kind, because that +/// is all the file gives us: a leading VIEWPORT is the trans-space hop, any +/// INSERTs after it are the block-instance path, and the first handle that is +/// neither is the geometry. A chain that is nothing but INSERTs (dimensioning +/// a block reference's insertion point) ends on the innermost INSERT. +pub(crate) fn walk_chain( + document: &CadDocument, + xrefs: &[Handle], +) -> Result { + if xrefs.is_empty() { + return Err(ChainError::Empty); + } + let mut viewport = None; + let mut block_path = Vec::new(); + let mut transform = Transform::identity(); + let mut owner = None; + for (index, &handle) in xrefs.iter().enumerate() { + if handle.is_null() { + return Err(ChainError::Invalid); + } + let node = document + .get_entity(handle) + .ok_or(ChainError::Missing(handle))?; + if owner.is_some_and(|owner| node.common().owner_handle != owner) { + return Err(ChainError::Invalid); + } + if index == 0 && matches!(node, EntityType::Viewport(_)) { + viewport = Some(handle); + owner = document.block_records.get("*Model_Space").map(|b| b.handle); + continue; + } + if index == xrefs.len() - 1 { + return Ok(ReferenceChain { + viewport, + block_path, + entity: handle, + transform, + }); + } + let EntityType::Insert(insert) = node else { + return Err(ChainError::Invalid); + }; + // Arrays need an instance index that this reference format does not carry. + if insert.row_count > 1 + || insert.column_count > 1 + || block_path.len() >= 8 + || block_path.contains(&handle) + { + return Err(ChainError::Invalid); + } + let block = document + .block_records + .get(&insert.block_name) + .ok_or(ChainError::Invalid)?; + owner = Some(block.handle); + block_path.push(handle); + transform = transform.compose(&crate::scene::render_graph::insert_transform( + document, insert, + )); + } + Err(ChainError::Empty) +} + +/// Invert an affine block transform, rejecting singular or nonfinite matrices. +pub(crate) fn invert(transform: &Transform) -> Option { + let m = &transform.matrix.m; + let matrix = glam::DMat4::from_cols_array_2d(&std::array::from_fn(|column| { + std::array::from_fn(|row| m[row][column]) + })); + if !matrix.is_finite() || matrix.determinant().abs() < 1e-18 { + return None; + } + let inverse = matrix.inverse(); + if !inverse.is_finite() { + return None; + } + let columns = inverse.to_cols_array_2d(); + Some(Transform::from_matrix(Matrix4 { + m: std::array::from_fn(|row| std::array::from_fn(|column| columns[column][row])), + })) +} + +// ── Feature evaluation ──────────────────────────────────────────────────── + +fn vector3(point: [f64; 3]) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> [f64; 3] { + [point.x, point.y, point.z] +} + +fn distance_squared(a: Vector3, b: Vector3) -> f64 { + let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z); + dx * dx + dy * dy + dz * dz +} + +fn lift(plane: &Plane, uv: [f64; 2]) -> Vector3 { + vector3(plane.point_at(uv)) +} + +/// Everything a feature evaluation may need beyond the reference itself. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct FeatureContext { + /// The stored osnap point, already expressed in *entity-local* + /// coordinates. Used as the disambiguating hint for every feature that has + /// more than one candidate (quadrant, intersection, tangent, nearest). + pub hint: Option, + /// The point the feature is measured *from*, in entity-local coordinates. + /// PERPENDICULAR and TANGENT are two-point features: the foot / touch + /// point only exists relative to an external point, which for a dimension + /// is the other definition point. Tangency requires this point. + pub from: Option, +} + +/// Evaluate the osnap feature `reference` names on `entity`, in the entity's +/// own coordinates. +/// +/// Returns `None` — never a guess — when the osnap type is one we do not +/// evaluate, or when the geometry no longer supports it. The caller keeps the +/// reference data intact and leaves the dimension where it was. +pub(crate) fn feature_point( + document: &CadDocument, + entity: &EntityType, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + match reference.osnap_type { + osnap::END | osnap::START_POINT if reference.main_subent_type == 2 => { + imported_endpoint(entity, reference, context.hint) + } + osnap::CEN => center_point(entity, reference), + osnap::MID => mid_point(entity, reference), + osnap::QUAD => quadrant_point(entity, reference, context.hint?), + osnap::NODE | osnap::INS => node_point(entity), + osnap::INTERSEC | osnap::APPARENT_INT => intersection_point( + document, + entity, + reference, + context.hint?, + reference.osnap_type == osnap::APPARENT_INT, + ), + osnap::PERP => perpendicular_point(entity, context.from.or(context.hint)?), + osnap::TAN => tangent_point(entity, reference, context), + osnap::NEAR => nearest_point(entity, reference, context.hint), + // END / START_POINT / PARA / NONE all address a named vertex or a + // stored curve parameter, which is what the legacy marker convention + // already encodes. The caller keeps that path. + _ => None, + } +} + +/// Whether [`feature_point`] knows how to evaluate this osnap type. A type we +/// do not handle keeps its stored data and stays unresolved. +pub(crate) fn evaluates(osnap_type: u8) -> bool { + matches!( + osnap_type, + osnap::END + | osnap::START_POINT + | osnap::CEN + | osnap::MID + | osnap::QUAD + | osnap::NODE + | osnap::INS + | osnap::INTERSEC + | osnap::APPARENT_INT + | osnap::PERP + | osnap::TAN + | osnap::NEAR + ) +} + +/// Whether this osnap type needs the *other* definition point to evaluate. +pub(crate) fn needs_from_point(osnap_type: u8) -> bool { + matches!(osnap_type, osnap::PERP | osnap::TAN) +} + +fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option { + match entity { + EntityType::Circle(circle) => Some(circle.center_wcs()), + EntityType::Arc(arc) => Some(arc.center_wcs()), + EntityType::Ellipse(ellipse) => Some(ellipse.center), + _ => { + // A polyline's bulge segment: `main_gs_marker` names the segment. + let planar = entity_curve(entity)?; + let KernelCurve::Polyline(polyline) = &planar.curve else { + return None; + }; + let segment = if reference.main_subent_type == 1 + && reference.main_gs_marker == super::dimension_assoc::POLYLINE_ARC_CENTER_MARKER + { + reference.osnap_distance.round().max(0.0) as usize + } else { + segment_index(reference)? + }; + let arc = polyline.segment_arc(segment)?; + Some(lift(&planar.plane, arc.center)) + } + } +} + +fn mid_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option { + let planar = entity_curve(entity)?; + match &planar.curve { + // For a polyline the marker names which segment's midpoint it is; + // without one, the midpoint of the whole run. + KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => { + let segments = planar.curve.segments(); + let segment = segments.get(segment_index(reference)?)?; + Some(lift(&planar.plane, segment.point_at(0.5))) + } + _ => Some(lift(&planar.plane, planar.curve.point_at(0.5))), + } +} + +fn quadrant_point( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Vector3, +) -> Option { + let planar = entity_curve(entity)?; + let (centre, radius) = match &planar.curve { + KernelCurve::Circle(circle) => (circle.centre, circle.radius), + KernelCurve::Arc(arc) => (arc.centre, arc.radius), + _ => return None, + }; + let valid_angle = |angle: f64| match &planar.curve { + KernelCurve::Arc(arc) => { + cadkernel::geom2d::angle_within_arc(angle, arc.start_angle, arc.end_angle) + } + _ => true, + }; + let at_angle = |angle: f64| { + lift( + &planar.plane, + KernelCurve::Circle(cadkernel::geom2d::Circle { centre, radius }) + .point_at(angle.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU), + ) + }; + // New references retain the chosen source-local angle, so translation + // cannot silently select another quadrant using the old world point. + if reference.main_subent_type == 1 && reference.main_gs_marker == -2 { + let angle = reference.osnap_distance; + return (angle.is_finite() && valid_angle(angle)).then(|| at_angle(angle)); + } + // An imported point can identify an unchanged quadrant, but cannot prove + // which quadrant was intended after it moves away from the stored hint. + (0..4) + .map(|index| std::f64::consts::FRAC_PI_2 * index as f64) + .filter(|angle| valid_angle(*angle)) + .map(at_angle) + .find(|candidate| distance_squared(*candidate, hint) < 1e-12) +} + +fn node_point(entity: &EntityType) -> Option { + match entity { + EntityType::Point(point) => Some(point.location), + EntityType::Insert(insert) => Some(insert.insert_point), + EntityType::Text(text) => Some(text.insertion_point), + EntityType::MText(text) => Some(text.insertion_point), + EntityType::Shape(shape) => Some(shape.insertion_point), + _ => None, + } +} + +fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option { + let planar = entity_curve(entity)?; + let uv = planar.plane.project(dpoint(from))?; + cadkernel::geom2d::snap::perpendicular_from(&planar.curve, uv) + .into_iter() + .map(|candidate| lift(&planar.plane, candidate.point)) + .min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from))) +} + +/// Perpendicularity must be evaluated after a block's nonuniform scale. +pub(crate) fn perpendicular_in_model( + entity: &EntityType, + transform: &Transform, + from: Vector3, +) -> Option { + use glam::DVec3; + let source = entity_curve(entity)?; + let origin = transform.apply(vector3(source.plane.origin)); + let x = DVec3::from_array(dpoint( + transform.apply_rotation(vector3(source.plane.x_axis)), + )); + let y = DVec3::from_array(dpoint( + transform.apply_rotation(vector3(source.plane.y_axis)), + )); + let axis_x = x.try_normalize()?; + let normal = x.cross(y).try_normalize()?; + let axis_y = normal.cross(axis_x); + let plane = Plane::from_axes(dpoint(origin), axis_x.to_array(), axis_y.to_array()); + let curve = source.curve.transformed(&cadkernel::geom2d::Transform { + origin: [0.0, 0.0].into(), + x_axis: [x.dot(axis_x), x.dot(axis_y)].into(), + y_axis: [y.dot(axis_x), y.dot(axis_y)].into(), + })?; + let uv = plane.project(dpoint(from))?; + cadkernel::geom2d::snap::perpendicular_from(&curve, uv) + .into_iter() + .map(|candidate| lift(&plane, candidate.point)) + .min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from))) +} + +fn nearest_point( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Option, +) -> Option { + let planar = entity_curve(entity)?; + // Circles and arcs store an angle; other curves use a normalized parameter. + match &planar.curve { + KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity { + EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)), + EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), + _ => None, + }, + _ => { + let parameter = reference.osnap_distance; + if parameter.is_finite() && (0.0..=1.0).contains(¶meter) { + return Some(lift(&planar.plane, planar.curve.point_at(parameter))); + } + // No usable parameter: fall back to the point on the curve nearest + // the stored osnap point, which is what NEAR means anyway. + let uv = planar.plane.project(dpoint(hint?))?; + Some(lift(&planar.plane, closest_point(&planar.curve, uv).point)) + } + } +} + +// ── Tangent ─────────────────────────────────────────────────────────────── + +/// Tangent point for the line through `context.from`. +fn tangent_point( + entity: &EntityType, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + if let EntityType::Spline(spline) = entity { + return spline_tangent_point(spline, reference, context); + } + let planar = entity_curve(entity)?; + let from = context.from?; + let uv = planar.plane.project(dpoint(from))?; + cadkernel::geom2d::snap::tangent_from(&planar.curve, uv) + .into_iter() + .map(|candidate| lift(&planar.plane, candidate.point)) + .min_by(|a, b| { + distance_squared(*a, context.hint.unwrap_or(from)) + .total_cmp(&distance_squared(*b, context.hint.unwrap_or(from))) + }) +} + +fn spline_tangent_point( + spline: &acadrust::entities::Spline, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + let curve = crate::entities::spline::nurbs3(spline)?; + let (start, end) = curve.domain(); + let stored = reference.osnap_distance; + let u = if stored.is_finite() && stored > start - 1e-9 && stored < end + 1e-9 { + stored.clamp(start, end) + } else { + let hint = context.hint.or(context.from)?; + curve.parameter_at(dpoint(hint)).clamp(start, end) + }; + curve + .tangent_from_near(dpoint(context.from?), u) + .map(vector3) +} + +// ── Intersection ────────────────────────────────────────────────────────── + +/// Intersection of `entity` with the reference's second object chain. +/// +/// Transform both curves into the first entity's local plane and use the +/// kernel's exact/converged intersection solver. Apparent intersections may +/// project noncoplanar curves; only straight lines support an extended crossing. +fn intersection_point( + document: &CadDocument, + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Vector3, + apparent: bool, +) -> Option { + let main = walk_chain(document, &reference.xrefs).ok()?; + let other = walk_chain(document, &reference.intersection_objects).ok()?; + if main.viewport != other.viewport { + return None; + } + let inverse = invert(&main.transform)?; + let first = entity_curve(entity)?; + let second = entity_curve(document.get_entity(other.entity)?)?; + let map = |uv| { + let local = inverse.apply(other.transform.apply(lift(&second.plane, uv))); + let projected = first.plane.project(dpoint(local))?; + if !apparent && distance_squared(local, lift(&first.plane, projected)) > 1e-12 { + return None; + } + Some(projected) + }; + let origin = glam::DVec2::from_array(map([0.0, 0.0])?); + let x = glam::DVec2::from_array(map([1.0, 0.0])?) - origin; + let y = glam::DVec2::from_array(map([0.0, 1.0])?) - origin; + let second = second.curve.transformed(&cadkernel::geom2d::Transform { + origin: origin.to_array().into(), + x_axis: x.to_array().into(), + y_axis: y.to_array().into(), + })?; + let hint_uv = first.plane.project(dpoint(hint))?; + let mut points: Vec<_> = cadkernel::geom2d::intersect( + &first.curve, + &second, + cadkernel::geom2d::Tolerance::new(1e-9), + ) + .into_iter() + .map(|hit| hit.point) + .collect(); + if apparent && points.is_empty() { + // Extended apparent intersections are meaningful for straight lines. + // Curved geometry must actually cross after projection. + if let (KernelCurve::Line(a), KernelCurve::Line(b)) = (&first.curve, &second) { + let extend = |line: &cadkernel::geom2d::Line| { + KernelCurve::XLine(cadkernel::geom2d::XLine { + base: line.start, + direction: [line.end[0] - line.start[0], line.end[1] - line.start[1]], + }) + }; + points.extend( + cadkernel::geom2d::intersect( + &extend(a), + &extend(b), + cadkernel::geom2d::Tolerance::new(1e-9), + ) + .into_iter() + .map(|hit| hit.point), + ); + } + } + points + .into_iter() + .min_by(|a, b| { + let error = |p: &[f64; 2]| (p[0] - hint_uv[0]).powi(2) + (p[1] - hint_uv[1]).powi(2); + error(a).total_cmp(&error(b)) + }) + .map(|point| lift(&first.plane, point)) +} + +/// Internal markers are zero-based; imported edge markers are one-based. +fn segment_index(reference: &AssocDimensionReference) -> Option { + let marker = if reference.main_subent_type == 2 { + reference.main_gs_marker.checked_sub(1)? + } else { + reference.main_gs_marker + }; + usize::try_from(marker).ok() +} + +fn imported_endpoint( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Option, +) -> Option { + let planar = entity_curve(entity)?; + let t = match reference.osnap_distance { + v if v.abs() < 1e-9 => 0.0, + v if (v - 1.0).abs() < 1e-9 => 1.0, + _ => return None, + }; + let point = match &planar.curve { + KernelCurve::Polyline(_) => { + let segment = segment_index(reference)?; + let point = lift( + &planar.plane, + planar.curve.segments().get(segment)?.point_at(t), + ); + // Exporters can leave stale GS markers after editing a polyline. + // Beyond the unambiguous first-edge convention, require a real + // stored snap point to validate this imported endpoint. Zero and + // 2e50 sentinel points do not establish feature identity. + if segment > 0 && !hint.is_some_and(|hint| distance_squared(point, hint) < 1e-10) { + return None; + } + point + } + KernelCurve::Line(_) | KernelCurve::Arc(_) | KernelCurve::Nurbs(_) => { + lift(&planar.plane, planar.curve.point_at(t)) + } + _ => return None, + }; + Some(point) +} diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 8a5ee23f4..5497319d9 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -134,7 +134,7 @@ impl Scene { let _ = self.document.layers.add(layer); } - fn ensure_app_id(&mut self, name: &str) { + pub(super) fn ensure_app_id(&mut self, name: &str) { if name.trim().is_empty() || self.document.app_ids.contains(name) { return; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index eba50ad2d..51f432bc3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -31,6 +31,8 @@ mod camera_ops; pub(crate) mod centerline; pub(crate) mod centermark; pub(crate) mod dimension_assoc; +pub(crate) mod dimension_assoc_chain; +pub use dimension_assoc::{ReferenceStatus, ResolvedReference}; mod dwg_native_constraints; mod entity; #[cfg(test)] @@ -40,6 +42,8 @@ mod layout; mod limits; mod modify; mod mspace; +pub mod viewport_ref; +pub mod viewport_dimension_pick; pub mod named_parameters; mod page_setup; mod paper; @@ -5438,6 +5442,10 @@ impl Scene { self.document .set_viewport_annotation_scale(viewport, scale_handle); + // Viewport scale changed: the dimensions drawn on the sheet + // through it measure the same model geometry at a new paper + // size and have to be re-placed. + self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); @@ -5484,6 +5492,10 @@ impl Scene { vp.view_height = vp.height / factor; self.document .set_viewport_annotation_scale(viewport, scale_handle); + // Viewport scale changed: the dimensions drawn on the sheet + // through it measure the same model geometry at a new paper + // size and have to be re-placed. + self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); true diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 47b605db8..f91764067 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -730,6 +730,7 @@ impl Scene { // Complete group copies record their new Group objects and dictionary // entry as targeted object deltas inside copy_complete_groups. self.copy_complete_groups(&handle_map); + self.copy_dimension_associations(&handle_map); // The copies are new handles (natural memo misses, tessellated fresh) // and reference only already-cached blocks — no block defn changes. // Report them as additions so derived caches patch in exactly the copies. diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index a6c014aa0..fe6df1b7b 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -80,12 +80,173 @@ impl Scene { &self, canvas_px: (f32, f32), ) -> Option<(view::camera::Camera, iced::Rectangle)> { - let vp_handle = self.active_viewport?; + self.viewport_edit_frame_for(self.active_viewport?, canvas_px) + } + + /// Camera and canvas rectangle for snapping through an inactive viewport. + pub fn viewport_edit_frame_for( + &self, + vp_handle: Handle, + canvas_px: (f32, f32), + ) -> Option<(view::camera::Camera, iced::Rectangle)> { let cam = self.camera_for_viewport(vp_handle)?; let full = self.viewport_screen_rect(vp_handle, canvas_px)?; Some((cam, full)) } + /// Planar paper/model mapping derived from the viewport's display camera. + /// Oblique and perspective views have no supported planar mapping. + pub fn viewport_frame( + &self, + vp_handle: Handle, + ) -> Option { + use crate::scene::viewport_ref::ViewportFrame; + + let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) { + Some(EntityType::Viewport(vp)) => ( + glam::DVec2::new(vp.center.x, vp.center.y), + vp.height, + vp.status.locked, + ), + _ => return None, + }; + if !paper_center.is_finite() || !vp_height.is_finite() || vp_height < 1e-9 { + return None; + } + let cam = self.camera_for_viewport(vp_handle)?; + if cam.projection != view::camera::Projection::Orthographic { + return None; + } + // Screen right/up of the viewport camera, in model space. A planar + // frame exists only when both stay in the model XY plane and form a + // right-handed pair (a plan view, possibly twisted). Anything else is + // an oblique 3-D view: unsupported. + let right = (cam.rotation * glam::Vec3::X).as_dvec3(); + let up = (cam.rotation * glam::Vec3::Y).as_dvec3(); + if !right.is_finite() || !up.is_finite() || right.z.abs() > 1e-6 || up.z.abs() > 1e-6 { + return None; + } + let normal = right.cross(up); + if normal.z < 1.0 - 1e-6 { + return None; // mirrored or degenerate + } + // Model units visible across the viewport's height -> paper per model. + let model_height = cam.ortho_size() as f64 * 2.0; + if !model_height.is_finite() || model_height < 1e-12 { + return None; + } + let scale = vp_height / model_height; + if !scale.is_finite() || scale <= 0.0 { + return None; + } + // `ViewportFrame` rotates model geometry by `twist` (CCW) before + // scaling; the row that produces the paper X component is `right`, so + // cos(twist) = right.x and sin(twist) = -right.y. + let twist = (-right.y).atan2(right.x); + Some(ViewportFrame { + viewport: vp_handle, + paper_center, + model_target: glam::DVec2::new(cam.target.x as f64, cam.target.y as f64), + scale, + twist, + locked, + }) + } + + /// 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() + } + + /// Test the viewport rectangle and optional nonrectangular clip boundary. + pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else { + return false; + }; + let hw = (vp.width * 0.5).abs(); + let hh = (vp.height * 0.5).abs(); + if hw < 1e-9 || hh < 1e-9 { + return false; + } + if (paper.x - vp.center.x).abs() > hw || (paper.y - vp.center.y).abs() > hh { + return false; + } + if vp.clip_boundary_handle.is_null() { + return true; + } + let Some(boundary) = self.document.get_entity(vp.clip_boundary_handle) + .and_then(crate::entities::curve::entity_curve) + else { + return false; + }; + let Some(normal) = boundary.plane.normal() else { + return false; + }; + if !boundary.curve.is_closed() || normal[0].abs() > 1e-9 || normal[1].abs() > 1e-9 { + return false; + } + let Some(point) = boundary.plane.project([paper.x, paper.y, vp.center.z]) else { + return false; + }; + cadkernel::geom2d::contains( + &[boundary.curve], + point, + cadkernel::geom2d::Tolerance::new(1e-9), + ) + } +} + +#[cfg(test)] +mod clip_tests { + use super::*; + + #[test] + fn concave_clip_boundary_rejects_the_notch() { + let mut scene = Scene::new(); + let mut poly = acadrust::entities::LwPolyline::from_points( + [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (5.0, 5.0), (5.0, 10.0), (0.0, 10.0)] + .map(|(x, y)| acadrust::types::Vector2::new(x, y)) + .to_vec(), + ); + poly.is_closed = true; + let clip = scene.add_entity(EntityType::LwPolyline(poly)); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.center = acadrust::types::Vector3::new(5.0, 5.0, 0.0); + viewport.width = 20.0; + viewport.height = 20.0; + viewport.clip_boundary_handle = clip; + let viewport = scene.add_entity(EntityType::Viewport(viewport)); + for (x, y, inside) in [ + (2.0, 2.0, true), (8.0, 2.0, true), (8.0, 8.0, false), + (-1.0, 5.0, false), (5.0, 7.0, true), + ] { + assert_eq!( + scene.viewport_displays_paper_point(viewport, glam::DVec2::new(x, y)), inside, + ); + } + } +} + +impl Scene { + /// Fold the active viewport's saved view onto the effective camera (the /// auto-fit centre for stale UTM views) and persist it into `view_target` / /// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the @@ -109,6 +270,7 @@ impl Scene { vp.view_height = eff_h; } } + self.notify_viewport_changed(vp_handle); } /// Fit model-space bounds into the active floating viewport without moving @@ -147,6 +309,7 @@ impl Scene { viewport.custom_scale = viewport.height / viewport.view_height; } } + self.notify_viewport_changed(viewport_handle); self.camera_generation += 1; true } @@ -198,6 +361,7 @@ impl Scene { vp.view_target.y += model_delta.y as f64; vp.view_target.z += model_delta.z as f64; } + self.notify_viewport_changed(vp_handle); } /// Zoom the active viewport's model-space view by `steps` notches. @@ -247,6 +411,7 @@ impl Scene { } } } + self.notify_viewport_changed(vp_handle); } /// Orbit the active viewport's view direction by the given screen-pixel delta. @@ -324,6 +489,7 @@ impl Scene { vp.view_direction.z = dir.z as f64; vp.twist_angle = twist; } + self.notify_viewport_changed(vp_handle); } /// Mutate the active viewport's camera through a closure, then re-encode the @@ -352,6 +518,7 @@ impl Scene { .normalize_or(glam::Vec3::Y); let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up)); let twist = -roll as f64; + let mut changed = false; if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { if vp.status.locked { return false; @@ -364,9 +531,12 @@ impl Scene { tmp.projection == view::camera::Projection::Perspective; vp.lens_length = (12.0 / (tmp.fov_y * 0.5).tan().max(1e-6)) as f64; - return true; + changed = true; } - false + if changed { + self.notify_viewport_changed(vp_handle); + } + changed } /// Render mode of the active paper-space viewport, or `None` when no @@ -466,9 +636,6 @@ impl Scene { return false; } if let Some(handle) = self.active_viewport { - if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) { - return false; - } if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) { vp.view_target.x = camera.target.x; vp.view_target.y = camera.target.y; @@ -478,6 +645,10 @@ impl Scene { vp.view_height = camera.ortho_size() as f64 * 2.; vp.custom_scale = vp.height / vp.view_height; } + // Notify associations only after all camera fields are persisted. + if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) { + return false; + } } else { *self.camera.borrow_mut() = camera; } @@ -563,6 +734,26 @@ impl Scene { .map(|(h, _)| h) } + /// Content (floating) viewports of the current paper layout, in draw order + /// (the last entry draws on top). Empty in the Model layout. + pub fn layout_content_viewports(&self) -> Arc> { + if self.current_layout == "Model" { + return Arc::new(Vec::new()); + } + let (_, _, handles) = self.paper_viewport_handles(); + handles + } + + /// `true` when this viewport's contents are displayed (VP ON). The border + /// layer's visibility deliberately does NOT matter: a viewport whose frame + /// layer is off still shows its model content, so it still snaps. + pub fn viewport_displays_content(&self, vp_handle: Handle) -> bool { + matches!( + self.document.get_entity(vp_handle), + Some(EntityType::Viewport(vp)) if vp.status.is_on + ) + } + /// Return the handle of the first active user viewport in the current layout, /// or `None` if there are none. Used by the MS command. pub fn first_user_viewport(&self) -> Option { diff --git a/src/scene/project.rs b/src/scene/project.rs index f20c10e38..4d67327ac 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -674,10 +674,7 @@ pub(crate) fn clip_boundary_polygon_for_document( z: f32, ) -> Vec<[f32; 3]> { use std::f64::consts::TAU; - let Some(entity) = document - .entities() - .find(|e| e.common().handle == handle) - else { + let Some(entity) = document.get_entity(handle) else { return vec![]; }; // Circles and ellipses tessellate directly — their `to_render` returns a diff --git a/src/scene/viewport_dimension_pick.rs b/src/scene/viewport_dimension_pick.rs new file mode 100644 index 000000000..32ce10be5 --- /dev/null +++ b/src/scene/viewport_dimension_pick.rs @@ -0,0 +1,423 @@ +//! Pick dimension geometry through a layout viewport. Resolve block instances +//! in model space, then project the picked entity onto the paper sheet. + +use super::*; + +use crate::command::EntityTransform; +use crate::scene::viewport_ref::ViewportFrame; +use acadrust::types::{Matrix4, Transform}; +#[cfg(test)] +use glam::DVec2; +use glam::DVec3; + +/// A model entity resolved by clicking inside a paper-space content viewport. +#[derive(Clone, Debug)] +pub struct ViewportDimensionPick { + /// The viewport the click looked through. + pub frame: ViewportFrame, + /// Handle of the innermost model entity that owns the picked geometry. + pub entity_handle: Handle, + /// INSERT handles from the outermost instance down to the instance that + /// directly contains `entity_handle`; empty for top-level model geometry. + pub block_path: Vec, + /// The picked geometry with the block-instance transform *and* the + /// viewport model→paper transform applied, so paper-space dimension + /// commands can consume it unchanged. + pub paper_entity: EntityType, + /// The click position on the sheet. + pub paper_point: DVec3, + /// The click position in model space. + pub model_point: DVec3, +} + +impl Scene { + /// Resolve an explicit dimension object pick made on the sheet but landing + /// inside a content viewport, against the *model* geometry that viewport + /// displays. + /// + /// `aperture_paper` is the pick aperture in paper units. + pub fn dimension_pick_through_viewport( + &self, + paper: DVec3, + aperture_paper: f64, + ) -> Option { + 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 Some(top) = self.nearest_model_wire_handle(&frame, model_point, aperture_model) + else { + continue; + }; + let Some((entity, block_path)) = + self.resolve_measurable_entity(top, model_point, Some(frame.viewport)) + else { + continue; + }; + + if !planar_pick_distance(&entity, model_point).is_some_and(|d| d <= aperture_model) { + 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(); + return Some(ViewportDimensionPick { + 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 + /// `aperture` model units. Wires are the same tessellation the viewport + /// draws, so this matches what the user sees. + fn nearest_model_wire_handle( + &self, + frame: &ViewportFrame, + model_point: DVec3, + aperture: f64, + ) -> Option { + let viewport = frame.viewport; + 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 { + continue; + } + let Some(handle) = Self::handle_from_wire_name(&wire.name) else { + continue; + }; + let offset = wire + .render_instance + .as_ref() + .map(|inst| DVec3::from_array(inst.translation)) + .unwrap_or(DVec3::ZERO); + let distance = wire_polyline_nearest(wire, offset, model_point); + if let Some((distance, nearest)) = distance { + 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)); + } + } + } + best.map(|(_, handle)| handle) + } + + /// 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. + pub(crate) fn resolve_measurable_entity( + &self, + handle: Handle, + model_point: DVec3, + viewport: Option, + ) -> Option<(EntityType, Vec)> { + self.resolve_measurable_feature(handle, model_point, viewport, None) + } + + pub(crate) fn resolve_measurable_feature( + &self, + handle: Handle, + model_point: DVec3, + viewport: Option, + kind: Option, + ) -> Option<(EntityType, Vec)> { + let entity = self.document.get_entity(handle)?; + match entity { + EntityType::Insert(insert) => self.descend_block_instance( + insert, + Transform::identity(), + model_point, + 0, + viewport, + kind, + ), + other => { + feature_pick_distance(other, model_point, kind)?; + Some((other.clone(), Vec::new())) + } + } + } + + /// Depth-first search for the nearest measurable entity inside an INSERT. + /// Returns the entity already transformed into WCS plus the INSERT path. + fn descend_block_instance( + &self, + insert: &acadrust::entities::Insert, + outer: Transform, + model_point: DVec3, + depth: usize, + viewport: Option, + kind: Option, + ) -> Option<(EntityType, Vec)> { + const MAX_DEPTH: usize = 8; + if depth >= MAX_DEPTH { + return None; + } + if insert.row_count > 1 || insert.column_count > 1 { + return None; + } + let insert_handle = insert.common.handle; + let local = crate::scene::render_graph::insert_transform(&self.document, insert); + let combined = local.then(&outer); + let block = self.document.block_records.get(&insert.block_name)?; + + let mut best: Option<(f64, EntityType, Vec)> = None; + for &child in &block.entity_handles { + 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 let EntityType::Insert(child_insert) = child_entity { + if let Some((entity, mut nested_path)) = self.descend_block_instance( + child_insert, + combined, + model_point, + depth + 1, + viewport, + kind, + ) { + if let Some(distance) = feature_pick_distance(&entity, model_point, kind) { + if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { + let mut full = vec![insert_handle]; + full.append(&mut nested_path); + best = Some((distance, entity, full)); + } + } + } + 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 + && kind != Some(crate::snap::SnapType::Center) + && (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, + &EntityTransform::Affine(combined), + ); + let Some(distance) = feature_pick_distance(&placed, model_point, kind) else { + continue; + }; + if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { + best = Some((distance, placed, vec![insert_handle])); + } + } + let (_, entity, found_path) = best?; + Some((entity, found_path)) + } +} + +/// Model→paper for a viewport, as an affine entity transform. Z passes +/// through unchanged so planar geometry keeps its elevation. +pub fn viewport_model_to_paper_transform(frame: &ViewportFrame) -> Transform { + let (sin, cos) = frame.twist.sin_cos(); + let s = frame.scale; + let a = cos * s; + let b = sin * s; + // t = paper_center - R*s*model_target + let tx = frame.paper_center.x - (a * frame.model_target.x - b * frame.model_target.y); + let ty = frame.paper_center.y - (b * frame.model_target.x + a * frame.model_target.y); + Transform::from_matrix(Matrix4 { + m: [ + [a, -b, 0.0, tx], + [b, a, 0.0, ty], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }) +} + +/// Distance from `point` to a wire's polyline, in XY. `None` when the wire has +/// no usable segment. +fn wire_polyline_nearest( + wire: &crate::scene::WireModel, + offset: DVec3, + point: DVec3, +) -> Option<(f64, DVec3)> { + let count = wire.points.len(); + if count == 0 { + return None; + } + let at = |i: usize| -> DVec3 { + let hi = wire.points[i]; + let lo = wire.points_low.get(i).copied().unwrap_or([0.0; 3]); + DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + 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); + if !current.x.is_finite() || !current.y.is_finite() { + previous = None; + continue; + } + let nearest = previous.map_or(current, |start| { + let segment = cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: start.truncate().to_array(), + end: current.truncate().to_array(), + }); + let near = cadkernel::geom2d::closest_point(&segment, point.truncate().to_array()); + start.lerp(current, near.t) + }); + let distance = (nearest - point).truncate().length(); + if distance < best { + best = distance; + best_point = nearest; + } + previous = Some(current); + } + 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 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 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)] +mod tests { + use super::*; + + fn frame() -> ViewportFrame { + ViewportFrame { + viewport: Handle::NULL, + paper_center: DVec2::new(100.0, 50.0), + model_target: DVec2::new(1000.0, 2000.0), + scale: 0.1, + twist: 0.4, + locked: false, + } + } + + #[test] + fn affine_transform_matches_the_frame_mapping() { + let f = frame(); + let t = viewport_model_to_paper_transform(&f); + for model in [ + DVec3::new(1000.0, 2000.0, 0.0), + DVec3::new(1100.0, 2000.0, 0.0), + DVec3::new(940.5, 2113.25, 0.0), + ] { + let expected = f.model_to_paper(model); + let actual = t.apply(acadrust::types::Vector3::new(model.x, model.y, model.z)); + assert!( + (actual.x - expected.x).abs() < 1e-9 && (actual.y - expected.y).abs() < 1e-9, + "{actual:?} != {expected:?}" + ); + } + } +} + +/// Distance to the particular feature accepted by a snap, rather than to the +/// nearest curve. A circle's center is deliberately not on its circumference. +pub(crate) fn feature_pick_distance( + entity: &EntityType, + point: DVec3, + kind: Option, +) -> Option { + use crate::snap::SnapType as S; + let points: Vec = match kind { + Some(S::Center) => match entity { + EntityType::Circle(c) => vec![c.center_wcs()], + EntityType::Arc(a) => vec![a.center_wcs()], + EntityType::Ellipse(e) => vec![e.center], + _ => return None, + }, + Some(S::Endpoint) => crate::scene::dimension_assoc::source_points(entity), + Some(S::Node) => match entity { + EntityType::Point(p) => vec![p.location], + _ => return None, + }, + Some(S::Midpoint | S::Quadrant) => { + let planar = crate::entities::curve::entity_curve(entity)?; + cadkernel::geom2d::snap::characteristic_points(&planar.curve) + .into_iter() + .filter(|p| match kind { + Some(S::Midpoint) => p.kind == cadkernel::geom2d::snap::SnapKind::Midpoint, + _ => p.kind == cadkernel::geom2d::snap::SnapKind::Quadrant, + }) + .map(|p| { + let p = planar.plane.point_at(p.point); + acadrust::types::Vector3::new(p[0], p[1], p[2]) + }) + .collect() + } + _ => return planar_pick_distance(entity, point), + }; + points + .into_iter() + .map(|p| (DVec3::new(p.x, p.y, p.z) - point).truncate().length()) + .min_by(f64::total_cmp) +} diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs new file mode 100644 index 000000000..60fd4df70 --- /dev/null +++ b/src/scene/viewport_ref.rs @@ -0,0 +1,303 @@ +//! Coordinates and feature identity shared by viewport acquisition and dimensions. + +use acadrust::types::Handle; +use glam::{DMat3, DVec2, DVec3}; + +use crate::command::DimensionAssociationSource; +use crate::snap::SnapType; + +/// Planar orthographic mapping between a layout viewport's paper rectangle and +/// the model plane it displays. Handles pan (`model_target` / `paper_center`), +/// scale (paper units per model unit) and in-plane rotation (`twist`, +/// radians, counter-clockwise, applied to model geometry before scaling). +/// +/// Constructed by `Scene::viewport_frame`. All arithmetic is `f64`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewportFrame { + /// Viewport entity handle. + pub viewport: Handle, + /// Paper-space centre of the viewport rectangle. + pub paper_center: DVec2, + /// Model-space point displayed at `paper_center`. + pub model_target: DVec2, + /// Paper units per model unit (e.g. 0.1 for a 1:10 viewport). + pub scale: f64, + /// In-plane rotation (DXF `twist_angle`), radians. + pub twist: f64, + /// `true` when the viewport is display-locked (snapping still allowed). + pub locked: bool, +} + +impl ViewportFrame { + /// Model -> paper, as a 2-D affine matrix (homogeneous, column vectors). + pub fn model_to_paper_matrix(&self) -> DMat3 { + let (s, c) = self.twist.sin_cos(); + let rs = DMat3::from_cols( + DVec3::new(c * self.scale, s * self.scale, 0.0), + DVec3::new(-s * self.scale, c * self.scale, 0.0), + DVec3::new(0.0, 0.0, 1.0), + ); + let t_model = DMat3::from_translation(-self.model_target); + let t_paper = DMat3::from_translation(self.paper_center); + t_paper * rs * t_model + } + + /// Paper -> model (inverse of [`Self::model_to_paper_matrix`]). + pub fn paper_to_model_matrix(&self) -> DMat3 { + self.model_to_paper_matrix().inverse() + } + + /// Project a model-space point onto the paper sheet. `z` passes through. + pub fn model_to_paper(&self, p: DVec3) -> DVec3 { + let q = self.model_to_paper_matrix().transform_point2(p.truncate()); + DVec3::new(q.x, q.y, p.z) + } + + /// Lift a paper-sheet point into model space. `z` passes through. + pub fn paper_to_model(&self, p: DVec3) -> DVec3 { + let q = self.paper_to_model_matrix().transform_point2(p.truncate()); + DVec3::new(q.x, q.y, p.z) + } + + /// Model -> paper for a direction (rotation + scale, no translation). + pub fn model_to_paper_dir(&self, d: DVec2) -> DVec2 { + self.model_to_paper_matrix().transform_vector2(d) + } + + /// Factor a paper-space length must be multiplied by to recover the model + /// length it displays (`1 / scale`). This is the *viewport compensation* + /// used by [`MeasurementScale`]. + pub fn paper_to_model_length_factor(&self) -> f64 { + 1.0 / self.scale + } +} + +/// Identity of the geometry a snap landed on, in model space, including the +/// block-instance path when the entity lives inside a block reference. +/// +/// `source.handle` is the innermost entity (the one owning the feature). +/// `block_path` is the ordered list of INSERT handles from the outermost +/// instance in the space down to the instance that directly contains the +/// entity; empty for top-level geometry. +#[derive(Clone, Debug, PartialEq)] +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 +/// dimension commands to distinguish *placement* (paper) from *measurement* +/// (model) and for association creation to record where it came from. +/// +/// Invariant: when `viewport` is `Some`, `paper_point` is the point on the +/// sheet and `model_point` is that same point in model space through the +/// viewport frame. When `viewport` is `None` the snap was on paper-space (or +/// model-space) geometry directly and `paper_point == model_point`. +#[derive(Clone, Debug, PartialEq)] +pub struct AcceptedSnap { + /// Point in the coordinate system of the current space (paper when a + /// layout is active). + pub paper_point: DVec3, + /// Point in model space (identical to `paper_point` when `viewport` is + /// `None`). + pub model_point: DVec3, + /// The viewport the snap looked through, if any. + pub viewport: Option, + /// The frame in effect when the snap was accepted (so a later measurement + /// uses the same scale even if the viewport is changed afterwards). + pub frame: Option, + /// Geometry identity, when the snap landed on an entity feature (not a + /// grid/free point). + pub source: Option, +} + +impl AcceptedSnap { + /// A plain point in the current space with no viewport and no source. + pub fn free(point: DVec3) -> Self { + Self { + paper_point: point, + model_point: point, + viewport: None, + frame: None, + source: None, + } + } + + pub fn through_viewport(&self) -> bool { + self.viewport.is_some() + } + + /// `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 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: 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 { + intersection: snap + .secondary_source + .map(|source| Box::new(source_ref(source))), + ..source_ref(source) + }), + } + } + + /// The paper point with the viewport context of `self`, but no geometry + /// identity — used when a command constrains a snapped point (ortho/polar, + /// axis lock) so the recorded point matches what was committed. + pub fn with_paper_point(mut self, paper_point: DVec3) -> Self { + // Compare in the sheet plane only: paper space is flat and callers + // clamp `z` to 0, which must not be mistaken for the point having been + // moved off the snapped feature (and must not discard the snapped + // model point's real elevation). + let moved = self + .paper_point + .truncate() + .distance_squared(paper_point.truncate()) + > 1e-18; + if moved { + // The committed point is no longer the feature that was snapped. + self.source = None; + self.model_point = match &self.frame { + Some(frame) => frame.paper_to_model(paper_point), + None => paper_point, + }; + } + self.paper_point = paper_point; + self + } +} + +/// Length dimensions store raw distances in their owning space. The displayed +/// value uses `user_lfac * viewport_compensation`; angular dimensions are exempt. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeasurementScale { + pub user_lfac: f64, + pub viewport_compensation: f64, +} + +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; + } + + /// Combined factor applied to a distance measured on the paper sheet. + pub fn paper_factor(&self) -> f64 { + self.user_lfac * self.viewport_compensation + } + + /// Positive DIMLFAC applies everywhere; negative DIMLFAC applies only on + /// paper. Zero means one. Saved viewport compensation is already in DIMLFAC. + pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 { + if dimlfac == 0.0 { + 1.0 + } else if dimlfac > 0.0 { + dimlfac + } else if paper_space { + -dimlfac + } else { + 1.0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(scale: f64, twist: f64) -> ViewportFrame { + ViewportFrame { + viewport: Handle::NULL, + paper_center: DVec2::new(100.0, 50.0), + model_target: DVec2::new(1000.0, 2000.0), + scale, + twist, + locked: false, + } + } + + #[test] + fn round_trips_with_scale_and_twist() { + let f = frame(0.1, 0.3); + let m = DVec3::new(1234.5, 1987.25, 0.0); + let p = f.model_to_paper(m); + let back = f.paper_to_model(p); + assert!((back - m).length() < 1e-9); + } + + #[test] + fn scale_maps_lengths() { + let f = frame(0.1, 0.0); + let a = f.model_to_paper(DVec3::new(0.0, 0.0, 0.0)); + let b = f.model_to_paper(DVec3::new(100.0, 0.0, 0.0)); + assert!(((b - a).length() - 10.0).abs() < 1e-9); + assert!((f.paper_to_model_length_factor() - 10.0).abs() < 1e-12); + assert_eq!(frame(1e-14, 0.0).paper_to_model_length_factor(), 1e14); + } + + #[test] + fn dimlfac_sign_depends_on_dimension_space() { + for (factor, paper, model) in [(-2.0, 2.0, 1.0), (0.0, 1.0, 1.0), (3.5, 3.5, 3.5)] { + assert_eq!(MeasurementScale::user_lfac_for_space(factor, true), paper); + assert_eq!(MeasurementScale::user_lfac_for_space(factor, false), model); + } + } +} diff --git a/src/snap.rs b/src/snap.rs index e2ae67b2f..fa837110b 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -9,8 +9,9 @@ use iced::time::Instant; use iced::{Point, Rectangle}; use cadkernel::geom2d::Curve; +use acadrust::types::Handle; -use crate::command::TangentObject; +use crate::command::{DimensionAssociationSource, TangentObject}; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; @@ -86,6 +87,37 @@ pub struct SnapResult { pub extension_origin: Option, /// Source direction paired with `extension_origin`. pub extension_dir: Option, + /// Layout viewport this snap was seen *through*, when the snap ran against + /// model geometry displayed by a paper-space viewport. `None` for ordinary + /// model-space / paper-sheet snaps. Filled by the paper-space viewport snap + /// query, never by the engine itself. + pub viewport: Option, + /// Owning entity of the geometry the snap landed on, when the engine could + /// attribute the feature to a wire. Block sub-entities report the top-level + /// INSERT handle; the block path is resolved later by + /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. + 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 { + /// Handle of the entity the snap landed on, when known. + pub fn source_handle(&self) -> Option { + self.source.map(|s| s.handle) + } +} + +/// Entity handle carried by a wire (`WireModel::name` is the decimal handle +/// value). Returns `None` for preview / interim wires with a symbolic name. +#[inline] +pub(crate) fn wire_source(wire: &WireModel) -> Option { + wire.name + .parse::() + .ok() + .map(|v| DimensionAssociationSource::inferred(Handle::new(v))) } /// Object-snap-tracking alignment: the cursor projected onto a ray from an @@ -954,6 +986,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, + secondary_source: None, + model_point: None, }) } @@ -1087,6 +1123,10 @@ impl Snapper { extension_base2: None, extension_origin: None, 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); @@ -1182,7 +1222,10 @@ impl Snapper { return best; } - let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { + let mut try_pt = |world: glam::DVec3, + snap_type: SnapType, + src: Option, + secondary: Option| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1209,12 +1252,18 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: src, + secondary_source: secondary, + model_point: None, }); } }; // ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ────── - let mut try_snap_hint = |world: DVec3, hint: SnapHint| { + let mut try_snap_hint = |world: DVec3, + hint: SnapHint, + src: Option| { let snap_type = match hint { SnapHint::Center => SnapType::Center, SnapHint::Node => SnapType::Node, @@ -1224,23 +1273,24 @@ impl Snapper { SnapHint::Endpoint => SnapType::Endpoint, }; if self.is_on(snap_type) { - try_pt(world, snap_type); + try_pt(world, snap_type, src, None); } }; if let Some(points) = wires.snap_points() { for point_ref in points { - let Some(&(world, hint)) = wires - .source_wire(point_ref.wire) - .and_then(|wire| wire.snap_pts.get(point_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(point_ref.wire) else { continue; }; - try_snap_hint(world, hint); + let Some(&(world, hint)) = wire.snap_pts.get(point_ref.index as usize) else { + continue; + }; + try_snap_hint(world, hint, wire_source(wire)); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for &(world, hint) in &wire.snap_pts { - try_snap_hint(world, hint); + try_snap_hint(world, hint, src); } } } @@ -1250,13 +1300,18 @@ impl Snapper { if self.is_on(SnapType::Endpoint) { if let Some(vertices) = wires.key_vertices() { for vertex_ref in vertices { - let Some(&point) = wires - .source_wire(vertex_ref.wire) - .and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(vertex_ref.wire) else { continue; }; - try_pt(DVec3::from_array(point), SnapType::Endpoint); + let Some(&point) = wire.key_vertices.get(vertex_ref.index as usize) else { + continue; + }; + try_pt( + DVec3::from_array(point), + SnapType::Endpoint, + wire_source(wire), + None, + ); } // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains @@ -1270,17 +1325,23 @@ impl Snapper { continue; } if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + 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); + try_pt( + wp_f64(wire, wire.points.len() - 1), + SnapType::Endpoint, + wire_source(wire), + None, + ); } } } else { for wire in in_range_wires.iter() { + 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); + try_pt(DVec3::from_array(point), SnapType::Endpoint, src, None); } } else { let closed = wire @@ -1289,10 +1350,15 @@ impl Snapper { .any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); if !closed { if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + 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); + try_pt( + wp_f64(wire, wire.points.len() - 1), + SnapType::Endpoint, + src, + None, + ); } } } @@ -1319,16 +1385,17 @@ 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); + try_pt((a + b) * 0.5, SnapType::Midpoint, wire_source(wire), None); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for segment in wire.key_vertices.windows(2) { 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); + try_pt((a + b) * 0.5, SnapType::Midpoint, src, None); } } } @@ -1342,14 +1409,17 @@ impl Snapper { try_pt( nearest_on_segment(cursor_world, seg.a, seg.b), SnapType::Nearest, + wires.source_wire(seg.wire).and_then(wire_source), + None, ); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); 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); + try_pt(p, SnapType::Nearest, src, None); } } } @@ -1362,14 +1432,20 @@ impl Snapper { if let Some(segments) = &local_segments { for seg in segments { if let Some(foot) = perp_foot(q, seg.a, seg.b) { - try_pt(foot, SnapType::Perpendicular); + try_pt( + foot, + SnapType::Perpendicular, + wires.source_wire(seg.wire).and_then(wire_source), + None, + ); } } } else { for wire in in_range_wires.iter() { + 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); + try_pt(foot, SnapType::Perpendicular, src, None); } } } @@ -1387,12 +1463,18 @@ impl Snapper { ray_segment_intersect_3d(origin, through, segment.a, segment.b) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt( + point, + SnapType::Intersection, + wires.source_wire(segment.wire).and_then(wire_source), + None, + ); } } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for index in 0..wire.points.len().saturating_sub(1) { if let Some(point) = ray_segment_intersect_3d( origin, @@ -1401,7 +1483,7 @@ impl Snapper { wp_f64(wire, index + 1), ) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src, None); } } } @@ -1483,8 +1565,11 @@ impl Snapper { for (idx, &wire_i) in local_wires.iter().enumerate() { for &wire_j in &local_wires[idx + 1..] { if let Some(points) = exact_curve_intersections(wire_i, wire_j) { + // Two wires meet here; attribute the feature to the + // first. Association resolution requires both source paths. + let src = wire_source(wire_i); for point in points { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src, wire_source(wire_j)); } resolved_pairs.insert(pair_key(wire_i, wire_j)); } @@ -1515,7 +1600,12 @@ impl Snapper { world_to_screen(pt, view_rot, eye, bounds), cursor_screen, ) <= f32::EPSILON; - try_pt(pt, SnapType::Intersection); + try_pt( + pt, + SnapType::Intersection, + wires.source_wire(a.wire).and_then(wire_source), + wires.source_wire(b.wire).and_then(wire_source), + ); if exact_cursor { break 'intersection_sweep; } @@ -1535,8 +1625,9 @@ impl Snapper { // Curved pairs are solved exactly (bug #1052); see // `exact_curve_intersections`'s doc comment. 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); + try_pt(pt, SnapType::Intersection, src, wire_source(wire_j)); } continue; } @@ -1560,7 +1651,12 @@ impl Snapper { continue; } if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection); + try_pt( + pt, + SnapType::Intersection, + wire_source(wire_i), + wire_source(wire_j), + ); } } } @@ -1586,7 +1682,7 @@ impl Snapper { bounds, self.osnap_radius_px, ) { - try_pt(ext, SnapType::Extension); + try_pt(ext, SnapType::Extension, None, None); } } } @@ -1633,7 +1729,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); + try_pt(pt, SnapType::Intersection, None, None); } } } @@ -1686,6 +1782,8 @@ 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_b.wire).and_then(wire_source), ); if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { break 'apparent_sweep; @@ -1726,6 +1824,8 @@ impl Snapper { try_pt( wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection, + wire_source(wire_i), + wire_source(wire_j), ); } } @@ -1947,6 +2047,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), + secondary_source: None, + model_point: None, }); } } @@ -2009,6 +2113,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), + secondary_source: None, + model_point: None, }); } }; @@ -2112,6 +2220,29 @@ fn snap_tier(t: SnapType) -> u8 { } } +/// Merge paper and viewport snaps using the engine's ordering. +/// Both screen positions must use canvas pixels. +pub fn merge_snap( + a: Option, + b: Option, + cursor: Point, +) -> Option { + match (a, b) { + (Some(a), Some(b)) => { + let (at, asub) = (snap_tier(a.snap_type), snap_priority(a.snap_type)); + let (bt, bsub) = (snap_tier(b.snap_type), snap_priority(b.snap_type)); + let bd2 = dist2(b.screen, cursor); + let ad2 = dist2(a.screen, cursor); + if snap_better(bt, bd2, bsub, (at, ad2, asub)) { + Some(b) + } else { + Some(a) + } + } + (some, None) | (None, some) => some, + } +} + /// Candidate ordering: tier first, then cursor distance, and only when two /// candidates are effectively equidistant (coincident features) the classic /// sub-priority — so an Endpoint still beats an Intersection sitting on the diff --git a/tests/fixtures/dim_assoc/README.md b/tests/fixtures/dim_assoc/README.md new file mode 100644 index 000000000..4a01c475d --- /dev/null +++ b/tests/fixtures/dim_assoc/README.md @@ -0,0 +1,10 @@ +# Viewport association fixture + +`viewport_associations.dxf` contains line, circle, arc, polyline, spline, +block-instance, and intersection references through a 1:2 viewport. +`src/app/viewport_dimension_tests.rs` tests refresh and intersection source edits. + +Each dimension stores `DIMLFAC=-2` and `ACAD_DIMASSOC_CALC_DIMLFAC=-2` so its paper +definition points measure in model units. The spline tangent from `(0, 300)` +touches `(90, 283.125)` at parameter `0.75`; tests verify its tangency and preserve +the dimension if an edited reference cannot resolve. diff --git a/tests/fixtures/dim_assoc/viewport_associations.dxf b/tests/fixtures/dim_assoc/viewport_associations.dxf new file mode 100644 index 000000000..dcb198569 --- /dev/null +++ b/tests/fixtures/dim_assoc/viewport_associations.dxf @@ -0,0 +1,1746 @@ +0 +SECTION +2 +HEADER +9 +$ACADVER +1 +AC1021 +9 +$HANDSEED +5 +FFFF +9 +$INSBASE +10 +0.0 +20 +0.0 +30 +0.0 +9 +$DIMASSOC +280 +2 +9 +$DIMLFAC +40 +1.0 +0 +ENDSEC +0 +SECTION +2 +TABLES +0 +TABLE +2 +LTYPE +5 +5 +100 +AcDbSymbolTable +70 +1 +0 +LTYPE +5 +14 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord +2 +ByLayer +70 +0 +3 + +72 +65 +73 +0 +40 +0.0 +0 +ENDTAB +0 +TABLE +2 +LAYER +5 +2 +100 +AcDbSymbolTable +70 +1 +0 +LAYER +5 +10 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord +2 +0 +70 +0 +62 +7 +6 +ByLayer +370 +-3 +390 +F +0 +ENDTAB +0 +TABLE +2 +STYLE +5 +3 +100 +AcDbSymbolTable +70 +1 +0 +STYLE +5 +13 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord +2 +Standard +70 +0 +40 +0.0 +41 +1.0 +50 +0.0 +71 +0 +42 +2.5 +3 +txt +4 + +0 +ENDTAB +0 +TABLE +2 +DIMSTYLE +5 +A +100 +AcDbSymbolTable +70 +1 +0 +DIMSTYLE +105 +11 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord +2 +Standard +70 +0 +340 +13 +0 +ENDTAB +0 +TABLE +2 +APPID +5 +9 +100 +AcDbSymbolTable +70 +1 +0 +APPID +5 +12 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord +2 +ACAD +70 +0 +0 +ENDTAB +0 +TABLE +2 +BLOCK_RECORD +5 +1 +100 +AcDbSymbolTable +70 +3 +0 +BLOCK_RECORD +5 +1F +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +*Model_Space +70 +0 +280 +1 +281 +0 +0 +BLOCK_RECORD +5 +20 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +*Paper_Space +70 +0 +280 +1 +281 +0 +0 +BLOCK_RECORD +5 +21 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +TESTBLK +70 +0 +280 +1 +281 +0 +0 +ENDTAB +0 +ENDSEC +0 +SECTION +2 +BLOCKS +0 +BLOCK +5 +101F +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +*Model_Space +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +*Model_Space +1 + +0 +ENDBLK +5 +201F +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +BLOCK +5 +1020 +330 +20 +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +*Paper_Space +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +*Paper_Space +1 + +0 +ENDBLK +5 +2020 +330 +20 +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +BLOCK +5 +1021 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +TESTBLK +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +TESTBLK +1 + +0 +LINE +5 +106 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +0.0 +20 +0.0 +30 +0.0 +11 +40.0 +21 +0.0 +31 +0.0 +0 +ENDBLK +5 +2021 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +LINE +5 +100 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +0.0 +20 +0.0 +30 +0.0 +11 +100.0 +21 +0.0 +31 +0.0 +0 +LINE +5 +107 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +60.0 +20 +-30.0 +30 +0.0 +11 +60.0 +21 +30.0 +31 +0.0 +0 +CIRCLE +5 +101 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbCircle +10 +200.0 +20 +0.0 +30 +0.0 +40 +25.0 +0 +ARC +5 +102 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbCircle +10 +300.0 +20 +0.0 +30 +0.0 +40 +25.0 +100 +AcDbArc +50 +0.0 +51 +90.0 +0 +LWPOLYLINE +5 +103 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbPolyline +90 +3 +70 +0 +10 +0.0 +20 +100.0 +42 +0.5 +10 +100.0 +20 +100.0 +10 +100.0 +20 +200.0 +0 +SPLINE +5 +104 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbSpline +210 +0.0 +220 +0.0 +230 +1.0 +70 +8 +71 +3 +72 +8 +73 +4 +74 +0 +40 +0.0 +40 +0.0 +40 +0.0 +40 +0.0 +40 +1.0 +40 +1.0 +40 +1.0 +40 +1.0 +10 +0.0 +20 +300.0 +30 +0.0 +10 +40.0 +20 +360.0 +30 +0.0 +10 +80.0 +20 +240.0 +30 +0.0 +10 +120.0 +20 +300.0 +30 +0.0 +0 +INSERT +5 +105 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockReference +2 +TESTBLK +10 +400.0 +20 +0.0 +30 +0.0 +41 +2.0 +42 +2.0 +43 +1.0 +50 +0.0 +0 +VIEWPORT +5 +200 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbViewport +10 +150.0 +20 +100.0 +30 +0.0 +40 +200.0 +41 +150.0 +68 +2 +69 +2 +100 +AcDbViewport +12 +0.0 +22 +0.0 +13 +0.0 +23 +0.0 +14 +1.0 +24 +1.0 +15 +1.0 +25 +1.0 +16 +0.0 +26 +0.0 +36 +1.0 +17 +150.0 +27 +150.0 +37 +0.0 +42 +50.0 +43 +0.0 +44 +0.0 +45 +300.0 +50 +0.0 +51 +0.0 +72 +100 +90 +32864 +281 +0 +71 +1 +74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 +79 +0 +146 +0.0 +170 +0 +61 +5 +292 +0 +282 +1 +141 +0.0 +142 +0.0 +63 +250 +421 +3355443 +0 +DIMENSION +5 +201 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +20.0 +30 +0.0 +11 +80.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +100.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +25.0 +33 +0.0 +14 +125.0 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +202 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +180.0 +20 +20.0 +30 +0.0 +11 +180.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +50.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +162.5 +23 +25.0 +33 +0.0 +14 +187.5 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +203 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +250.0 +20 +20.0 +30 +0.0 +11 +250.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +35.355339 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +237.5 +23 +25.0 +33 +0.0 +14 +225.0 +24 +37.5 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +204 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +140.0 +30 +0.0 +11 +80.0 +21 +145.0 +31 +0.0 +70 +1 +71 +5 +42 +141.421356 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +75.0 +33 +0.0 +14 +125.0 +24 +125.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +205 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +190.0 +30 +0.0 +11 +80.0 +21 +195.0 +31 +0.0 +70 +1 +71 +5 +42 +91.56836585306085 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +175.0 +33 +0.0 +14 +120.0 +24 +166.5625 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +206 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +330.0 +20 +20.0 +30 +0.0 +11 +330.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +80.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +275.0 +23 +25.0 +33 +0.0 +14 +315.0 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +207 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +35.0 +30 +0.0 +11 +80.0 +21 +40.0 +31 +0.0 +70 +1 +71 +5 +42 +60.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +25.0 +33 +0.0 +14 +105.0 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +ENDSEC +0 +SECTION +2 +OBJECTS +0 +DICTIONARY +5 +C +330 +0 +100 +AcDbDictionary +281 +1 +3 +ACAD_LAYOUT +350 +D +0 +DICTIONARY +5 +D +330 +C +100 +AcDbDictionary +281 +1 +0 +DIMASSOC +5 +300 +102 +{ACAD_REACTORS +330 +201 +102 +} +330 +C +100 +AcDbDimAssoc +330 +201 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +1 +40 +0.0 +10 +100.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +301 +102 +{ACAD_REACTORS +330 +202 +102 +} +330 +C +100 +AcDbDimAssoc +330 +202 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +5 +331 +200 +331 +101 +73 +1 +91 +0 +40 +0.0 +10 +175.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +5 +331 +200 +331 +101 +73 +1 +91 +0 +40 +0.0 +10 +225.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +302 +102 +{ACAD_REACTORS +330 +203 +102 +} +330 +C +100 +AcDbDimAssoc +330 +203 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +102 +73 +1 +91 +0 +40 +0.0 +10 +325.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +102 +73 +1 +91 +1 +40 +0.0 +10 +300.0 +20 +25.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +303 +102 +{ACAD_REACTORS +330 +204 +102 +} +330 +C +100 +AcDbDimAssoc +330 +204 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +103 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +100.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +103 +73 +1 +91 +2 +40 +0.0 +10 +100.0 +20 +200.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +304 +102 +{ACAD_REACTORS +330 +205 +102 +} +330 +C +100 +AcDbDimAssoc +330 +205 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +104 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +300.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +9 +331 +200 +331 +104 +73 +1 +91 +-2 +40 +0.75 +10 +90.0 +20 +283.125 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +305 +102 +{ACAD_REACTORS +330 +206 +102 +} +330 +C +100 +AcDbDimAssoc +330 +206 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +105 +331 +106 +73 +1 +91 +0 +40 +0.0 +10 +400.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +105 +331 +106 +73 +1 +91 +1 +40 +0.0 +10 +480.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +306 +102 +{ACAD_REACTORS +330 +207 +102 +} +330 +C +100 +AcDbDimAssoc +330 +207 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +6 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +60.0 +20 +0.0 +30 +0.0 +332 +200 +332 +107 +74 +1 +92 +0 +75 +0 +0 +ENDSEC +0 +EOF