forked from 1b-agent/opencadstudio-upstream
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:
commit
d10a0cf798
35 files changed
+7077
-85
No files matched your search
Generated
+2
-2
@@ -921,7 +921,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "cadkernel"
|
name = "cadkernel"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"acadrust",
|
"acadrust",
|
||||||
"cavalier_contours",
|
"cavalier_contours",
|
||||||
@@ -932,7 +932,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "cadkernel-constraints"
|
name = "cadkernel-constraints"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"nalgebra",
|
"nalgebra",
|
||||||
]
|
]
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ clap = { version = "4", features = ["derive", "string"] }
|
|||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] }
|
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 = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2", features = ["acis", "offset"] }
|
||||||
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" }
|
cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "7ce27f2" }
|
||||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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/`.
|
||||||
@@ -97,6 +97,10 @@ impl OpenCADStudio {
|
|||||||
self.tabs[i].scene.clear_preview_wire();
|
self.tabs[i].scene.clear_preview_wire();
|
||||||
self.tabs[i].snap_result = None;
|
self.tabs[i].snap_result = None;
|
||||||
self.last_point = 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.from_point = None;
|
||||||
self.snapper.clear_tracking();
|
self.snapper.clear_tracking();
|
||||||
self.otrack_active = None;
|
self.otrack_active = None;
|
||||||
@@ -417,6 +421,13 @@ impl OpenCADStudio {
|
|||||||
if !self.command_point_allowed(i, *point) {
|
if !self.command_point_allowed(i, *point) {
|
||||||
return Task::none();
|
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 {
|
if default_start {
|
||||||
let StepInput::Point(point) = &input else {
|
let StepInput::Point(point) = &input else {
|
||||||
@@ -430,6 +441,9 @@ impl OpenCADStudio {
|
|||||||
self.push_ucs_to_cmd(i);
|
self.push_ucs_to_cmd(i);
|
||||||
}
|
}
|
||||||
if let StepInput::EntityPick(handle, point) = &input {
|
if let StepInput::EntityPick(handle, point) = &input {
|
||||||
|
if !self.dimension_acquisition_allowed(i, None) {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
let solid_pick = matches!(
|
let solid_pick = matches!(
|
||||||
self.tabs[i].scene.document.get_entity(*handle),
|
self.tabs[i].scene.document.get_entity(*handle),
|
||||||
Some(
|
Some(
|
||||||
@@ -502,6 +516,11 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
let ctrl = self.ctrl_down;
|
let ctrl = self.ctrl_down;
|
||||||
let shift = self.shift_down;
|
let shift = self.shift_down;
|
||||||
|
let picked_handle = if let StepInput::EntityPick(h, _) = &input {
|
||||||
|
Some(*h)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let result: Option<CmdResult> = {
|
let result: Option<CmdResult> = {
|
||||||
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
|
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -520,6 +539,10 @@ impl OpenCADStudio {
|
|||||||
StepInput::Escape => Some(cmd.on_escape()),
|
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 {
|
match result {
|
||||||
Some(r) => self.apply_cmd_result(r),
|
Some(r) => self.apply_cmd_result(r),
|
||||||
None => Task::none(),
|
None => Task::none(),
|
||||||
@@ -1876,7 +1899,7 @@ impl OpenCADStudio {
|
|||||||
};
|
};
|
||||||
if is_associative_dimension && association_enabled {
|
if is_associative_dimension && association_enabled {
|
||||||
if let Some(handle) = committed {
|
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]
|
self.tabs[i]
|
||||||
.scene
|
.scene
|
||||||
.attach_dimension_association(handle, sources);
|
.attach_dimension_association(handle, sources);
|
||||||
@@ -2383,7 +2406,7 @@ impl OpenCADStudio {
|
|||||||
let committed = self.commit_entity_handle(entity);
|
let committed = self.commit_entity_handle(entity);
|
||||||
if is_associative_dimension && association_enabled {
|
if is_associative_dimension && association_enabled {
|
||||||
if let Some(handle) = committed {
|
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]
|
self.tabs[i]
|
||||||
.scene
|
.scene
|
||||||
.attach_dimension_association(handle, sources);
|
.attach_dimension_association(handle, sources);
|
||||||
@@ -2416,6 +2439,20 @@ impl OpenCADStudio {
|
|||||||
&entity,
|
&entity,
|
||||||
acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_))
|
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 {
|
let inherited_dimension = if preserve_base_style {
|
||||||
match &entity {
|
match &entity {
|
||||||
acadrust::EntityType::Dimension(dimension) => Some((
|
acadrust::EntityType::Dimension(dimension) => Some((
|
||||||
@@ -2479,12 +2516,12 @@ impl OpenCADStudio {
|
|||||||
if let Some(handle) =
|
if let Some(handle) =
|
||||||
self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style)
|
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)];
|
let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)];
|
||||||
match association {
|
match association {
|
||||||
crate::command::DimensionAssociationInput::Infer(source) => {
|
crate::command::DimensionAssociationInput::Infer(source) => {
|
||||||
let sources: Vec<_> = source.map_or_else(
|
let sources: Vec<_> = source.map_or_else(
|
||||||
|| self.tabs[i].scene.infer_dimension_sources(handle),
|
|| self.infer_dimension_sources_guarded(i, handle),
|
||||||
|source| {
|
|source| {
|
||||||
if single_source_dimension {
|
if single_source_dimension {
|
||||||
vec![Some(source)]
|
vec![Some(source)]
|
||||||
@@ -2513,6 +2550,9 @@ impl OpenCADStudio {
|
|||||||
changes.sort_by_key(|(handle, _)| handle.value());
|
changes.sort_by_key(|(handle, _)| handle.value());
|
||||||
changes.dedup_by_key(|(handle, _)| handle.value());
|
changes.dedup_by_key(|(handle, _)| handle.value());
|
||||||
self.tabs[i].scene.bump_entities(&changes);
|
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
|
pending
|
||||||
@@ -2599,9 +2639,7 @@ impl OpenCADStudio {
|
|||||||
crate::command::DimensionAssociationInput::Infer(source) => {
|
crate::command::DimensionAssociationInput::Infer(source) => {
|
||||||
source.map_or_else(
|
source.map_or_else(
|
||||||
|| {
|
|| {
|
||||||
self.tabs[i]
|
self.infer_dimension_sources_guarded(i, handle)
|
||||||
.scene
|
|
||||||
.infer_dimension_sources(handle)
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|source| {
|
.map(|source| {
|
||||||
source.map(
|
source.map(
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ impl OpenCADStudio {
|
|||||||
// Reset the last committed point so the first click of the new command
|
// 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.
|
// is not constrained by ortho/polar relative to a previous command's endpoint.
|
||||||
self.last_point = None;
|
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
|
// Starting a command restarts the right-click cycle, so its first
|
||||||
// right-click acts as Enter rather than opening the context menu.
|
// right-click acts as Enter rather than opening the context menu.
|
||||||
self.tabs[i]
|
self.tabs[i]
|
||||||
|
|||||||
@@ -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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ pub use automation::{export_headless, serve};
|
|||||||
mod annotation_data;
|
mod annotation_data;
|
||||||
mod command_driver;
|
mod command_driver;
|
||||||
pub(crate) mod commands;
|
pub(crate) mod commands;
|
||||||
|
pub(crate) mod dim_viewport;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod viewport_dimension_tests;
|
||||||
mod document;
|
mod document;
|
||||||
mod drafting_settings;
|
mod drafting_settings;
|
||||||
pub(crate) mod expr_eval;
|
pub(crate) mod expr_eval;
|
||||||
@@ -733,6 +736,16 @@ pub(super) struct OpenCADStudio {
|
|||||||
show_layout_tabs: bool,
|
show_layout_tabs: bool,
|
||||||
/// Last point committed by a drawing command — used as ortho/polar base.
|
/// Last point committed by a drawing command — used as ortho/polar base.
|
||||||
last_point: Option<glam::DVec3>,
|
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
|
/// Endpoint + unit exit-tangent of the most recently drawn line/arc, so
|
||||||
/// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing
|
/// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing
|
||||||
/// ended. `None` once a non-line/arc entity is committed.
|
/// ended. `None` once a non-line/arc entity is committed.
|
||||||
@@ -3821,6 +3834,9 @@ impl OpenCADStudio {
|
|||||||
show_file_tabs: true,
|
show_file_tabs: true,
|
||||||
show_layout_tabs: true,
|
show_layout_tabs: true,
|
||||||
last_point: None,
|
last_point: None,
|
||||||
|
vp_snap_frame: None,
|
||||||
|
accepted_snaps: Vec::new(),
|
||||||
|
pending_click_snap: None,
|
||||||
main_window: None,
|
main_window: None,
|
||||||
thumbnail_capture_clean: false,
|
thumbnail_capture_clean: false,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|||||||
@@ -1277,6 +1277,35 @@ impl OpenCADStudio {
|
|||||||
&self.tabs[i].scene.document,
|
&self.tabs[i].scene.document,
|
||||||
d.base().common.handle,
|
d.base().common.handle,
|
||||||
);
|
);
|
||||||
|
let statuses = self.tabs[i]
|
||||||
|
.scene
|
||||||
|
.dimension_association_status(d.base().common.handle);
|
||||||
|
let slots = self.tabs[i]
|
||||||
|
.scene
|
||||||
|
.dimension_association_slot_points(d.base().common.handle)
|
||||||
|
.len();
|
||||||
|
let status = if statuses.is_empty() {
|
||||||
|
t!("Nonassociative")
|
||||||
|
} else if statuses.iter().any(|(_, status)| {
|
||||||
|
matches!(status, crate::scene::ReferenceStatus::Broken(_))
|
||||||
|
}) {
|
||||||
|
t!("Broken reference")
|
||||||
|
} else if statuses.iter().any(|(_, status)| {
|
||||||
|
matches!(status, crate::scene::ReferenceStatus::Unresolved)
|
||||||
|
}) {
|
||||||
|
t!("Unresolved reference")
|
||||||
|
} else if statuses.len() < slots {
|
||||||
|
t!("Partially associated")
|
||||||
|
} else {
|
||||||
|
t!("Associated")
|
||||||
|
};
|
||||||
|
general.props.push(crate::scene::model::object::Property {
|
||||||
|
label: t!("Association status").into_owned(),
|
||||||
|
field: "association_status",
|
||||||
|
value: crate::scene::model::object::PropValue::ReadOnly(
|
||||||
|
status.into_owned(),
|
||||||
|
),
|
||||||
|
});
|
||||||
general.props.push(crate::scene::model::object::Property {
|
general.props.push(crate::scene::model::object::Property {
|
||||||
label: t!("Associative").into_owned(),
|
label: t!("Associative").into_owned(),
|
||||||
field: "associative",
|
field: "associative",
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ mod file;
|
|||||||
mod style;
|
mod style;
|
||||||
mod util;
|
mod util;
|
||||||
mod viewport;
|
mod viewport;
|
||||||
|
mod viewport_snap;
|
||||||
|
|
||||||
impl OpenCADStudio {
|
impl OpenCADStudio {
|
||||||
pub(in crate::app) fn reset_modal_geometry(&mut self) {
|
pub(in crate::app) fn reset_modal_geometry(&mut self) {
|
||||||
|
|||||||
@@ -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| {
|
let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| {
|
||||||
!command.needs_entity_pick()
|
!command.needs_entity_pick()
|
||||||
&& !command.needs_tangent_pick()
|
&& !command.needs_tangent_pick()
|
||||||
@@ -2306,6 +2331,10 @@ impl OpenCADStudio {
|
|||||||
extension_base2: None,
|
extension_base2: None,
|
||||||
extension_origin: None,
|
extension_origin: None,
|
||||||
extension_dir: 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() {
|
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
cmd.set_acquisition_hint(Some(pick.label));
|
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.
|
// Snap runs in model space; the result is already model.
|
||||||
let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw);
|
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
|
// 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 shift = self.shift_down;
|
||||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
|
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
|
||||||
// Shift-swap state for TRIM/EXTEND (#336).
|
// Shift-swap state for TRIM/EXTEND (#336).
|
||||||
@@ -3964,6 +4014,7 @@ impl OpenCADStudio {
|
|||||||
c.set_entity_pick_direction(entity_pick_direction);
|
c.set_entity_pick_direction(entity_pick_direction);
|
||||||
c.on_entity_pick(handle, entity_pick_point)
|
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.
|
// HATCHEDIT: after pick, inject hatch model data into the command.
|
||||||
if self.tabs[i]
|
if self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
@@ -4034,11 +4085,39 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
result
|
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]
|
} else if self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|command| command.entity_pick_accepts_points())
|
.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.refresh_command_point_pick_context(i);
|
||||||
self.tabs[i]
|
self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
@@ -4107,6 +4186,20 @@ impl OpenCADStudio {
|
|||||||
self.tabs[i].dyn_active = 0;
|
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);
|
self.last_point = Some(world_pt);
|
||||||
// The one-shot snap override is spent by this pick —
|
// The one-shot snap override is spent by this pick —
|
||||||
// restore the running osnap configuration (#337).
|
// restore the running osnap configuration (#337).
|
||||||
@@ -4142,6 +4235,7 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.sync_dimension_snaps(i);
|
||||||
if let Some(r) = result {
|
if let Some(r) = result {
|
||||||
let task = self.apply_cmd_result(r);
|
let task = self.apply_cmd_result(r);
|
||||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||||
|
|||||||
@@ -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
@@ -183,7 +183,7 @@ pub struct SelectionEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Association source with an optional sub-entity marker.
|
/// Association source with an optional sub-entity marker.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct DimensionAssociationSource {
|
pub struct DimensionAssociationSource {
|
||||||
pub handle: Handle,
|
pub handle: Handle,
|
||||||
pub marker: Option<i32>,
|
pub marker: Option<i32>,
|
||||||
@@ -2245,7 +2245,23 @@ pub trait CadCommand: Send {
|
|||||||
CmdResult::Cancel
|
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 {
|
fn needs_entity_pick(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,65 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option<XData
|
|||||||
write_pairs(doc, handle, kept);
|
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> {
|
pub fn property_real_code(field: &str) -> Option<i16> {
|
||||||
Some(match field {
|
Some(match field {
|
||||||
"dim_arrow_size" => DIMASZ,
|
"dim_arrow_size" => DIMASZ,
|
||||||
|
|||||||
@@ -1613,9 +1613,56 @@ pub(crate) fn resolved_dimension_style(
|
|||||||
style.dimtxsty = record.name.clone();
|
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
|
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
|
/// Build the linear-dimension property groups from the assigned style plus
|
||||||
/// any entity-level DSTYLE overrides. Conditional rows remain visible but are
|
/// any entity-level DSTYLE overrides. Conditional rows remain visible but are
|
||||||
/// read-only while their controlling option is disabled.
|
/// read-only while their controlling option is disabled.
|
||||||
@@ -2097,7 +2144,13 @@ pub fn style_sections(
|
|||||||
property(
|
property(
|
||||||
t!("Measurement").as_ref(),
|
t!("Measurement").as_ref(),
|
||||||
"measurement",
|
"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(
|
property(
|
||||||
t!("Text override").as_ref(),
|
t!("Text override").as_ref(),
|
||||||
@@ -2196,7 +2249,12 @@ pub fn style_sections(
|
|||||||
number(
|
number(
|
||||||
t!("Dim scale linear").as_ref(),
|
t!("Dim scale linear").as_ref(),
|
||||||
"dim_scale_linear",
|
"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,
|
true,
|
||||||
),
|
),
|
||||||
number(
|
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, ""));
|
.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 scaled = measurement * lfac;
|
||||||
let use_sub_units = zin & 4 != 0 && scaled.abs() < 1.0 && sub_factor.abs() > 1e-12;
|
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
|
// For values below one unit, DIMMZF replaces DIMLFAC; applying it after
|
||||||
|
|||||||
@@ -161,6 +161,24 @@ impl CadCommand for AlignedDimensionCommand {
|
|||||||
CmdResult::Cancel
|
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 {
|
fn input_kind(&self) -> InputKind {
|
||||||
if self.awaiting_text {
|
if self.awaiting_text {
|
||||||
InputKind::FreeText
|
InputKind::FreeText
|
||||||
|
|||||||
@@ -482,6 +482,42 @@ impl CadCommand for AngularDimensionCommand {
|
|||||||
CmdResult::Cancel
|
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 {
|
fn on_escape(&mut self) -> CmdResult {
|
||||||
CmdResult::Cancel
|
CmdResult::Cancel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ impl CadCommand for DiameterDimensionCommand {
|
|||||||
CmdResult::Cancel
|
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 {
|
fn on_escape(&mut self) -> CmdResult {
|
||||||
CmdResult::Cancel
|
CmdResult::Cancel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,24 @@ impl CadCommand for LinearDimensionCommand {
|
|||||||
CmdResult::Cancel
|
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 {
|
fn on_escape(&mut self) -> CmdResult {
|
||||||
CmdResult::Cancel
|
CmdResult::Cancel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,23 @@ impl CadCommand for RadiusDimensionCommand {
|
|||||||
CmdResult::Cancel
|
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 {
|
fn on_escape(&mut self) -> CmdResult {
|
||||||
CmdResult::Cancel
|
CmdResult::Cancel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -567,6 +567,7 @@ impl Scene {
|
|||||||
viewport.view_target.x = center.x;
|
viewport.view_target.x = center.x;
|
||||||
viewport.view_target.y = center.y;
|
viewport.view_target.y = center.y;
|
||||||
viewport.view_target.z = center.z;
|
viewport.view_target.z = center.z;
|
||||||
|
self.notify_viewport_changed(handle);
|
||||||
} else {
|
} else {
|
||||||
self.camera.borrow_mut().target = center;
|
self.camera.borrow_mut().target = center;
|
||||||
}
|
}
|
||||||
|
|||||||
+1092
-25
File diff suppressed because it is too large.
Load diff
@@ -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(¶meter) {
|
||||||
|
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
@@ -134,7 +134,7 @@ impl Scene {
|
|||||||
let _ = self.document.layers.add(layer);
|
let _ = self.document.layers.add(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_app_id(&mut self, name: &str) {
|
pub(super) fn ensure_app_id(&mut self, name: &str) {
|
||||||
if name.trim().is_empty() || self.document.app_ids.contains(name) {
|
if name.trim().is_empty() || self.document.app_ids.contains(name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ mod camera_ops;
|
|||||||
pub(crate) mod centerline;
|
pub(crate) mod centerline;
|
||||||
pub(crate) mod centermark;
|
pub(crate) mod centermark;
|
||||||
pub(crate) mod dimension_assoc;
|
pub(crate) mod dimension_assoc;
|
||||||
|
pub(crate) mod dimension_assoc_chain;
|
||||||
|
pub use dimension_assoc::{ReferenceStatus, ResolvedReference};
|
||||||
mod dwg_native_constraints;
|
mod dwg_native_constraints;
|
||||||
mod entity;
|
mod entity;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -40,6 +42,8 @@ mod layout;
|
|||||||
mod limits;
|
mod limits;
|
||||||
mod modify;
|
mod modify;
|
||||||
mod mspace;
|
mod mspace;
|
||||||
|
pub mod viewport_ref;
|
||||||
|
pub mod viewport_dimension_pick;
|
||||||
pub mod named_parameters;
|
pub mod named_parameters;
|
||||||
mod page_setup;
|
mod page_setup;
|
||||||
mod paper;
|
mod paper;
|
||||||
@@ -5438,6 +5442,10 @@ impl Scene {
|
|||||||
self.document
|
self.document
|
||||||
.set_viewport_annotation_scale(viewport, scale_handle);
|
.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.resident_wire_sets.borrow_mut().clear();
|
||||||
self.bump_geometry();
|
self.bump_geometry();
|
||||||
|
|
||||||
@@ -5484,6 +5492,10 @@ impl Scene {
|
|||||||
vp.view_height = vp.height / factor;
|
vp.view_height = vp.height / factor;
|
||||||
self.document
|
self.document
|
||||||
.set_viewport_annotation_scale(viewport, scale_handle);
|
.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.resident_wire_sets.borrow_mut().clear();
|
||||||
self.bump_geometry();
|
self.bump_geometry();
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -730,6 +730,7 @@ impl Scene {
|
|||||||
// Complete group copies record their new Group objects and dictionary
|
// Complete group copies record their new Group objects and dictionary
|
||||||
// entry as targeted object deltas inside copy_complete_groups.
|
// entry as targeted object deltas inside copy_complete_groups.
|
||||||
self.copy_complete_groups(&handle_map);
|
self.copy_complete_groups(&handle_map);
|
||||||
|
self.copy_dimension_associations(&handle_map);
|
||||||
// The copies are new handles (natural memo misses, tessellated fresh)
|
// The copies are new handles (natural memo misses, tessellated fresh)
|
||||||
// and reference only already-cached blocks — no block defn changes.
|
// and reference only already-cached blocks — no block defn changes.
|
||||||
// Report them as additions so derived caches patch in exactly the copies.
|
// Report them as additions so derived caches patch in exactly the copies.
|
||||||
|
|||||||
+197
-6
@@ -80,12 +80,173 @@ impl Scene {
|
|||||||
&self,
|
&self,
|
||||||
canvas_px: (f32, f32),
|
canvas_px: (f32, f32),
|
||||||
) -> Option<(view::camera::Camera, iced::Rectangle)> {
|
) -> 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 cam = self.camera_for_viewport(vp_handle)?;
|
||||||
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
|
let full = self.viewport_screen_rect(vp_handle, canvas_px)?;
|
||||||
Some((cam, full))
|
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
|
/// 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` /
|
/// 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
|
/// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the
|
||||||
@@ -109,6 +270,7 @@ impl Scene {
|
|||||||
vp.view_height = eff_h;
|
vp.view_height = eff_h;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.notify_viewport_changed(vp_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fit model-space bounds into the active floating viewport without moving
|
/// 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;
|
viewport.custom_scale = viewport.height / viewport.view_height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.notify_viewport_changed(viewport_handle);
|
||||||
self.camera_generation += 1;
|
self.camera_generation += 1;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -198,6 +361,7 @@ impl Scene {
|
|||||||
vp.view_target.y += model_delta.y as f64;
|
vp.view_target.y += model_delta.y as f64;
|
||||||
vp.view_target.z += model_delta.z 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.
|
/// 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.
|
/// 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.view_direction.z = dir.z as f64;
|
||||||
vp.twist_angle = twist;
|
vp.twist_angle = twist;
|
||||||
}
|
}
|
||||||
|
self.notify_viewport_changed(vp_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutate the active viewport's camera through a closure, then re-encode the
|
/// Mutate the active viewport's camera through a closure, then re-encode the
|
||||||
@@ -352,6 +518,7 @@ impl Scene {
|
|||||||
.normalize_or(glam::Vec3::Y);
|
.normalize_or(glam::Vec3::Y);
|
||||||
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up));
|
||||||
let twist = -roll as f64;
|
let twist = -roll as f64;
|
||||||
|
let mut changed = false;
|
||||||
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) {
|
||||||
if vp.status.locked {
|
if vp.status.locked {
|
||||||
return false;
|
return false;
|
||||||
@@ -364,9 +531,12 @@ impl Scene {
|
|||||||
tmp.projection == view::camera::Projection::Perspective;
|
tmp.projection == view::camera::Projection::Perspective;
|
||||||
vp.lens_length =
|
vp.lens_length =
|
||||||
(12.0 / (tmp.fov_y * 0.5).tan().max(1e-6)) as f64;
|
(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
|
/// Render mode of the active paper-space viewport, or `None` when no
|
||||||
@@ -466,9 +636,6 @@ impl Scene {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Some(handle) = self.active_viewport {
|
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) {
|
if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) {
|
||||||
vp.view_target.x = camera.target.x;
|
vp.view_target.x = camera.target.x;
|
||||||
vp.view_target.y = camera.target.y;
|
vp.view_target.y = camera.target.y;
|
||||||
@@ -478,6 +645,10 @@ impl Scene {
|
|||||||
vp.view_height = camera.ortho_size() as f64 * 2.;
|
vp.view_height = camera.ortho_size() as f64 * 2.;
|
||||||
vp.custom_scale = vp.height / vp.view_height;
|
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 {
|
} else {
|
||||||
*self.camera.borrow_mut() = camera;
|
*self.camera.borrow_mut() = camera;
|
||||||
}
|
}
|
||||||
@@ -563,6 +734,26 @@ impl Scene {
|
|||||||
.map(|(h, _)| h)
|
.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,
|
/// Return the handle of the first active user viewport in the current layout,
|
||||||
/// or `None` if there are none. Used by the MS command.
|
/// or `None` if there are none. Used by the MS command.
|
||||||
pub fn first_user_viewport(&self) -> Option<Handle> {
|
pub fn first_user_viewport(&self) -> Option<Handle> {
|
||||||
|
|||||||
@@ -674,10 +674,7 @@ pub(crate) fn clip_boundary_polygon_for_document(
|
|||||||
z: f32,
|
z: f32,
|
||||||
) -> Vec<[f32; 3]> {
|
) -> Vec<[f32; 3]> {
|
||||||
use std::f64::consts::TAU;
|
use std::f64::consts::TAU;
|
||||||
let Some(entity) = document
|
let Some(entity) = document.get_entity(handle) else {
|
||||||
.entities()
|
|
||||||
.find(|e| e.common().handle == handle)
|
|
||||||
else {
|
|
||||||
return vec![];
|
return vec![];
|
||||||
};
|
};
|
||||||
// Circles and ellipses tessellate directly — their `to_render` returns a
|
// Circles and ellipses tessellate directly — their `to_render` returns a
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -9,8 +9,9 @@ use iced::time::Instant;
|
|||||||
use iced::{Point, Rectangle};
|
use iced::{Point, Rectangle};
|
||||||
|
|
||||||
use cadkernel::geom2d::Curve;
|
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::model::wire_model::{SnapHint, TangentGeom, WireModel};
|
||||||
use crate::scene::pick::interaction_index::WireSource;
|
use crate::scene::pick::interaction_index::WireSource;
|
||||||
const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0;
|
const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0;
|
||||||
@@ -86,6 +87,37 @@ pub struct SnapResult {
|
|||||||
pub extension_origin: Option<glam::DVec3>,
|
pub extension_origin: Option<glam::DVec3>,
|
||||||
/// Source direction paired with `extension_origin`.
|
/// Source direction paired with `extension_origin`.
|
||||||
pub extension_dir: Option<glam::DVec3>,
|
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
|
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
|
||||||
@@ -954,6 +986,10 @@ impl Snapper {
|
|||||||
extension_base2: None,
|
extension_base2: None,
|
||||||
extension_origin: None,
|
extension_origin: None,
|
||||||
extension_dir: None,
|
extension_dir: None,
|
||||||
|
viewport: None,
|
||||||
|
source: None,
|
||||||
|
secondary_source: None,
|
||||||
|
model_point: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1087,6 +1123,10 @@ impl Snapper {
|
|||||||
extension_base2: None,
|
extension_base2: None,
|
||||||
extension_origin: None,
|
extension_origin: None,
|
||||||
extension_dir: None,
|
extension_dir: None,
|
||||||
|
viewport: None,
|
||||||
|
source: None,
|
||||||
|
secondary_source: None,
|
||||||
|
model_point: None,
|
||||||
});
|
});
|
||||||
best_rank = snap_tier(SnapType::Grid);
|
best_rank = snap_tier(SnapType::Grid);
|
||||||
best_sub = snap_priority(SnapType::Grid);
|
best_sub = snap_priority(SnapType::Grid);
|
||||||
@@ -1182,7 +1222,10 @@ impl Snapper {
|
|||||||
return best;
|
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);
|
let screen = world_to_screen(world, view_rot, eye, bounds);
|
||||||
if !in_bounds(screen) {
|
if !in_bounds(screen) {
|
||||||
return;
|
return;
|
||||||
@@ -1209,12 +1252,18 @@ impl Snapper {
|
|||||||
extension_base2: None,
|
extension_base2: None,
|
||||||
extension_origin: None,
|
extension_origin: None,
|
||||||
extension_dir: None,
|
extension_dir: None,
|
||||||
|
viewport: None,
|
||||||
|
source: src,
|
||||||
|
secondary_source: secondary,
|
||||||
|
model_point: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ──────
|
// ── 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 {
|
let snap_type = match hint {
|
||||||
SnapHint::Center => SnapType::Center,
|
SnapHint::Center => SnapType::Center,
|
||||||
SnapHint::Node => SnapType::Node,
|
SnapHint::Node => SnapType::Node,
|
||||||
@@ -1224,23 +1273,24 @@ impl Snapper {
|
|||||||
SnapHint::Endpoint => SnapType::Endpoint,
|
SnapHint::Endpoint => SnapType::Endpoint,
|
||||||
};
|
};
|
||||||
if self.is_on(snap_type) {
|
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() {
|
if let Some(points) = wires.snap_points() {
|
||||||
for point_ref in points {
|
for point_ref in points {
|
||||||
let Some(&(world, hint)) = wires
|
let Some(wire) = wires.source_wire(point_ref.wire) else {
|
||||||
.source_wire(point_ref.wire)
|
|
||||||
.and_then(|wire| wire.snap_pts.get(point_ref.index as usize))
|
|
||||||
else {
|
|
||||||
continue;
|
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 {
|
} else {
|
||||||
for wire in in_range_wires.iter() {
|
for wire in in_range_wires.iter() {
|
||||||
|
let src = wire_source(wire);
|
||||||
for &(world, hint) in &wire.snap_pts {
|
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 self.is_on(SnapType::Endpoint) {
|
||||||
if let Some(vertices) = wires.key_vertices() {
|
if let Some(vertices) = wires.key_vertices() {
|
||||||
for vertex_ref in vertices {
|
for vertex_ref in vertices {
|
||||||
let Some(&point) = wires
|
let Some(wire) = wires.source_wire(vertex_ref.wire) else {
|
||||||
.source_wire(vertex_ref.wire)
|
|
||||||
.and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize))
|
|
||||||
else {
|
|
||||||
continue;
|
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
|
// Tessellated open curves have no key-vertex set. Their only
|
||||||
// endpoints are first/last, so testing candidate wires remains
|
// endpoints are first/last, so testing candidate wires remains
|
||||||
@@ -1270,17 +1325,23 @@ impl Snapper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !wire.points.is_empty() {
|
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 {
|
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 {
|
} else {
|
||||||
for wire in in_range_wires.iter() {
|
for wire in in_range_wires.iter() {
|
||||||
|
let src = wire_source(wire);
|
||||||
if !wire.key_vertices.is_empty() {
|
if !wire.key_vertices.is_empty() {
|
||||||
for &point in &wire.key_vertices {
|
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 {
|
} else {
|
||||||
let closed = wire
|
let closed = wire
|
||||||
@@ -1289,10 +1350,15 @@ impl Snapper {
|
|||||||
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
|
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
|
||||||
if !closed {
|
if !closed {
|
||||||
if !wire.points.is_empty() {
|
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 {
|
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 a = DVec3::from_array(a);
|
||||||
let b = DVec3::from_array(b);
|
let b = DVec3::from_array(b);
|
||||||
if a.distance_squared(b) > 1e-12 {
|
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 {
|
} else {
|
||||||
for wire in in_range_wires.iter() {
|
for wire in in_range_wires.iter() {
|
||||||
|
let src = wire_source(wire);
|
||||||
for segment in wire.key_vertices.windows(2) {
|
for segment in wire.key_vertices.windows(2) {
|
||||||
let a = DVec3::from_array(segment[0]);
|
let a = DVec3::from_array(segment[0]);
|
||||||
let b = DVec3::from_array(segment[1]);
|
let b = DVec3::from_array(segment[1]);
|
||||||
if a.distance_squared(b) > 1e-12 {
|
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(
|
try_pt(
|
||||||
nearest_on_segment(cursor_world, seg.a, seg.b),
|
nearest_on_segment(cursor_world, seg.a, seg.b),
|
||||||
SnapType::Nearest,
|
SnapType::Nearest,
|
||||||
|
wires.source_wire(seg.wire).and_then(wire_source),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for wire in in_range_wires.iter() {
|
for wire in in_range_wires.iter() {
|
||||||
|
let src = wire_source(wire);
|
||||||
for i in 0..wire.points.len().saturating_sub(1) {
|
for i in 0..wire.points.len().saturating_sub(1) {
|
||||||
let p =
|
let p =
|
||||||
nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1));
|
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 {
|
if let Some(segments) = &local_segments {
|
||||||
for seg in segments {
|
for seg in segments {
|
||||||
if let Some(foot) = perp_foot(q, seg.a, seg.b) {
|
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 {
|
} else {
|
||||||
for wire in in_range_wires.iter() {
|
for wire in in_range_wires.iter() {
|
||||||
|
let src = wire_source(wire);
|
||||||
for i in 0..wire.points.len().saturating_sub(1) {
|
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)) {
|
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)
|
ray_segment_intersect_3d(origin, through, segment.a, segment.b)
|
||||||
{
|
{
|
||||||
if (point - origin).length_squared() > 1e-18 {
|
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 {
|
} else {
|
||||||
for wire in in_range_wires.iter() {
|
for wire in in_range_wires.iter() {
|
||||||
|
let src = wire_source(wire);
|
||||||
for index in 0..wire.points.len().saturating_sub(1) {
|
for index in 0..wire.points.len().saturating_sub(1) {
|
||||||
if let Some(point) = ray_segment_intersect_3d(
|
if let Some(point) = ray_segment_intersect_3d(
|
||||||
origin,
|
origin,
|
||||||
@@ -1401,7 +1483,7 @@ impl Snapper {
|
|||||||
wp_f64(wire, index + 1),
|
wp_f64(wire, index + 1),
|
||||||
) {
|
) {
|
||||||
if (point - origin).length_squared() > 1e-18 {
|
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 (idx, &wire_i) in local_wires.iter().enumerate() {
|
||||||
for &wire_j in &local_wires[idx + 1..] {
|
for &wire_j in &local_wires[idx + 1..] {
|
||||||
if let Some(points) = exact_curve_intersections(wire_i, wire_j) {
|
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 {
|
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));
|
resolved_pairs.insert(pair_key(wire_i, wire_j));
|
||||||
}
|
}
|
||||||
@@ -1515,7 +1600,12 @@ impl Snapper {
|
|||||||
world_to_screen(pt, view_rot, eye, bounds),
|
world_to_screen(pt, view_rot, eye, bounds),
|
||||||
cursor_screen,
|
cursor_screen,
|
||||||
) <= f32::EPSILON;
|
) <= 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 {
|
if exact_cursor {
|
||||||
break 'intersection_sweep;
|
break 'intersection_sweep;
|
||||||
}
|
}
|
||||||
@@ -1535,8 +1625,9 @@ impl Snapper {
|
|||||||
// Curved pairs are solved exactly (bug #1052); see
|
// Curved pairs are solved exactly (bug #1052); see
|
||||||
// `exact_curve_intersections`'s doc comment.
|
// `exact_curve_intersections`'s doc comment.
|
||||||
if let Some(pts) = exact_curve_intersections(wire_i, wire_j) {
|
if let Some(pts) = exact_curve_intersections(wire_i, wire_j) {
|
||||||
|
let src = wire_source(wire_i);
|
||||||
for pt in pts {
|
for pt in pts {
|
||||||
try_pt(pt, SnapType::Intersection);
|
try_pt(pt, SnapType::Intersection, src, wire_source(wire_j));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1560,7 +1651,12 @@ impl Snapper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) {
|
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,
|
bounds,
|
||||||
self.osnap_radius_px,
|
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),
|
// feet (which sit closer to the cursor on their own lines),
|
||||||
// or the cursor would snap to a line instead of the crossing.
|
// 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);
|
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(
|
try_pt(
|
||||||
segment_a.a + ta as f64 * (segment_a.b - segment_a.a),
|
segment_a.a + ta as f64 * (segment_a.b - segment_a.a),
|
||||||
SnapType::ApparentIntersection,
|
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 {
|
if dist2(apparent_screen, cursor_screen) <= f32::EPSILON {
|
||||||
break 'apparent_sweep;
|
break 'apparent_sweep;
|
||||||
@@ -1726,6 +1824,8 @@ impl Snapper {
|
|||||||
try_pt(
|
try_pt(
|
||||||
wa0 + ta as f64 * (wa1 - wa0),
|
wa0 + ta as f64 * (wa1 - wa0),
|
||||||
SnapType::ApparentIntersection,
|
SnapType::ApparentIntersection,
|
||||||
|
wire_source(wire_i),
|
||||||
|
wire_source(wire_j),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1947,6 +2047,10 @@ impl Snapper {
|
|||||||
extension_base2: None,
|
extension_base2: None,
|
||||||
extension_origin: None,
|
extension_origin: None,
|
||||||
extension_dir: 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_base2: None,
|
||||||
extension_origin: None,
|
extension_origin: None,
|
||||||
extension_dir: 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
|
/// Candidate ordering: tier first, then cursor distance, and only when two
|
||||||
/// candidates are effectively equidistant (coincident features) the classic
|
/// candidates are effectively equidistant (coincident features) the classic
|
||||||
/// sub-priority — so an Endpoint still beats an Intersection sitting on the
|
/// sub-priority — so an Endpoint still beats an Intersection sitting on the
|
||||||
|
|||||||
Vendored
+10
@@ -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.
|
||||||
+1746
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user