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

Generated
+2 -2
View File
@@ -921,7 +921,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7ce27f2#7ce27f2980da88734dff922c169eb2b9cd5dc404"
dependencies = [
"acadrust",
"cavalier_contours",
@@ -932,7 +932,7 @@ dependencies = [
[[package]]
name = "cadkernel-constraints"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=7ce27f2#7ce27f2980da88734dff922c169eb2b9cd5dc404"
dependencies = [
"nalgebra",
]
+2 -2
View File
@@ -34,8 +34,8 @@ clap = { version = "4", features = ["derive", "string"] }
env_logger = "0.11"
log = "0.4"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] }
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2", features = ["acis", "offset"] }
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2" }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="350" viewBox="0 0 900 350">
<style>text{font-family:Arial,sans-serif;fill:#263238} .title{font-size:22px;font-weight:600} .label{font-size:16px} .small{font-size:13px;fill:#54616a} .model{stroke:#1479b8;stroke-width:4;fill:none} .paper{stroke:#a94610;stroke-width:2;stroke-dasharray:9 5} .dim{stroke:#263238;stroke-width:1.5;fill:none}</style>
<defs><marker id="arrow" viewBox="0 0 8 8" refX="4" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M8 0L0 4L8 8" fill="#263238"/></marker></defs>
<rect width="900" height="350" rx="10" fill="white"/>
<text class="title" x="30" y="38">The dimension follows its paper-space centerline</text>
<rect x="30" y="65" width="400" height="220" fill="#f5f8fa" stroke="#c3ced5"/>
<rect x="470" y="65" width="400" height="220" fill="#f5f8fa" stroke="#c3ced5"/>
<text class="label" x="48" y="93">Before</text><text class="label" x="488" y="93">Centerline moved 1 paper unit right</text>
<path class="model" d="M85 160H385M525 160H825"/>
<circle cx="385" cy="160" r="5" fill="#1479b8"/><circle cx="825" cy="160" r="5" fill="#1479b8"/>
<path class="paper" d="M235 113V257M705 113V257"/>
<path class="dim" d="M385 171V244M825 171V244"/>
<path class="dim" d="M235 232H385" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<path class="dim" d="M705 232H825" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<rect x="291" y="217" width="38" height="28" fill="#f5f8fa"/><rect x="746" y="217" width="38" height="28" fill="#f5f8fa"/>
<text class="label" x="310" y="237" text-anchor="middle">50</text><text class="label" x="765" y="237" text-anchor="middle">40</text>
<text class="label" x="30" y="316">The model endpoint stays fixed. The dimension updates from 50 to 40.</text>
<text class="small" x="30" y="340">Association schematic; viewport 1:10, dimension style unit factor = 1, DIMASSOC = 2.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="350" viewBox="0 0 900 350">
<style>text{font-family:Arial,sans-serif;fill:#263238} .title{font-size:22px;font-weight:600} .label{font-size:16px} .small{font-size:13px;fill:#54616a} .model{stroke:#1479b8;stroke-width:4;fill:none} .paper{stroke:#a94610;stroke-width:2;stroke-dasharray:9 5} .dim{stroke:#263238;stroke-width:1.5;fill:none}</style>
<defs><marker id="arrow" viewBox="0 0 8 8" refX="4" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M8 0L0 4L8 8" fill="#263238"/></marker></defs>
<rect width="900" height="350" rx="10" fill="white"/>
<text class="title" x="30" y="38">Dimension from a paper centerline to a model endpoint</text>
<rect x="30" y="65" width="430" height="245" fill="#f5f8fa" stroke="#c3ced5"/>
<text class="small" x="45" y="89">Viewport 1:10</text>
<path class="model" d="M100 160H400"/>
<circle cx="400" cy="160" r="5" fill="#1479b8"/>
<path class="paper" d="M250 110V260"/>
<text class="small" x="100" y="144">Model geometry</text>
<path class="dim" d="M400 171V244"/>
<path class="dim" d="M250 232H400" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<rect x="306" y="217" width="38" height="28" fill="#f5f8fa"/>
<text class="label" x="325" y="237" text-anchor="middle">50</text>
<text class="small" x="45" y="290">Brown dashed line: paper-space centerline</text>
<text class="label" x="500" y="116">5 paper units between the picked points</text>
<text class="label" x="500" y="155">5 × 10 = 50 model units</text>
<text class="label" x="500" y="206">The viewport supplies the length scale.</text>
<text class="label" x="500" y="240">Either pick order gives the same value.</text>
<text class="small" x="30" y="336">Measurement schematic; dimension style unit factor = 1.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+74
View File
@@ -0,0 +1,74 @@
# Dimensions through layout viewports
In paper space, snap to model geometry displayed through a planar, orthographic
viewport. `DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER`
also support their usual object-pick modes through these viewports.
## Measurement
- Model geometry supplies the viewport scale. A 100-unit model length displayed
at 1:10 measures 100, although it occupies 10 paper units.
- A paper-space centerline or free sheet point can be combined with model
geometry through one viewport, in either pick order. The paper-space span is
measured using that viewport's scale.
- Sheet-only dimensions measure paper units, even inside a viewport rectangle.
- Model picks from a second viewport are rejected without advancing the command.
Dimension-line and text placement do not change the measurement scale.
- The current dimension style's unit conversion is preserved. Angular dimensions
do not use length compensation. `DIMASSOC=0` explodes the compensated dimension.
Snapping and object picking respect visibility, clipping, and viewport draw order.
Paper geometry has priority in object picks. Ordinary selection uses the active
drawing space. Object picks reuse the resident interaction index and resolve
nested block transforms.
![Measurement schematic: a five-unit sheet span measures 50 through a 1:10 viewport.](viewport-dimension-creation.svg)
## Saved scale
Length dimensions store paper-space definition points and a negative `DIMLFAC`
override: user unit factor multiplied by `1 / viewport scale`.
`OCS_VIEWPORT_MEASUREMENT` XData stores version `1` (integer16), the positive user
factor (real), and viewport compensation (real). Keeping the factors separate
allows compensation to change without losing the user's unit conversion.
Both the override and XData survive DXF/DWG saves and undo/redo.
## Associations
With `DIMASSOC=2`, acquired features retain their viewport, block-instance path,
source entity, and snap feature. Intersections retain both source chains.
Source edits and viewport changes update the dimension and its compensation,
preserving style overrides and user-positioned text on the sheet.
A sheet centerline stays on the sheet; a model endpoint follows its viewport.
Moving the viewport therefore changes the distance between them. Undo/redo and
DXF/DWG saves preserve the references.
Properties shows association status. If a required reference is broken or cannot
be resolved, the entire dimension keeps its last valid geometry and reference
records. A successful refresh regenerates its displayed and exported geometry.
![Association schematic: moving the sheet centerline changes the dimension from 50 to 40.](viewport-dimension-association.svg)
## Limits
Unsupported cases preserve saved dimensions:
- Perspective or oblique views; missing or malformed reference chains.
- Array instances without an addressable instance index, nesting beyond eight
levels, and radial measurements of nonuniformly scaled circles or arcs.
- Intersections without two identifiable source chains inside a batched block.
- Ambiguous imported endpoint, midpoint, or quadrant markers. A stored point can
validate a feature; it cannot establish an otherwise unknown identity.
- Tangency without a verified solution, or two perpendicular/tangent references
requiring a coupled solve.
- Imported viewport scaling without OCS scale metadata or
`ACAD_DIMASSOC_CALC_DIMLFAC` identifying the saved compensation.
## Tests
Run `cargo test --locked --lib viewport_dimension`. Tests cover the five commands,
mixed sheet/model input, styles, clipping, object picks, source/viewport edits,
snap features, nested paths, intersections, preserved overrides, undo/redo, and
DXF/DWG persistence. The synthetic association fixture is in
`tests/fixtures/dim_assoc/`.
+45 -7
View File
@@ -97,6 +97,10 @@ impl OpenCADStudio {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].snap_result = None;
self.last_point = None;
// Points collected in the space being left are meaningless in the new
// one.
self.clear_accepted_snaps();
self.pending_click_snap = None;
self.snapper.from_point = None;
self.snapper.clear_tracking();
self.otrack_active = None;
@@ -417,6 +421,13 @@ impl OpenCADStudio {
if !self.command_point_allowed(i, *point) {
return Task::none();
}
// Typed / dynamic-input / headless points carry no snap, but the
// accepted-snap list must stay index-parallel with the points the
// command collects. Interactive picks record themselves in
// the click handler and never reach here.
if !self.record_accepted_snap(i, None, None, *point) {
return Task::none();
}
}
if default_start {
let StepInput::Point(point) = &input else {
@@ -430,6 +441,9 @@ impl OpenCADStudio {
self.push_ucs_to_cmd(i);
}
if let StepInput::EntityPick(handle, point) = &input {
if !self.dimension_acquisition_allowed(i, None) {
return Task::none();
}
let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(
@@ -502,6 +516,11 @@ impl OpenCADStudio {
}
let ctrl = self.ctrl_down;
let shift = self.shift_down;
let picked_handle = if let StepInput::EntityPick(h, _) = &input {
Some(*h)
} else {
None
};
let result: Option<CmdResult> = {
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
return Task::none();
@@ -520,6 +539,10 @@ impl OpenCADStudio {
StepInput::Escape => Some(cmd.on_escape()),
}
};
if let Some(handle) = picked_handle {
self.record_dimension_entity_points(i, None, handle, Vec::new());
}
self.sync_dimension_snaps(i);
match result {
Some(r) => self.apply_cmd_result(r),
None => Task::none(),
@@ -1876,7 +1899,7 @@ impl OpenCADStudio {
};
if is_associative_dimension && association_enabled {
if let Some(handle) = committed {
let sources = self.tabs[i].scene.infer_dimension_sources(handle);
let sources = self.infer_dimension_sources_guarded(i, handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources);
@@ -2383,7 +2406,7 @@ impl OpenCADStudio {
let committed = self.commit_entity_handle(entity);
if is_associative_dimension && association_enabled {
if let Some(handle) = committed {
let sources = self.tabs[i].scene.infer_dimension_sources(handle);
let sources = self.infer_dimension_sources_guarded(i, handle);
self.tabs[i]
.scene
.attach_dimension_association(handle, sources);
@@ -2416,6 +2439,20 @@ impl OpenCADStudio {
&entity,
acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_))
);
// A dimension placed on the sheet but measuring model geometry
// through a viewport carries the compensation as a negative
// DIMLFAC override.
if !preserve_base_style {
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut entity,
);
}
if !self.apply_viewport_dimension_measurement(i, &mut entity) {
return Task::none();
}
// Projected points cannot use direct paper-space source inference.
let association_allowed = self.dimension_association_allowed(i);
let inherited_dimension = if preserve_base_style {
match &entity {
acadrust::EntityType::Dimension(dimension) => Some((
@@ -2479,12 +2516,12 @@ impl OpenCADStudio {
if let Some(handle) =
self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style)
{
if association_mode == 2 {
if association_mode == 2 && association_allowed {
let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)];
match association {
crate::command::DimensionAssociationInput::Infer(source) => {
let sources: Vec<_> = source.map_or_else(
|| self.tabs[i].scene.infer_dimension_sources(handle),
|| self.infer_dimension_sources_guarded(i, handle),
|source| {
if single_source_dimension {
vec![Some(source)]
@@ -2513,6 +2550,9 @@ impl OpenCADStudio {
changes.sort_by_key(|(handle, _)| handle.value());
changes.dedup_by_key(|(handle, _)| handle.value());
self.tabs[i].scene.bump_entities(&changes);
} else if association_mode == 2 {
// Preserve the acquired viewport and source paths.
self.attach_viewport_dimension_association(i, handle);
}
}
pending
@@ -2599,9 +2639,7 @@ impl OpenCADStudio {
crate::command::DimensionAssociationInput::Infer(source) => {
source.map_or_else(
|| {
self.tabs[i]
.scene
.infer_dimension_sources(handle)
self.infer_dimension_sources_guarded(i, handle)
.into_iter()
.map(|source| {
source.map(
+3
View File
@@ -123,6 +123,9 @@ impl OpenCADStudio {
// Reset the last committed point so the first click of the new command
// is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None;
// A new command collects its own points, so the previous command's
// accepted snaps must not leak into it.
self.clear_accepted_snaps();
// Starting a command restarts the right-click cycle, so its first
// right-click acts as Enter rather than opening the context menu.
self.tabs[i]
+256
View File
@@ -0,0 +1,256 @@
//! Paper-space dimension acquisition and measurement. Acquiring model geometry
//! supplies a viewport scale; sheet-only dimensions retain paper units.
use super::OpenCADStudio;
use crate::command::DimensionAssociationSource;
use crate::entities::dim_override;
use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, SnapSourceRef, ViewportFrame};
use acadrust::entities::Dimension;
use acadrust::types::Handle;
use acadrust::EntityType;
use glam::DVec3;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum DimensionMeasureSpace {
Direct,
Viewport(ViewportFrame),
ConflictingViewports,
}
impl OpenCADStudio {
pub(crate) fn finish_command_click(&mut self, i: usize) {
let mut selection = self.tabs[i].scene.selection.borrow_mut();
selection.left_down = false;
selection.left_press_pos = None;
selection.left_press_time = None;
selection.left_dragging = false;
}
pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace {
let scene = &self.tabs[i].scene;
if scene.current_layout == "Model" || scene.active_viewport.is_some() {
return DimensionMeasureSpace::Direct;
}
let mut frame: Option<ViewportFrame> = None;
for f in self.accepted_snaps().iter().filter_map(|snap| snap.frame) {
match frame {
None => frame = Some(f),
Some(previous) if previous.viewport == f.viewport => {}
Some(_) => return DimensionMeasureSpace::ConflictingViewports,
}
}
frame.map_or(
DimensionMeasureSpace::Direct,
DimensionMeasureSpace::Viewport,
)
}
/// Reject an incompatible measuring input before it advances the command.
pub(crate) fn dimension_acquisition_allowed(
&mut self,
i: usize,
viewport: Option<Handle>,
) -> bool {
if !self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.measures_through_viewports())
{
return true;
}
// Sheet geometry and free sheet points may accompany one viewport.
// A second viewport would make the measurement scale ambiguous.
if viewport.is_some_and(|viewport| {
self.accepted_snaps()
.iter()
.filter_map(|s| s.viewport)
.any(|previous| previous != viewport)
}) {
self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref());
return false;
}
true
}
/// A command can reject a degenerate point without advancing its step.
pub(crate) fn sync_dimension_snaps(&mut self, i: usize) {
if let Some(cmd) = self.tabs[i]
.active_cmd
.as_ref()
.filter(|c| c.measures_through_viewports())
{
self.accepted_snaps
.truncate(cmd.dimension_acquired_points().len());
}
}
/// Record each definition point actually acquired by an object pick.
/// Arcs may supply three angular slots, lines two, and radial picks one.
pub(crate) fn record_dimension_entity_points(
&mut self,
i: usize,
frame: Option<ViewportFrame>,
entity: Handle,
block_path: Vec<Handle>,
) {
let Some(cmd) = self.tabs[i]
.active_cmd
.as_ref()
.filter(|c| c.measures_through_viewports())
else {
return;
};
let points = cmd.dimension_acquired_points();
for paper_point in points.into_iter().skip(self.accepted_snaps.len()) {
self.push_accepted_snap(AcceptedSnap {
paper_point,
model_point: frame.map_or(paper_point, |f| f.paper_to_model(paper_point)),
viewport: frame.map(|f| f.viewport),
frame,
source: Some(SnapSourceRef {
source: DimensionAssociationSource::inferred(entity),
block_path: block_path.clone(),
snap_type: crate::snap::SnapType::ObjectPick,
intersection: None,
}),
});
}
}
pub(crate) fn apply_viewport_dimension_measurement(
&mut self,
i: usize,
entity: &mut EntityType,
) -> bool {
let EntityType::Dimension(dimension) = entity else {
return true;
};
let angular = matches!(
dimension,
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)
);
let frame = match self.dimension_measure_space(i) {
DimensionMeasureSpace::Direct => return true,
DimensionMeasureSpace::ConflictingViewports => {
self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref());
return false;
}
DimensionMeasureSpace::Viewport(f) => f,
};
if angular {
return true;
}
let dimlfac = self.pending_dimension_style_dimlfac(i, entity);
let scale = MeasurementScale {
user_lfac: MeasurementScale::user_lfac_for_space(dimlfac, true),
viewport_compensation: frame.paper_to_model_length_factor(),
};
if !scale.paper_factor().is_finite() || scale.paper_factor() <= 0.0 {
return false;
}
// Owner is needed even by DIMASSOC=0's explode path, before insertion.
entity.common_mut().owner_handle = self.tabs[i].scene.current_layout_block_handle_pub();
scale.write_to_entity(entity);
true
}
pub(crate) fn try_dimension_viewport_entity_pick(
&mut self,
i: usize,
paper: DVec3,
aperture_paper: f64,
) -> Option<crate::command::CmdResult> {
let pick = self.tabs[i]
.scene
.dimension_pick_through_viewport(paper, aperture_paper)?;
if !self.dimension_acquisition_allowed(i, Some(pick.frame.viewport)) {
return Some(crate::command::CmdResult::NeedPoint);
}
let command = self.tabs[i].active_cmd.as_mut()?;
command.inject_picked_entity(pick.paper_entity);
let result = command.on_entity_pick(pick.entity_handle, pick.paper_point);
self.record_dimension_entity_points(
i,
Some(pick.frame),
pick.entity_handle,
pick.block_path,
);
Some(result)
}
fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 {
let EntityType::Dimension(dimension) = entity else {
return 1.0;
};
dim_override::real(
&dimension.base().common.extended_data,
dim_override::DIMLFAC,
)
.or_else(|| {
self.tabs[i]
.scene
.document
.dim_styles
.iter()
.find(|s| s.name.eq_ignore_ascii_case(&dimension.base().style_name))
.map(|s| s.dimlfac)
})
.unwrap_or(1.0)
}
}
impl OpenCADStudio {
pub(crate) fn viewport_dimension_snaps(
&self,
i: usize,
dimension: Handle,
) -> Vec<Option<AcceptedSnap>> {
let slots = self.tabs[i]
.scene
.dimension_association_slot_points(dimension);
if slots.is_empty() {
return Vec::new();
}
let measuring = self.accepted_snaps();
slots
.iter()
.enumerate()
.map(|(index, slot)| {
let snap = measuring.get(index)?.clone();
let frame = snap.frame;
let slot = DVec3::new(slot.x, slot.y, slot.z);
if snap
.paper_point
.truncate()
.distance_squared(slot.truncate())
<= 1e-12
{
return Some(snap);
}
Some(AcceptedSnap {
paper_point: slot,
model_point: frame.map_or(slot, |frame| frame.paper_to_model(slot)),
..snap
})
})
.collect()
}
/// Record the viewport association for a dimension that has just been
/// committed while measuring through a layout viewport.
///
/// The stored reference chains establish dependencies. Only the new
/// dimension needs a render update; its sources have not changed.
pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) {
let snaps = self.viewport_dimension_snaps(i, dimension);
if snaps.iter().flatten().all(|snap| snap.source.is_none()) {
return;
}
self.tabs[i]
.scene
.attach_viewport_dimension_association(dimension, &snaps);
self.tabs[i]
.scene
.bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]);
}
}
+16
View File
@@ -10,6 +10,9 @@ pub use automation::{export_headless, serve};
mod annotation_data;
mod command_driver;
pub(crate) mod commands;
pub(crate) mod dim_viewport;
#[cfg(test)]
mod viewport_dimension_tests;
mod document;
mod drafting_settings;
pub(crate) mod expr_eval;
@@ -733,6 +736,16 @@ pub(super) struct OpenCADStudio {
show_layout_tabs: bool,
/// Last point committed by a drawing command — used as ortho/polar base.
last_point: Option<glam::DVec3>,
/// Viewport used by the current snap; the displayed point is in paper space.
pub(crate) vp_snap_frame: Option<crate::scene::viewport_ref::ViewportFrame>,
/// Acquired coordinates and source identities in command-step order.
/// Cleared when the command starts or ends.
accepted_snaps: Vec<crate::scene::viewport_ref::AcceptedSnap>,
/// Click result retained until the command accepts its point.
pending_click_snap: Option<(
crate::snap::SnapResult,
Option<crate::scene::viewport_ref::ViewportFrame>,
)>,
/// Endpoint + unit exit-tangent of the most recently drawn line/arc, so
/// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing
/// ended. `None` once a non-line/arc entity is committed.
@@ -3821,6 +3834,9 @@ impl OpenCADStudio {
show_file_tabs: true,
show_layout_tabs: true,
last_point: None,
vp_snap_frame: None,
accepted_snaps: Vec::new(),
pending_click_snap: None,
main_window: None,
thumbnail_capture_clean: false,
#[cfg(not(target_arch = "wasm32"))]
+29
View File
@@ -1277,6 +1277,35 @@ impl OpenCADStudio {
&self.tabs[i].scene.document,
d.base().common.handle,
);
let statuses = self.tabs[i]
.scene
.dimension_association_status(d.base().common.handle);
let slots = self.tabs[i]
.scene
.dimension_association_slot_points(d.base().common.handle)
.len();
let status = if statuses.is_empty() {
t!("Nonassociative")
} else if statuses.iter().any(|(_, status)| {
matches!(status, crate::scene::ReferenceStatus::Broken(_))
}) {
t!("Broken reference")
} else if statuses.iter().any(|(_, status)| {
matches!(status, crate::scene::ReferenceStatus::Unresolved)
}) {
t!("Unresolved reference")
} else if statuses.len() < slots {
t!("Partially associated")
} else {
t!("Associated")
};
general.props.push(crate::scene::model::object::Property {
label: t!("Association status").into_owned(),
field: "association_status",
value: crate::scene::model::object::PropValue::ReadOnly(
status.into_owned(),
),
});
general.props.push(crate::scene::model::object::Property {
label: t!("Associative").into_owned(),
field: "associative",
+1
View File
@@ -95,6 +95,7 @@ mod file;
mod style;
mod util;
mod viewport;
mod viewport_snap;
impl OpenCADStudio {
pub(in crate::app) fn reset_modal_geometry(&mut self) {
+94
View File
@@ -2017,6 +2017,31 @@ impl OpenCADStudio {
)
};
// Paper-space snapping through layout viewports. `edit_cam`
// is None here, so `cursor_world` is the paper point and the sheet
// fills the canvas (pane-local == canvas pixels). The viewport hit
// arrives already projected onto the sheet, so everything
// downstream keeps working in paper coordinates — LINE still draws
// at the projected paper point.
self.vp_snap_frame = None;
if edit_cam.is_none()
&& !needs_entity
&& !is_gathering
&& !needs_structure
&& !needs_tan
{
if let Some((vp_hit, frame)) =
self.paper_viewport_snap(i, p_full, vp_size, cursor_world)
{
let merged =
crate::snap::merge_snap(self.tabs[i].snap_result, Some(vp_hit), p_full);
if merged.is_some_and(|hit| hit.viewport.is_some()) {
self.vp_snap_frame = Some(frame);
}
self.tabs[i].snap_result = merged;
}
}
let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| {
!command.needs_entity_pick()
&& !command.needs_tangent_pick()
@@ -2306,6 +2331,10 @@ impl OpenCADStudio {
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: None,
secondary_source: None,
model_point: None,
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(Some(pick.label));
@@ -3630,6 +3659,23 @@ impl OpenCADStudio {
}
}
}
// Paper-space snapping through layout viewports. Mirrors
// the cursor-move pass: the click recomputes snapping from
// scratch, so the viewport query has to run here too. The hit
// comes back already projected onto the sheet.
let mut click_frame: Option<crate::scene::viewport_ref::ViewportFrame> = None;
if edit_cam.is_none() && !needs_entity_click && !needs_tan {
if let Some((vp_hit, frame)) =
self.paper_viewport_snap(i, p_full, (vw, vh), raw)
{
let merged = crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full);
if merged.is_some_and(|hit| hit.viewport.is_some()) {
click_frame = Some(frame);
}
snap_hit = merged;
}
}
self.pending_click_snap = snap_hit.map(|hit| (hit, click_frame));
// Snap runs in model space; the result is already model.
let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw);
// When no UCS is active clamp to world XY; with a UCS the point is
@@ -3957,6 +4003,10 @@ impl OpenCADStudio {
}
}
if !self.dimension_acquisition_allowed(i, None) {
self.finish_command_click(i);
return Task::none();
}
let shift = self.shift_down;
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
// Shift-swap state for TRIM/EXTEND (#336).
@@ -3964,6 +4014,7 @@ impl OpenCADStudio {
c.set_entity_pick_direction(entity_pick_direction);
c.on_entity_pick(handle, entity_pick_point)
});
self.record_dimension_entity_points(i, None, handle, Vec::new());
// HATCHEDIT: after pick, inject hatch model data into the command.
if self.tabs[i]
.active_cmd
@@ -4034,11 +4085,39 @@ impl OpenCADStudio {
}
}
result
} else if let Some(result) = {
let dimension = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.measures_through_viewports());
if dimension {
let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0
/ (bounds.height as f64).max(1.0);
self.try_dimension_viewport_entity_pick(
i,
pick_wcs,
crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px,
)
} else {
None
}
} {
Some(result)
} else if self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.entity_pick_accepts_points())
{
let pending = self.pending_click_snap.take();
if !self.record_accepted_snap(
i,
pending.map(|(hit, _)| hit),
pending.and_then(|(_, frame)| frame),
pick_wcs,
) {
self.finish_command_click(i);
return Task::none();
}
self.refresh_command_point_pick_context(i);
self.tabs[i]
.active_cmd
@@ -4107,6 +4186,20 @@ impl OpenCADStudio {
self.tabs[i].dyn_active = 0;
}
}
// Record what this point step accepted, alongside the point
// itself: paper point, model point, viewport, frame and
// geometry identity. Commands that don't care keep consuming
// `world_pt` exactly as before.
let pending = self.pending_click_snap.take();
if !self.record_accepted_snap(
i,
pending.map(|(hit, _)| hit),
pending.and_then(|(_, frame)| frame),
world_pt,
) {
self.finish_command_click(i);
return Task::none();
}
self.last_point = Some(world_pt);
// The one-shot snap override is spent by this pick —
// restore the running osnap configuration (#337).
@@ -4142,6 +4235,7 @@ impl OpenCADStudio {
}
};
self.sync_dimension_snaps(i);
if let Some(r) = result {
let task = self.apply_cmd_result(r);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
+299
View File
@@ -0,0 +1,299 @@
//! Snap to displayed model geometry while a paper-space command is active.
//! Viewport queries use the renderer's resident wires and interaction index,
//! then project the accepted model point onto the sheet. Ordinary selection
//! continues to use paper-space hit testing.
use iced::Point;
use acadrust::types::Handle;
use crate::app::OpenCADStudio;
use crate::scene::viewport_ref::{AcceptedSnap, ViewportFrame};
use crate::snap::{SnapResult, SnapType};
/// Bound on the retained accepted-snap history. A command's point steps are a
/// handful at most; the cap only stops a long polyline from growing forever.
const MAX_ACCEPTED_SNAPS: usize = 64;
impl OpenCADStudio {
/// Snaps accepted by the active command's point steps, oldest first, in
/// step order.
pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] {
&self.accepted_snaps
}
/// Infer direct sources only when the measurement uses the active space.
/// Projected model points must retain their acquired source identities;
/// proximity on the sheet could bind them to unrelated paper geometry.
pub(crate) fn infer_dimension_sources_guarded(
&self,
tab: usize,
dimension: Handle,
) -> Vec<Option<Handle>> {
if !self.dimension_association_allowed(tab) {
return Vec::new();
}
self.tabs[tab].scene.infer_dimension_sources(dimension)
}
/// Whether the dimension can use ordinary active-space associations.
/// Placement clicks are excluded by the measurement classification.
pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool {
matches!(
self.dimension_measure_space(tab),
crate::app::dim_viewport::DimensionMeasureSpace::Direct
)
}
/// Forget the accepted snaps of a finished / abandoned command.
pub(crate) fn clear_accepted_snaps(&mut self) {
self.accepted_snaps.clear();
}
/// Record the snap a point step just accepted. `snap` is the displayed snap
/// result (already in the current space's coordinates) or `None` for a free
/// / typed point; `committed` is the point the command actually received
/// after ortho / polar / axis-lock / dynamic-input resolution.
pub(crate) fn record_accepted_snap(
&mut self,
tab: usize,
snap: Option<SnapResult>,
frame: Option<ViewportFrame>,
committed: glam::DVec3,
) -> bool {
let mut accepted = match snap {
// A viewport snap keeps its frame only when the hit really came
// through it, so a paper-sheet snap never claims a model point.
Some(hit) => {
let frame = hit.viewport.and(frame);
AcceptedSnap::from_snap(&hit, frame).with_paper_point(committed)
}
None => AcceptedSnap::free(committed),
};
if self.tabs[tab]
.active_cmd
.as_ref()
.is_some_and(|cmd| cmd.measures_through_viewports())
{
if self.tabs[tab]
.active_cmd
.as_ref()
.is_some_and(|cmd| cmd.dimension_placement_pending())
{
return true;
}
if !self.dimension_acquisition_allowed(tab, accepted.viewport) {
return false;
}
}
self.resolve_source_block_path(tab, &mut accepted);
self.push_accepted_snap(accepted);
true
}
/// Resolve the instance path once, when a feature is accepted. Failure
/// drops source identity so a failed descent cannot attach to the insert's
/// origin. Ambiguous intersections within one batched block remain unlinked.
fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) {
let resolve = |source: &mut crate::scene::viewport_ref::SnapSourceRef| {
if !source.block_path.is_empty() {
return true;
}
if !matches!(
self.tabs[tab]
.scene
.document
.get_entity(source.source.handle),
Some(acadrust::EntityType::Insert(_))
) {
return true;
}
if source.snap_type == SnapType::Insertion {
let Some(acadrust::EntityType::Insert(insert)) = self.tabs[tab]
.scene
.document
.get_entity(source.source.handle)
else {
return false;
};
let origin = glam::DVec3::new(
insert.insert_point.x,
insert.insert_point.y,
insert.insert_point.z,
);
return origin.distance(accepted.model_point) < 1e-5;
}
let Some((entity, path)) = self.tabs[tab].scene.resolve_measurable_feature(
source.source.handle,
accepted.model_point,
accepted.viewport,
Some(source.snap_type),
) else {
return false;
};
let distance = crate::scene::viewport_dimension_pick::feature_pick_distance(
&entity,
accepted.model_point,
Some(source.snap_type),
);
if !distance.is_some_and(|distance| distance < 1e-5) {
return false;
}
source.source =
crate::command::DimensionAssociationSource::inferred(entity.common().handle);
source.block_path = path;
true
};
let Some(source) = accepted.source.as_mut() else {
return;
};
if !resolve(source) {
accepted.source = None;
return;
}
if let Some(other) = source.intersection.as_mut() {
if !resolve(other)
|| (other.source.handle == source.source.handle
&& other.block_path == source.block_path)
{
source.intersection = None;
}
}
}
/// Retain a bounded history for point and object picks.
pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) {
if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS {
self.accepted_snaps.remove(0);
}
self.accepted_snaps.push(accepted);
}
/// Query visible viewport geometry with a canvas-pixel cursor and sheet point.
/// Returns paper coordinates and canvas pixels, plus the source viewport frame.
pub(in crate::app) fn paper_viewport_snap(
&mut self,
i: usize,
cursor_canvas: Point,
canvas: (f32, f32),
cursor_paper: glam::DVec3,
) -> Option<(SnapResult, ViewportFrame)> {
if !self.snapper.snap_enabled {
return None;
}
{
let scene = &self.tabs[i].scene;
if scene.current_layout == "Model" || scene.active_viewport.is_some() {
return None;
}
}
if canvas.0 < 1.0 || canvas.1 < 1.0 {
return None;
}
for frame in self.tabs[i]
.scene
.viewport_frames_at_paper_point(cursor_paper)
{
let handle = frame.viewport;
let Some((cam, rect)) = self.tabs[i].scene.viewport_edit_frame_for(handle, canvas)
else {
continue;
};
if rect.width < 1.0 || rect.height < 1.0 {
continue;
}
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: rect.width,
height: rect.height,
};
let local = Point::new(cursor_canvas.x - rect.x, cursor_canvas.y - rect.y);
let view_rot = cam.view_proj_rte(bounds);
let eye = cam.eye();
// The cursor in MODEL space, through the frame — exactly the point
// the viewport camera projects back onto `local`.
let model_cursor = frame.paper_to_model(cursor_paper);
let wires = self.tabs[i]
.scene
.model_wires_for_viewport_arc(handle, bounds.height);
let candidates = self.tabs[i].scene.interaction_candidates_near(
wires,
model_cursor,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
);
// The grid belongs to the sheet. Translate the construction
// anchor for perpendicular/tangent snaps into this viewport's
// model coordinates, then restore the shared snapper state.
let saved_grid = self.snapper.grid_snap_on;
let saved_from = self.snapper.from_point;
self.snapper.grid_snap_on = false;
self.snapper.from_point =
saved_from.map(|p| frame.paper_to_model(p.as_dvec3()).as_vec3());
let hit = self.snapper.snap(
model_cursor,
local,
&candidates,
view_rot,
eye,
bounds,
glam::Vec3::ZERO,
(glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z),
None,
);
self.snapper.grid_snap_on = saved_grid;
self.snapper.from_point = saved_from;
let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else {
continue;
};
// The snap aperture can reach outside the visible clip boundary.
let paper = frame.model_to_paper(hit.world);
if !self.tabs[i]
.scene
.viewport_displays_paper_point(handle, paper.truncate())
{
continue;
}
return Some((project_hit_to_paper(hit, &frame, rect, handle), frame));
}
None
}
}
/// Re-express a snap taken in a viewport's model space as a paper-space result:
/// the point projects onto the sheet through `frame`, and the pane-local pixel
/// positions shift by the viewport's screen origin so they share the canvas
/// frame with the ordinary paper snap.
fn project_hit_to_paper(
hit: SnapResult,
frame: &ViewportFrame,
rect: iced::Rectangle,
viewport: Handle,
) -> SnapResult {
let to_canvas = |p: Point| Point::new(p.x + rect.x, p.y + rect.y);
SnapResult {
world: frame.model_to_paper(hit.world),
screen: to_canvas(hit.screen),
snap_type: hit.snap_type,
// Tangent geometry is model-space and would be consumed as paper-space
// by TTR/TTT; drop it rather than hand over a wrong circle.
tangent_obj: None,
extension_base: hit.extension_base.map(to_canvas),
extension_base2: hit.extension_base2.map(to_canvas),
extension_origin: hit.extension_origin.map(|p| frame.model_to_paper(p)),
extension_dir: hit.extension_dir.map(|d| {
let mapped = frame.model_to_paper_dir(d.truncate());
glam::DVec3::new(mapped.x, mapped.y, d.z)
}),
viewport: Some(viewport),
source: hit.source,
secondary_source: hit.secondary_source,
model_point: Some(hit.world),
}
}
File diff suppressed because it is too large. Load diff
+18 -2
View File
@@ -183,7 +183,7 @@ pub struct SelectionEntity {
}
/// Association source with an optional sub-entity marker.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DimensionAssociationSource {
pub handle: Handle,
pub marker: Option<i32>,
@@ -2245,7 +2245,23 @@ pub trait CadCommand: Send {
CmdResult::Cancel
}
/// Returns `true` when the command needs entity picking (hit-test) instead of point picking.
/// Supports acquiring dimension geometry through a paper-space viewport.
fn measures_through_viewports(&self) -> bool {
false
}
/// Definition points acquired so far, in the command's working space.
/// Used to retain references only for accepted measuring inputs.
fn dimension_acquired_points(&self) -> Vec<DVec3> {
Vec::new()
}
/// The next point places annotation rather than acquiring geometry.
fn dimension_placement_pending(&self) -> bool {
false
}
/// Needs entity hit-testing instead of point input.
fn needs_entity_pick(&self) -> bool {
false
}
+59
View File
@@ -235,6 +235,65 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option<XData
write_pairs(doc, handle, kept);
}
/// Set — or, with `value: None`, clear — a single override on an entity that
/// is **not yet in the document** (a command's pending commit), leaving the
/// other overrides intact.
///
/// The document-backed [`set`] additionally registers the `ACAD` APPID; that
/// APPID is present in every drawing OCS writes, and committing the entity
/// goes through the normal add path, so an uncommitted write needs no
/// document. Keeping the override on the entity means it is part of the single
/// committed object and rides the ordinary undo/redo history.
pub fn set_on_entity(entity: &mut EntityType, code: i16, value: Option<XDataValue>) {
let common = entity.common_mut();
let mut kept: Vec<(i16, XDataValue)> = pairs(&common.extended_data)
.into_iter()
.filter(|(c, _)| *c != code)
.collect();
if let Some(v) = value {
kept.push((code, v));
}
let legacy = common.extended_data.get_record("ACAD_DSTYLE").is_some()
&& common.extended_data.get_record("ACAD").is_some_and(
|rec| !matches!(rec.values.first(), Some(XDataValue::String(n)) if n == "DSTYLE"),
);
let app = if legacy { "ACAD_DSTYLE" } else { "ACAD" };
let values = if kept.is_empty() {
None
} else {
let mut vals = Vec::new();
if !legacy {
vals.push(XDataValue::String("DSTYLE".to_string()));
}
vals.push(XDataValue::ControlString("{".to_string()));
for (code, value) in kept {
vals.push(XDataValue::Integer16(code));
vals.push(value);
}
vals.push(XDataValue::ControlString("}".to_string()));
Some(vals)
};
let mut rebuilt = ExtendedData::new();
for record in common.extended_data.records() {
if record.application_name != app && !(!legacy && record.application_name == "ACAD_DSTYLE")
{
rebuilt.add_record(record.clone());
}
}
if let Some(vals) = values {
let mut record = acadrust::xdata::ExtendedDataRecord::new(app);
for v in vals {
record.add_value(v);
}
rebuilt.add_record(record);
}
rebuilt.raw_dwg_eed = common.extended_data.raw_dwg_eed.clone();
common.extended_data = rebuilt;
}
pub fn property_real_code(field: &str) -> Option<i16> {
Some(match field {
"dim_arrow_size" => DIMASZ,
+68 -3
View File
@@ -1613,9 +1613,56 @@ pub(crate) fn resolved_dimension_style(
style.dimtxsty = record.name.clone();
}
}
// Negative DIMLFAC applies only in paper space; preserve its sign on export.
if style.dimlfac < 0.0 {
style.dimlfac = crate::scene::viewport_ref::MeasurementScale::user_lfac_for_space(
style.dimlfac,
dimension_in_paper_space(dimension, document),
);
}
style
}
/// Whether the dimension's owner is paper space, even without a Layout object.
pub(crate) fn dimension_in_paper_space(dimension: &Dimension, document: &CadDocument) -> bool {
let owner = dimension.base().common.owner_handle;
if !owner.is_valid() {
return false;
}
document.objects.values().any(|object| {
matches!(object,
acadrust::objects::ObjectType::Layout(layout)
if layout.name != "Model" && layout.block_record == owner
)
}) || document.block_records.iter().any(|block| {
block.handle == owner && block.name.to_ascii_uppercase().starts_with("*PAPER_SPACE")
})
}
/// The value a dimension's text reports for its measurement: the raw
/// geometric measurement times the resolved linear factor. Angular dimensions
/// never scale.
///
/// `actual_measurement` (DXF group 42) stays **raw** — that is what the format
/// specifies and what export writes — so this is the one helper UI surfaces
/// use when they want the number the user reads.
pub(crate) fn displayed_measurement(dimension: &Dimension, style: Option<&DimStyle>) -> f64 {
let raw = dimension.measurement();
if matches!(
dimension,
Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)
) {
return raw;
}
let lfac = style.map(|s| s.dimlfac).unwrap_or(1.0);
if lfac.abs() < 1e-12 {
raw
} else {
raw * lfac
}
}
/// Build the linear-dimension property groups from the assigned style plus
/// any entity-level DSTYLE overrides. Conditional rows remain visible but are
/// read-only while their controlling option is disabled.
@@ -2097,7 +2144,13 @@ pub fn style_sections(
property(
t!("Measurement").as_ref(),
"measurement",
PropValue::ReadOnly(format!("{:.4}", dimension.measurement())),
// The value the dimension text reports (DIMLFAC applied,
// including viewport compensation) — not the raw paper
// distance, which would disagree with what is drawn.
PropValue::ReadOnly(format!(
"{:.4}",
displayed_measurement(dimension, Some(s))
)),
),
property(
t!("Text override").as_ref(),
@@ -2196,7 +2249,12 @@ pub fn style_sections(
number(
t!("Dim scale linear").as_ref(),
"dim_scale_linear",
real(ov::DIMLFAC, s.dimlfac),
// The *raw* signed DIMLFAC: a negative value is the
// paper-space / viewport-compensation convention, and
// hiding its sign would make the row uneditable in
// practice. `s.dimlfac` is sign-resolved, so fall back to
// the unresolved source style instead.
real(ov::DIMLFAC, style.dimlfac),
true,
),
number(
@@ -6403,7 +6461,14 @@ fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String {
})
.unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0, 1.0, ""));
let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac };
// `resolved_dimension_style` has already applied the DXF sign convention,
// so a negative value here means the caller passed an unresolved style;
// fall back to 1.0 rather than reporting a negative length.
let lfac = if lfac.abs() < 1e-12 || lfac < 0.0 {
1.0
} else {
lfac
};
let scaled = measurement * lfac;
let use_sub_units = zin & 4 != 0 && scaled.abs() < 1.0 && sub_factor.abs() > 1e-12;
// For values below one unit, DIMMZF replaces DIMLFAC; applying it after
+18
View File
@@ -161,6 +161,24 @@ impl CadCommand for AlignedDimensionCommand {
CmdResult::Cancel
}
/// Points and object picks may come through a paper-space viewport;
/// the committed dimension then reports the model measurement.
fn measures_through_viewports(&self) -> bool {
true
}
fn dimension_acquired_points(&self) -> Vec<DVec3> {
match self.step {
Step::First => vec![],
Step::Second(p) => vec![p],
Step::DimLine { p1, p2 } => vec![p1, p2],
}
}
fn dimension_placement_pending(&self) -> bool {
matches!(self.step, Step::DimLine { .. })
}
fn input_kind(&self) -> InputKind {
if self.awaiting_text {
InputKind::FreeText
+36
View File
@@ -482,6 +482,42 @@ impl CadCommand for AngularDimensionCommand {
CmdResult::Cancel
}
/// Points and object picks may come through a paper-space viewport;
/// the committed dimension then reports the model measurement.
fn measures_through_viewports(&self) -> bool {
true
}
fn dimension_acquired_points(&self) -> Vec<DVec3> {
match self.step {
Step::Vertex => vec![],
Step::FirstRay(p) => vec![p],
Step::SecondRay { vertex, first } | Step::CircleSecondRay { vertex, first, .. } => {
vec![vertex, first]
}
Step::SecondLine {
first_start,
first_end,
..
} => vec![first_start, first_end],
Step::ArcPoint3 {
vertex,
first,
second,
} => vec![vertex, first, second],
Step::ArcPoint2 {
first_start,
first_end,
second_start,
second_end,
} => vec![first_start, first_end, second_start, second_end],
}
}
fn dimension_placement_pending(&self) -> bool {
matches!(self.step, Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. })
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
+17
View File
@@ -132,6 +132,23 @@ impl CadCommand for DiameterDimensionCommand {
CmdResult::Cancel
}
/// Points and object picks may come through a paper-space viewport;
/// the committed dimension then reports the model measurement.
fn measures_through_viewports(&self) -> bool {
true
}
fn dimension_acquired_points(&self) -> Vec<DVec3> {
match self.step {
Step::SelectObject => vec![],
Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))],
}
}
fn dimension_placement_pending(&self) -> bool {
matches!(self.step, Step::DimLine(_))
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
+18
View File
@@ -217,6 +217,24 @@ impl CadCommand for LinearDimensionCommand {
CmdResult::Cancel
}
/// Points and object picks may come through a paper-space viewport;
/// the committed dimension then reports the model measurement.
fn measures_through_viewports(&self) -> bool {
true
}
fn dimension_acquired_points(&self) -> Vec<DVec3> {
match self.step {
Step::FirstPoint => vec![],
Step::SecondPoint(p) => vec![p],
Step::DimensionLine { first, second } => vec![first, second],
}
}
fn dimension_placement_pending(&self) -> bool {
matches!(self.step, Step::DimensionLine { .. })
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
+17
View File
@@ -130,6 +130,23 @@ impl CadCommand for RadiusDimensionCommand {
CmdResult::Cancel
}
/// Points and object picks may come through a paper-space viewport;
/// the committed dimension then reports the model measurement.
fn measures_through_viewports(&self) -> bool {
true
}
fn dimension_acquired_points(&self) -> Vec<DVec3> {
match self.step {
Step::SelectObject => vec![],
Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))],
}
}
fn dimension_placement_pending(&self) -> bool {
matches!(self.step, Step::DimLine(_))
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
+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);
}
}
}
+164 -33
View File
@@ -9,8 +9,9 @@ use iced::time::Instant;
use iced::{Point, Rectangle};
use cadkernel::geom2d::Curve;
use acadrust::types::Handle;
use crate::command::TangentObject;
use crate::command::{DimensionAssociationSource, TangentObject};
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
use crate::scene::pick::interaction_index::WireSource;
const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0;
@@ -86,6 +87,37 @@ pub struct SnapResult {
pub extension_origin: Option<glam::DVec3>,
/// Source direction paired with `extension_origin`.
pub extension_dir: Option<glam::DVec3>,
/// Layout viewport this snap was seen *through*, when the snap ran against
/// model geometry displayed by a paper-space viewport. `None` for ordinary
/// model-space / paper-sheet snaps. Filled by the paper-space viewport snap
/// query, never by the engine itself.
pub viewport: Option<Handle>,
/// Owning entity of the geometry the snap landed on, when the engine could
/// attribute the feature to a wire. Block sub-entities report the top-level
/// INSERT handle; the block path is resolved later by
/// [`crate::scene::viewport_ref::SnapSourceRef::block_path`].
pub source: Option<DimensionAssociationSource>,
/// Second real object at an intersection, when both objects are known.
pub secondary_source: Option<DimensionAssociationSource>,
/// Original model point when `world` has been projected onto a sheet.
pub model_point: Option<glam::DVec3>,
}
impl SnapResult {
/// Handle of the entity the snap landed on, when known.
pub fn source_handle(&self) -> Option<Handle> {
self.source.map(|s| s.handle)
}
}
/// Entity handle carried by a wire (`WireModel::name` is the decimal handle
/// value). Returns `None` for preview / interim wires with a symbolic name.
#[inline]
pub(crate) fn wire_source(wire: &WireModel) -> Option<DimensionAssociationSource> {
wire.name
.parse::<u64>()
.ok()
.map(|v| DimensionAssociationSource::inferred(Handle::new(v)))
}
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
@@ -954,6 +986,10 @@ impl Snapper {
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: None,
secondary_source: None,
model_point: None,
})
}
@@ -1087,6 +1123,10 @@ impl Snapper {
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: None,
secondary_source: None,
model_point: None,
});
best_rank = snap_tier(SnapType::Grid);
best_sub = snap_priority(SnapType::Grid);
@@ -1182,7 +1222,10 @@ impl Snapper {
return best;
}
let mut try_pt = |world: glam::DVec3, snap_type: SnapType| {
let mut try_pt = |world: glam::DVec3,
snap_type: SnapType,
src: Option<DimensionAssociationSource>,
secondary: Option<DimensionAssociationSource>| {
let screen = world_to_screen(world, view_rot, eye, bounds);
if !in_bounds(screen) {
return;
@@ -1209,12 +1252,18 @@ impl Snapper {
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: src,
secondary_source: secondary,
model_point: None,
});
}
};
// ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ──────
let mut try_snap_hint = |world: DVec3, hint: SnapHint| {
let mut try_snap_hint = |world: DVec3,
hint: SnapHint,
src: Option<DimensionAssociationSource>| {
let snap_type = match hint {
SnapHint::Center => SnapType::Center,
SnapHint::Node => SnapType::Node,
@@ -1224,23 +1273,24 @@ impl Snapper {
SnapHint::Endpoint => SnapType::Endpoint,
};
if self.is_on(snap_type) {
try_pt(world, snap_type);
try_pt(world, snap_type, src, None);
}
};
if let Some(points) = wires.snap_points() {
for point_ref in points {
let Some(&(world, hint)) = wires
.source_wire(point_ref.wire)
.and_then(|wire| wire.snap_pts.get(point_ref.index as usize))
else {
let Some(wire) = wires.source_wire(point_ref.wire) else {
continue;
};
try_snap_hint(world, hint);
let Some(&(world, hint)) = wire.snap_pts.get(point_ref.index as usize) else {
continue;
};
try_snap_hint(world, hint, wire_source(wire));
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for &(world, hint) in &wire.snap_pts {
try_snap_hint(world, hint);
try_snap_hint(world, hint, src);
}
}
}
@@ -1250,13 +1300,18 @@ impl Snapper {
if self.is_on(SnapType::Endpoint) {
if let Some(vertices) = wires.key_vertices() {
for vertex_ref in vertices {
let Some(&point) = wires
.source_wire(vertex_ref.wire)
.and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize))
else {
let Some(wire) = wires.source_wire(vertex_ref.wire) else {
continue;
};
try_pt(DVec3::from_array(point), SnapType::Endpoint);
let Some(&point) = wire.key_vertices.get(vertex_ref.index as usize) else {
continue;
};
try_pt(
DVec3::from_array(point),
SnapType::Endpoint,
wire_source(wire),
None,
);
}
// Tessellated open curves have no key-vertex set. Their only
// endpoints are first/last, so testing candidate wires remains
@@ -1270,17 +1325,23 @@ impl Snapper {
continue;
}
if !wire.points.is_empty() {
try_pt(wp_f64(wire, 0), SnapType::Endpoint);
try_pt(wp_f64(wire, 0), SnapType::Endpoint, wire_source(wire), None);
}
if wire.points.len() > 1 {
try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint);
try_pt(
wp_f64(wire, wire.points.len() - 1),
SnapType::Endpoint,
wire_source(wire),
None,
);
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
if !wire.key_vertices.is_empty() {
for &point in &wire.key_vertices {
try_pt(DVec3::from_array(point), SnapType::Endpoint);
try_pt(DVec3::from_array(point), SnapType::Endpoint, src, None);
}
} else {
let closed = wire
@@ -1289,10 +1350,15 @@ impl Snapper {
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
if !closed {
if !wire.points.is_empty() {
try_pt(wp_f64(wire, 0), SnapType::Endpoint);
try_pt(wp_f64(wire, 0), SnapType::Endpoint, src, None);
}
if wire.points.len() > 1 {
try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint);
try_pt(
wp_f64(wire, wire.points.len() - 1),
SnapType::Endpoint,
src,
None,
);
}
}
}
@@ -1319,16 +1385,17 @@ impl Snapper {
let a = DVec3::from_array(a);
let b = DVec3::from_array(b);
if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint);
try_pt((a + b) * 0.5, SnapType::Midpoint, wire_source(wire), None);
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for segment in wire.key_vertices.windows(2) {
let a = DVec3::from_array(segment[0]);
let b = DVec3::from_array(segment[1]);
if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint);
try_pt((a + b) * 0.5, SnapType::Midpoint, src, None);
}
}
}
@@ -1342,14 +1409,17 @@ impl Snapper {
try_pt(
nearest_on_segment(cursor_world, seg.a, seg.b),
SnapType::Nearest,
wires.source_wire(seg.wire).and_then(wire_source),
None,
);
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for i in 0..wire.points.len().saturating_sub(1) {
let p =
nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1));
try_pt(p, SnapType::Nearest);
try_pt(p, SnapType::Nearest, src, None);
}
}
}
@@ -1362,14 +1432,20 @@ impl Snapper {
if let Some(segments) = &local_segments {
for seg in segments {
if let Some(foot) = perp_foot(q, seg.a, seg.b) {
try_pt(foot, SnapType::Perpendicular);
try_pt(
foot,
SnapType::Perpendicular,
wires.source_wire(seg.wire).and_then(wire_source),
None,
);
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for i in 0..wire.points.len().saturating_sub(1) {
if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) {
try_pt(foot, SnapType::Perpendicular);
try_pt(foot, SnapType::Perpendicular, src, None);
}
}
}
@@ -1387,12 +1463,18 @@ impl Snapper {
ray_segment_intersect_3d(origin, through, segment.a, segment.b)
{
if (point - origin).length_squared() > 1e-18 {
try_pt(point, SnapType::Intersection);
try_pt(
point,
SnapType::Intersection,
wires.source_wire(segment.wire).and_then(wire_source),
None,
);
}
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for index in 0..wire.points.len().saturating_sub(1) {
if let Some(point) = ray_segment_intersect_3d(
origin,
@@ -1401,7 +1483,7 @@ impl Snapper {
wp_f64(wire, index + 1),
) {
if (point - origin).length_squared() > 1e-18 {
try_pt(point, SnapType::Intersection);
try_pt(point, SnapType::Intersection, src, None);
}
}
}
@@ -1483,8 +1565,11 @@ impl Snapper {
for (idx, &wire_i) in local_wires.iter().enumerate() {
for &wire_j in &local_wires[idx + 1..] {
if let Some(points) = exact_curve_intersections(wire_i, wire_j) {
// Two wires meet here; attribute the feature to the
// first. Association resolution requires both source paths.
let src = wire_source(wire_i);
for point in points {
try_pt(point, SnapType::Intersection);
try_pt(point, SnapType::Intersection, src, wire_source(wire_j));
}
resolved_pairs.insert(pair_key(wire_i, wire_j));
}
@@ -1515,7 +1600,12 @@ impl Snapper {
world_to_screen(pt, view_rot, eye, bounds),
cursor_screen,
) <= f32::EPSILON;
try_pt(pt, SnapType::Intersection);
try_pt(
pt,
SnapType::Intersection,
wires.source_wire(a.wire).and_then(wire_source),
wires.source_wire(b.wire).and_then(wire_source),
);
if exact_cursor {
break 'intersection_sweep;
}
@@ -1535,8 +1625,9 @@ impl Snapper {
// Curved pairs are solved exactly (bug #1052); see
// `exact_curve_intersections`'s doc comment.
if let Some(pts) = exact_curve_intersections(wire_i, wire_j) {
let src = wire_source(wire_i);
for pt in pts {
try_pt(pt, SnapType::Intersection);
try_pt(pt, SnapType::Intersection, src, wire_source(wire_j));
}
continue;
}
@@ -1560,7 +1651,12 @@ impl Snapper {
continue;
}
if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) {
try_pt(pt, SnapType::Intersection);
try_pt(
pt,
SnapType::Intersection,
wire_source(wire_i),
wire_source(wire_j),
);
}
}
}
@@ -1586,7 +1682,7 @@ impl Snapper {
bounds,
self.osnap_radius_px,
) {
try_pt(ext, SnapType::Extension);
try_pt(ext, SnapType::Extension, None, None);
}
}
}
@@ -1633,7 +1729,7 @@ impl Snapper {
// feet (which sit closer to the cursor on their own lines),
// or the cursor would snap to a line instead of the crossing.
let pt = glam::DVec3::new(a0.x + t1 * d1.x, a0.y + t1 * d1.y, a0.z);
try_pt(pt, SnapType::Intersection);
try_pt(pt, SnapType::Intersection, None, None);
}
}
}
@@ -1686,6 +1782,8 @@ impl Snapper {
try_pt(
segment_a.a + ta as f64 * (segment_a.b - segment_a.a),
SnapType::ApparentIntersection,
wires.source_wire(segment_a.wire).and_then(wire_source),
wires.source_wire(segment_b.wire).and_then(wire_source),
);
if dist2(apparent_screen, cursor_screen) <= f32::EPSILON {
break 'apparent_sweep;
@@ -1726,6 +1824,8 @@ impl Snapper {
try_pt(
wa0 + ta as f64 * (wa1 - wa0),
SnapType::ApparentIntersection,
wire_source(wire_i),
wire_source(wire_j),
);
}
}
@@ -1947,6 +2047,10 @@ impl Snapper {
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: wire_source(wire),
secondary_source: None,
model_point: None,
});
}
}
@@ -2009,6 +2113,10 @@ impl Snapper {
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: wire_source(wire),
secondary_source: None,
model_point: None,
});
}
};
@@ -2112,6 +2220,29 @@ fn snap_tier(t: SnapType) -> u8 {
}
}
/// Merge paper and viewport snaps using the engine's ordering.
/// Both screen positions must use canvas pixels.
pub fn merge_snap(
a: Option<SnapResult>,
b: Option<SnapResult>,
cursor: Point,
) -> Option<SnapResult> {
match (a, b) {
(Some(a), Some(b)) => {
let (at, asub) = (snap_tier(a.snap_type), snap_priority(a.snap_type));
let (bt, bsub) = (snap_tier(b.snap_type), snap_priority(b.snap_type));
let bd2 = dist2(b.screen, cursor);
let ad2 = dist2(a.screen, cursor);
if snap_better(bt, bd2, bsub, (at, ad2, asub)) {
Some(b)
} else {
Some(a)
}
}
(some, None) | (None, some) => some,
}
}
/// Candidate ordering: tier first, then cursor distance, and only when two
/// candidates are effectively equidistant (coincident features) the classic
/// sub-priority — so an Endpoint still beats an Intersection sitting on the
+10
View File
@@ -0,0 +1,10 @@
# Viewport association fixture
`viewport_associations.dxf` contains line, circle, arc, polyline, spline,
block-instance, and intersection references through a 1:2 viewport.
`src/app/viewport_dimension_tests.rs` tests refresh and intersection source edits.
Each dimension stores `DIMLFAC=-2` and `ACAD_DIMASSOC_CALC_DIMLFAC=-2` so its paper
definition points measure in model units. The spline tangent from `(0, 300)`
touches `(90, 283.125)` at parameter `0.75`; tests verify its tangency and preserve
the dimension if an edited reference cannot resolve.
File diff suppressed because it is too large. Load diff