Merge pull request #1301: Add associative viewport dimensions

Refresh associations after complete SpaceMouse camera updates and recognize imported paper-space owners without Layout objects. Use kernel containment, intersection, perpendicular and spline tangent solving; pin cadkernel 7ce27f2 and add regressions.
This commit is contained in:
Hakan Seven committed 2026-09-16 09:56:33 +03:00
commit d10a0cf798
35 files changed
+7077 -85

No files matched your search

+1
View File
@@ -567,6 +567,7 @@ impl Scene {
viewport.view_target.x = center.x;
viewport.view_target.y = center.y;
viewport.view_target.z = center.z;
self.notify_viewport_changed(handle);
} else {
self.camera.borrow_mut().target = center;
}
File diff suppressed because it is too large. Load diff
+585
View File
@@ -0,0 +1,585 @@
//! Resolve a dimension feature through `[viewport?, insert..., entity]`.
//! `xrefs` stores the primary chain; `intersection_objects` stores the second
//! intersection source. Features are evaluated in entity-local coordinates and
//! transformed through their block instances. The caller projects viewport
//! references onto the sheet. All arithmetic uses `f64`.
use acadrust::objects::AssocDimensionReference;
use acadrust::types::{Handle, Matrix4, Transform, Vector3};
use acadrust::{CadDocument, EntityType};
use cadkernel::geom2d::{closest_point, Curve as KernelCurve};
use cadkernel::space::Plane;
use crate::entities::curve::entity_curve;
/// `AcDb::OsnapMode`, the value stored in
/// [`AssocDimensionReference::osnap_type`].
///
/// These are ObjectARX codes, independent of [`crate::snap::SnapType`].
#[allow(dead_code)] // Include codes preserved from unsupported imported modes.
pub(crate) mod osnap {
pub const NONE: u8 = 0;
pub const END: u8 = 1;
pub const MID: u8 = 2;
pub const CEN: u8 = 3;
pub const NODE: u8 = 4;
pub const QUAD: u8 = 5;
pub const INTERSEC: u8 = 6;
pub const INS: u8 = 7;
pub const PERP: u8 = 8;
pub const TAN: u8 = 9;
pub const NEAR: u8 = 10;
pub const APPARENT_INT: u8 = 11;
pub const PARA: u8 = 12;
pub const START_POINT: u8 = 13;
}
/// Translate a live snap mode into the `AcDb::OsnapMode` we persist.
///
/// Modes with no ObjectARX equivalent (grid, object pick) map to
/// [`osnap::NONE`]; a reference carrying `NONE` resolves by marker/parameter
/// alone.
pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 {
use crate::snap::SnapType as S;
match snap {
S::Endpoint => osnap::END,
S::Midpoint => osnap::MID,
S::Center => osnap::CEN,
S::Node => osnap::NODE,
S::Quadrant => osnap::QUAD,
S::Intersection => osnap::INTERSEC,
S::Insertion => osnap::INS,
S::Perpendicular => osnap::PERP,
S::Tangent => osnap::TAN,
S::Nearest | S::Extension => osnap::NEAR,
S::ApparentIntersection => osnap::APPARENT_INT,
S::Parallel => osnap::PARA,
S::Grid | S::ObjectPick => osnap::NONE,
}
}
/// A resolved `xrefs` chain.
#[derive(Clone, Debug)]
pub(crate) struct ReferenceChain {
/// The layout viewport the reference is seen through, when the chain
/// starts with one. `None` for a plain model-space reference.
pub viewport: Option<Handle>,
/// INSERT handles from the outermost instance down to the instance that
/// directly contains [`Self::entity`]. Empty for top-level geometry.
pub block_path: Vec<Handle>,
/// The innermost entity — the one that owns the feature.
pub entity: Handle,
/// Block-path transform: entity-local coordinates -> model coordinates.
/// Identity when `block_path` is empty.
pub transform: Transform,
}
/// Why a chain could not be walked. The data is never discarded on a failure:
/// the caller keeps the reference exactly as read and leaves the dimension
/// showing its last valid appearance.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ChainError {
/// `xrefs` was empty — nothing to resolve.
Empty,
Invalid,
/// A handle in the chain names an object that is not in the document
/// (erased source; restored by an undo).
Missing(Handle),
}
/// Walk `xrefs` into a [`ReferenceChain`].
///
/// The chain is read positionally rather than by a stored kind, because that
/// is all the file gives us: a leading VIEWPORT is the trans-space hop, any
/// INSERTs after it are the block-instance path, and the first handle that is
/// neither is the geometry. A chain that is nothing but INSERTs (dimensioning
/// a block reference's insertion point) ends on the innermost INSERT.
pub(crate) fn walk_chain(
document: &CadDocument,
xrefs: &[Handle],
) -> Result<ReferenceChain, ChainError> {
if xrefs.is_empty() {
return Err(ChainError::Empty);
}
let mut viewport = None;
let mut block_path = Vec::new();
let mut transform = Transform::identity();
let mut owner = None;
for (index, &handle) in xrefs.iter().enumerate() {
if handle.is_null() {
return Err(ChainError::Invalid);
}
let node = document
.get_entity(handle)
.ok_or(ChainError::Missing(handle))?;
if owner.is_some_and(|owner| node.common().owner_handle != owner) {
return Err(ChainError::Invalid);
}
if index == 0 && matches!(node, EntityType::Viewport(_)) {
viewport = Some(handle);
owner = document.block_records.get("*Model_Space").map(|b| b.handle);
continue;
}
if index == xrefs.len() - 1 {
return Ok(ReferenceChain {
viewport,
block_path,
entity: handle,
transform,
});
}
let EntityType::Insert(insert) = node else {
return Err(ChainError::Invalid);
};
// Arrays need an instance index that this reference format does not carry.
if insert.row_count > 1
|| insert.column_count > 1
|| block_path.len() >= 8
|| block_path.contains(&handle)
{
return Err(ChainError::Invalid);
}
let block = document
.block_records
.get(&insert.block_name)
.ok_or(ChainError::Invalid)?;
owner = Some(block.handle);
block_path.push(handle);
transform = transform.compose(&crate::scene::render_graph::insert_transform(
document, insert,
));
}
Err(ChainError::Empty)
}
/// Invert an affine block transform, rejecting singular or nonfinite matrices.
pub(crate) fn invert(transform: &Transform) -> Option<Transform> {
let m = &transform.matrix.m;
let matrix = glam::DMat4::from_cols_array_2d(&std::array::from_fn(|column| {
std::array::from_fn(|row| m[row][column])
}));
if !matrix.is_finite() || matrix.determinant().abs() < 1e-18 {
return None;
}
let inverse = matrix.inverse();
if !inverse.is_finite() {
return None;
}
let columns = inverse.to_cols_array_2d();
Some(Transform::from_matrix(Matrix4 {
m: std::array::from_fn(|row| std::array::from_fn(|column| columns[column][row])),
}))
}
// ── Feature evaluation ────────────────────────────────────────────────────
fn vector3(point: [f64; 3]) -> Vector3 {
Vector3::new(point[0], point[1], point[2])
}
fn dpoint(point: Vector3) -> [f64; 3] {
[point.x, point.y, point.z]
}
fn distance_squared(a: Vector3, b: Vector3) -> f64 {
let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z);
dx * dx + dy * dy + dz * dz
}
fn lift(plane: &Plane, uv: [f64; 2]) -> Vector3 {
vector3(plane.point_at(uv))
}
/// Everything a feature evaluation may need beyond the reference itself.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct FeatureContext {
/// The stored osnap point, already expressed in *entity-local*
/// coordinates. Used as the disambiguating hint for every feature that has
/// more than one candidate (quadrant, intersection, tangent, nearest).
pub hint: Option<Vector3>,
/// The point the feature is measured *from*, in entity-local coordinates.
/// PERPENDICULAR and TANGENT are two-point features: the foot / touch
/// point only exists relative to an external point, which for a dimension
/// is the other definition point. Tangency requires this point.
pub from: Option<Vector3>,
}
/// Evaluate the osnap feature `reference` names on `entity`, in the entity's
/// own coordinates.
///
/// Returns `None` — never a guess — when the osnap type is one we do not
/// evaluate, or when the geometry no longer supports it. The caller keeps the
/// reference data intact and leaves the dimension where it was.
pub(crate) fn feature_point(
document: &CadDocument,
entity: &EntityType,
reference: &AssocDimensionReference,
context: FeatureContext,
) -> Option<Vector3> {
match reference.osnap_type {
osnap::END | osnap::START_POINT if reference.main_subent_type == 2 => {
imported_endpoint(entity, reference, context.hint)
}
osnap::CEN => center_point(entity, reference),
osnap::MID => mid_point(entity, reference),
osnap::QUAD => quadrant_point(entity, reference, context.hint?),
osnap::NODE | osnap::INS => node_point(entity),
osnap::INTERSEC | osnap::APPARENT_INT => intersection_point(
document,
entity,
reference,
context.hint?,
reference.osnap_type == osnap::APPARENT_INT,
),
osnap::PERP => perpendicular_point(entity, context.from.or(context.hint)?),
osnap::TAN => tangent_point(entity, reference, context),
osnap::NEAR => nearest_point(entity, reference, context.hint),
// END / START_POINT / PARA / NONE all address a named vertex or a
// stored curve parameter, which is what the legacy marker convention
// already encodes. The caller keeps that path.
_ => None,
}
}
/// Whether [`feature_point`] knows how to evaluate this osnap type. A type we
/// do not handle keeps its stored data and stays unresolved.
pub(crate) fn evaluates(osnap_type: u8) -> bool {
matches!(
osnap_type,
osnap::END
| osnap::START_POINT
| osnap::CEN
| osnap::MID
| osnap::QUAD
| osnap::NODE
| osnap::INS
| osnap::INTERSEC
| osnap::APPARENT_INT
| osnap::PERP
| osnap::TAN
| osnap::NEAR
)
}
/// Whether this osnap type needs the *other* definition point to evaluate.
pub(crate) fn needs_from_point(osnap_type: u8) -> bool {
matches!(osnap_type, osnap::PERP | osnap::TAN)
}
fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option<Vector3> {
match entity {
EntityType::Circle(circle) => Some(circle.center_wcs()),
EntityType::Arc(arc) => Some(arc.center_wcs()),
EntityType::Ellipse(ellipse) => Some(ellipse.center),
_ => {
// A polyline's bulge segment: `main_gs_marker` names the segment.
let planar = entity_curve(entity)?;
let KernelCurve::Polyline(polyline) = &planar.curve else {
return None;
};
let segment = if reference.main_subent_type == 1
&& reference.main_gs_marker == super::dimension_assoc::POLYLINE_ARC_CENTER_MARKER
{
reference.osnap_distance.round().max(0.0) as usize
} else {
segment_index(reference)?
};
let arc = polyline.segment_arc(segment)?;
Some(lift(&planar.plane, arc.center))
}
}
}
fn mid_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option<Vector3> {
let planar = entity_curve(entity)?;
match &planar.curve {
// For a polyline the marker names which segment's midpoint it is;
// without one, the midpoint of the whole run.
KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => {
let segments = planar.curve.segments();
let segment = segments.get(segment_index(reference)?)?;
Some(lift(&planar.plane, segment.point_at(0.5)))
}
_ => Some(lift(&planar.plane, planar.curve.point_at(0.5))),
}
}
fn quadrant_point(
entity: &EntityType,
reference: &AssocDimensionReference,
hint: Vector3,
) -> Option<Vector3> {
let planar = entity_curve(entity)?;
let (centre, radius) = match &planar.curve {
KernelCurve::Circle(circle) => (circle.centre, circle.radius),
KernelCurve::Arc(arc) => (arc.centre, arc.radius),
_ => return None,
};
let valid_angle = |angle: f64| match &planar.curve {
KernelCurve::Arc(arc) => {
cadkernel::geom2d::angle_within_arc(angle, arc.start_angle, arc.end_angle)
}
_ => true,
};
let at_angle = |angle: f64| {
lift(
&planar.plane,
KernelCurve::Circle(cadkernel::geom2d::Circle { centre, radius })
.point_at(angle.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU),
)
};
// New references retain the chosen source-local angle, so translation
// cannot silently select another quadrant using the old world point.
if reference.main_subent_type == 1 && reference.main_gs_marker == -2 {
let angle = reference.osnap_distance;
return (angle.is_finite() && valid_angle(angle)).then(|| at_angle(angle));
}
// An imported point can identify an unchanged quadrant, but cannot prove
// which quadrant was intended after it moves away from the stored hint.
(0..4)
.map(|index| std::f64::consts::FRAC_PI_2 * index as f64)
.filter(|angle| valid_angle(*angle))
.map(at_angle)
.find(|candidate| distance_squared(*candidate, hint) < 1e-12)
}
fn node_point(entity: &EntityType) -> Option<Vector3> {
match entity {
EntityType::Point(point) => Some(point.location),
EntityType::Insert(insert) => Some(insert.insert_point),
EntityType::Text(text) => Some(text.insertion_point),
EntityType::MText(text) => Some(text.insertion_point),
EntityType::Shape(shape) => Some(shape.insertion_point),
_ => None,
}
}
fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option<Vector3> {
let planar = entity_curve(entity)?;
let uv = planar.plane.project(dpoint(from))?;
cadkernel::geom2d::snap::perpendicular_from(&planar.curve, uv)
.into_iter()
.map(|candidate| lift(&planar.plane, candidate.point))
.min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from)))
}
/// Perpendicularity must be evaluated after a block's nonuniform scale.
pub(crate) fn perpendicular_in_model(
entity: &EntityType,
transform: &Transform,
from: Vector3,
) -> Option<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))?;
cadkernel::geom2d::snap::perpendicular_from(&curve, uv)
.into_iter()
.map(|candidate| lift(&plane, candidate.point))
.min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from)))
}
fn nearest_point(
entity: &EntityType,
reference: &AssocDimensionReference,
hint: Option<Vector3>,
) -> Option<Vector3> {
let planar = entity_curve(entity)?;
// Circles and arcs store an angle; other curves use a normalized parameter.
match &planar.curve {
KernelCurve::Circle(_) | KernelCurve::Arc(_) => match entity {
EntityType::Circle(circle) => Some(circle.point_at_angle_wcs(reference.osnap_distance)),
EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)),
_ => None,
},
_ => {
let parameter = reference.osnap_distance;
if parameter.is_finite() && (0.0..=1.0).contains(&parameter) {
return Some(lift(&planar.plane, planar.curve.point_at(parameter)));
}
// No usable parameter: fall back to the point on the curve nearest
// the stored osnap point, which is what NEAR means anyway.
let uv = planar.plane.project(dpoint(hint?))?;
Some(lift(&planar.plane, closest_point(&planar.curve, uv).point))
}
}
}
// ── Tangent ───────────────────────────────────────────────────────────────
/// Tangent point for the line through `context.from`.
fn tangent_point(
entity: &EntityType,
reference: &AssocDimensionReference,
context: FeatureContext,
) -> Option<Vector3> {
if let EntityType::Spline(spline) = entity {
return spline_tangent_point(spline, reference, context);
}
let planar = entity_curve(entity)?;
let from = context.from?;
let uv = planar.plane.project(dpoint(from))?;
cadkernel::geom2d::snap::tangent_from(&planar.curve, uv)
.into_iter()
.map(|candidate| lift(&planar.plane, candidate.point))
.min_by(|a, b| {
distance_squared(*a, context.hint.unwrap_or(from))
.total_cmp(&distance_squared(*b, context.hint.unwrap_or(from)))
})
}
fn spline_tangent_point(
spline: &acadrust::entities::Spline,
reference: &AssocDimensionReference,
context: FeatureContext,
) -> Option<Vector3> {
let curve = crate::entities::spline::nurbs3(spline)?;
let (start, end) = curve.domain();
let stored = reference.osnap_distance;
let u = if stored.is_finite() && stored > start - 1e-9 && stored < end + 1e-9 {
stored.clamp(start, end)
} else {
let hint = context.hint.or(context.from)?;
curve.parameter_at(dpoint(hint)).clamp(start, end)
};
curve
.tangent_from_near(dpoint(context.from?), u)
.map(vector3)
}
// ── Intersection ──────────────────────────────────────────────────────────
/// Intersection of `entity` with the reference's second object chain.
///
/// Transform both curves into the first entity's local plane and use the
/// kernel's exact/converged intersection solver. Apparent intersections may
/// project noncoplanar curves; only straight lines support an extended crossing.
fn intersection_point(
document: &CadDocument,
entity: &EntityType,
reference: &AssocDimensionReference,
hint: Vector3,
apparent: bool,
) -> Option<Vector3> {
let main = walk_chain(document, &reference.xrefs).ok()?;
let other = walk_chain(document, &reference.intersection_objects).ok()?;
if main.viewport != other.viewport {
return None;
}
let inverse = invert(&main.transform)?;
let first = entity_curve(entity)?;
let second = entity_curve(document.get_entity(other.entity)?)?;
let map = |uv| {
let local = inverse.apply(other.transform.apply(lift(&second.plane, uv)));
let projected = first.plane.project(dpoint(local))?;
if !apparent && distance_squared(local, lift(&first.plane, projected)) > 1e-12 {
return None;
}
Some(projected)
};
let origin = glam::DVec2::from_array(map([0.0, 0.0])?);
let x = glam::DVec2::from_array(map([1.0, 0.0])?) - origin;
let y = glam::DVec2::from_array(map([0.0, 1.0])?) - origin;
let second = second.curve.transformed(&cadkernel::geom2d::Transform {
origin: origin.to_array().into(),
x_axis: x.to_array().into(),
y_axis: y.to_array().into(),
})?;
let hint_uv = first.plane.project(dpoint(hint))?;
let mut points: Vec<_> = cadkernel::geom2d::intersect(
&first.curve,
&second,
cadkernel::geom2d::Tolerance::new(1e-9),
)
.into_iter()
.map(|hit| hit.point)
.collect();
if apparent && points.is_empty() {
// Extended apparent intersections are meaningful for straight lines.
// Curved geometry must actually cross after projection.
if let (KernelCurve::Line(a), KernelCurve::Line(b)) = (&first.curve, &second) {
let extend = |line: &cadkernel::geom2d::Line| {
KernelCurve::XLine(cadkernel::geom2d::XLine {
base: line.start,
direction: [line.end[0] - line.start[0], line.end[1] - line.start[1]],
})
};
points.extend(
cadkernel::geom2d::intersect(
&extend(a),
&extend(b),
cadkernel::geom2d::Tolerance::new(1e-9),
)
.into_iter()
.map(|hit| hit.point),
);
}
}
points
.into_iter()
.min_by(|a, b| {
let error = |p: &[f64; 2]| (p[0] - hint_uv[0]).powi(2) + (p[1] - hint_uv[1]).powi(2);
error(a).total_cmp(&error(b))
})
.map(|point| lift(&first.plane, point))
}
/// Internal markers are zero-based; imported edge markers are one-based.
fn segment_index(reference: &AssocDimensionReference) -> Option<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);
}
fn ensure_app_id(&mut self, name: &str) {
pub(super) fn ensure_app_id(&mut self, name: &str) {
if name.trim().is_empty() || self.document.app_ids.contains(name) {
return;
}
+12
View File
@@ -31,6 +31,8 @@ mod camera_ops;
pub(crate) mod centerline;
pub(crate) mod centermark;
pub(crate) mod dimension_assoc;
pub(crate) mod dimension_assoc_chain;
pub use dimension_assoc::{ReferenceStatus, ResolvedReference};
mod dwg_native_constraints;
mod entity;
#[cfg(test)]
@@ -40,6 +42,8 @@ mod layout;
mod limits;
mod modify;
mod mspace;
pub mod viewport_ref;
pub mod viewport_dimension_pick;
pub mod named_parameters;
mod page_setup;
mod paper;
@@ -5438,6 +5442,10 @@ impl Scene {
self.document
.set_viewport_annotation_scale(viewport, scale_handle);
// Viewport scale changed: the dimensions drawn on the sheet
// through it measure the same model geometry at a new paper
// size and have to be re-placed.
self.notify_viewport_changed(viewport);
self.resident_wire_sets.borrow_mut().clear();
self.bump_geometry();
@@ -5484,6 +5492,10 @@ impl Scene {
vp.view_height = vp.height / factor;
self.document
.set_viewport_annotation_scale(viewport, scale_handle);
// Viewport scale changed: the dimensions drawn on the sheet
// through it measure the same model geometry at a new paper
// size and have to be re-placed.
self.notify_viewport_changed(viewport);
self.resident_wire_sets.borrow_mut().clear();
self.bump_geometry();
true
+1
View File
@@ -730,6 +730,7 @@ impl Scene {
// Complete group copies record their new Group objects and dictionary
// entry as targeted object deltas inside copy_complete_groups.
self.copy_complete_groups(&handle_map);
self.copy_dimension_associations(&handle_map);
// The copies are new handles (natural memo misses, tessellated fresh)
// and reference only already-cached blocks — no block defn changes.
// Report them as additions so derived caches patch in exactly the copies.
+197 -6
View File
@@ -80,12 +80,173 @@ impl Scene {
&self,
canvas_px: (f32, f32),
) -> Option<(view::camera::Camera, iced::Rectangle)> {
let vp_handle = self.active_viewport?;
self.viewport_edit_frame_for(self.active_viewport?, canvas_px)
}
/// Camera and canvas rectangle for snapping through an inactive viewport.
pub fn viewport_edit_frame_for(
&self,
vp_handle: Handle,
canvas_px: (f32, f32),
) -> Option<(view::camera::Camera, iced::Rectangle)> {
let cam = self.camera_for_viewport(vp_handle)?;
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
Some((cam, full))
}
/// Planar paper/model mapping derived from the viewport's display camera.
/// Oblique and perspective views have no supported planar mapping.
pub fn viewport_frame(
&self,
vp_handle: Handle,
) -> Option<crate::scene::viewport_ref::ViewportFrame> {
use crate::scene::viewport_ref::ViewportFrame;
let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) {
Some(EntityType::Viewport(vp)) => (
glam::DVec2::new(vp.center.x, vp.center.y),
vp.height,
vp.status.locked,
),
_ => return None,
};
if !paper_center.is_finite() || !vp_height.is_finite() || vp_height < 1e-9 {
return None;
}
let cam = self.camera_for_viewport(vp_handle)?;
if cam.projection != view::camera::Projection::Orthographic {
return None;
}
// Screen right/up of the viewport camera, in model space. A planar
// frame exists only when both stay in the model XY plane and form a
// right-handed pair (a plan view, possibly twisted). Anything else is
// an oblique 3-D view: unsupported.
let right = (cam.rotation * glam::Vec3::X).as_dvec3();
let up = (cam.rotation * glam::Vec3::Y).as_dvec3();
if !right.is_finite() || !up.is_finite() || right.z.abs() > 1e-6 || up.z.abs() > 1e-6 {
return None;
}
let normal = right.cross(up);
if normal.z < 1.0 - 1e-6 {
return None; // mirrored or degenerate
}
// Model units visible across the viewport's height -> paper per model.
let model_height = cam.ortho_size() as f64 * 2.0;
if !model_height.is_finite() || model_height < 1e-12 {
return None;
}
let scale = vp_height / model_height;
if !scale.is_finite() || scale <= 0.0 {
return None;
}
// `ViewportFrame` rotates model geometry by `twist` (CCW) before
// scaling; the row that produces the paper X component is `right`, so
// cos(twist) = right.x and sin(twist) = -right.y.
let twist = (-right.y).atan2(right.x);
Some(ViewportFrame {
viewport: vp_handle,
paper_center,
model_target: glam::DVec2::new(cam.target.x as f64, cam.target.y as f64),
scale,
twist,
locked,
})
}
/// Visible, supported content viewports at a sheet point, topmost first.
/// Shared by snapping and explicit dimension object picking.
pub fn viewport_frames_at_paper_point(
&self,
paper: glam::DVec3,
) -> Vec<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()
}
/// Test the viewport rectangle and optional nonrectangular clip boundary.
pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool {
let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else {
return false;
};
let hw = (vp.width * 0.5).abs();
let hh = (vp.height * 0.5).abs();
if hw < 1e-9 || hh < 1e-9 {
return false;
}
if (paper.x - vp.center.x).abs() > hw || (paper.y - vp.center.y).abs() > hh {
return false;
}
if vp.clip_boundary_handle.is_null() {
return true;
}
let Some(boundary) = self.document.get_entity(vp.clip_boundary_handle)
.and_then(crate::entities::curve::entity_curve)
else {
return false;
};
let Some(normal) = boundary.plane.normal() else {
return false;
};
if !boundary.curve.is_closed() || normal[0].abs() > 1e-9 || normal[1].abs() > 1e-9 {
return false;
}
let Some(point) = boundary.plane.project([paper.x, paper.y, vp.center.z]) else {
return false;
};
cadkernel::geom2d::contains(
&[boundary.curve],
point,
cadkernel::geom2d::Tolerance::new(1e-9),
)
}
}
#[cfg(test)]
mod clip_tests {
use super::*;
#[test]
fn concave_clip_boundary_rejects_the_notch() {
let mut scene = Scene::new();
let mut poly = acadrust::entities::LwPolyline::from_points(
[(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (5.0, 5.0), (5.0, 10.0), (0.0, 10.0)]
.map(|(x, y)| acadrust::types::Vector2::new(x, y))
.to_vec(),
);
poly.is_closed = true;
let clip = scene.add_entity(EntityType::LwPolyline(poly));
let mut viewport = acadrust::entities::Viewport::new();
viewport.center = acadrust::types::Vector3::new(5.0, 5.0, 0.0);
viewport.width = 20.0;
viewport.height = 20.0;
viewport.clip_boundary_handle = clip;
let viewport = scene.add_entity(EntityType::Viewport(viewport));
for (x, y, inside) in [
(2.0, 2.0, true), (8.0, 2.0, true), (8.0, 8.0, false),
(-1.0, 5.0, false), (5.0, 7.0, true),
] {
assert_eq!(
scene.viewport_displays_paper_point(viewport, glam::DVec2::new(x, y)), inside,
);
}
}
}
impl Scene {
/// Fold the active viewport's saved view onto the effective camera (the
/// auto-fit centre for stale UTM views) and persist it into `view_target` /
/// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the
@@ -109,6 +270,7 @@ impl Scene {
vp.view_height = eff_h;
}
}
self.notify_viewport_changed(vp_handle);
}
/// Fit model-space bounds into the active floating viewport without moving
@@ -147,6 +309,7 @@ impl Scene {
viewport.custom_scale = viewport.height / viewport.view_height;
}
}
self.notify_viewport_changed(viewport_handle);
self.camera_generation += 1;
true
}
@@ -198,6 +361,7 @@ impl Scene {
vp.view_target.y += model_delta.y as f64;
vp.view_target.z += model_delta.z as f64;
}
self.notify_viewport_changed(vp_handle);
}
/// Zoom the active viewport's model-space view by `steps` notches.
@@ -247,6 +411,7 @@ impl Scene {
}
}
}
self.notify_viewport_changed(vp_handle);
}
/// Orbit the active viewport's view direction by the given screen-pixel delta.
@@ -324,6 +489,7 @@ impl Scene {
vp.view_direction.z = dir.z as f64;
vp.twist_angle = twist;
}
self.notify_viewport_changed(vp_handle);
}
/// Mutate the active viewport's camera through a closure, then re-encode the
@@ -352,6 +518,7 @@ impl Scene {
.normalize_or(glam::Vec3::Y);
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
let twist = -roll as f64;
let mut changed = false;
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
if vp.status.locked {
return false;
@@ -364,9 +531,12 @@ impl Scene {
tmp.projection == view::camera::Projection::Perspective;
vp.lens_length =
(12.0 / (tmp.fov_y * 0.5).tan().max(1e-6)) as f64;
return true;
changed = true;
}
false
if changed {
self.notify_viewport_changed(vp_handle);
}
changed
}
/// Render mode of the active paper-space viewport, or `None` when no
@@ -466,9 +636,6 @@ impl Scene {
return false;
}
if let Some(handle) = self.active_viewport {
if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) {
return false;
}
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
vp.view_target.x = camera.target.x;
vp.view_target.y = camera.target.y;
@@ -478,6 +645,10 @@ impl Scene {
vp.view_height = camera.ortho_size() as f64 * 2.;
vp.custom_scale = vp.height / vp.view_height;
}
// Notify associations only after all camera fields are persisted.
if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) {
return false;
}
} else {
*self.camera.borrow_mut() = camera;
}
@@ -563,6 +734,26 @@ impl Scene {
.map(|(h, _)| h)
}
/// Content (floating) viewports of the current paper layout, in draw order
/// (the last entry draws on top). Empty in the Model layout.
pub fn layout_content_viewports(&self) -> Arc<Vec<Handle>> {
if self.current_layout == "Model" {
return Arc::new(Vec::new());
}
let (_, _, handles) = self.paper_viewport_handles();
handles
}
/// `true` when this viewport's contents are displayed (VP ON). The border
/// layer's visibility deliberately does NOT matter: a viewport whose frame
/// layer is off still shows its model content, so it still snaps.
pub fn viewport_displays_content(&self, vp_handle: Handle) -> bool {
matches!(
self.document.get_entity(vp_handle),
Some(EntityType::Viewport(vp)) if vp.status.is_on
)
}
/// Return the handle of the first active user viewport in the current layout,
/// or `None` if there are none. Used by the MS command.
pub fn first_user_viewport(&self) -> Option<Handle> {
+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
+423
View File
@@ -0,0 +1,423 @@
//! Pick dimension geometry through a layout viewport. Resolve block instances
//! in model space, then project the picked entity onto the paper sheet.
use super::*;
use crate::command::EntityTransform;
use crate::scene::viewport_ref::ViewportFrame;
use acadrust::types::{Matrix4, Transform};
#[cfg(test)]
use glam::DVec2;
use glam::DVec3;
/// A model entity resolved by clicking inside a paper-space content viewport.
#[derive(Clone, Debug)]
pub struct ViewportDimensionPick {
/// The viewport the click looked through.
pub frame: ViewportFrame,
/// Handle of the innermost model entity that owns the picked geometry.
pub entity_handle: Handle,
/// INSERT handles from the outermost instance down to the instance that
/// directly contains `entity_handle`; empty for top-level model geometry.
pub block_path: Vec<Handle>,
/// The picked geometry with the block-instance transform *and* the
/// viewport model→paper transform applied, so paper-space dimension
/// commands can consume it unchanged.
pub paper_entity: EntityType,
/// The click position on the sheet.
pub paper_point: DVec3,
/// The click position in model space.
pub model_point: DVec3,
}
impl Scene {
/// Resolve an explicit dimension object pick made on the sheet but landing
/// inside a content viewport, against the *model* geometry that viewport
/// displays.
///
/// `aperture_paper` is the pick aperture in paper units.
pub fn dimension_pick_through_viewport(
&self,
paper: DVec3,
aperture_paper: f64,
) -> Option<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, model_point, aperture_model)
else {
continue;
};
let Some((entity, block_path)) =
self.resolve_measurable_entity(top, model_point, Some(frame.viewport))
else {
continue;
};
if !planar_pick_distance(&entity, model_point).is_some_and(|d| d <= aperture_model) {
continue;
}
let mut paper_entity = entity;
crate::scene::view::dispatch::apply_transform(
&mut paper_entity,
&EntityTransform::Affine(viewport_model_to_paper_transform(&frame)),
);
let entity_handle = paper_entity.as_entity().handle();
return Some(ViewportDimensionPick {
frame,
entity_handle: if entity_handle.is_valid() {
entity_handle
} else {
top
},
block_path,
paper_entity,
paper_point: paper,
model_point,
});
}
None
}
/// Nearest resident model wire of `viewport` to `model_point`, within
/// `aperture` model units. Wires are the same tessellation the viewport
/// draws, so this matches what the user sees.
fn nearest_model_wire_handle(
&self,
frame: &ViewportFrame,
model_point: DVec3,
aperture: f64,
) -> Option<Handle> {
let viewport = frame.viewport;
let camera = self.camera_for_viewport(viewport)?;
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: 1000.0,
height: 1000.0,
};
let radius_px =
(aperture / (2.0 * camera.ortho_size() as f64) * bounds.height as f64) as f32;
let wires = self.interaction_pick_candidates_near(
self.model_wires_for_viewport_arc(viewport, 0.0),
model_point,
camera.view_proj_rte(bounds),
camera.eye(),
bounds,
radius_px.max(1.0),
);
let mut best: Option<(f64, Handle)> = None;
for wire in wires.iter() {
if !wire.display_visible {
continue;
}
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
continue;
};
let offset = wire
.render_instance
.as_ref()
.map(|inst| DVec3::from_array(inst.translation))
.unwrap_or(DVec3::ZERO);
let distance = wire_polyline_nearest(wire, offset, model_point);
if let Some((distance, nearest)) = distance {
if !self.viewport_displays_paper_point(
viewport,
frame.model_to_paper(nearest).truncate(),
) {
continue;
}
if distance <= aperture && best.as_ref().is_none_or(|(d, _)| distance < *d) {
best = Some((distance, handle));
}
}
}
best.map(|(_, handle)| handle)
}
/// Turn a picked handle into a measurable planar entity in model (WCS)
/// coordinates, descending through block instances and baking the
/// instance transform into the returned clone.
pub(crate) fn resolve_measurable_entity(
&self,
handle: Handle,
model_point: DVec3,
viewport: Option<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>)> {
let entity = self.document.get_entity(handle)?;
match entity {
EntityType::Insert(insert) => self.descend_block_instance(
insert,
Transform::identity(),
model_point,
0,
viewport,
kind,
),
other => {
feature_pick_distance(other, model_point, kind)?;
Some((other.clone(), Vec::new()))
}
}
}
/// Depth-first search for the nearest measurable entity inside an INSERT.
/// Returns the entity already transformed into WCS plus the INSERT path.
fn descend_block_instance(
&self,
insert: &acadrust::entities::Insert,
outer: Transform,
model_point: DVec3,
depth: usize,
viewport: Option<Handle>,
kind: Option<crate::snap::SnapType>,
) -> Option<(EntityType, Vec<Handle>)> {
const MAX_DEPTH: usize = 8;
if depth >= MAX_DEPTH {
return None;
}
if insert.row_count > 1 || insert.column_count > 1 {
return None;
}
let insert_handle = insert.common.handle;
let local = crate::scene::render_graph::insert_transform(&self.document, insert);
let combined = local.then(&outer);
let block = self.document.block_records.get(&insert.block_name)?;
let mut best: Option<(f64, EntityType, Vec<Handle>)> = None;
for &child in &block.entity_handles {
let Some(child_entity) = self.document.get_entity(child) else {
continue;
};
let common = child_entity.common();
let layer = self.document.layers.get(&common.layer);
let frozen = viewport.and_then(|h| self.document.get_entity(h)).is_some_and(|entity| {
matches!(entity, EntityType::Viewport(vp) if layer.is_some_and(|l| vp.frozen_layers.contains(&l.handle)))
});
if common.invisible || layer.is_some_and(|l| l.flags.off || l.flags.frozen) || frozen {
continue;
}
if let EntityType::Insert(child_insert) = child_entity {
if let Some((entity, mut nested_path)) = self.descend_block_instance(
child_insert,
combined,
model_point,
depth + 1,
viewport,
kind,
) {
if let Some(distance) = feature_pick_distance(&entity, model_point, kind) {
if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
let mut full = vec![insert_handle];
full.append(&mut nested_path);
best = Some((distance, entity, full));
}
}
}
continue;
}
// A nonuniform block transform turns circular geometry into an
// ellipse. The entity transform API retains the circle type, so
// decline this object pick instead of manufacturing a radius.
let curved = matches!(child_entity, EntityType::Circle(_) | EntityType::Arc(_))
|| matches!(child_entity, EntityType::LwPolyline(p) if p.vertices.iter().any(|v| v.bulge != 0.0));
let m = &combined.matrix.m;
let x = DVec3::new(m[0][0], m[1][0], m[2][0]);
let y = DVec3::new(m[0][1], m[1][1], m[2][1]);
let magnitude = x.length_squared().max(y.length_squared());
if curved
&& kind != Some(crate::snap::SnapType::Center)
&& (magnitude < 1e-24
|| (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-10
|| x.dot(y).abs() > magnitude * 1e-10)
{
continue;
}
let mut placed = child_entity.clone();
crate::scene::view::dispatch::apply_transform(
&mut placed,
&EntityTransform::Affine(combined),
);
let Some(distance) = feature_pick_distance(&placed, model_point, kind) else {
continue;
};
if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
best = Some((distance, placed, vec![insert_handle]));
}
}
let (_, entity, found_path) = best?;
Some((entity, found_path))
}
}
/// Model→paper for a viewport, as an affine entity transform. Z passes
/// through unchanged so planar geometry keeps its elevation.
pub fn viewport_model_to_paper_transform(frame: &ViewportFrame) -> Transform {
let (sin, cos) = frame.twist.sin_cos();
let s = frame.scale;
let a = cos * s;
let b = sin * s;
// t = paper_center - R*s*model_target
let tx = frame.paper_center.x - (a * frame.model_target.x - b * frame.model_target.y);
let ty = frame.paper_center.y - (b * frame.model_target.x + a * frame.model_target.y);
Transform::from_matrix(Matrix4 {
m: [
[a, -b, 0.0, tx],
[b, a, 0.0, ty],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
})
}
/// Distance from `point` to a wire's polyline, in XY. `None` when the wire has
/// no usable segment.
fn wire_polyline_nearest(
wire: &crate::scene::WireModel,
offset: DVec3,
point: DVec3,
) -> Option<(f64, DVec3)> {
let count = wire.points.len();
if count == 0 {
return None;
}
let at = |i: usize| -> DVec3 {
let hi = wire.points[i];
let lo = wire.points_low.get(i).copied().unwrap_or([0.0; 3]);
DVec3::new(
hi[0] as f64 + lo[0] as f64,
hi[1] as f64 + lo[1] as f64,
hi[2] as f64 + lo[2] as f64,
) + offset
};
let mut best = f64::INFINITY;
let mut best_point = DVec3::ZERO;
let mut previous: Option<DVec3> = None;
for i in 0..count {
let current = at(i);
if !current.x.is_finite() || !current.y.is_finite() {
previous = None;
continue;
}
let nearest = previous.map_or(current, |start| {
let segment = cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line {
start: start.truncate().to_array(),
end: current.truncate().to_array(),
});
let near = cadkernel::geom2d::closest_point(&segment, point.truncate().to_array());
start.lerp(current, near.t)
});
let distance = (nearest - point).truncate().length();
if distance < best {
best = distance;
best_point = nearest;
}
previous = Some(current);
}
best.is_finite().then_some((best, best_point))
}
/// Distance from `point` to the planar geometry object-pick dimensioning
/// supports. `None` for entity types that cannot be dimensioned by picking.
pub fn planar_pick_distance(entity: &EntityType, point: DVec3) -> Option<f64> {
let planar = crate::entities::curve::entity_curve(entity)?;
let normal = DVec3::from_array(planar.plane.normal()?);
if normal.cross(DVec3::Z).length_squared() > 1e-12 {
return None;
}
let uv = planar.plane.project(point.to_array())?;
let nearest = cadkernel::geom2d::closest_point(&planar.curve, uv);
Some(
(DVec3::from_array(planar.plane.point_at(nearest.point)) - point)
.truncate()
.length(),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn frame() -> ViewportFrame {
ViewportFrame {
viewport: Handle::NULL,
paper_center: DVec2::new(100.0, 50.0),
model_target: DVec2::new(1000.0, 2000.0),
scale: 0.1,
twist: 0.4,
locked: false,
}
}
#[test]
fn affine_transform_matches_the_frame_mapping() {
let f = frame();
let t = viewport_model_to_paper_transform(&f);
for model in [
DVec3::new(1000.0, 2000.0, 0.0),
DVec3::new(1100.0, 2000.0, 0.0),
DVec3::new(940.5, 2113.25, 0.0),
] {
let expected = f.model_to_paper(model);
let actual = t.apply(acadrust::types::Vector3::new(model.x, model.y, model.z));
assert!(
(actual.x - expected.x).abs() < 1e-9 && (actual.y - expected.y).abs() < 1e-9,
"{actual:?} != {expected:?}"
);
}
}
}
/// Distance to the particular feature accepted by a snap, rather than to the
/// nearest curve. A circle's center is deliberately not on its circumference.
pub(crate) fn feature_pick_distance(
entity: &EntityType,
point: DVec3,
kind: Option<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)
}
+303
View File
@@ -0,0 +1,303 @@
//! Coordinates and feature identity shared by viewport acquisition and dimensions.
use acadrust::types::Handle;
use glam::{DMat3, DVec2, DVec3};
use crate::command::DimensionAssociationSource;
use crate::snap::SnapType;
/// Planar orthographic mapping between a layout viewport's paper rectangle and
/// the model plane it displays. Handles pan (`model_target` / `paper_center`),
/// scale (paper units per model unit) and in-plane rotation (`twist`,
/// radians, counter-clockwise, applied to model geometry before scaling).
///
/// Constructed by `Scene::viewport_frame`. All arithmetic is `f64`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewportFrame {
/// Viewport entity handle.
pub viewport: Handle,
/// Paper-space centre of the viewport rectangle.
pub paper_center: DVec2,
/// Model-space point displayed at `paper_center`.
pub model_target: DVec2,
/// Paper units per model unit (e.g. 0.1 for a 1:10 viewport).
pub scale: f64,
/// In-plane rotation (DXF `twist_angle`), radians.
pub twist: f64,
/// `true` when the viewport is display-locked (snapping still allowed).
pub locked: bool,
}
impl ViewportFrame {
/// Model -> paper, as a 2-D affine matrix (homogeneous, column vectors).
pub fn model_to_paper_matrix(&self) -> DMat3 {
let (s, c) = self.twist.sin_cos();
let rs = DMat3::from_cols(
DVec3::new(c * self.scale, s * self.scale, 0.0),
DVec3::new(-s * self.scale, c * self.scale, 0.0),
DVec3::new(0.0, 0.0, 1.0),
);
let t_model = DMat3::from_translation(-self.model_target);
let t_paper = DMat3::from_translation(self.paper_center);
t_paper * rs * t_model
}
/// Paper -> model (inverse of [`Self::model_to_paper_matrix`]).
pub fn paper_to_model_matrix(&self) -> DMat3 {
self.model_to_paper_matrix().inverse()
}
/// Project a model-space point onto the paper sheet. `z` passes through.
pub fn model_to_paper(&self, p: DVec3) -> DVec3 {
let q = self.model_to_paper_matrix().transform_point2(p.truncate());
DVec3::new(q.x, q.y, p.z)
}
/// Lift a paper-sheet point into model space. `z` passes through.
pub fn paper_to_model(&self, p: DVec3) -> DVec3 {
let q = self.paper_to_model_matrix().transform_point2(p.truncate());
DVec3::new(q.x, q.y, p.z)
}
/// Model -> paper for a direction (rotation + scale, no translation).
pub fn model_to_paper_dir(&self, d: DVec2) -> DVec2 {
self.model_to_paper_matrix().transform_vector2(d)
}
/// Factor a paper-space length must be multiplied by to recover the model
/// length it displays (`1 / scale`). This is the *viewport compensation*
/// used by [`MeasurementScale`].
pub fn paper_to_model_length_factor(&self) -> f64 {
1.0 / self.scale
}
}
/// Identity of the geometry a snap landed on, in model space, including the
/// block-instance path when the entity lives inside a block reference.
///
/// `source.handle` is the innermost entity (the one owning the feature).
/// `block_path` is the ordered list of INSERT handles from the outermost
/// instance in the space down to the instance that directly contains the
/// entity; empty for top-level geometry.
#[derive(Clone, Debug, PartialEq)]
pub struct SnapSourceRef {
pub source: DimensionAssociationSource,
pub block_path: Vec<Handle>,
pub snap_type: SnapType,
/// The other entity/instance path for a true intersection.
pub intersection: Option<Box<SnapSourceRef>>,
}
/// A snap accepted by a point-input command, with enough information for
/// dimension commands to distinguish *placement* (paper) from *measurement*
/// (model) and for association creation to record where it came from.
///
/// Invariant: when `viewport` is `Some`, `paper_point` is the point on the
/// sheet and `model_point` is that same point in model space through the
/// viewport frame. When `viewport` is `None` the snap was on paper-space (or
/// model-space) geometry directly and `paper_point == model_point`.
#[derive(Clone, Debug, PartialEq)]
pub struct AcceptedSnap {
/// Point in the coordinate system of the current space (paper when a
/// layout is active).
pub paper_point: DVec3,
/// Point in model space (identical to `paper_point` when `viewport` is
/// `None`).
pub model_point: DVec3,
/// The viewport the snap looked through, if any.
pub viewport: Option<Handle>,
/// The frame in effect when the snap was accepted (so a later measurement
/// uses the same scale even if the viewport is changed afterwards).
pub frame: Option<ViewportFrame>,
/// Geometry identity, when the snap landed on an entity feature (not a
/// grid/free point).
pub source: Option<SnapSourceRef>,
}
impl AcceptedSnap {
/// A plain point in the current space with no viewport and no source.
pub fn free(point: DVec3) -> Self {
Self {
paper_point: point,
model_point: point,
viewport: None,
frame: None,
source: None,
}
}
pub fn through_viewport(&self) -> bool {
self.viewport.is_some()
}
/// `snap.world` is always in the command's current space. A projected
/// viewport hit also retains its original model coordinate explicitly.
pub fn from_snap(snap: &crate::snap::SnapResult, frame: Option<ViewportFrame>) -> Self {
let frame = frame.filter(|frame| snap.viewport == Some(frame.viewport));
let source_ref = |source| SnapSourceRef {
source,
block_path: Vec::new(),
snap_type: snap.snap_type,
intersection: None,
};
Self {
paper_point: snap.world,
model_point: frame.map_or(snap.world, |frame| {
snap.model_point
.unwrap_or_else(|| frame.paper_to_model(snap.world))
}),
viewport: frame.map(|f| f.viewport),
frame,
source: snap.source.map(|source| SnapSourceRef {
intersection: snap
.secondary_source
.map(|source| Box::new(source_ref(source))),
..source_ref(source)
}),
}
}
/// The paper point with the viewport context of `self`, but no geometry
/// identity — used when a command constrains a snapped point (ortho/polar,
/// axis lock) so the recorded point matches what was committed.
pub fn with_paper_point(mut self, paper_point: DVec3) -> Self {
// Compare in the sheet plane only: paper space is flat and callers
// clamp `z` to 0, which must not be mistaken for the point having been
// moved off the snapped feature (and must not discard the snapped
// model point's real elevation).
let moved = self
.paper_point
.truncate()
.distance_squared(paper_point.truncate())
> 1e-18;
if moved {
// The committed point is no longer the feature that was snapped.
self.source = None;
self.model_point = match &self.frame {
Some(frame) => frame.paper_to_model(paper_point),
None => paper_point,
};
}
self.paper_point = paper_point;
self
}
}
/// Length dimensions store raw distances in their owning space. The displayed
/// value uses `user_lfac * viewport_compensation`; angular dimensions are exempt.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeasurementScale {
pub user_lfac: f64,
pub viewport_compensation: f64,
}
impl MeasurementScale {
/// OCS bookkeeping keeps user scaling separate from the changing viewport
/// scale. Standard DIMLFAC still carries the full factor for other readers.
pub const APP_ID: &'static str = "OCS_VIEWPORT_MEASUREMENT";
pub fn read(data: &acadrust::xdata::ExtendedData) -> Option<Self> {
use acadrust::xdata::XDataValue;
let record = data.get_record(Self::APP_ID)?;
let [XDataValue::Integer16(1), XDataValue::Real(user), XDataValue::Real(compensation)] =
record.values.as_slice()
else {
return None;
};
if !user.is_finite() || *user <= 0.0 || !compensation.is_finite() || *compensation <= 0.0 {
return None;
}
Some(Self {
user_lfac: *user,
viewport_compensation: *compensation,
})
}
pub fn write_to_entity(self, entity: &mut acadrust::EntityType) {
use acadrust::xdata::{ExtendedData, ExtendedDataRecord, XDataValue};
crate::entities::dim_override::set_on_entity(
entity,
crate::entities::dim_override::DIMLFAC,
Some(XDataValue::Real(-self.paper_factor())),
);
let common = entity.common_mut();
let mut data = ExtendedData::new();
for record in common
.extended_data
.records()
.iter()
.filter(|r| r.application_name != Self::APP_ID)
{
data.add_record(record.clone());
}
let mut record = ExtendedDataRecord::new(Self::APP_ID);
record.add_value(XDataValue::Integer16(1));
record.add_value(XDataValue::Real(self.user_lfac));
record.add_value(XDataValue::Real(self.viewport_compensation));
data.add_record(record);
// Parsed records are authoritative after changing measurement data.
common.extended_data = data;
}
/// Combined factor applied to a distance measured on the paper sheet.
pub fn paper_factor(&self) -> f64 {
self.user_lfac * self.viewport_compensation
}
/// Positive DIMLFAC applies everywhere; negative DIMLFAC applies only on
/// paper. Zero means one. Saved viewport compensation is already in DIMLFAC.
pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 {
if dimlfac == 0.0 {
1.0
} else if dimlfac > 0.0 {
dimlfac
} else if paper_space {
-dimlfac
} else {
1.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(scale: f64, twist: f64) -> ViewportFrame {
ViewportFrame {
viewport: Handle::NULL,
paper_center: DVec2::new(100.0, 50.0),
model_target: DVec2::new(1000.0, 2000.0),
scale,
twist,
locked: false,
}
}
#[test]
fn round_trips_with_scale_and_twist() {
let f = frame(0.1, 0.3);
let m = DVec3::new(1234.5, 1987.25, 0.0);
let p = f.model_to_paper(m);
let back = f.paper_to_model(p);
assert!((back - m).length() < 1e-9);
}
#[test]
fn scale_maps_lengths() {
let f = frame(0.1, 0.0);
let a = f.model_to_paper(DVec3::new(0.0, 0.0, 0.0));
let b = f.model_to_paper(DVec3::new(100.0, 0.0, 0.0));
assert!(((b - a).length() - 10.0).abs() < 1e-9);
assert!((f.paper_to_model_length_factor() - 10.0).abs() < 1e-12);
assert_eq!(frame(1e-14, 0.0).paper_to_model_length_factor(), 1e14);
}
#[test]
fn dimlfac_sign_depends_on_dimension_space() {
for (factor, paper, model) in [(-2.0, 2.0, 1.0), (0.0, 1.0, 1.0), (3.5, 3.5, 3.5)] {
assert_eq!(MeasurementScale::user_lfac_for_space(factor, true), paper);
assert_eq!(MeasurementScale::user_lfac_for_space(factor, false), model);
}
}
}