Fix viewport dimension acquisition and preserve measurement scaling

This commit is contained in:
Christopher Overbeck committed 2026-09-15 14:13:19 -05:00
1 parent 369e0c9fc5
commit 2ee3782850
18 files changed
+957 -577

No files matched your search

+14 -3
View File
@@ -160,6 +160,18 @@ 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()
}
/// Does `paper` lie inside the viewport's displayed area?
///
/// Always bounded by the viewport's paper rectangle; a viewport with a
@@ -184,9 +196,8 @@ impl Scene {
}
let poly = self.clip_boundary_polygon(vp.clip_boundary_handle, vp.center.z as f32);
if poly.len() < 3 {
// A clip entity we cannot tessellate: fall back to the rectangle
// rather than refusing every snap in the viewport.
return true;
// An unsupported boundary cannot establish snap eligibility.
return false;
}
point_in_polygon_xy(paper, &poly)
}
+128 -114
View File
@@ -21,7 +21,9 @@ use super::*;
use crate::command::EntityTransform;
use crate::scene::viewport_ref::ViewportFrame;
use acadrust::types::{Matrix4, Transform};
use glam::{DVec2, DVec3};
#[cfg(test)]
use glam::DVec2;
use glam::DVec3;
/// A model entity resolved by clicking inside a paper-space content viewport.
#[derive(Clone, Debug)]
@@ -44,36 +46,16 @@ pub struct ViewportDimPick {
}
impl Scene {
/// The smallest *on* content viewport of the current layout whose paper
/// rectangle contains `paper`. `None` on the Model tab, while editing
/// inside a viewport (MSPACE), or when the point is on bare sheet.
pub fn content_viewport_at_paper_point(&self, paper: DVec3) -> Option<Handle> {
if self.current_layout == "Model" || self.active_viewport.is_some() {
return None;
}
let (_, _, handles) = self.paper_viewport_handles();
handles
.iter()
.filter_map(|handle| {
let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else {
return None;
};
if !vp.status.is_on || vp.width <= 0.0 || vp.height <= 0.0 {
return None;
}
let dx = (paper.x - vp.center.x).abs();
let dy = (paper.y - vp.center.y).abs();
(dx <= vp.width * 0.5 && dy <= vp.height * 0.5)
.then(|| (*handle, vp.width * vp.height))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(handle, _)| handle)
self.viewport_frames_at_paper_point(paper)
.first()
.map(|f| f.viewport)
}
/// [`Scene::content_viewport_at_paper_point`] resolved to a frame.
pub fn viewport_frame_at_paper_point(&self, paper: DVec3) -> Option<ViewportFrame> {
self.viewport_frame(self.content_viewport_at_paper_point(paper)?)
self.viewport_frames_at_paper_point(paper)
.into_iter()
.next()
}
/// Resolve an explicit dimension object pick made on the sheet but landing
@@ -86,28 +68,42 @@ impl Scene {
paper: DVec3,
aperture_paper: f64,
) -> Option<ViewportDimPick> {
let frame = self.viewport_frame_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);
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 top = self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model)?;
let (entity, block_path) = self.resolve_measurable_entity(top, model_point)?;
let Some(top) =
self.nearest_model_wire_handle(frame.viewport, model_point, aperture_model)
else {
continue;
};
let Some((entity, block_path)) =
self.resolve_measurable_entity(top, model_point, Some(frame.viewport))
else {
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();
Some(ViewportDimPick {
frame,
entity_handle: if entity_handle.is_valid() { entity_handle } else { top },
block_path,
paper_entity,
paper_point: paper,
model_point,
})
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(ViewportDimPick {
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
@@ -119,7 +115,23 @@ impl Scene {
model_point: DVec3,
aperture: f64,
) -> Option<Handle> {
let wires = self.model_wires_for_viewport_arc(viewport, 0.0);
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 {
@@ -133,8 +145,17 @@ impl Scene {
.as_ref()
.map(|inst| DVec3::from_array(inst.translation))
.unwrap_or(DVec3::ZERO);
let distance = wire_polyline_distance(wire, offset, model_point);
if let Some(distance) = distance {
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(),
) {
continue;
}
if distance <= aperture && best.as_ref().is_none_or(|(d, _)| distance < *d) {
best = Some((distance, handle));
}
@@ -146,10 +167,11 @@ impl Scene {
/// 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.
fn resolve_measurable_entity(
pub(crate) fn resolve_measurable_entity(
&self,
handle: Handle,
model_point: DVec3,
viewport: Option<Handle>,
) -> Option<(EntityType, Vec<Handle>)> {
let entity = self.document.get_entity(handle)?;
match entity {
@@ -161,6 +183,7 @@ impl Scene {
model_point,
&mut path,
0,
viewport,
)?;
Some((found.0, found.1))
}
@@ -180,6 +203,7 @@ impl Scene {
model_point: DVec3,
path: &mut Vec<Handle>,
depth: usize,
viewport: Option<Handle>,
) -> Option<(EntityType, Vec<Handle>)> {
const MAX_DEPTH: usize = 8;
if depth > MAX_DEPTH {
@@ -199,6 +223,14 @@ impl Scene {
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 matches!(child_entity, EntityType::Insert(_)) {
let mut nested = Vec::new();
if let Some((entity, mut nested_path)) = self.descend_block_instance(
@@ -207,6 +239,7 @@ impl Scene {
model_point,
&mut nested,
depth + 1,
viewport,
) {
if let Some(distance) = planar_pick_distance(&entity, model_point) {
if best.as_ref().is_none_or(|(d, _, _)| distance < *d) {
@@ -218,6 +251,22 @@ impl Scene {
}
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
&& (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,
@@ -259,11 +308,11 @@ pub fn viewport_model_to_paper_transform(frame: &ViewportFrame) -> Transform {
/// Distance from `point` to a wire's polyline, in XY. `None` when the wire has
/// no usable segment.
fn wire_polyline_distance(
fn wire_polyline_nearest(
wire: &crate::scene::WireModel,
offset: DVec3,
point: DVec3,
) -> Option<f64> {
) -> Option<(f64, DVec3)> {
let count = wire.points.len();
if count == 0 {
return None;
@@ -278,6 +327,7 @@ fn wire_polyline_distance(
) + 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);
@@ -285,77 +335,41 @@ fn wire_polyline_distance(
previous = None;
continue;
}
match previous {
Some(start) => best = best.min(segment_distance_xy(start, current, point)),
None => best = best.min((current.truncate() - point.truncate()).length()),
let nearest = previous.map_or(current, |start| {
let delta = current - start;
let len2 = delta.truncate().length_squared();
let t = if len2 > 1e-24 {
((point - start).truncate().dot(delta.truncate()) / len2).clamp(0.0, 1.0)
} else {
0.0
};
start + delta * t
});
let distance = (nearest - point).truncate().length();
if distance < best {
best = distance;
best_point = nearest;
}
previous = Some(current);
}
best.is_finite().then_some(best)
}
fn segment_distance_xy(a: DVec3, b: DVec3, p: DVec3) -> f64 {
let ab = b.truncate() - a.truncate();
let ap = p.truncate() - a.truncate();
let len2 = ab.length_squared();
if len2 < 1e-24 {
return ap.length();
}
let t = (ap.dot(ab) / len2).clamp(0.0, 1.0);
(ap - ab * t).length()
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 p = point.truncate();
match entity {
EntityType::Line(line) => Some(segment_distance_xy(
DVec3::new(line.start.x, line.start.y, line.start.z),
DVec3::new(line.end.x, line.end.y, line.end.z),
point,
)),
EntityType::Circle(circle) => {
let c = DVec2::new(circle.center.x, circle.center.y);
Some(((p - c).length() - circle.radius).abs())
}
EntityType::Arc(arc) => {
let c = DVec2::new(arc.center.x, arc.center.y);
Some(((p - c).length() - arc.radius).abs())
}
EntityType::LwPolyline(polyline) => polyline_distance(
polyline
.vertices
.iter()
.map(|v| DVec3::new(v.location.x, v.location.y, polyline.elevation)),
polyline.is_closed,
point,
),
EntityType::Polyline2D(polyline) => polyline_distance(
polyline
.vertices
.iter()
.map(|v| DVec3::new(v.location.x, v.location.y, polyline.elevation)),
polyline.is_closed(),
point,
),
_ => None,
}
}
fn polyline_distance(
points: impl IntoIterator<Item = DVec3>,
closed: bool,
point: DVec3,
) -> Option<f64> {
let points: Vec<DVec3> = points.into_iter().collect();
if points.len() < 2 {
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 count = if closed { points.len() } else { points.len() - 1 };
(0..count)
.map(|i| segment_distance_xy(points[i], points[(i + 1) % points.len()], point))
.reduce(f64::min)
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)]
+69 -104
View File
@@ -1,20 +1,4 @@
//! Shared interfaces for paper-space snapping through layout viewports and
//! for viewport-aware dimension measurement.
//!
//! Three PRs build on this module concurrently:
//! * PR1 (viewport snap) constructs [`ViewportFrame`] from the live viewport
//! camera and fills [`SnapSourceRef`] / [`AcceptedSnap`] when a snap is
//! accepted in paper space.
//! * PR2 (dimension measurement) consumes [`AcceptedSnap`] and uses
//! [`MeasurementScale`] to split the user's DIMLFAC from viewport
//! compensation so the compensation is applied exactly once.
//! * PR3 (associations) turns [`SnapSourceRef`] into persistent
//! `AssocDimensionReference` chains (dimension -> viewport -> block path ->
//! entity -> feature) and resolves them back.
//!
//! Anything here is a contract between those branches. Extend by adding
//! fields/methods; do not rename or remove what exists without updating the
//! sibling branches.
//! Coordinates and feature identity shared by viewport acquisition and dimensions.
use acadrust::types::Handle;
use glam::{DMat3, DVec2, DVec3};
@@ -27,8 +11,7 @@ use crate::snap::SnapType;
/// scale (paper units per model unit) and in-plane rotation (`twist`,
/// radians, counter-clockwise, applied to model geometry before scaling).
///
/// PR1 owns construction (`Scene::viewport_frame`); everyone else only reads
/// it. All arithmetic is `f64`.
/// Constructed by `Scene::viewport_frame`. All arithmetic is `f64`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewportFrame {
/// Viewport entity handle.
@@ -105,6 +88,8 @@ 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
@@ -149,31 +134,29 @@ impl AcceptedSnap {
self.viewport.is_some()
}
/// Build an accepted snap from a [`crate::snap::SnapResult`].
///
/// * `frame` `Some`: the snap ran against MODEL geometry through that
/// viewport, so `snap.world` is a model point and the paper point is its
/// projection onto the sheet.
/// * `frame` `None`: the snap ran in the current space directly, so paper
/// and model point coincide.
///
/// PR1 fills `source` from `SnapResult::source` (the owning entity handle
/// the snap engine attributed the feature to); `block_path` stays empty
/// until PR3 resolves block instance chains.
/// `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 (paper_point, model_point) = match &frame {
Some(frame) => (frame.model_to_paper(snap.world), snap.world),
None => (snap.world, snap.world),
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,
model_point,
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 {
source,
block_path: Vec::new(),
snap_type: snap.snap_type,
intersection: snap
.secondary_source
.map(|source| Box::new(source_ref(source))),
..source_ref(source)
}),
}
}
@@ -222,6 +205,53 @@ pub struct MeasurementScale {
}
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;
}
pub const IDENTITY: Self = Self {
user_lfac: 1.0,
viewport_compensation: 1.0,
@@ -272,8 +302,7 @@ impl MeasurementScale {
///
/// 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 (see
/// [`MeasurementScale::viewport_dimlfac_override`]). This is the same rule
/// into the persisted magnitude. This is the same rule
/// as [`MeasurementScale::from_dimlfac`] with `viewport.is_some()`
/// replaced by `paper_space`.
pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 {
@@ -287,45 +316,6 @@ impl MeasurementScale {
1.0
}
}
/// The persisted representation of viewport compensation, PR2's single
/// source of truth.
///
/// A paper-space dimension that measures model geometry through a viewport
/// keeps its definition points in **paper** coordinates and carries a
/// **negative DIMLFAC override** whose magnitude is
/// `user_lfac * viewport_compensation`. Because a viewport frame is a
/// similarity (uniform scale + in-plane twist), the raw paper measurement
/// times that magnitude is exactly the model measurement times the user's
/// linear factor — so the compensation is applied exactly once, at
/// formatting time, and round-trips through DXF/DWG unchanged (AutoCAD
/// applies |DIMLFAC| to paper-space dimensions by the same rule).
///
/// Returns `None` when no override is needed:
/// * the compensation is 1:1, or
/// * the drawing/style DIMLFAC is already negative with exactly this
/// magnitude (the drawing-wide DIMLFAC is set for this purpose).
///
/// `style_dimlfac` is the effective DIMLFAC the dimension would inherit
/// *without* an override.
pub fn viewport_dimlfac_override(style_dimlfac: f64, compensation: f64) -> Option<f64> {
if !compensation.is_finite() || compensation.abs() < 1e-12 {
return None;
}
if (compensation - 1.0).abs() < 1e-12 {
return None;
}
if style_dimlfac < 0.0
&& ((-style_dimlfac) - compensation).abs() <= 1e-9 * compensation.abs().max(1.0)
{
return None;
}
// A positive style DIMLFAC is the user's own multiplier and must be
// preserved; a negative one of a *different* magnitude was meant for
// another viewport scale, so the user multiplier there is 1.0.
let user = if style_dimlfac > 0.0 { style_dimlfac } else { 1.0 };
Some(-(user * compensation))
}
}
#[cfg(test)]
@@ -392,29 +382,4 @@ mod tests {
);
}
}
#[test]
fn override_encodes_compensation_once() {
// 1:10 viewport, no user factor -> -10, and a 10-unit paper span then
// formats as 100.
let ovr = MeasurementScale::viewport_dimlfac_override(1.0, 10.0).unwrap();
assert!((ovr + 10.0).abs() < 1e-12);
let lfac = MeasurementScale::user_lfac_for_space(ovr, true);
assert!((10.0 * lfac - 100.0).abs() < 1e-9);
// Same dimension read back in model space must not scale.
assert_eq!(MeasurementScale::user_lfac_for_space(ovr, false), 1.0);
}
#[test]
fn override_preserves_user_multiplier_and_skips_redundant_cases() {
assert_eq!(MeasurementScale::viewport_dimlfac_override(1.0, 1.0), None);
// Drawing-wide negative DIMLFAC already carries this compensation.
assert_eq!(MeasurementScale::viewport_dimlfac_override(-10.0, 10.0), None);
// Positive user factor is folded in.
let ovr = MeasurementScale::viewport_dimlfac_override(25.4, 10.0).unwrap();
assert!((ovr + 254.0).abs() < 1e-9);
// Negative but for a different scale -> user multiplier is 1.0.
let ovr = MeasurementScale::viewport_dimlfac_override(-2.0, 10.0).unwrap();
assert!((ovr + 10.0).abs() < 1e-12);
}
}