Resolve viewport dimension features and preserve valid measurements on refresh

This commit is contained in:
Christopher Overbeck committed 2026-09-15 14:53:45 -05:00
1 parent 7f018ab0f1
commit 64ce0b2e8c
11 files changed
+1680 -344

No files matched your search

+54 -4
View File
@@ -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 override remains sufficient for displaying the saved measurement in other CAD
readers. The data survives DXF/DWG saves and undo/redo. readers. The data survives DXF/DWG saves and undo/redo.
The dependent association change uses this metadata when viewport scale changes. With `DIMASSOC=2`, acquired features attach a dimension to its viewport, each
This creation change alone creates nonassociative viewport dimensions and leaves block instance, and the source entity. Intersections retain both source chains.
the drawing's `DIMASSOC` setting unchanged. Direct model/paper associations retain Source edits and viewport changes update the definition points and recompute
their existing behavior. 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 ## 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, negative style factors, exploded dimensions, undo/redo, DXF/DWG persistence,
clipping, overlapping viewports, pan, scale, and twist. These are headless tests; clipping, overlapping viewports, pan, scale, and twist. These are headless tests;
they do not establish visual acceptance in the desktop UI. 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.
+14 -17
View File
@@ -208,7 +208,9 @@ impl OpenCADStudio {
i: usize, i: usize,
dimension: Handle, dimension: Handle,
) -> Vec<Option<AcceptedSnap>> { ) -> Vec<Option<AcceptedSnap>> {
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() { if slots.is_empty() {
return Vec::new(); return Vec::new();
} }
@@ -220,7 +222,12 @@ impl OpenCADStudio {
let snap = measuring.get(index)?.clone(); let snap = measuring.get(index)?.clone();
let frame = snap.frame?; let frame = snap.frame?;
let slot = DVec3::new(slot.x, slot.y, slot.z); 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); return Some(snap);
} }
Some(AcceptedSnap { Some(AcceptedSnap {
@@ -235,9 +242,8 @@ impl OpenCADStudio {
/// Record the viewport association for a dimension that has just been /// Record the viewport association for a dimension that has just been
/// committed while measuring through a layout viewport. /// committed while measuring through a layout viewport.
/// ///
/// Announces the whole chain — the viewport, every INSERT on the block /// The stored reference chains establish dependencies. Only the new
/// path and the source entity — so the dependency index picks the new /// dimension needs a render update; its sources have not changed.
/// association up immediately.
pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) { pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) {
let snaps = self.viewport_dimension_snaps(i, dimension); let snaps = self.viewport_dimension_snaps(i, dimension);
if snaps.iter().flatten().all(|snap| snap.source.is_none()) { if snaps.iter().flatten().all(|snap| snap.source.is_none()) {
@@ -246,17 +252,8 @@ impl OpenCADStudio {
self.tabs[i] self.tabs[i]
.scene .scene
.attach_viewport_dimension_association(dimension, &snaps); .attach_viewport_dimension_association(dimension, &snaps);
let mut changes = vec![(dimension, crate::scene::ChangeKind::Modified)]; self.tabs[i]
changes.extend( .scene
self.tabs[i] .bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]);
.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);
} }
} }
+18
View File
@@ -1277,6 +1277,24 @@ impl OpenCADStudio {
&self.tabs[i].scene.document, &self.tabs[i].scene.document,
d.base().common.handle, 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 { general.props.push(crate::scene::model::object::Property {
label: t!("Associative").into_owned(), label: t!("Associative").into_owned(),
field: "associative", field: "associative",
+61 -23
View File
@@ -137,35 +137,73 @@ impl OpenCADStudio {
true true
} }
/// Replace a snap source that is a block INSTANCE with the entity inside /// Resolve the instance path once, when a feature is accepted. Failure
/// it that actually owns the snapped feature, plus the INSERT path. /// drops source identity so a failed descent cannot attach to the insert's
/// /// origin. Ambiguous intersections within one batched block remain unlinked.
/// 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.
fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) { 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 { let Some(source) = accepted.source.as_mut() else {
return; return;
}; };
if !source.block_path.is_empty() { if !resolve(source) {
accepted.source = None;
return; return;
} }
if let Some((entity, path)) = self.tabs[tab] if let Some(other) = source.intersection.as_mut() {
.scene if !resolve(other)
.resolve_block_snap_source(source.source.handle, accepted.model_point) || (other.source.handle == source.source.handle
{ && other.block_path == source.block_path)
source.source = {
crate::command::DimensionAssociationSource::inferred(entity); source.intersection = None;
source.block_path = path; }
} }
} }
+719 -1
View File
@@ -239,11 +239,20 @@ fn viewport_dimension_linear_aligned_and_radial_object_picks() {
#[test] #[test]
fn viewport_dimension_angular_preserves_angle_without_length_factor() { fn viewport_dimension_angular_preserves_angle_without_length_factor() {
let (mut app, line, frame) = fixture(); 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.dispatch_command("DIMANGULAR");
let _ = app.feed_command(StepInput::Enter); let _ = app.feed_command(StepInput::Enter);
point(&mut app, frame, line, DVec3::ZERO); point(&mut app, frame, line, DVec3::ZERO);
point(&mut app, frame, line, DVec3::X * 100.0); 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 _ = app.feed_command(StepInput::Point(DVec3::new(70.0, 70.0, 0.0)));
let doc = &app.tabs[app.active_tab].scene.document; let doc = &app.tabs[app.active_tab].scene.document;
assert!((dimension(doc).measurement() - 90.0).abs() < 1e-5); assert!((dimension(doc).measurement() - 90.0).abs() < 1e-5);
@@ -377,3 +386,712 @@ fn viewport_dimension_exploded_commit_uses_compensated_text_and_undo() {
.entities() .entities()
.any(|e| matches!(e, EntityType::Text(_) | EntityType::MText(_)))); .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::<Vec<_>>(),
);
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);
}
+351 -78
View File
@@ -14,10 +14,8 @@ use std::f64::consts::TAU;
use crate::command::DimensionAssociationSource; use crate::command::DimensionAssociationSource;
use super::dimension_assoc_chain::{ use super::dimension_assoc_chain::{self as chain, ChainError, FeatureContext, ReferenceChain};
self as chain, ChainError, FeatureContext, ReferenceChain, use super::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame};
};
use super::viewport_ref::{AcceptedSnap, ViewportFrame};
use super::{ChangeKind, Scene}; use super::{ChangeKind, Scene};
pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4; 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 { pub(crate) fn model_to_space(&self, point: Vector3) -> Vector3 {
match &self.frame { match &self.frame {
Some(frame) => { Some(frame) => {
let mapped = let mapped = frame.model_to_paper(glam::DVec3::new(point.x, point.y, point.z));
frame.model_to_paper(glam::DVec3::new(point.x, point.y, point.z));
Vector3::new(mapped.x, mapped.y, mapped.z) Vector3::new(mapped.x, mapped.y, mapped.z)
} }
None => point, None => point,
@@ -416,8 +413,7 @@ impl ChainMap {
pub(crate) fn space_to_model(&self, point: Vector3) -> Vector3 { pub(crate) fn space_to_model(&self, point: Vector3) -> Vector3 {
match &self.frame { match &self.frame {
Some(frame) => { Some(frame) => {
let mapped = let mapped = frame.paper_to_model(glam::DVec3::new(point.x, point.y, point.z));
frame.paper_to_model(glam::DVec3::new(point.x, point.y, point.z));
Vector3::new(mapped.x, mapped.y, mapped.z) Vector3::new(mapped.x, mapped.y, mapped.z)
} }
None => point, None => point,
@@ -462,11 +458,18 @@ impl ChainMap {
/// Only the plane moves: the centre, radius and angles are coordinates /// Only the plane moves: the centre, radius and angles are coordinates
/// *within* the plane, so they stay as they are and every point the /// *within* the plane, so they stay as they are and every point the
/// geometry produces comes out in the dimension's space already. /// geometry produces comes out in the dimension's space already.
fn map_radial(&self, radial: RadialSourceGeometry) -> RadialSourceGeometry { fn map_radial(&self, radial: RadialSourceGeometry) -> Option<RadialSourceGeometry> {
RadialSourceGeometry { let plane = self.map_plane(radial.plane);
plane: self.map_plane(radial.plane), let x = glam::DVec3::from_array(plane.x_axis);
..radial 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 { fn chain_map(scene: &Scene, walked: &ReferenceChain) -> ChainMap {
ChainMap { ChainMap {
transform: walked.transform, 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 /// 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 /// 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. /// only ever a *hint* — it disambiguates candidates, it is never the answer.
fn local_hint(scene: &Scene, map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option<Vector3> { fn local_hint(
scene: &Scene,
map: &ChainMap,
entity: &EntityType,
stored: Vector3,
) -> Option<Vector3> {
let _ = scene; let _ = scene;
let inverse = chain::invert(&map.transform)?; let inverse = chain::invert(&map.transform)?;
let mut candidates = vec![inverse.apply(stored)]; 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) { let walked = match chain::walk_chain(&scene.document, &reference.xrefs) {
Ok(walked) => walked, Ok(walked) => walked,
Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), 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 { let Some(entity) = scene.document.get_entity(walked.entity) else {
return Err(ReferenceStatus::Broken(walked.entity)); return Err(ReferenceStatus::Broken(walked.entity));
}; };
let map = chain_map(scene, &walked); let map = chain_map(scene, &walked);
let inverse = chain::invert(&map.transform); if walked.viewport.is_some() && map.frame.is_none() {
return Err(ReferenceStatus::Unresolved);
let mut local = None; }
if chain::evaluates(reference.osnap_type) { let inverse = chain::invert(&map.transform).ok_or(ReferenceStatus::Unresolved)?;
let hint = local_hint(scene, &map, entity, reference.osnap_point); if matches!(
let from = from_space reference.osnap_type,
.zip(inverse) chain::osnap::INTERSEC | chain::osnap::APPARENT_INT
.map(|(point, inverse)| inverse.apply(map.space_to_model(point))); ) {
local = chain::feature_point( 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, &scene.document,
entity, entity,
reference, reference,
FeatureContext { hint, from }, 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); let model = map.to_model(local);
Ok(ResolvedReference { Ok(ResolvedReference {
model, model,
@@ -798,9 +840,11 @@ pub(crate) fn dimension_is_associative(
}; };
association.dimension == dimension association.dimension == dimension
&& association.associativity != 0 && association.associativity != 0
&& association.references.iter().flatten().any(|reference| { && association
chain::walk_chain(document, &reference.xrefs).is_ok() .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) (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 point = |index: usize| {
let reference = association.references.get(index)?.first()?; let reference = association.references.get(index)?.first()?;
Some(ParametricRef::point( Some(ParametricRef::point(
@@ -1207,8 +1260,7 @@ impl Scene {
self.attach_viewport_radial_association(dimension, snaps); self.attach_viewport_radial_association(dimension, snaps);
return; return;
} }
let mut references: [Vec<AssocDimensionReference>; 4] = let mut references: [Vec<AssocDimensionReference>; 4] = std::array::from_fn(|_| Vec::new());
std::array::from_fn(|_| Vec::new());
let mut associativity = 0; let mut associativity = 0;
let mut trans_space = false; let mut trans_space = false;
let mut reactor_targets = Vec::new(); let mut reactor_targets = Vec::new();
@@ -1233,25 +1285,41 @@ impl Scene {
reactor_targets.extend(xrefs.iter().copied()); reactor_targets.extend(xrefs.iter().copied());
let osnap_type = chain::osnap_type_for(source.snap_type); let osnap_type = chain::osnap_type_for(source.snap_type);
// An explicitly-acquired sub-entity wins; otherwise fall back to let local = Self::point_in_entity_space(
// the same marker inference model-space creation already uses, in &self.document,
// the entity's own coordinates. &source.block_path,
let (marker, parameter) = match source.source.marker { dvec3(snap.model_point),
Some(marker) => (marker, source.source.parameter), );
None => { let marker_parameter = source
let local = Self::point_in_entity_space( .source
&self.document, .marker
&source.block_path, .map(|marker| (marker, source.source.parameter))
dvec3(snap.model_point), .or_else(|| feature_reference(entity, local, source.snap_type));
); let Some((marker, parameter)) = marker_parameter else {
source_reference(entity, local).unwrap_or((0, 0.0)) 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; associativity |= 1 << index;
references[index].push(AssocDimensionReference { references[index].push(AssocDimensionReference {
class_name: "AcDbOsnapPointRef".to_string(), class_name: "AcDbOsnapPointRef".to_string(),
osnap_type, osnap_type,
xrefs, xrefs,
intersection_objects,
main_subent_type: 1, main_subent_type: 1,
main_gs_marker: marker, main_gs_marker: marker,
osnap_distance: parameter, osnap_distance: parameter,
@@ -1385,13 +1453,41 @@ impl Scene {
/// the erased source comes back and the same query starts answering /// the erased source comes back and the same query starts answering
/// [`ReferenceStatus::Resolved`] again, with the reference record itself /// [`ReferenceStatus::Resolved`] again, with the reference record itself
/// never having been rewritten. /// never having been rewritten.
pub fn dimension_association_status( pub fn dimension_association_status(&self, dimension: Handle) -> Vec<(usize, ReferenceStatus)> {
&self,
dimension: Handle,
) -> Vec<(usize, ReferenceStatus)> {
let Some(association) = self.dimension_association(dimension) else { let Some(association) = self.dimension_association(dimension) else {
return Vec::new(); 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 association
.references .references
.iter() .iter()
@@ -1400,8 +1496,16 @@ impl Scene {
let reference = slot.first()?; let reference = slot.first()?;
Some(( Some((
index, index,
match resolve_reference_chain(self, reference, None) { match resolve_reference_chain(
Ok(_) => ReferenceStatus::Resolved, 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, Err(status) => status,
}, },
)) ))
@@ -1437,10 +1541,7 @@ impl Scene {
} }
/// The DIMASSOC payload attached to `dimension`, if there is one. /// The DIMASSOC payload attached to `dimension`, if there is one.
pub fn dimension_association( pub fn dimension_association(&self, dimension: Handle) -> Option<&AssocDimensionAssociation> {
&self,
dimension: Handle,
) -> Option<&AssocDimensionAssociation> {
self.document.objects.values().find_map(|object| { self.document.objects.values().find_map(|object| {
let ObjectType::Associative(object) = object else { let ObjectType::Associative(object) = object else {
return None; return None;
@@ -1557,12 +1658,8 @@ impl Scene {
} }
} }
pub(crate) fn infer_dimension_sources( pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec<Option<Handle>> {
&self, let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else {
dimension: Handle,
) -> Vec<Option<Handle>> {
let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension)
else {
return Vec::new(); return Vec::new();
}; };
let radial_data = match entity { let radial_data = match entity {
@@ -1659,11 +1756,15 @@ impl Scene {
let mut refreshed = Vec::new(); let mut refreshed = Vec::new();
for association in associations { for association in associations {
// Two passes. PERPENDICULAR and TANGENT are measured *from* the // Dependent snaps need a resolved independent definition point.
// other definition point, so the independent references have to // Two such features require a coupled solve; old points are not
// land first; a dimension with two dependent references keeps its // evidence of a solution after the geometry changes.
// old points as the from-points, which is stable because they were if association.references.iter().flatten()
// valid the last time round. .filter(|r| chain::needs_from_point(r.osnap_type)).count() > 1
{
continue;
}
// Resolve independent references first, then perpendicular/tangent.
let mut resolved: [Option<ResolvedReference>; 4] = std::array::from_fn(|index| { let mut resolved: [Option<ResolvedReference>; 4] = std::array::from_fn(|index| {
let reference = association.references[index].first()?; let reference = association.references[index].first()?;
if chain::needs_from_point(reference.osnap_type) { if chain::needs_from_point(reference.osnap_type) {
@@ -1675,9 +1776,7 @@ impl Scene {
.document .document
.get_entity(association.dimension) .get_entity(association.dimension)
.and_then(|entity| match entity { .and_then(|entity| match entity {
EntityType::Dimension(dimension) => { EntityType::Dimension(dimension) => Some(dimension_reference_points(dimension)),
Some(dimension_reference_points(dimension))
}
_ => None, _ => None,
}) })
.unwrap_or_default(); .unwrap_or_default();
@@ -1699,6 +1798,19 @@ impl Scene {
}); });
resolved[index] = resolve_reference_chain(self, reference, from).ok(); 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 // The map of the first live reference governs how the whole
// dimension is placed and measured — all of a dimension's points // dimension is placed and measured — all of a dimension's points
// are in one space. // are in one space.
@@ -1725,7 +1837,7 @@ impl Scene {
// Mapped once, here: everything the match arms below build out // Mapped once, here: everything the match arms below build out
// of it then lands in the dimension's own space. // of it then lands in the dimension's own space.
Some(( Some((
chain_map(self, &walked).map_radial(radial), chain_map(self, &walked).map_radial(radial)?,
reference.osnap_distance, reference.osnap_distance,
)) ))
}); });
@@ -1738,15 +1850,34 @@ impl Scene {
_ => return None, _ => return None,
}; };
let radial = radial_source_for_marker(entity, segment)?; 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) { if radial_source.is_none() && resolved.iter().all(Option::is_none) {
continue; 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() { if self.is_recording_undo() {
let before = self.document.get_entity_arc(association.dimension); let before = self.document.get_entity_arc(association.dimension);
self.record_undo_before(association.dimension, before); self.record_undo_before(association.dimension, before);
} }
if measurement_scale.is_some() {
self.ensure_app_id(MeasurementScale::APP_ID);
}
let Some(EntityType::Dimension(dimension)) = let Some(EntityType::Dimension(dimension)) =
self.document.get_entity_mut(association.dimension) self.document.get_entity_mut(association.dimension)
else { else {
@@ -2005,8 +2136,8 @@ impl Scene {
.zip(resolved.iter()) .zip(resolved.iter())
.all(|(model, space)| match (model, space, map.frame.as_ref()) { .all(|(model, space)| match (model, space, map.frame.as_ref()) {
(Some(model), Some(space), Some(frame)) => { (Some(model), Some(space), Some(frame)) => {
let projected = let projected = frame
frame.model_to_paper(glam::DVec3::new(model.x, model.y, model.z)); .model_to_paper(glam::DVec3::new(model.x, model.y, model.z));
(projected.x - space.x).abs() < 1e-6 (projected.x - space.x).abs() < 1e-6
&& (projected.y - space.y).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", "resolved paper points must be the model points through the frame",
); );
dimension.base_mut().actual_measurement = measurement; 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.push((association.dimension, ChangeKind::Modified));
} }
refreshed refreshed
@@ -2053,3 +2208,121 @@ fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 {
let axis = [linear.rotation.cos(), linear.rotation.sin()]; let axis = [linear.rotation.cos(), linear.rotation.sin()];
((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() ((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<MeasurementScale> {
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,
})
}
+263 -178
View File
@@ -37,7 +37,7 @@
use acadrust::objects::AssocDimensionReference; use acadrust::objects::AssocDimensionReference;
use acadrust::types::{Handle, Matrix4, Transform, Vector3}; use acadrust::types::{Handle, Matrix4, Transform, Vector3};
use acadrust::{CadDocument, EntityType}; 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 cadkernel::space::Plane;
use crate::entities::curve::entity_curve; 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 /// and `NEAR` for a point taken at a parameter on a circle, so those two keep
/// meaning exactly what they used to. /// meaning exactly what they used to.
#[allow(dead_code)] // The full code table is kept so a reader can see what a #[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(crate) mod osnap {
pub const NONE: u8 = 0; pub const NONE: u8 = 0;
pub const END: u8 = 1; pub const END: u8 = 1;
@@ -115,6 +115,7 @@ pub(crate) struct ReferenceChain {
pub(crate) enum ChainError { pub(crate) enum ChainError {
/// `xrefs` was empty — nothing to resolve. /// `xrefs` was empty — nothing to resolve.
Empty, Empty,
Invalid,
/// A handle in the chain names an object that is not in the document /// A handle in the chain names an object that is not in the document
/// (erased source; restored by an undo). /// (erased source; restored by an undo).
Missing(Handle), Missing(Handle),
@@ -131,55 +132,54 @@ pub(crate) fn walk_chain(
document: &CadDocument, document: &CadDocument,
xrefs: &[Handle], xrefs: &[Handle],
) -> Result<ReferenceChain, ChainError> { ) -> Result<ReferenceChain, ChainError> {
if xrefs.is_empty() {
return Err(ChainError::Empty);
}
let mut viewport = None; let mut viewport = None;
let mut block_path: Vec<Handle> = Vec::new(); let mut block_path = Vec::new();
let mut entity = None;
let mut transform = Transform::identity(); let mut transform = Transform::identity();
let mut owner = None;
for (index, handle) in xrefs.iter().copied().enumerate() { for (index, &handle) in xrefs.iter().enumerate() {
if handle.is_null() { 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; continue;
} }
let Some(node) = document.get_entity(handle) else { if index == xrefs.len() - 1 {
return Err(ChainError::Missing(handle)); return Ok(ReferenceChain {
}; viewport,
match node { block_path,
// Only a *leading* viewport is the trans-space hop. A viewport entity: handle,
// deeper in the chain would be geometry being dimensioned (the transform,
// 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;
}
} }
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,
));
} }
Err(ChainError::Empty)
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,
})
} }
/// Accumulated entity-local -> model transform for an ordered INSERT path. /// 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(); let mut transform = Transform::identity();
for handle in block_path { for handle in block_path {
if let Some(EntityType::Insert(insert)) = document.get_entity(*handle) { if let Some(EntityType::Insert(insert)) = document.get_entity(*handle) {
transform = transform = transform.compose(&crate::scene::render_graph::insert_transform(
transform.compose(&crate::scene::render_graph::insert_transform(document, insert)); document, insert,
));
} }
} }
transform transform
@@ -270,7 +271,7 @@ pub(crate) struct FeatureContext {
/// The point the feature is measured *from*, in entity-local coordinates. /// The point the feature is measured *from*, in entity-local coordinates.
/// PERPENDICULAR and TANGENT are two-point features: the foot / touch /// PERPENDICULAR and TANGENT are two-point features: the foot / touch
/// point only exists relative to an external point, which for a dimension /// 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<Vector3>, pub from: Option<Vector3>,
} }
@@ -287,9 +288,12 @@ pub(crate) fn feature_point(
context: FeatureContext, context: FeatureContext,
) -> Option<Vector3> { ) -> Option<Vector3> {
match reference.osnap_type { 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::CEN => center_point(entity, reference),
osnap::MID => mid_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::NODE | osnap::INS => node_point(entity),
osnap::INTERSEC | osnap::APPARENT_INT => intersection_point( osnap::INTERSEC | osnap::APPARENT_INT => intersection_point(
document, document,
@@ -313,7 +317,9 @@ pub(crate) fn feature_point(
pub(crate) fn evaluates(osnap_type: u8) -> bool { pub(crate) fn evaluates(osnap_type: u8) -> bool {
matches!( matches!(
osnap_type, osnap_type,
osnap::CEN osnap::END
| osnap::START_POINT
| osnap::CEN
| osnap::MID | osnap::MID
| osnap::QUAD | osnap::QUAD
| osnap::NODE | osnap::NODE
@@ -342,7 +348,13 @@ fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Opt
let KernelCurve::Polyline(polyline) = &planar.curve else { let KernelCurve::Polyline(polyline) = &planar.curve else {
return None; 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)?; let arc = polyline.segment_arc(segment)?;
Some(lift(&planar.plane, arc.center)) 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. // without one, the midpoint of the whole run.
KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => { KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => {
let segments = planar.curve.segments(); 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, segment.point_at(0.5)))
} }
_ => Some(lift(&planar.plane, planar.curve.point_at(0.5))), _ => Some(lift(&planar.plane, planar.curve.point_at(0.5))),
} }
} }
fn quadrant_point(entity: &EntityType, hint: Vector3) -> Option<Vector3> { fn quadrant_point(
entity: &EntityType,
reference: &AssocDimensionReference,
hint: Vector3,
) -> Option<Vector3> {
let planar = entity_curve(entity)?; let planar = entity_curve(entity)?;
let (centre, radius) = match &planar.curve { let (centre, radius) = match &planar.curve {
KernelCurve::Circle(circle) => (circle.centre, circle.radius), KernelCurve::Circle(circle) => (circle.centre, circle.radius),
KernelCurve::Arc(arc) => (arc.centre, arc.radius), KernelCurve::Arc(arc) => (arc.centre, arc.radius),
_ => return None, _ => return None,
}; };
// Four candidates on the curve's own plane; the stored point picks one, so let valid_angle = |angle: f64| match &planar.curve {
// a rotated source keeps the quadrant the user actually snapped to. 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) (0..4)
.map(|index| { .map(|index| std::f64::consts::FRAC_PI_2 * index as f64)
let angle = std::f64::consts::FRAC_PI_2 * index as f64; .filter(|angle| valid_angle(*angle))
lift( .map(at_angle)
&planar.plane, .find(|candidate| distance_squared(*candidate, hint) < 1e-12)
[
centre[0] + radius * angle.cos(),
centre[1] + radius * angle.sin(),
],
)
})
.min_by(|first, second| {
distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint))
})
} }
fn node_point(entity: &EntityType) -> Option<Vector3> { fn node_point(entity: &EntityType) -> Option<Vector3> {
@@ -402,7 +430,50 @@ fn node_point(entity: &EntityType) -> Option<Vector3> {
fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option<Vector3> { fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option<Vector3> {
let planar = entity_curve(entity)?; let planar = entity_curve(entity)?;
let uv = planar.plane.project(dpoint(from))?; 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<Vector3> {
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( fn nearest_point(
@@ -416,9 +487,7 @@ fn nearest_point(
// Every other curve carries the kernel's normalised 0..1 parameter. // Every other curve carries the kernel's normalised 0..1 parameter.
match &planar.curve { match &planar.curve {
KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity { KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity {
EntityType::Circle(circle) => { EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)),
Some(circle.point_at_angle_wcs(reference.osnap_distance))
}
EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)),
_ => None, _ => None,
}, },
@@ -454,39 +523,14 @@ fn tangent_point(
return spline_tangent_point(spline, reference, context); return spline_tangent_point(spline, reference, context);
} }
let planar = entity_curve(entity)?; let planar = entity_curve(entity)?;
let (centre, radius) = match &planar.curve { let from = context.from?;
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 uv = planar.plane.project(dpoint(from))?; let uv = planar.plane.project(dpoint(from))?;
let (dx, dy) = (uv[0] - centre[0], uv[1] - centre[1]); cadkernel::geom2d::snap::tangent_from(&planar.curve, uv)
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
.into_iter() .into_iter()
.min_by(|first, second| { .map(|candidate| lift(&planar.plane, candidate.point))
distance_squared(*first, hint).total_cmp(&distance_squared(*second, hint)) .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)?; let hint = context.hint.or(context.from)?;
curve.parameter_at(dpoint(hint)).clamp(start, end) curve.parameter_at(dpoint(hint)).clamp(start, end)
}; };
let Some(from) = context.from.or(context.hint) else { let from = glam::DVec3::from_array(dpoint(context.from?));
// No external point: the stored parameter is the whole answer. // A tangent is parallel to C(u)-P. Minimize the vector cross product,
return Some(vector3(curve.point_at_knot(u))); // which also detects nonplanar curves that have no tangent from P.
};
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 f = |u: f64| { let f = |u: f64| {
let c = curve.point_at_knot(u); let c = glam::DVec3::from_array(curve.point_at_knot(u));
let d = curve.derivative_at_knot(u); let d = glam::DVec3::from_array(curve.derivative_at_knot(u));
(c[0] - p[0]) * d[0] + (c[1] - p[1]) * d[1] + (c[2] - p[2]) * d[2] (c - from).cross(d)
}; };
let span = (end - start).abs().max(1e-12); let span = (end - start).abs();
let step = span * 1e-6; if !span.is_finite() || span <= 1e-15 {
for _ in 0..24 { return None;
}
for _ in 0..48 {
let value = f(u); 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; break;
} }
let slope = (f((u + step).min(end)) - f((u - step).max(start))) / (2.0 * step); let next = (u - value.dot(derivative) / derivative.length_squared()).clamp(start, end);
if !slope.is_finite() || slope.abs() < 1e-18 { if (next - u).abs() < span * 1e-14 {
break;
}
let next = (u - value / slope).clamp(start, end);
if !next.is_finite() || (next - u).abs() <= span * 1e-14 {
u = next; u = next;
break; break;
} }
u = next; 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 ──────────────────────────────────────────────────────────
/// Intersection of `entity` with the reference's second object chain. /// Intersection of `entity` with the reference's second object chain.
/// ///
/// Both curves are tessellated on the first curve's plane and crossed /// Transform both curves into the first entity's local plane and use the
/// pairwise; the crossing nearest the stored osnap point wins, so a pair of /// kernel's exact/converged intersection solver. Apparent intersections may
/// curves that meet more than once keeps the corner the user picked. With /// project noncoplanar curves; only straight lines support an extended crossing.
/// `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.
fn intersection_point( fn intersection_point(
document: &CadDocument, document: &CadDocument,
entity: &EntityType, entity: &EntityType,
@@ -555,59 +601,53 @@ fn intersection_point(
hint: Vector3, hint: Vector3,
apparent: bool, apparent: bool,
) -> Option<Vector3> { ) -> Option<Vector3> {
let main = walk_chain(document, &reference.xrefs).ok()?;
let other = walk_chain(document, &reference.intersection_objects).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 first = entity_curve(entity)?;
let second = entity_curve(other_entity)?; let second = entity_curve(document.get_entity(other.entity)?)?;
// The first curve's plane is the working frame. The second curve is let map = |uv| {
// evaluated through its *own* block path, so each of its points is lifted let local = inverse.apply(other.transform.apply(lift(&second.plane, uv)));
// to model space and then projected back onto that frame before crossing. let projected = first.plane.project(dpoint(local))?;
let a = first.curve.tessellate(DEFAULT_SEGMENTS_PER_RADIAN); if !apparent && distance_squared(local, lift(&first.plane, projected)) > 1e-12 {
let b = second return None;
.curve }
.tessellate(DEFAULT_SEGMENTS_PER_RADIAN) Some(projected)
.into_iter() };
.map(|uv| { let origin = glam::DVec2::from_array(map([0.0, 0.0])?);
let world = other.transform.apply(lift(&second.plane, uv)); let x = glam::DVec2::from_array(map([1.0, 0.0])?) - origin;
first.plane.project(dpoint(world)).unwrap_or(uv) let y = glam::DVec2::from_array(map([0.0, 1.0])?) - origin;
}) let second = second.curve.transformed(&cadkernel::geom2d::Transform {
.collect::<Vec<_>>(); 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 hint_uv = first.plane.project(dpoint(hint))?;
let mut best: Option<([f64; 2], f64)> = None; let mut points: Vec<_> = cadkernel::geom2d::intersect(
for pair_a in a.windows(2) { &first.curve,
for pair_b in b.windows(2) { &second,
let Some(point) = segment_cross(pair_a[0], pair_a[1], pair_b[0], pair_b[1], false) cadkernel::geom2d::Tolerance::new(1e-9),
else { )
continue; .into_iter()
}; .map(|hit| hit.point)
let error = (point[0] - hint_uv[0]).powi(2) + (point[1] - hint_uv[1]).powi(2); .collect();
if best.is_none_or(|(_, previous)| error < previous) { if apparent && points.is_empty() {
best = Some((point, error)); // 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 { points
// Nothing actually crosses: take the two segments nearest the stored .into_iter()
// point and cross their infinite extensions. .min_by(|a, b| {
let nearest = |run: &[[f64; 2]]| -> Option<([f64; 2], [f64; 2])> { let error = |p: &[f64; 2]| (p[0] - hint_uv[0]).powi(2) + (p[1] - hint_uv[1]).powi(2);
run.windows(2) error(a).total_cmp(&error(b))
.map(|pair| { })
let mid = [ .map(|point| lift(&first.plane, point))
(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))
} }
/// Crossing of segments `a0->a1` and `b0->b1`. With `extend` the parameters /// 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]) 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<usize> {
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<Vector3>,
) -> Option<Vector3> {
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)
}
+1 -1
View File
@@ -134,7 +134,7 @@ impl Scene {
let _ = self.document.layers.add(layer); 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) { if name.trim().is_empty() || self.document.app_ids.contains(name) {
return; return;
} }
+65 -36
View File
@@ -84,6 +84,9 @@ impl Scene {
continue; continue;
}; };
if !planar_pick_distance(&entity, model_point).is_some_and(|d| d <= aperture_model) {
continue;
}
let mut paper_entity = entity; let mut paper_entity = entity;
crate::scene::view::dispatch::apply_transform( crate::scene::view::dispatch::apply_transform(
&mut paper_entity, &mut paper_entity,
@@ -106,39 +109,6 @@ impl Scene {
None 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<Handle>)> {
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 /// Nearest resident model wire of `viewport` to `model_point`, within
/// `aperture` model units. Wires are the same tessellation the viewport /// `aperture` model units. Wires are the same tessellation the viewport
/// draws, so this matches what the user sees. /// draws, so this matches what the user sees.
@@ -205,6 +175,16 @@ impl Scene {
handle: Handle, handle: Handle,
model_point: DVec3, model_point: DVec3,
viewport: Option<Handle>, viewport: Option<Handle>,
) -> Option<(EntityType, Vec<Handle>)> {
self.resolve_measurable_feature(handle, model_point, viewport, None)
}
pub(crate) fn resolve_measurable_feature(
&self,
handle: Handle,
model_point: DVec3,
viewport: Option<Handle>,
kind: Option<crate::snap::SnapType>,
) -> Option<(EntityType, Vec<Handle>)> { ) -> Option<(EntityType, Vec<Handle>)> {
let entity = self.document.get_entity(handle)?; let entity = self.document.get_entity(handle)?;
match entity { match entity {
@@ -217,11 +197,12 @@ impl Scene {
&mut path, &mut path,
0, 0,
viewport, viewport,
kind,
)?; )?;
Some((found.0, found.1)) Some((found.0, found.1))
} }
other => { other => {
planar_pick_distance(other, model_point)?; feature_pick_distance(other, model_point, kind)?;
Some((other.clone(), Vec::new())) Some((other.clone(), Vec::new()))
} }
} }
@@ -237,6 +218,7 @@ impl Scene {
path: &mut Vec<Handle>, path: &mut Vec<Handle>,
depth: usize, depth: usize,
viewport: Option<Handle>, viewport: Option<Handle>,
kind: Option<crate::snap::SnapType>,
) -> Option<(EntityType, Vec<Handle>)> { ) -> Option<(EntityType, Vec<Handle>)> {
const MAX_DEPTH: usize = 8; const MAX_DEPTH: usize = 8;
if depth > MAX_DEPTH { if depth > MAX_DEPTH {
@@ -245,6 +227,9 @@ impl Scene {
let EntityType::Insert(insert) = &insert_entity else { let EntityType::Insert(insert) = &insert_entity else {
return None; return None;
}; };
if insert.row_count > 1 || insert.column_count > 1 {
return None;
}
let insert_handle = insert.common.handle; let insert_handle = insert.common.handle;
let local = crate::scene::render_graph::insert_transform(&self.document, insert); let local = crate::scene::render_graph::insert_transform(&self.document, insert);
let combined = local.then(&outer); let combined = local.then(&outer);
@@ -273,8 +258,9 @@ impl Scene {
&mut nested, &mut nested,
depth + 1, depth + 1,
viewport, 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) { if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
let mut full = vec![insert_handle]; let mut full = vec![insert_handle];
full.append(&mut nested_path); 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 y = DVec3::new(m[0][1], m[1][1], m[2][1]);
let magnitude = x.length_squared().max(y.length_squared()); let magnitude = x.length_squared().max(y.length_squared());
if curved if curved
&& kind != Some(crate::snap::SnapType::Center)
&& (magnitude < 1e-24 && (magnitude < 1e-24
|| (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-10 || (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-10
|| x.dot(y).abs() > magnitude * 1e-10) || x.dot(y).abs() > magnitude * 1e-10)
@@ -305,7 +292,7 @@ impl Scene {
&mut placed, &mut placed,
&EntityTransform::Affine(combined), &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; continue;
}; };
if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { 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<crate::snap::SnapType>,
) -> Option<f64> {
use crate::snap::SnapType as S;
let points: Vec<acadrust::types::Vector3> = 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)
}
+16
View File
@@ -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.
+118 -6
View File
@@ -788,6 +788,22 @@ AcDbAlignedDimension
0.0 0.0
50 50
0.0 0.0
1001
ACAD_DIMASSOC_CALC_DIMLFAC
1040
-2.0
1001
ACAD
1000
DSTYLE
1002
{
1070
144
1040
-2.0
1002
}
0 0
DIMENSION DIMENSION
5 5
@@ -844,6 +860,22 @@ AcDbAlignedDimension
0.0 0.0
50 50
0.0 0.0
1001
ACAD_DIMASSOC_CALC_DIMLFAC
1040
-2.0
1001
ACAD
1000
DSTYLE
1002
{
1070
144
1040
-2.0
1002
}
0 0
DIMENSION DIMENSION
5 5
@@ -900,6 +932,22 @@ AcDbAlignedDimension
0.0 0.0
50 50
0.0 0.0
1001
ACAD_DIMASSOC_CALC_DIMLFAC
1040
-2.0
1001
ACAD
1000
DSTYLE
1002
{
1070
144
1040
-2.0
1002
}
0 0
DIMENSION DIMENSION
5 5
@@ -956,6 +1004,22 @@ AcDbAlignedDimension
0.0 0.0
50 50
0.0 0.0
1001
ACAD_DIMASSOC_CALC_DIMLFAC
1040
-2.0
1001
ACAD
1000
DSTYLE
1002
{
1070
144
1040
-2.0
1002
}
0 0
DIMENSION DIMENSION
5 5
@@ -991,7 +1055,7 @@ AcDbDimension
71 71
5 5
42 42
120.0 91.56836585306085
1 1
3 3
@@ -1005,13 +1069,29 @@ AcDbAlignedDimension
33 33
0.0 0.0
14 14
135.0 120.0
24 24
175.0 166.5625
34 34
0.0 0.0
50 50
0.0 0.0
1001
ACAD_DIMASSOC_CALC_DIMLFAC
1040
-2.0
1001
ACAD
1000
DSTYLE
1002
{
1070
144
1040
-2.0
1002
}
0 0
DIMENSION DIMENSION
5 5
@@ -1068,6 +1148,22 @@ AcDbAlignedDimension
0.0 0.0
50 50
0.0 0.0
1001
ACAD_DIMASSOC_CALC_DIMLFAC
1040
-2.0
1001
ACAD
1000
DSTYLE
1002
{
1070
144
1040
-2.0
1002
}
0 0
DIMENSION DIMENSION
5 5
@@ -1124,6 +1220,22 @@ AcDbAlignedDimension
0.0 0.0
50 50
0.0 0.0
1001
ACAD_DIMASSOC_CALC_DIMLFAC
1040
-2.0
1001
ACAD
1000
DSTYLE
1002
{
1070
144
1040
-2.0
1002
}
0 0
ENDSEC ENDSEC
0 0
@@ -1475,11 +1587,11 @@ AcDbOsnapPointRef
91 91
-2 -2
40 40
1.0 0.75
10 10
120.0 90.0
20 20
300.0 283.125
30 30
0.0 0.0
75 75