From 64ce0b2e8c1486ec89a2df57896b1f8de8088779 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Tue, 15 Sep 2026 14:53:45 -0500 Subject: [PATCH] Resolve viewport dimension features and preserve valid measurements on refresh --- docs/viewport-dimensions.md | 58 +- src/app/dim_viewport.rs | 31 +- src/app/properties.rs | 18 + src/app/update/viewport_snap.rs | 84 +- src/app/viewport_dimension_tests.rs | 720 +++++++++++++++++- src/scene/dimension_assoc.rs | 429 +++++++++-- src/scene/dimension_assoc_chain.rs | 441 ++++++----- src/scene/entity.rs | 2 +- src/scene/viewport_dim_seam.rs | 101 ++- tests/fixtures/dim_assoc/README.md | 16 + .../dim_assoc/viewport_associations.dxf | 124 ++- 11 files changed, 1680 insertions(+), 344 deletions(-) create mode 100644 tests/fixtures/dim_assoc/README.md diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md index 09f85208c..7525a9abc 100644 --- a/docs/viewport-dimensions.md +++ b/docs/viewport-dimensions.md @@ -34,10 +34,46 @@ user's unit conversion separate from viewport scale. The ordinary `DIMLFAC` override remains sufficient for displaying the saved measurement in other CAD readers. The data survives DXF/DWG saves and undo/redo. -The dependent association change uses this metadata when viewport scale changes. -This creation change alone creates nonassociative viewport dimensions and leaves -the drawing's `DIMASSOC` setting unchanged. Direct model/paper associations retain -their existing behavior. +With `DIMASSOC=2`, acquired features attach a dimension to its viewport, each +block instance, and the source entity. Intersections retain both source chains. +Source edits and viewport changes update the definition points and recompute +compensation while preserving explicit user-positioned sheet text and dimension +style overrides. A successful update invalidates the saved picture block so the +new geometry and number are rendered and exported. Undo restores both the +reference data and the previous picture. + +Properties reports whether the dimension is associated, partially associated, +unresolved, broken, or nonassociative. A broken or unsupported required reference +preserves the entire dimension's last valid appearance and its original records. +Creating another dimension does not announce geometry changes to its sources. + +## Imported references and support limits + +The resolver distinguishes the application's vertex/parameter convention from +imported edge references. Imported start/end and midpoint modes use their edge +marker and parameter together. The original regression drawing's first-edge +start/end pair now reproduces its `0.128` measurement. An imported polyline +endpoint beyond the first edge requires a usable stored point to validate the +marker; zero/sentinel data cannot establish that identity. Ambiguous imports +remain unresolved. Imported viewport scale is updated only when existing OCS +metadata or `ACAD_DIMASSOC_CALC_DIMLFAC` separates compensation from user scaling. + +Unsupported cases retain their saved data and appearance: + +- Perspective/oblique viewport projections and malformed/missing reference chains. +- Ambiguous intersections within one batched block; they are not attached to a + guessed pair of children. Intersections between separate identified chains work. +- Array inserts without an addressable instance index, nesting beyond eight levels, + and radial measurements of nonuniformly transformed circular geometry. +- Imported quadrant references whose moved geometry no longer validates the stored + point; newly acquired circle/arc quadrants keep their source-local angle. +- Tangency with no verified solution, two dependent perpendicular/tangent references + requiring a coupled solve, unsupported snap modes, and imported measurement data that does not identify the old viewport compensation. + +The osnap codes and reference fields follow the +[Autodesk DIMASSOC reference](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-C0B96256-A911-4B4D-85E6-EB4AF2C91E27.htm). +The restricted imported-marker interpretation above is validated by fixtures; +it is not a claim that every exporter uses an interchangeable marker scheme. ## Validation @@ -46,3 +82,17 @@ commits for all five commands, object picks, input retries, active positive and negative style factors, exploded dimensions, undo/redo, DXF/DWG persistence, clipping, overlapping viewports, pan, scale, and twist. These are headless tests; they do not establish visual acceptance in the desktop UI. + +Association tests also exercise regeneration, nearest and quadrant parameters, +angular arc/polyline object picks, spline tangency, +perpendicularity under nonuniform block scale, nested paths, intersection source +edits, preserved overrides, erased sources, copy detachment, saved picture +invalidation, and history. The checked-in DXF fixture is exercised directly. +The original private drawing can be checked separately: + +```powershell +$env:OPENCAD_VIEWPORT_REGRESSION_DXF = 'C:\path\to\115448.dxf' +cargo test --locked --offline --lib viewport_dimension_original -- --ignored --nocapture +``` + +Do not treat passing headless checks as completed desktop visual acceptance. diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs index 3bea9c53a..a80b151c4 100644 --- a/src/app/dim_viewport.rs +++ b/src/app/dim_viewport.rs @@ -208,7 +208,9 @@ impl OpenCADStudio { i: usize, dimension: Handle, ) -> Vec> { - let slots = self.tabs[i].scene.dimension_association_slot_points(dimension); + let slots = self.tabs[i] + .scene + .dimension_association_slot_points(dimension); if slots.is_empty() { return Vec::new(); } @@ -220,7 +222,12 @@ impl OpenCADStudio { 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 { + if snap + .paper_point + .truncate() + .distance_squared(slot.truncate()) + <= 1e-12 + { return Some(snap); } Some(AcceptedSnap { @@ -235,9 +242,8 @@ impl OpenCADStudio { /// Record the viewport association for a dimension that has just been /// committed while measuring through a layout viewport. /// - /// Announces the whole chain — the viewport, every INSERT on the block - /// path and the source entity — so the dependency index picks the new - /// association up immediately. + /// 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()) { @@ -246,17 +252,8 @@ impl OpenCADStudio { self.tabs[i] .scene .attach_viewport_dimension_association(dimension, &snaps); - let mut changes = vec![(dimension, crate::scene::ChangeKind::Modified)]; - changes.extend( - self.tabs[i] - .scene - .dimension_association_sources(dimension) - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)), - ); - changes.sort_by_key(|(handle, _)| handle.value()); - changes.dedup_by_key(|(handle, _)| handle.value()); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i] + .scene + .bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]); } - } diff --git a/src/app/properties.rs b/src/app/properties.rs index a942c5ca6..6a6081a37 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1277,6 +1277,24 @@ 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/viewport_snap.rs b/src/app/update/viewport_snap.rs index 977a28415..4a638f6e3 100644 --- a/src/app/update/viewport_snap.rs +++ b/src/app/update/viewport_snap.rs @@ -137,35 +137,73 @@ impl OpenCADStudio { true } - /// Replace a snap source that is a block INSTANCE with the entity inside - /// it that actually owns the snapped feature, plus the INSERT path. - /// - /// The snap engine can only report what the wire carries, and the - /// renderer stamps every wire of an expanded block with the *top-level - /// INSERT's* handle — nested inserts included. `SnapSourceRef` promises - /// the innermost entity in `source.handle` and the instance chain in - /// `block_path`, so the descent happens here, once, at accept time (not - /// per pointer move). - /// - /// When the descent cannot identify the inner entity the source stays the - /// INSERT with an empty path. See - /// [`crate::scene::Scene::resolve_block_snap_source`] for exactly when - /// that happens; the resulting association is coarser (it tracks the whole - /// block instance) but never wrong. + /// 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_dim_seam::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 !source.block_path.is_empty() { + if !resolve(source) { + accepted.source = None; return; } - if let Some((entity, path)) = self.tabs[tab] - .scene - .resolve_block_snap_source(source.source.handle, accepted.model_point) - { - source.source = - crate::command::DimensionAssociationSource::inferred(entity); - source.block_path = path; + 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; + } } } diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs index 5ac9cb658..44d1aff54 100644 --- a/src/app/viewport_dimension_tests.rs +++ b/src/app/viewport_dimension_tests.rs @@ -239,11 +239,20 @@ fn viewport_dimension_linear_aligned_and_radial_object_picks() { #[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, line, DVec3::Y * 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); @@ -377,3 +386,712 @@ fn viewport_dimension_exploded_commit_uses_compensated_text_and_undo() { .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 the original local regression drawing"] +fn viewport_dimension_original_drawing_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::block_transform(&scene.document, &[root, nested]); + 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); +} diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 9321958f8..f8ccb3cb9 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -14,10 +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, ViewportFrame}; +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; @@ -404,8 +402,7 @@ impl ChainMap { 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)); + 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, @@ -416,8 +413,7 @@ impl ChainMap { 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)); + 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, @@ -462,11 +458,18 @@ impl ChainMap { /// 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) -> RadialSourceGeometry { - RadialSourceGeometry { - plane: self.map_plane(radial.plane), - ..radial + 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 }) } } @@ -500,7 +503,9 @@ pub enum ReferenceStatus { fn chain_map(scene: &Scene, walked: &ReferenceChain) -> ChainMap { ChainMap { transform: walked.transform, - frame: walked.viewport.and_then(|handle| scene.viewport_frame(handle)), + frame: walked + .viewport + .and_then(|handle| scene.viewport_frame(handle)), } } @@ -531,7 +536,12 @@ fn hint_error(entity: &EntityType, point: Vector3) -> f64 { /// the same drawing. Rather than guess, both readings are mapped down to the /// entity and the one that actually lands on the geometry wins. The point is /// only ever a *hint* — it disambiguates candidates, it is never the answer. -fn local_hint(scene: &Scene, map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option { +fn local_hint( + scene: &Scene, + map: &ChainMap, + entity: &EntityType, + stored: Vector3, +) -> Option { let _ = scene; let inverse = chain::invert(&map.transform)?; let mut candidates = vec![inverse.apply(stored)]; @@ -558,34 +568,66 @@ pub fn resolve_reference_chain( 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) => return Err(ReferenceStatus::Unresolved), + 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); - let inverse = chain::invert(&map.transform); - - let mut local = None; - if chain::evaluates(reference.osnap_type) { - let hint = local_hint(scene, &map, entity, reference.osnap_point); - let from = from_space - .zip(inverse) - .map(|(point, inverse)| inverse.apply(map.space_to_model(point))); - local = chain::feature_point( + 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(scene, &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); } - // Every reference — including one whose osnap type we do not evaluate — - // falls back to the marker/parameter convention, which is what the - // pre-PR3 writer and plain END references use. - let local = match local.or_else(|| resolve_local_by_marker(entity, reference)) { - Some(local) => local, - None => return Err(ReferenceStatus::Unresolved), - }; let model = map.to_model(local); Ok(ResolvedReference { model, @@ -798,9 +840,11 @@ pub(crate) fn dimension_is_associative( }; association.dimension == dimension && association.associativity != 0 - && association.references.iter().flatten().any(|reference| { - chain::walk_chain(document, &reference.xrefs).is_ok() - }) + && association + .references + .iter() + .flatten() + .any(|reference| chain::walk_chain(document, &reference.xrefs).is_ok()) }) } @@ -827,6 +871,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( @@ -1207,8 +1260,7 @@ impl Scene { self.attach_viewport_radial_association(dimension, snaps); return; } - let mut references: [Vec; 4] = - std::array::from_fn(|_| Vec::new()); + 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(); @@ -1233,25 +1285,41 @@ impl Scene { reactor_targets.extend(xrefs.iter().copied()); let osnap_type = chain::osnap_type_for(source.snap_type); - // An explicitly-acquired sub-entity wins; otherwise fall back to - // the same marker inference model-space creation already uses, in - // the entity's own coordinates. - let (marker, parameter) = match source.source.marker { - Some(marker) => (marker, source.source.parameter), - None => { - let local = Self::point_in_entity_space( - &self.document, - &source.block_path, - dvec3(snap.model_point), - ); - source_reference(entity, local).unwrap_or((0, 0.0)) - } + let local = Self::point_in_entity_space( + &self.document, + &source.block_path, + dvec3(snap.model_point), + ); + 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()); + } 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, @@ -1385,13 +1453,41 @@ impl Scene { /// the erased source comes back and the same query starts answering /// [`ReferenceStatus::Resolved`] again, with the reference record itself /// never having been rewritten. - pub fn dimension_association_status( - &self, - dimension: Handle, - ) -> Vec<(usize, ReferenceStatus)> { + 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 points = self.dimension_association_slot_points(dimension); association .references .iter() @@ -1400,8 +1496,16 @@ impl Scene { let reference = slot.first()?; Some(( index, - match resolve_reference_chain(self, reference, None) { - Ok(_) => ReferenceStatus::Resolved, + 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, }, )) @@ -1437,10 +1541,7 @@ impl Scene { } /// The DIMASSOC payload attached to `dimension`, if there is one. - pub fn dimension_association( - &self, - dimension: Handle, - ) -> Option<&AssocDimensionAssociation> { + pub fn dimension_association(&self, dimension: Handle) -> Option<&AssocDimensionAssociation> { self.document.objects.values().find_map(|object| { let ObjectType::Associative(object) = object else { return None; @@ -1557,12 +1658,8 @@ impl Scene { } } - pub(crate) fn infer_dimension_sources( - &self, - dimension: Handle, - ) -> Vec> { - let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) - else { + pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec> { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return Vec::new(); }; let radial_data = match entity { @@ -1659,11 +1756,15 @@ impl Scene { let mut refreshed = Vec::new(); for association in associations { - // Two passes. PERPENDICULAR and TANGENT are measured *from* the - // other definition point, so the independent references have to - // land first; a dimension with two dependent references keeps its - // old points as the from-points, which is stable because they were - // valid the last time round. + // 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) { @@ -1675,9 +1776,7 @@ impl Scene { .document .get_entity(association.dimension) .and_then(|entity| match entity { - EntityType::Dimension(dimension) => { - Some(dimension_reference_points(dimension)) - } + EntityType::Dimension(dimension) => Some(dimension_reference_points(dimension)), _ => None, }) .unwrap_or_default(); @@ -1699,6 +1798,19 @@ impl Scene { }); 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; + } + let viewports: std::collections::HashSet<_> = + resolved.iter().flatten().map(|r| r.viewport).collect(); + if viewports.len() > 1 { + continue; + } // The map of the first live reference governs how the whole // dimension is placed and measured — all of a dimension's points // are in one space. @@ -1725,7 +1837,7 @@ impl Scene { // 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), + chain_map(self, &walked).map_radial(radial)?, reference.osnap_distance, )) }); @@ -1738,15 +1850,34 @@ impl Scene { _ => return None, }; let radial = radial_source_for_marker(entity, segment)?; - Some(chain_map(self, &walked).map_radial(radial)) + 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 { @@ -2005,8 +2136,8 @@ impl Scene { .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)); + 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 } @@ -2015,6 +2146,30 @@ impl Scene { "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 @@ -2053,3 +2208,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 index ce83ac307..2bcf31342 100644 --- a/src/scene/dimension_assoc_chain.rs +++ b/src/scene/dimension_assoc_chain.rs @@ -37,7 +37,7 @@ use acadrust::objects::AssocDimensionReference; use acadrust::types::{Handle, Matrix4, Transform, Vector3}; use acadrust::{CadDocument, EntityType}; -use cadkernel::geom2d::{closest_point, Curve as KernelCurve, DEFAULT_SEGMENTS_PER_RADIAN}; +use cadkernel::geom2d::{closest_point, Curve as KernelCurve}; use cadkernel::space::Plane; use crate::entities::curve::entity_curve; @@ -50,7 +50,7 @@ use crate::entities::curve::entity_curve; /// and `NEAR` for a point taken at a parameter on a circle, so those two keep /// meaning exactly what they used to. #[allow(dead_code)] // The full code table is kept so a reader can see what a -// stored `osnap_type` means, including the modes we pass through untouched. + // stored `osnap_type` means, including the modes we pass through untouched. pub(crate) mod osnap { pub const NONE: u8 = 0; pub const END: u8 = 1; @@ -115,6 +115,7 @@ pub(crate) struct ReferenceChain { 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), @@ -131,55 +132,54 @@ 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 = Vec::new(); - let mut entity = None; + let mut block_path = Vec::new(); let mut transform = Transform::identity(); - - for (index, handle) in xrefs.iter().copied().enumerate() { + 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; } - let Some(node) = document.get_entity(handle) else { - return Err(ChainError::Missing(handle)); - }; - match node { - // Only a *leading* viewport is the trans-space hop. A viewport - // deeper in the chain would be geometry being dimensioned (the - // paper-space rectangle itself), so it falls through to `entity`. - EntityType::Viewport(_) if index == 0 && viewport.is_none() => { - viewport = Some(handle); - } - EntityType::Insert(insert) => { - block_path.push(handle); - transform = - transform.compose(&crate::scene::render_graph::insert_transform(document, insert)); - } - _ => { - entity = Some(handle); - break; - } + 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.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, + )); } - - let entity = match entity { - Some(entity) => entity, - // Nothing but INSERTs: the innermost one is the geometry, and it is - // no longer part of the path leading to it. - None => match block_path.pop() { - Some(innermost) => { - transform = block_transform(document, &block_path); - innermost - } - None => return Err(ChainError::Empty), - }, - }; - Ok(ReferenceChain { - viewport, - block_path, - entity, - transform, - }) + Err(ChainError::Empty) } /// Accumulated entity-local -> model transform for an ordered INSERT path. @@ -187,8 +187,9 @@ pub(crate) fn block_transform(document: &CadDocument, block_path: &[Handle]) -> let mut transform = Transform::identity(); for handle in block_path { if let Some(EntityType::Insert(insert)) = document.get_entity(*handle) { - transform = - transform.compose(&crate::scene::render_graph::insert_transform(document, insert)); + transform = transform.compose(&crate::scene::render_graph::insert_transform( + document, insert, + )); } } transform @@ -270,7 +271,7 @@ pub(crate) struct FeatureContext { /// 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. `None` falls back to `hint`. + /// is the other definition point. Tangency requires this point. pub from: Option, } @@ -287,9 +288,12 @@ pub(crate) fn feature_point( 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, context.hint?), + osnap::QUAD => quadrant_point(entity, reference, context.hint?), osnap::NODE | osnap::INS => node_point(entity), osnap::INTERSEC | osnap::APPARENT_INT => intersection_point( document, @@ -313,7 +317,9 @@ pub(crate) fn feature_point( pub(crate) fn evaluates(osnap_type: u8) -> bool { matches!( osnap_type, - osnap::CEN + osnap::END + | osnap::START_POINT + | osnap::CEN | osnap::MID | osnap::QUAD | osnap::NODE @@ -342,7 +348,13 @@ fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Opt let KernelCurve::Polyline(polyline) = &planar.curve else { return None; }; - let segment = reference.main_gs_marker.max(0) as usize; + 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)) } @@ -356,36 +368,52 @@ fn mid_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option // 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(reference.main_gs_marker as usize)?; + 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, hint: Vector3) -> Option { +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, }; - // Four candidates on the curve's own plane; the stored point picks one, so - // a rotated source keeps the quadrant the user actually snapped to. + let valid_angle = |angle: f64| match &planar.curve { + KernelCurve::Arc(arc) => { + (angle - arc.start_angle).rem_euclid(std::f64::consts::TAU) <= arc.sweep() + 1e-9 + } + _ => true, + }; + let at_angle = |angle: f64| { + lift( + &planar.plane, + [ + centre[0] + radius * angle.cos(), + centre[1] + radius * angle.sin(), + ], + ) + }; + // 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| { - let angle = std::f64::consts::FRAC_PI_2 * index as f64; - lift( - &planar.plane, - [ - centre[0] + radius * angle.cos(), - centre[1] + radius * angle.sin(), - ], - ) - }) - .min_by(|first, second| { - distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint)) - }) + .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 { @@ -402,7 +430,50 @@ fn node_point(entity: &EntityType) -> Option { fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option { let planar = entity_curve(entity)?; let uv = planar.plane.project(dpoint(from))?; - Some(lift(&planar.plane, closest_point(&planar.curve, uv).point)) + let near = closest_point(&planar.curve, uv); + let direction = planar.curve.tangent_at(near.t); + let delta = [near.point[0] - uv[0], near.point[1] - uv[1]]; + let residual = delta[0] * direction[0] + delta[1] * direction[1]; + if residual.abs() > 1e-8 * delta[0].hypot(delta[1]).max(1.0) { + return None; + } + Some(lift(&planar.plane, near.point)) +} + +/// 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))?; + let near = closest_point(&curve, uv); + let tangent = curve.tangent_at(near.t); + let delta = [near.point[0] - uv[0], near.point[1] - uv[1]]; + if (delta[0] * tangent[0] + delta[1] * tangent[1]).abs() + > 1e-8 * delta[0].hypot(delta[1]).max(1.0) + { + return None; + } + Some(lift(&plane, near.point)) } fn nearest_point( @@ -416,9 +487,7 @@ fn nearest_point( // Every other curve carries the kernel's normalised 0..1 parameter. match &planar.curve { KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity { - EntityType::Circle(circle) => { - Some(circle.point_at_angle_wcs(reference.osnap_distance)) - } + 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, }, @@ -454,39 +523,14 @@ fn tangent_point( return spline_tangent_point(spline, reference, context); } 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), - _ => { - // Generic curve: no closed form and no spline evaluator. Hold the - // stored parameter if there is one, else leave it unresolved. - return nearest_point(entity, reference, context.hint); - } - }; - let from = context.from.or(context.hint)?; + let from = context.from?; let uv = planar.plane.project(dpoint(from))?; - let (dx, dy) = (uv[0] - centre[0], uv[1] - centre[1]); - let distance = (dx * dx + dy * dy).sqrt(); - if !distance.is_finite() || distance <= radius + 1e-12 || radius <= 1e-12 { - return None; - } - // Two touch points, symmetric about the centre-to-`from` line. - let base = dy.atan2(dx); - let offset = (radius / distance).clamp(-1.0, 1.0).acos(); - let candidates = [base + offset, base - offset].map(|angle| { - lift( - &planar.plane, - [ - centre[0] + radius * angle.cos(), - centre[1] + radius * angle.sin(), - ], - ) - }); - let hint = context.hint.unwrap_or(candidates[0]); - candidates + cadkernel::geom2d::snap::tangent_from(&planar.curve, uv) .into_iter() - .min_by(|first, second| { - distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint)) + .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))) }) } @@ -504,50 +548,52 @@ fn spline_tangent_point( let hint = context.hint.or(context.from)?; curve.parameter_at(dpoint(hint)).clamp(start, end) }; - let Some(from) = context.from.or(context.hint) else { - // No external point: the stored parameter is the whole answer. - return Some(vector3(curve.point_at_knot(u))); - }; - let p = dpoint(from); - // Newton on f(u) = (C(u) - P) · C'(u). f'(u) is approximated by a central - // difference of f, which converges in a handful of steps from a seed that - // is already on the right lobe and never leaves the domain. + let from = glam::DVec3::from_array(dpoint(context.from?)); + // A tangent is parallel to C(u)-P. Minimize the vector cross product, + // which also detects nonplanar curves that have no tangent from P. let f = |u: f64| { - let c = curve.point_at_knot(u); - let d = curve.derivative_at_knot(u); - (c[0] - p[0]) * d[0] + (c[1] - p[1]) * d[1] + (c[2] - p[2]) * d[2] + let c = glam::DVec3::from_array(curve.point_at_knot(u)); + let d = glam::DVec3::from_array(curve.derivative_at_knot(u)); + (c - from).cross(d) }; - let span = (end - start).abs().max(1e-12); - let step = span * 1e-6; - for _ in 0..24 { + let span = (end - start).abs(); + if !span.is_finite() || span <= 1e-15 { + return None; + } + for _ in 0..48 { let value = f(u); - if value.abs() <= span * 1e-12 { + let lo = (u - span * 1e-6).max(start); + let hi = (u + span * 1e-6).min(end); + let derivative = (f(hi) - f(lo)) / (hi - lo); + if !value.is_finite() || !derivative.is_finite() { + return None; + } + if derivative.length_squared() <= 1e-30 { break; } - let slope = (f((u + step).min(end)) - f((u - step).max(start))) / (2.0 * step); - if !slope.is_finite() || slope.abs() < 1e-18 { - break; - } - let next = (u - value / slope).clamp(start, end); - if !next.is_finite() || (next - u).abs() <= span * 1e-14 { + let next = (u - value.dot(derivative) / derivative.length_squared()).clamp(start, end); + if (next - u).abs() < span * 1e-14 { u = next; break; } u = next; } - Some(vector3(curve.point_at_knot(u))) + let c = glam::DVec3::from_array(curve.point_at_knot(u)); + let d = glam::DVec3::from_array(curve.derivative_at_knot(u)); + let scale = (c - from).length() * d.length(); + if !c.is_finite() || scale <= 1e-20 || f(u).length() > 1e-8 * scale { + return None; + } + Some(vector3(c.to_array())) } // ── Intersection ────────────────────────────────────────────────────────── /// Intersection of `entity` with the reference's second object chain. /// -/// Both curves are tessellated on the first curve's plane and crossed -/// pairwise; the crossing nearest the stored osnap point wins, so a pair of -/// curves that meet more than once keeps the corner the user picked. With -/// `apparent` the segment parameters are not clamped, which recovers the -/// crossing of the two *extended* nearest segments — the best-effort AutoCAD -/// calls an apparent intersection in a plan view. +/// 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, @@ -555,59 +601,53 @@ fn intersection_point( hint: Vector3, apparent: bool, ) -> Option { + let main = walk_chain(document, &reference.xrefs).ok()?; let other = walk_chain(document, &reference.intersection_objects).ok()?; - let other_entity = document.get_entity(other.entity)?; + if main.viewport != other.viewport { + return None; + } + let inverse = invert(&main.transform)?; let first = entity_curve(entity)?; - let second = entity_curve(other_entity)?; - // The first curve's plane is the working frame. The second curve is - // evaluated through its *own* block path, so each of its points is lifted - // to model space and then projected back onto that frame before crossing. - let a = first.curve.tessellate(DEFAULT_SEGMENTS_PER_RADIAN); - let b = second - .curve - .tessellate(DEFAULT_SEGMENTS_PER_RADIAN) - .into_iter() - .map(|uv| { - let world = other.transform.apply(lift(&second.plane, uv)); - first.plane.project(dpoint(world)).unwrap_or(uv) - }) - .collect::>(); + 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 best: Option<([f64; 2], f64)> = None; - for pair_a in a.windows(2) { - for pair_b in b.windows(2) { - let Some(point) = segment_cross(pair_a[0], pair_a[1], pair_b[0], pair_b[1], false) - else { - continue; - }; - let error = (point[0] - hint_uv[0]).powi(2) + (point[1] - hint_uv[1]).powi(2); - if best.is_none_or(|(_, previous)| error < previous) { - best = Some((point, error)); - } + 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) { + points.extend(segment_cross(a.start, a.end, b.start, b.end, true)); } } - if best.is_none() && apparent { - // Nothing actually crosses: take the two segments nearest the stored - // point and cross their infinite extensions. - let nearest = |run: &[[f64; 2]]| -> Option<([f64; 2], [f64; 2])> { - run.windows(2) - .map(|pair| { - let mid = [ - (pair[0][0] + pair[1][0]) * 0.5, - (pair[0][1] + pair[1][1]) * 0.5, - ]; - let error = - (mid[0] - hint_uv[0]).powi(2) + (mid[1] - hint_uv[1]).powi(2); - ((pair[0], pair[1]), error) - }) - .min_by(|first, second| first.1.total_cmp(&second.1)) - .map(|(pair, _)| pair) - }; - let (a0, a1) = nearest(&a)?; - let (b0, b1) = nearest(&b)?; - best = segment_cross(a0, a1, b0, b1, true).map(|point| (point, 0.0)); - } - best.map(|(point, _)| lift(&first.plane, 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)) } /// Crossing of segments `a0->a1` and `b0->b1`. With `extend` the parameters @@ -633,3 +673,48 @@ fn segment_cross( } Some([a0[0] + d[0] * t, a0[1] + d[1] * t]) } + +/// 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/viewport_dim_seam.rs b/src/scene/viewport_dim_seam.rs index 4f032a4b3..efb62e1b5 100644 --- a/src/scene/viewport_dim_seam.rs +++ b/src/scene/viewport_dim_seam.rs @@ -84,6 +84,9 @@ impl Scene { 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, @@ -106,39 +109,6 @@ impl Scene { None } - /// Resolve a snapped INSERT handle into the innermost entity that owns the - /// geometry, plus the INSERT path down to it. - /// - /// The renderer names every wire of an expanded block with the **top-level - /// INSERT's** handle (see `block_cache`), including wires belonging to - /// nested inserts, so a snap inside a block reports the INSERT and nothing - /// finer. This walks the block definition from that INSERT looking for the - /// nearest measurable entity to `model_point`, which recovers the real - /// source handle and the instance path for the association chain. - /// - /// Returns `None` — and the caller then keeps the INSERT itself as the - /// source, which still gives a correct, if coarse, association — when: - /// * `top` is not an INSERT (the common case: `Some((top, vec![]))`); - /// * the geometry is a type [`planar_pick_distance`] does not handle - /// (text, hatch, spline, solid, 3-D geometry); - /// * the nesting is deeper than eight levels. - /// - /// Depth is not limited to one level: [`Scene::descend_block_instance`] - /// recurses, and the returned path lists every INSERT from the outermost - /// down to the one that directly contains the entity. - pub fn resolve_block_snap_source( - &self, - top: Handle, - model_point: DVec3, - ) -> Option<(Handle, Vec)> { - if !matches!(self.document.get_entity(top), Some(EntityType::Insert(_))) { - return None; - } - let (entity, path) = self.resolve_measurable_entity(top, model_point, None)?; - let handle = entity.as_entity().handle(); - handle.is_valid().then_some((handle, path)) - } - /// 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. @@ -205,6 +175,16 @@ impl Scene { 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 { @@ -217,11 +197,12 @@ impl Scene { &mut path, 0, viewport, + kind, )?; Some((found.0, found.1)) } other => { - planar_pick_distance(other, model_point)?; + feature_pick_distance(other, model_point, kind)?; Some((other.clone(), Vec::new())) } } @@ -237,6 +218,7 @@ impl Scene { path: &mut Vec, depth: usize, viewport: Option, + kind: Option, ) -> Option<(EntityType, Vec)> { const MAX_DEPTH: usize = 8; if depth > MAX_DEPTH { @@ -245,6 +227,9 @@ impl Scene { let EntityType::Insert(insert) = &insert_entity else { 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); @@ -273,8 +258,9 @@ impl Scene { &mut nested, depth + 1, viewport, + kind, ) { - if let Some(distance) = planar_pick_distance(&entity, model_point) { + 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); @@ -294,6 +280,7 @@ impl Scene { 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) @@ -305,7 +292,7 @@ impl Scene { &mut placed, &EntityTransform::Affine(combined), ); - let Some(distance) = planar_pick_distance(&placed, model_point) else { + let Some(distance) = feature_pick_distance(&placed, model_point, kind) else { continue; }; if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { @@ -438,3 +425,45 @@ mod tests { } } } + +/// 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/tests/fixtures/dim_assoc/README.md b/tests/fixtures/dim_assoc/README.md new file mode 100644 index 000000000..a006911b7 --- /dev/null +++ b/tests/fixtures/dim_assoc/README.md @@ -0,0 +1,16 @@ +# Viewport association fixture + +`viewport_associations.dxf` is a synthetic paper-space fixture for line, circle, +arc, polyline, spline, block-instance, and intersection references through one +1:2 viewport. `src/app/viewport_dimension_tests.rs` loads it and exercises actual +association refresh and an edit to the second intersection source. + +Each dimension carries `DIMLFAC=-2` and `ACAD_DIMASSOC_CALC_DIMLFAC=-2`. These are +necessary for its paper definition points and saved model measurements to agree; +the original fixture omitted both factors. The spline tangent also uses the +actual touch point `(90, 283.125)` at parameter `0.75`, measured from `(0, 300)`. +The original endpoint `(120, 300)` was not tangent. Tests verify the cross product +with the spline derivative and preservation after an unsupported reference edit. + +The larger original `115448.dxf` is private and is not included. An opt-in test +uses `OPENCAD_VIEWPORT_REGRESSION_DXF` to read a local copy without modifying it. diff --git a/tests/fixtures/dim_assoc/viewport_associations.dxf b/tests/fixtures/dim_assoc/viewport_associations.dxf index 69eee3ed5..dcb198569 100644 --- a/tests/fixtures/dim_assoc/viewport_associations.dxf +++ b/tests/fixtures/dim_assoc/viewport_associations.dxf @@ -788,6 +788,22 @@ AcDbAlignedDimension 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 @@ -844,6 +860,22 @@ AcDbAlignedDimension 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 @@ -900,6 +932,22 @@ AcDbAlignedDimension 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 @@ -956,6 +1004,22 @@ AcDbAlignedDimension 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 @@ -991,7 +1055,7 @@ AcDbDimension 71 5 42 -120.0 +91.56836585306085 1 3 @@ -1005,13 +1069,29 @@ AcDbAlignedDimension 33 0.0 14 -135.0 +120.0 24 -175.0 +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 @@ -1068,6 +1148,22 @@ AcDbAlignedDimension 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 @@ -1124,6 +1220,22 @@ AcDbAlignedDimension 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 @@ -1475,11 +1587,11 @@ AcDbOsnapPointRef 91 -2 40 -1.0 +0.75 10 -120.0 +90.0 20 -300.0 +283.125 30 0.0 75