Track mixed paper and viewport dimension references

This commit is contained in:
Christopher Overbeck committed 2026-09-15 16:00:11 -05:00
1 parent bd00d679a9
commit 5424a19482
28 files changed
+823 -846

No files matched your search

+125 -149
View File
@@ -370,15 +370,7 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option<i32> {
// ── Chain-aware reference resolution ──────────────────────────────────────
/// How a resolved feature point travels from the entity it belongs to up into
/// the space the dimension is drawn in.
///
/// `transform` is the block-instance path (entity-local -> model).
/// `frame` is the layout viewport the chain looks through, when it has one;
/// with a frame the dimension lives on the sheet and every point it holds is
/// a *paper* point, while the value it displays is a *model* length. Keeping
/// both directions in one place is what stops the viewport compensation from
/// being applied twice (see `viewport_ref::measurement_rule`).
/// Transform an entity-local feature through its block path and optional viewport.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ChainMap {
pub transform: Transform,
@@ -528,21 +520,10 @@ fn hint_error(entity: &EntityType, point: Vector3) -> f64 {
f64::INFINITY
}
/// The stored osnap point, brought into the entity's own coordinates.
///
/// Which space the file wrote it in is not recoverable from the record: our
/// own pre-PR3 writer stored a model/WCS point, AutoCAD's trans-space records
/// hold the point in the space the dimension is drawn in, and both appear in
/// the same drawing. Rather than guess, both readings are mapped down to the
/// entity and the one that actually lands on the geometry wins. The point is
/// only ever a *hint* — it disambiguates candidates, it is never the answer.
fn local_hint(
scene: &Scene,
map: &ChainMap,
entity: &EntityType,
stored: Vector3,
) -> Option<Vector3> {
let _ = scene;
/// Interpret a stored point as a source-space or dimension-space hint.
/// Validate both interpretations against the geometry before using the hint
/// to disambiguate a feature; the stored point is never the resolved result.
fn local_hint(map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option<Vector3> {
let inverse = chain::invert(&map.transform)?;
let mut candidates = vec![inverse.apply(stored)];
if map.frame.is_some() {
@@ -610,7 +591,7 @@ pub fn resolve_reference_chain(
.max(reference.osnap_point.z.abs())
< 1e40
{
local_hint(scene, &map, entity, reference.osnap_point)
local_hint(&map, entity, reference.osnap_point)
} else {
None
};
@@ -636,9 +617,8 @@ pub fn resolve_reference_chain(
})
}
/// The pre-PR3 marker/parameter resolution, unchanged in behaviour but now
/// evaluated in the *entity's own* coordinates rather than assumed to be in
/// model space. Lifting through the block path is the caller's job.
/// Resolve a marker/parameter in entity-local coordinates.
/// The caller applies the block and viewport transforms.
fn resolve_local_by_marker(
entity: &EntityType,
reference: &AssocDimensionReference,
@@ -1224,8 +1204,7 @@ impl Scene {
}
/// Persist the association for a dimension whose points were picked
/// through [`AcceptedSnap`]s — the entry point PR2 calls once it has
/// created a paper-space dimension that measures model geometry.
/// through [`AcceptedSnap`]s, preserving each source and its owning space.
///
/// `snaps` is positional: one slot per definition point, in the same order
/// [`dimension_reference_points`] returns them (linear/aligned: first,
@@ -1245,13 +1224,7 @@ impl Scene {
else {
return;
};
// A radial dimension has ONE reference, and it is an
// `AcDbOsnapPointRef` of osnap type 10 carrying the *radial* marker
// (which circle / which polyline arc segment) and the angle around it,
// not a vertex marker. `source_reference` would return the generic
// curve-parameter marker `-2`, which `radial_source_for_marker` cannot
// find, so radial goes down its own path — the same one
// `attach_dimension_association_sources` uses for model space.
// Radial references identify a circle/arc and angle, rather than a vertex.
let radial_dimension = matches!(
dimension_entity,
Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_)
@@ -1278,18 +1251,17 @@ impl Scene {
let mut xrefs = Vec::new();
if let Some(viewport) = snap.viewport {
xrefs.push(viewport);
trans_space = true;
}
xrefs.extend(source.block_path.iter().copied());
xrefs.push(entity_handle);
reactor_targets.extend(xrefs.iter().copied());
let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else {
continue;
};
let Some(inverse) = chain::invert(&walked.transform) else {
continue;
};
let local = inverse.apply(dvec3(snap.model_point));
let osnap_type = chain::osnap_type_for(source.snap_type);
let local = Self::point_in_entity_space(
&self.document,
&source.block_path,
dvec3(snap.model_point),
);
let marker_parameter = source
.source
.marker
@@ -1314,6 +1286,8 @@ impl Scene {
}
reactor_targets.extend(intersection_objects.iter().copied());
}
trans_space |= snap.viewport.is_some();
reactor_targets.extend(xrefs.iter().copied());
associativity |= 1 << index;
references[index].push(AssocDimensionReference {
class_name: "AcDbOsnapPointRef".to_string(),
@@ -1323,8 +1297,8 @@ impl Scene {
main_subent_type: 1,
main_gs_marker: marker,
osnap_distance: parameter,
// Stored in MODEL space. `local_hint` reads either convention,
// so an imported paper-space point still resolves.
// Store the source's owning-space point: model coordinates for
// viewport features, paper coordinates for direct sheet features.
osnap_point: dvec3(snap.model_point),
..AssocDimensionReference::default()
});
@@ -1351,12 +1325,7 @@ impl Scene {
dimension: Handle,
snaps: &[Option<AcceptedSnap>],
) {
let Some(snap) = snaps
.iter()
.flatten()
.find(|snap| snap.source.is_some())
.cloned()
else {
let Some(snap) = snaps.iter().flatten().find(|snap| snap.source.is_some()) else {
return;
};
let source = snap.source.as_ref().expect("filtered above");
@@ -1364,14 +1333,17 @@ impl Scene {
let Some(entity) = self.document.get_entity(entity_handle) else {
return;
};
// The acquired point in the source entity's own coordinates, so the
// radial geometry and the angle are both computed where the entity
// lives.
let local = Self::point_in_entity_space(
&self.document,
&source.block_path,
dvec3(snap.model_point),
);
let mut xrefs = Vec::new();
xrefs.extend(snap.viewport);
xrefs.extend(source.block_path.iter().copied());
xrefs.push(entity_handle);
let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else {
return;
};
let Some(inverse) = chain::invert(&walked.transform) else {
return;
};
let local = inverse.apply(dvec3(snap.model_point));
let radial = match source.source.marker {
Some(marker) => radial_source_for_marker(entity, marker),
None => radial_source_at(entity, local),
@@ -1382,14 +1354,7 @@ impl Scene {
} else {
radial.angle_at(dpoint(local))
};
let mut xrefs = Vec::new();
let mut trans_space = false;
if let Some(viewport) = snap.viewport {
xrefs.push(viewport);
trans_space = true;
}
xrefs.extend(source.block_path.iter().copied());
xrefs.push(entity_handle);
let trans_space = snap.viewport.is_some();
let reactor_targets = xrefs.clone();
let reference = AssocDimensionReference {
class_name: "AcDbOsnapPointRef".to_string(),
@@ -1431,28 +1396,64 @@ impl Scene {
}
}
/// A model-space point brought down into the coordinates of the entity at
/// the end of `block_path`. Identity for top-level geometry.
fn point_in_entity_space(
document: &acadrust::CadDocument,
block_path: &[Handle],
point: Vector3,
) -> Vector3 {
if block_path.is_empty() {
return point;
/// Select one measurement viewport while allowing direct references to
/// geometry owned by the dimension's paper layout. A model-space chain
/// without its viewport cannot be interpreted as a paper-space anchor.
fn association_measurement_frame(
&self,
association: &AssocDimensionAssociation,
) -> Result<Option<ViewportFrame>, ()> {
let mut viewport = None;
for reference in association.references.iter().flatten() {
for handles in [&reference.xrefs, &reference.intersection_objects] {
let Some(root) = handles.first() else {
continue;
};
if matches!(
self.document.get_entity(*root),
Some(EntityType::Viewport(_))
) {
if viewport.is_some_and(|previous| previous != *root) {
return Err(());
}
viewport = Some(*root);
}
}
}
chain::invert(&chain::block_transform(document, block_path))
.map(|inverse| inverse.apply(point))
.unwrap_or(point)
let Some(viewport) = viewport else {
return if association.trans_space {
Err(())
} else {
Ok(None)
};
};
let owner = self
.document
.get_entity(association.dimension)
.ok_or(())?
.common()
.owner_handle;
for reference in association.references.iter().flatten() {
for handles in [&reference.xrefs, &reference.intersection_objects] {
let Some(root) = handles.first() else {
continue;
};
if self
.document
.get_entity(*root)
.ok_or(())?
.common()
.owner_handle
!= owner
{
return Err(());
}
}
}
self.viewport_frame(viewport).map(Some).ok_or(())
}
/// Per-slot health of a dimension's association, for the UI and for tests.
///
/// Nothing here is persisted: brokenness is *derived* from whether the
/// chain still resolves. That is what makes undo restore a link for free —
/// the erased source comes back and the same query starts answering
/// [`ReferenceStatus::Resolved`] again, with the reference record itself
/// never having been rewritten.
/// Resolve each reference's current status without changing its saved data.
pub fn dimension_association_status(&self, dimension: Handle) -> Vec<(usize, ReferenceStatus)> {
let Some(association) = self.dimension_association(dimension) else {
return Vec::new();
@@ -1485,8 +1486,15 @@ impl Scene {
.is_some()
});
let measurement_supported = measurement_supported
&& association.references.iter().flatten()
.filter(|r| chain::needs_from_point(r.osnap_type)).count() <= 1;
&& association
.references
.iter()
.flatten()
.filter(|r| chain::needs_from_point(r.osnap_type))
.count()
<= 1;
let measurement_supported =
measurement_supported && self.association_measurement_frame(association).is_ok();
let points = self.dimension_association_slot_points(dimension);
association
.references
@@ -1519,7 +1527,7 @@ impl Scene {
///
/// Layer visibility and viewport clipping are deliberately not consulted:
/// hiding a layer or clipping a viewport changes what a *new* snap may
/// acquire (PR1's concern), never what an existing association points at.
/// acquire, never what an existing association points at.
pub fn dimension_association_sources(&self, dimension: Handle) -> Vec<Handle> {
let Some(association) = self.dimension_association(dimension) else {
return Vec::new();
@@ -1553,15 +1561,8 @@ impl Scene {
})
}
/// Duplicate dimension associations across a COPY/MIRROR/ARRAY handle map.
///
/// A copied dimension only keeps an association when *every* handle in
/// every chain was copied too — the viewport, each INSERT on the path and
/// the source entity. Then the whole chain is remapped onto the copies, so
/// the copy measures the copied geometry. Copying a dimension on its own
/// leaves it with no association at all, which is exactly what the model
/// space path did before this: an all-or-nothing rule, never a copy that
/// silently drives off the original's geometry.
/// Preserve an association only when the dimension and every source-chain
/// handle were copied. Remap the complete chain to the new entities.
pub(crate) fn copy_dimension_associations(
&mut self,
handle_map: &rustc_hash::FxHashMap<Handle, Handle>,
@@ -1591,7 +1592,7 @@ impl Scene {
if !complete {
return None;
}
let remap = |handles: &Vec<Handle>| -> Vec<Handle> {
let remap = |handles: &[Handle]| -> Vec<Handle> {
handles
.iter()
.map(|handle| handle_map.get(handle).copied().unwrap_or(*handle))
@@ -1640,15 +1641,7 @@ impl Scene {
}
}
/// Report a layout viewport as changed so the dimensions drawn through it
/// are regenerated.
///
/// A viewport move / pan / zoom / twist changes where model geometry lands
/// on the sheet without touching that geometry, so nothing in the ordinary
/// entity-change path would notice. Every reference that looks through a
/// viewport carries that viewport's handle as `xrefs[0]`, so announcing the
/// viewport itself is all that is needed — the existing dependency lookup
/// in `refresh_associative_dimensions` finds the associations from there.
/// Refresh dimensions when their viewport moves, pans, zooms, or twists.
pub fn notify_viewport_changed(&mut self, viewport: Handle) {
if matches!(
self.document.get_entity(viewport),
@@ -1750,17 +1743,25 @@ impl Scene {
.chain(reference.intersection_objects.iter())
.any(|handle| changed.contains(handle))
})
.then_some(association.clone())
.then(|| association.clone())
})
.collect();
let mut refreshed = Vec::new();
for association in associations {
let Ok(frame) = self.association_measurement_frame(&association) else {
continue;
};
// Dependent snaps need a resolved independent definition point.
// Two such features require a coupled solve; old points are not
// evidence of a solution after the geometry changes.
if association.references.iter().flatten()
.filter(|r| chain::needs_from_point(r.osnap_type)).count() > 1
if association
.references
.iter()
.flatten()
.filter(|r| chain::needs_from_point(r.osnap_type))
.count()
> 1
{
continue;
}
@@ -1806,27 +1807,20 @@ impl Scene {
{
continue;
}
let viewports: std::collections::HashSet<_> =
resolved.iter().flatten().map(|r| r.viewport).collect();
if viewports.len() > 1 {
continue;
}
// The map of the first live reference governs how the whole
// dimension is placed and measured — all of a dimension's points
// are in one space.
let map = resolved
.iter()
.flatten()
.next()
.and_then(|value| value.viewport)
.and_then(|handle| self.viewport_frame(handle))
// Every point is already in paper space: direct sheet references
// stay there, and model references are projected individually.
// Only the measurement factor is shared by the whole dimension.
let map = frame
.map(|frame| ChainMap {
transform: Transform::identity(),
frame: Some(frame),
})
.unwrap_or_else(ChainMap::identity);
let model_points: [Option<Vector3>; 4] =
std::array::from_fn(|index| resolved[index].map(|value| value.model));
let model_points: [Option<Vector3>; 4] = std::array::from_fn(|index| {
resolved[index]
.filter(|value| value.viewport.is_some())
.map(|value| value.model)
});
let resolved: [Option<Vector3>; 4] =
std::array::from_fn(|index| resolved[index].map(|value| value.space));
@@ -2117,18 +2111,7 @@ impl Scene {
Dimension::Linear(linear) => linear_measurement(linear),
_ => dimension.measurement(),
};
// `actual_measurement` stays RAW, in the dimension's own space —
// paper, for a dimension drawn on the sheet through a viewport.
// The viewport compensation is carried by the negative DIMLFAC
// override and applied once, by the formatter. See
// `viewport_ref::measurement_rule`: substituting the model
// distance here (or multiplying by `MeasurementScale::model_factor`)
// would make a refreshed dimension read a different number from an
// identical freshly-created one.
//
// The similarity property of a viewport frame is what makes this
// exact: paper_span * (1 / scale) is the model span, for a rotated
// linear projection just as much as for a free distance.
// Store the raw sheet measurement; the formatter applies DIMLFAC once.
debug_assert!(
map.frame.is_none()
|| model_points
@@ -2176,12 +2159,7 @@ impl Scene {
}
}
/// How far the midpoint of a dimension's measured points moved.
///
/// Used to carry the sheet annotation — the dimension line's standoff and a
/// user-dragged text position — along with the geometry. Slots that did not
/// resolve keep their old point, so a one-ended edit shifts the annotation by
/// half the movement, which is what keeps the dimension line centred.
/// Average displacement of the dimension's resolved definition points.
fn centroid_shift(before: [Option<Vector3>; 2], after: [Option<Vector3>; 2]) -> Vector3 {
let mut delta = Vector3::new(0.0, 0.0, 0.0);
let mut count = 0.0;
@@ -2194,8 +2172,6 @@ fn centroid_shift(before: [Option<Vector3>; 2], after: [Option<Vector3>; 2]) ->
if count == 0.0 {
return Vector3::new(0.0, 0.0, 0.0);
}
// Averaged over the slots that actually moved, not over both, so a
// dimension with one broken reference still tracks the live one.
Vector3::new(delta.x / count, delta.y / count, delta.z / count)
}
+28 -107
View File
@@ -1,38 +1,8 @@
//! Full reference chains for associative dimensions.
//!
//! An `AssocDimensionReference` addresses a *feature* — an osnap point on a
//! piece of geometry — through a chain of handles:
//!
//! ```text
//! dimension -> [viewport] -> [insert ...] -> entity -> feature
//! ```
//!
//! `AssocDimensionReference::xrefs` is that chain, outermost first. AutoCAD
//! calls the same list `mainObjectIds`; `intersection_objects`
//! (`intersectObjectIds`) is the second chain an INTERSECTION / APPARENT
//! INTERSECTION osnap needs.
//!
//! The pre-PR3 resolver only ever read `xrefs[0]`, which is correct for a
//! model-space dimension on top-level geometry and wrong for everything else:
//! a paper-space dimension measuring through a layout viewport stores the
//! VIEWPORT as `xrefs[0]`, so the resolver stopped at a viewport entity, found
//! no curve on it and gave up. Nine of the ten association objects in the
//! 115448 regression drawing are of that shape.
//!
//! This module owns:
//! * [`walk_chain`] — turn `xrefs` into a viewport, a block-instance path
//! with its accumulated transform, and the innermost entity.
//! * [`feature_point`] — evaluate the osnap feature on that entity, in the
//! entity's own coordinates, then lift it through the block path into
//! model space.
//!
//! Mapping model -> paper through the viewport frame is deliberately *not*
//! done here; the caller owns it, because only the caller knows whether the
//! association is `trans_space` and which [`ViewportFrame`] is current.
//!
//! All arithmetic is `f64`.
//!
//! [`ViewportFrame`]: super::viewport_ref::ViewportFrame
//! Resolve a dimension feature through `[viewport?, insert..., entity]`.
//! `xrefs` stores the primary chain; `intersection_objects` stores the second
//! intersection source. Features are evaluated in entity-local coordinates and
//! transformed through their block instances. The caller projects viewport
//! references onto the sheet. All arithmetic uses `f64`.
use acadrust::objects::AssocDimensionReference;
use acadrust::types::{Handle, Matrix4, Transform, Vector3};
@@ -45,12 +15,8 @@ use crate::entities::curve::entity_curve;
/// `AcDb::OsnapMode`, the value stored in
/// [`AssocDimensionReference::osnap_type`].
///
/// These are the AutoCAD ObjectARX codes, not our own [`crate::snap::SnapType`]
/// discriminants. The pre-PR3 writer already emitted `END` for generic points
/// and `NEAR` for a point taken at a parameter on a circle, so those two keep
/// meaning exactly what they used to.
#[allow(dead_code)] // The full code table is kept so a reader can see what a
// stored `osnap_type` means, including the modes we pass through untouched.
/// These are ObjectARX codes, independent of [`crate::snap::SnapType`].
#[allow(dead_code)] // Include codes preserved from unsupported imported modes.
pub(crate) mod osnap {
pub const NONE: u8 = 0;
pub const END: u8 = 1;
@@ -72,7 +38,7 @@ pub(crate) mod osnap {
///
/// Modes with no ObjectARX equivalent (grid, object pick) map to
/// [`osnap::NONE`]; a reference carrying `NONE` resolves by marker/parameter
/// alone, exactly like the pre-PR3 records.
/// alone.
pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 {
use crate::snap::SnapType as S;
match snap {
@@ -166,7 +132,11 @@ pub(crate) fn walk_chain(
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) {
if insert.row_count > 1
|| insert.column_count > 1
|| block_path.len() >= 8
|| block_path.contains(&handle)
{
return Err(ChainError::Invalid);
}
let block = document
@@ -182,64 +152,23 @@ pub(crate) fn walk_chain(
Err(ChainError::Empty)
}
/// Accumulated entity-local -> model transform for an ordered INSERT path.
pub(crate) fn block_transform(document: &CadDocument, block_path: &[Handle]) -> Transform {
let mut transform = Transform::identity();
for handle in block_path {
if let Some(EntityType::Insert(insert)) = document.get_entity(*handle) {
transform = transform.compose(&crate::scene::render_graph::insert_transform(
document, insert,
));
}
}
transform
}
/// Affine inverse of `transform`.
///
/// `acadrust::types::Matrix4` has no inverse of its own, and a block path only
/// ever produces affine matrices (translate / rotate / scale), so this inverts
/// the 3x3 linear part by cofactors and back-substitutes the translation. A
/// degenerate (zero-scale) insert returns `None` rather than exploding.
/// Invert an affine block transform, rejecting singular or nonfinite matrices.
pub(crate) fn invert(transform: &Transform) -> Option<Transform> {
let m = &transform.matrix.m;
let a = [
[m[0][0], m[0][1], m[0][2]],
[m[1][0], m[1][1], m[1][2]],
[m[2][0], m[2][1], m[2][2]],
];
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
- a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
if !det.is_finite() || det.abs() < 1e-18 {
let matrix = glam::DMat4::from_cols_array_2d(&std::array::from_fn(|column| {
std::array::from_fn(|row| m[row][column])
}));
if !matrix.is_finite() || matrix.determinant().abs() < 1e-18 {
return None;
}
let inv_det = 1.0 / det;
let mut inverse = [[0.0f64; 3]; 3];
for row in 0..3 {
for col in 0..3 {
// Cofactor of (col, row) — transposed, which is the adjugate.
let (r0, r1) = ((col + 1) % 3, (col + 2) % 3);
let (c0, c1) = ((row + 1) % 3, (row + 2) % 3);
inverse[row][col] = (a[r0][c0] * a[r1][c1] - a[r0][c1] * a[r1][c0]) * inv_det;
}
let inverse = matrix.inverse();
if !inverse.is_finite() {
return None;
}
let t = [m[0][3], m[1][3], m[2][3]];
let translation = [
-(inverse[0][0] * t[0] + inverse[0][1] * t[1] + inverse[0][2] * t[2]),
-(inverse[1][0] * t[0] + inverse[1][1] * t[1] + inverse[1][2] * t[2]),
-(inverse[2][0] * t[0] + inverse[2][1] * t[1] + inverse[2][2] * t[2]),
];
Some(Transform {
matrix: Matrix4 {
m: [
[inverse[0][0], inverse[0][1], inverse[0][2], translation[0]],
[inverse[1][0], inverse[1][1], inverse[1][2], translation[1]],
[inverse[2][0], inverse[2][1], inverse[2][2], translation[2]],
[0.0, 0.0, 0.0, 1.0],
],
},
})
let columns = inverse.to_cols_array_2d();
Some(Transform::from_matrix(Matrix4 {
m: std::array::from_fn(|row| std::array::from_fn(|column| columns[column][row])),
}))
}
// ── Feature evaluation ────────────────────────────────────────────────────
@@ -482,9 +411,7 @@ fn nearest_point(
hint: Option<Vector3>,
) -> Option<Vector3> {
let planar = entity_curve(entity)?;
// Circles and arcs carry an *angle* in `osnap_distance` — that is the
// convention the pre-PR3 writer established and imported files share it.
// Every other curve carries the kernel's normalised 0..1 parameter.
// Circles and arcs store an angle; other curves use a normalized parameter.
match &planar.curve {
KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity {
EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)),
@@ -506,14 +433,8 @@ fn nearest_point(
// ── Tangent ───────────────────────────────────────────────────────────────
/// Tangent point on `entity` for the touch line through `context.from`.
///
/// Circles and arcs have a closed form. A spline does not, so the stored
/// parameter (`osnap_distance`) seeds a Newton refinement of the tangency
/// condition `(C(u) - P) · C'(u) = 0`; with no usable parameter the seed is
/// the point on the spline nearest the stored osnap point. That is what makes
/// the 115448 drawing's tangent-to-spline reference survive an edit to the
/// spline instead of silently freezing at its imported location.
/// Tangent point for the line through `context.from`.
/// Spline tangency solves `(C(u) - P) x C'(u) = 0` and validates the residual.
fn tangent_point(
entity: &EntityType,
reference: &AssocDimensionReference,
+1 -1
View File
@@ -43,7 +43,7 @@ mod limits;
mod modify;
mod mspace;
pub mod viewport_ref;
pub mod viewport_dim_seam;
pub mod viewport_dimension_pick;
pub mod named_parameters;
mod page_setup;
mod paper;
+30 -35
View File
@@ -83,14 +83,7 @@ impl Scene {
self.viewport_edit_frame_for(self.active_viewport?, canvas_px)
}
/// [`viewport_edit_frame`] for an explicitly-named viewport, rather than
/// only the active (MSPACE) one.
///
/// Paper-space snapping through viewports (PR1) needs the same camera +
/// screen rectangle pair for a viewport the user has NOT entered, so the
/// existing snap engine can run against that viewport's model wires on the
/// pixels the GPU actually drew them on. `viewport_edit_frame` is now a
/// thin wrapper over this.
/// Camera and canvas rectangle for snapping through an inactive viewport.
pub fn viewport_edit_frame_for(
&self,
vp_handle: Handle,
@@ -101,15 +94,12 @@ impl Scene {
Some((cam, full))
}
/// Planar paper <-> model mapping for a layout viewport, derived from the
/// *same* camera the renderer displays its contents with
/// ([`camera_for_viewport`]) so a snap computed through the frame lands on
/// the pixels the GPU drew.
///
/// Returns `None` for a viewport whose view is not an in-plane orthographic
/// top view (oblique / isometric / perspective viewports are unsupported by
/// the planar frame — callers fall back to no viewport snapping there).
pub fn viewport_frame(&self, vp_handle: Handle) -> Option<crate::scene::viewport_ref::ViewportFrame> {
/// Planar paper/model mapping derived from the viewport's display camera.
/// Oblique and perspective views have no supported planar mapping.
pub fn viewport_frame(
&self,
vp_handle: Handle,
) -> Option<crate::scene::viewport_ref::ViewportFrame> {
use crate::scene::viewport_ref::ViewportFrame;
let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) {
@@ -120,7 +110,7 @@ impl Scene {
),
_ => return None,
};
if vp_height.abs() < 1e-9 {
if !paper_center.is_finite() || !vp_height.is_finite() || vp_height < 1e-9 {
return None;
}
let cam = self.camera_for_viewport(vp_handle)?;
@@ -133,7 +123,7 @@ impl Scene {
// an oblique 3-D view: unsupported.
let right = (cam.rotation * glam::Vec3::X).as_dvec3();
let up = (cam.rotation * glam::Vec3::Y).as_dvec3();
if right.z.abs() > 1e-6 || up.z.abs() > 1e-6 {
if !right.is_finite() || !up.is_finite() || right.z.abs() > 1e-6 || up.z.abs() > 1e-6 {
return None;
}
let normal = right.cross(up);
@@ -142,7 +132,7 @@ impl Scene {
}
// Model units visible across the viewport's height -> paper per model.
let model_height = cam.ortho_size() as f64 * 2.0;
if model_height.abs() < 1e-12 {
if !model_height.is_finite() || model_height < 1e-12 {
return None;
}
let scale = vp_height / model_height;
@@ -162,23 +152,28 @@ impl Scene {
/// Visible, supported content viewports at a sheet point, topmost first.
/// Shared by snapping and explicit dimension object picking.
pub fn viewport_frames_at_paper_point(&self, paper: glam::DVec3) -> Vec<crate::scene::viewport_ref::ViewportFrame> {
if self.current_layout == "Model" || self.active_viewport.is_some() { return Vec::new(); }
self.layout_content_viewports().iter().rev().filter_map(|&handle| {
if !self.viewport_displays_content(handle) || !self.viewport_displays_paper_point(handle, paper.truncate()) {
return None;
}
self.viewport_frame(handle)
}).collect()
pub fn viewport_frames_at_paper_point(
&self,
paper: glam::DVec3,
) -> Vec<crate::scene::viewport_ref::ViewportFrame> {
if self.current_layout == "Model" || self.active_viewport.is_some() {
return Vec::new();
}
self.layout_content_viewports()
.iter()
.rev()
.filter_map(|&handle| {
if !self.viewport_displays_content(handle)
|| !self.viewport_displays_paper_point(handle, paper.truncate())
{
return None;
}
self.viewport_frame(handle)
})
.collect()
}
/// Does `paper` lie inside the viewport's displayed area?
///
/// Always bounded by the viewport's paper rectangle; a viewport with a
/// `clip_boundary_handle` (non-rectangular / clipped viewport) additionally
/// has to contain the point inside that boundary polygon. Used so a
/// paper-space snap never reports a model feature the viewport does not
/// actually show.
/// Test the viewport rectangle and optional nonrectangular clip boundary.
pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool {
let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else {
return false;
+1 -4
View File
@@ -674,10 +674,7 @@ pub(crate) fn clip_boundary_polygon_for_document(
z: f32,
) -> Vec<[f32; 3]> {
use std::f64::consts::TAU;
let Some(entity) = document
.entities()
.find(|e| e.common().handle == handle)
else {
let Some(entity) = document.get_entity(handle) else {
return vec![];
};
// Circles and ellipses tessellate directly — their `to_render` returns a
@@ -1,20 +1,5 @@
//! Object-pick resolution for paper-space dimensions taken through a layout
//! viewport.
//!
//! Paper-space hit testing only sees the sheet's own entities, so a dimension
//! command's explicit "select object" pick that lands inside a content
//! viewport finds nothing. [`Scene::dimension_pick_through_viewport`] maps the
//! click into model space through the viewport's
//! [`crate::scene::viewport_ref::ViewportFrame`], hit-tests the viewport's
//! resident model wires (descending into block instances and baking the
//! instance transform), and hands back a copy of the picked entity already
//! projected onto the sheet.
//!
//! The frame itself is built by [`Scene::viewport_frame`] (see
//! `crate::scene::mspace`), which derives it from the *live viewport camera*
//! so it always agrees with the pixels the renderer drew. It returns `None`
//! for oblique / perspective viewports, and every entry point here then
//! declines the pick rather than guessing a planar mapping.
//! Pick dimension geometry through a layout viewport. Resolve block instances
//! in model space, then project the picked entity onto the paper sheet.
use super::*;
@@ -27,7 +12,7 @@ use glam::DVec3;
/// A model entity resolved by clicking inside a paper-space content viewport.
#[derive(Clone, Debug)]
pub struct ViewportDimPick {
pub struct ViewportDimensionPick {
/// The viewport the click looked through.
pub frame: ViewportFrame,
/// Handle of the innermost model entity that owns the picked geometry.
@@ -46,18 +31,6 @@ pub struct ViewportDimPick {
}
impl Scene {
pub fn content_viewport_at_paper_point(&self, paper: DVec3) -> Option<Handle> {
self.viewport_frames_at_paper_point(paper)
.first()
.map(|f| f.viewport)
}
pub fn viewport_frame_at_paper_point(&self, paper: DVec3) -> Option<ViewportFrame> {
self.viewport_frames_at_paper_point(paper)
.into_iter()
.next()
}
/// Resolve an explicit dimension object pick made on the sheet but landing
/// inside a content viewport, against the *model* geometry that viewport
/// displays.
@@ -67,14 +40,13 @@ impl Scene {
&self,
paper: DVec3,
aperture_paper: f64,
) -> Option<ViewportDimPick> {
) -> Option<ViewportDimensionPick> {
for frame in self.viewport_frames_at_paper_point(paper) {
let model_point = frame.paper_to_model(paper);
let aperture_model =
(aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9);
let Some(top) =
self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model)
let Some(top) = self.nearest_model_wire_handle(&frame, model_point, aperture_model)
else {
continue;
};
@@ -93,7 +65,7 @@ impl Scene {
&EntityTransform::Affine(viewport_model_to_paper_transform(&frame)),
);
let entity_handle = paper_entity.as_entity().handle();
return Some(ViewportDimPick {
return Some(ViewportDimensionPick {
frame,
entity_handle: if entity_handle.is_valid() {
entity_handle
@@ -114,10 +86,11 @@ impl Scene {
/// draws, so this matches what the user sees.
fn nearest_model_wire_handle(
&self,
viewport: Handle,
frame: &ViewportFrame,
model_point: DVec3,
aperture: f64,
) -> Option<Handle> {
let viewport = frame.viewport;
let camera = self.camera_for_viewport(viewport)?;
let bounds = iced::Rectangle {
x: 0.0,
@@ -150,9 +123,6 @@ impl Scene {
.unwrap_or(DVec3::ZERO);
let distance = wire_polyline_nearest(wire, offset, model_point);
if let Some((distance, nearest)) = distance {
let Some(frame) = self.viewport_frame(viewport) else {
continue;
};
if !self.viewport_displays_paper_point(
viewport,
frame.model_to_paper(nearest).truncate(),
@@ -188,19 +158,14 @@ impl Scene {
) -> Option<(EntityType, Vec<Handle>)> {
let entity = self.document.get_entity(handle)?;
match entity {
EntityType::Insert(_) => {
let mut path = Vec::new();
let found = self.descend_block_instance(
entity.clone(),
Transform::identity(),
model_point,
&mut path,
0,
viewport,
kind,
)?;
Some((found.0, found.1))
}
EntityType::Insert(insert) => self.descend_block_instance(
insert,
Transform::identity(),
model_point,
0,
viewport,
kind,
),
other => {
feature_pick_distance(other, model_point, kind)?;
Some((other.clone(), Vec::new()))
@@ -212,21 +177,17 @@ impl Scene {
/// Returns the entity already transformed into WCS plus the INSERT path.
fn descend_block_instance(
&self,
insert_entity: EntityType,
insert: &acadrust::entities::Insert,
outer: Transform,
model_point: DVec3,
path: &mut Vec<Handle>,
depth: usize,
viewport: Option<Handle>,
kind: Option<crate::snap::SnapType>,
) -> Option<(EntityType, Vec<Handle>)> {
const MAX_DEPTH: usize = 8;
if depth > MAX_DEPTH {
if depth >= MAX_DEPTH {
return None;
}
let EntityType::Insert(insert) = &insert_entity else {
return None;
};
if insert.row_count > 1 || insert.column_count > 1 {
return None;
}
@@ -234,10 +195,9 @@ impl Scene {
let local = crate::scene::render_graph::insert_transform(&self.document, insert);
let combined = local.then(&outer);
let block = self.document.block_records.get(&insert.block_name)?;
let children: Vec<Handle> = block.entity_handles.clone();
let mut best: Option<(f64, EntityType, Vec<Handle>)> = None;
for child in children {
for &child in &block.entity_handles {
let Some(child_entity) = self.document.get_entity(child) else {
continue;
};
@@ -249,13 +209,11 @@ impl Scene {
if common.invisible || layer.is_some_and(|l| l.flags.off || l.flags.frozen) || frozen {
continue;
}
if matches!(child_entity, EntityType::Insert(_)) {
let mut nested = Vec::new();
if let EntityType::Insert(child_insert) = child_entity {
if let Some((entity, mut nested_path)) = self.descend_block_instance(
child_entity.clone(),
child_insert,
combined,
model_point,
&mut nested,
depth + 1,
viewport,
kind,
@@ -300,8 +258,6 @@ impl Scene {
}
}
let (_, entity, found_path) = best?;
path.clear();
path.extend(found_path.iter().copied());
Some((entity, found_path))
}
}
+8 -120
View File
@@ -187,17 +187,8 @@ impl AcceptedSnap {
}
}
/// The two independent factors that turn a raw measured distance into the
/// number a linear/aligned/radial dimension displays.
///
/// * `user_lfac` is the drawing/style's DIMLFAC as the user intends it
/// (always positive here; the sign convention of DXF DIMLFAC is resolved by
/// [`MeasurementScale::from_dimlfac`]).
/// * `viewport_compensation` undoes the viewport's paper-per-model scale for
/// a dimension placed in paper space but measuring model geometry. It is
/// `1.0` for model-space dimensions and for paper-only dimensions.
///
/// Angular dimensions never use either factor.
/// Length dimensions store raw distances in their owning space. The displayed
/// value uses `user_lfac * viewport_compensation`; angular dimensions are exempt.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeasurementScale {
pub user_lfac: f64,
@@ -252,59 +243,13 @@ impl MeasurementScale {
common.extended_data = data;
}
pub const IDENTITY: Self = Self {
user_lfac: 1.0,
viewport_compensation: 1.0,
};
/// Resolve the DXF DIMLFAC convention:
/// * `dimlfac > 0`: applies to every dimension.
/// * `dimlfac < 0`: `|dimlfac|` applies only to dimensions created in
/// paper space that measure model geometry through a viewport; model
/// space dimensions use `1.0`.
/// * `dimlfac == 0`: treated as `1.0`.
///
/// `viewport` supplies the compensation when the dimension measures
/// through a viewport; pass `None` for model-space or paper-only
/// dimensions.
pub fn from_dimlfac(dimlfac: f64, viewport: Option<&ViewportFrame>) -> Self {
let through_viewport = viewport.is_some();
let user_lfac = if dimlfac == 0.0 {
1.0
} else if dimlfac > 0.0 {
dimlfac
} else if through_viewport {
-dimlfac
} else {
1.0
};
let viewport_compensation = viewport
.map(ViewportFrame::paper_to_model_length_factor)
.unwrap_or(1.0);
Self {
user_lfac,
viewport_compensation,
}
}
/// Combined factor applied to a distance measured on the paper sheet.
pub fn paper_factor(&self) -> f64 {
self.user_lfac * self.viewport_compensation
}
/// Factor applied to a distance already measured in model space (the
/// viewport compensation must not be applied again).
pub fn model_factor(&self) -> f64 {
self.user_lfac
}
/// The DXF DIMLFAC sign convention, resolved without a [`ViewportFrame`].
///
/// Rendering an *existing* dimension only knows whether the entity lives
/// in a paper-space layout; the viewport compensation is already baked
/// into the persisted magnitude. This is the same rule
/// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()`
/// replaced by `paper_space`.
/// Positive DIMLFAC applies everywhere; negative DIMLFAC applies only on
/// paper. Zero means one. Saved viewport compensation is already in DIMLFAC.
pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 {
if dimlfac == 0.0 {
1.0
@@ -318,39 +263,6 @@ impl MeasurementScale {
}
}
/// # Where the viewport compensation lives, once
///
/// A dimension that sits on a paper layout but measures model geometry seen
/// through a viewport has **paper** definition points and displays a **model**
/// number. Exactly one rule governs that, everywhere:
///
/// > `Dimension::actual_measurement` (DXF group 42) is always the **raw**
/// > geometric measurement in the dimension's *own* space — paper, for a
/// > viewport dimension. The viewport compensation lives **only** in the
/// > magnitude of the negative DIMLFAC override
/// > ([`MeasurementScale::viewport_dimlfac_override`]), and is applied
/// > **once**, at format time, by the linear formatter.
///
/// So for a 1:10 viewport and a user DIMLFAC of 1: a 100-unit model line is
/// 10 paper units, `actual_measurement` is `10`, the override is `-10`,
/// [`MeasurementScale::user_lfac_for_space`] resolves that to `10` for a
/// paper-space dimension, and the text reads `100`.
///
/// The consequence for association refresh
/// (`Scene::refresh_associative_dimensions`) is that after re-placing a
/// viewport dimension's definition points on the sheet it must write the
/// **paper** measurement back — it must *not* substitute the model distance,
/// and it must *not* multiply by [`MeasurementScale::model_factor`]. Doing
/// either makes a refreshed dimension read a different number from an
/// identical freshly-created one (`user_lfac` too many times). This is the
/// rule that makes `attach_viewport_dimension_association` + refresh agree
/// with creation by construction, and it is why `actual_measurement` stays
/// raw: that is also what the DXF format specifies and what export writes.
///
/// Angular dimensions are exempt on both counts — a similarity preserves
/// angles, so there is no compensation and no override.
pub mod measurement_rule {}
#[cfg(test)]
mod tests {
use super::*;
@@ -385,34 +297,10 @@ mod tests {
}
#[test]
fn negative_dimlfac_only_applies_through_viewports() {
let f = frame(0.5, 0.0);
let through = MeasurementScale::from_dimlfac(-2.0, Some(&f));
assert_eq!(through.user_lfac, 2.0);
assert!((through.viewport_compensation - 2.0).abs() < 1e-12);
assert!((through.paper_factor() - 4.0).abs() < 1e-12);
assert!((through.model_factor() - 2.0).abs() < 1e-12);
let model = MeasurementScale::from_dimlfac(-2.0, None);
assert_eq!(model, MeasurementScale::IDENTITY);
let positive = MeasurementScale::from_dimlfac(3.0, None);
assert_eq!(positive.user_lfac, 3.0);
assert_eq!(positive.viewport_compensation, 1.0);
}
#[test]
fn user_lfac_for_space_matches_from_dimlfac() {
let f = frame(0.5, 0.0);
for lfac in [-2.0, 0.0, 1.0, 3.5] {
assert_eq!(
MeasurementScale::user_lfac_for_space(lfac, true),
MeasurementScale::from_dimlfac(lfac, Some(&f)).user_lfac
);
assert_eq!(
MeasurementScale::user_lfac_for_space(lfac, false),
MeasurementScale::from_dimlfac(lfac, None).user_lfac
);
fn dimlfac_sign_depends_on_dimension_space() {
for (factor, paper, model) in [(-2.0, 2.0, 1.0), (0.0, 1.0, 1.0), (3.5, 3.5, 3.5)] {
assert_eq!(MeasurementScale::user_lfac_for_space(factor, true), paper);
assert_eq!(MeasurementScale::user_lfac_for_space(factor, false), model);
}
}
}