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
@@ -97,6 +97,10 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].snap_result = None;
|
||||
self.last_point = None;
|
||||
// Points collected in the space being left are meaningless in the new
|
||||
// one.
|
||||
self.clear_accepted_snaps();
|
||||
self.pending_click_snap = None;
|
||||
self.snapper.from_point = None;
|
||||
self.snapper.clear_tracking();
|
||||
self.otrack_active = None;
|
||||
@@ -417,6 +421,13 @@ impl OpenCADStudio {
|
||||
if !self.command_point_allowed(i, *point) {
|
||||
return Task::none();
|
||||
}
|
||||
// Typed / dynamic-input / headless points carry no snap, but the
|
||||
// accepted-snap list must stay index-parallel with the points the
|
||||
// command collects. Interactive picks record themselves in
|
||||
// the click handler and never reach here.
|
||||
if !self.record_accepted_snap(i, None, None, *point) {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if default_start {
|
||||
let StepInput::Point(point) = &input else {
|
||||
@@ -430,6 +441,9 @@ impl OpenCADStudio {
|
||||
self.push_ucs_to_cmd(i);
|
||||
}
|
||||
if let StepInput::EntityPick(handle, point) = &input {
|
||||
if !self.dimension_acquisition_allowed(i, None) {
|
||||
return Task::none();
|
||||
}
|
||||
let solid_pick = matches!(
|
||||
self.tabs[i].scene.document.get_entity(*handle),
|
||||
Some(
|
||||
@@ -502,6 +516,11 @@ impl OpenCADStudio {
|
||||
}
|
||||
let ctrl = self.ctrl_down;
|
||||
let shift = self.shift_down;
|
||||
let picked_handle = if let StepInput::EntityPick(h, _) = &input {
|
||||
Some(*h)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result: Option<CmdResult> = {
|
||||
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
|
||||
return Task::none();
|
||||
@@ -520,6 +539,10 @@ impl OpenCADStudio {
|
||||
StepInput::Escape => Some(cmd.on_escape()),
|
||||
}
|
||||
};
|
||||
if let Some(handle) = picked_handle {
|
||||
self.record_dimension_entity_points(i, None, handle, Vec::new());
|
||||
}
|
||||
self.sync_dimension_snaps(i);
|
||||
match result {
|
||||
Some(r) => self.apply_cmd_result(r),
|
||||
None => Task::none(),
|
||||
@@ -1876,7 +1899,7 @@ impl OpenCADStudio {
|
||||
};
|
||||
if is_associative_dimension && association_enabled {
|
||||
if let Some(handle) = committed {
|
||||
let sources = self.tabs[i].scene.infer_dimension_sources(handle);
|
||||
let sources = self.infer_dimension_sources_guarded(i, handle);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.attach_dimension_association(handle, sources);
|
||||
@@ -2383,7 +2406,7 @@ impl OpenCADStudio {
|
||||
let committed = self.commit_entity_handle(entity);
|
||||
if is_associative_dimension && association_enabled {
|
||||
if let Some(handle) = committed {
|
||||
let sources = self.tabs[i].scene.infer_dimension_sources(handle);
|
||||
let sources = self.infer_dimension_sources_guarded(i, handle);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.attach_dimension_association(handle, sources);
|
||||
@@ -2416,6 +2439,20 @@ impl OpenCADStudio {
|
||||
&entity,
|
||||
acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_))
|
||||
);
|
||||
// A dimension placed on the sheet but measuring model geometry
|
||||
// through a viewport carries the compensation as a negative
|
||||
// DIMLFAC override.
|
||||
if !preserve_base_style {
|
||||
crate::scene::creation_style::apply_current_creation_styles(
|
||||
&self.tabs[i].scene.document,
|
||||
&mut entity,
|
||||
);
|
||||
}
|
||||
if !self.apply_viewport_dimension_measurement(i, &mut entity) {
|
||||
return Task::none();
|
||||
}
|
||||
// Projected points cannot use direct paper-space source inference.
|
||||
let association_allowed = self.dimension_association_allowed(i);
|
||||
let inherited_dimension = if preserve_base_style {
|
||||
match &entity {
|
||||
acadrust::EntityType::Dimension(dimension) => Some((
|
||||
@@ -2479,12 +2516,12 @@ impl OpenCADStudio {
|
||||
if let Some(handle) =
|
||||
self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style)
|
||||
{
|
||||
if association_mode == 2 {
|
||||
if association_mode == 2 && association_allowed {
|
||||
let mut changes = vec![(handle, crate::scene::ChangeKind::Modified)];
|
||||
match association {
|
||||
crate::command::DimensionAssociationInput::Infer(source) => {
|
||||
let sources: Vec<_> = source.map_or_else(
|
||||
|| self.tabs[i].scene.infer_dimension_sources(handle),
|
||||
|| self.infer_dimension_sources_guarded(i, handle),
|
||||
|source| {
|
||||
if single_source_dimension {
|
||||
vec![Some(source)]
|
||||
@@ -2513,6 +2550,9 @@ impl OpenCADStudio {
|
||||
changes.sort_by_key(|(handle, _)| handle.value());
|
||||
changes.dedup_by_key(|(handle, _)| handle.value());
|
||||
self.tabs[i].scene.bump_entities(&changes);
|
||||
} else if association_mode == 2 {
|
||||
// Preserve the acquired viewport and source paths.
|
||||
self.attach_viewport_dimension_association(i, handle);
|
||||
}
|
||||
}
|
||||
pending
|
||||
@@ -2599,9 +2639,7 @@ impl OpenCADStudio {
|
||||
crate::command::DimensionAssociationInput::Infer(source) => {
|
||||
source.map_or_else(
|
||||
|| {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.infer_dimension_sources(handle)
|
||||
self.infer_dimension_sources_guarded(i, handle)
|
||||
.into_iter()
|
||||
.map(|source| {
|
||||
source.map(
|
||||
|
||||
@@ -123,6 +123,9 @@ impl OpenCADStudio {
|
||||
// Reset the last committed point so the first click of the new command
|
||||
// is not constrained by ortho/polar relative to a previous command's endpoint.
|
||||
self.last_point = None;
|
||||
// A new command collects its own points, so the previous command's
|
||||
// accepted snaps must not leak into it.
|
||||
self.clear_accepted_snaps();
|
||||
// Starting a command restarts the right-click cycle, so its first
|
||||
// right-click acts as Enter rather than opening the context menu.
|
||||
self.tabs[i]
|
||||
|
||||
@@ -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 command_driver;
|
||||
pub(crate) mod commands;
|
||||
pub(crate) mod dim_viewport;
|
||||
#[cfg(test)]
|
||||
mod viewport_dimension_tests;
|
||||
mod document;
|
||||
mod drafting_settings;
|
||||
pub(crate) mod expr_eval;
|
||||
@@ -733,6 +736,16 @@ pub(super) struct OpenCADStudio {
|
||||
show_layout_tabs: bool,
|
||||
/// Last point committed by a drawing command — used as ortho/polar base.
|
||||
last_point: Option<glam::DVec3>,
|
||||
/// Viewport used by the current snap; the displayed point is in paper space.
|
||||
pub(crate) vp_snap_frame: Option<crate::scene::viewport_ref::ViewportFrame>,
|
||||
/// Acquired coordinates and source identities in command-step order.
|
||||
/// Cleared when the command starts or ends.
|
||||
accepted_snaps: Vec<crate::scene::viewport_ref::AcceptedSnap>,
|
||||
/// Click result retained until the command accepts its point.
|
||||
pending_click_snap: Option<(
|
||||
crate::snap::SnapResult,
|
||||
Option<crate::scene::viewport_ref::ViewportFrame>,
|
||||
)>,
|
||||
/// Endpoint + unit exit-tangent of the most recently drawn line/arc, so
|
||||
/// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing
|
||||
/// ended. `None` once a non-line/arc entity is committed.
|
||||
@@ -3821,6 +3834,9 @@ impl OpenCADStudio {
|
||||
show_file_tabs: true,
|
||||
show_layout_tabs: true,
|
||||
last_point: None,
|
||||
vp_snap_frame: None,
|
||||
accepted_snaps: Vec::new(),
|
||||
pending_click_snap: None,
|
||||
main_window: None,
|
||||
thumbnail_capture_clean: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1277,6 +1277,35 @@ impl OpenCADStudio {
|
||||
&self.tabs[i].scene.document,
|
||||
d.base().common.handle,
|
||||
);
|
||||
let statuses = self.tabs[i]
|
||||
.scene
|
||||
.dimension_association_status(d.base().common.handle);
|
||||
let slots = self.tabs[i]
|
||||
.scene
|
||||
.dimension_association_slot_points(d.base().common.handle)
|
||||
.len();
|
||||
let status = if statuses.is_empty() {
|
||||
t!("Nonassociative")
|
||||
} else if statuses.iter().any(|(_, status)| {
|
||||
matches!(status, crate::scene::ReferenceStatus::Broken(_))
|
||||
}) {
|
||||
t!("Broken reference")
|
||||
} else if statuses.iter().any(|(_, status)| {
|
||||
matches!(status, crate::scene::ReferenceStatus::Unresolved)
|
||||
}) {
|
||||
t!("Unresolved reference")
|
||||
} else if statuses.len() < slots {
|
||||
t!("Partially associated")
|
||||
} else {
|
||||
t!("Associated")
|
||||
};
|
||||
general.props.push(crate::scene::model::object::Property {
|
||||
label: t!("Association status").into_owned(),
|
||||
field: "association_status",
|
||||
value: crate::scene::model::object::PropValue::ReadOnly(
|
||||
status.into_owned(),
|
||||
),
|
||||
});
|
||||
general.props.push(crate::scene::model::object::Property {
|
||||
label: t!("Associative").into_owned(),
|
||||
field: "associative",
|
||||
|
||||
@@ -95,6 +95,7 @@ mod file;
|
||||
mod style;
|
||||
mod util;
|
||||
mod viewport;
|
||||
mod viewport_snap;
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn reset_modal_geometry(&mut self) {
|
||||
|
||||
@@ -2017,6 +2017,31 @@ impl OpenCADStudio {
|
||||
)
|
||||
};
|
||||
|
||||
// Paper-space snapping through layout viewports. `edit_cam`
|
||||
// is None here, so `cursor_world` is the paper point and the sheet
|
||||
// fills the canvas (pane-local == canvas pixels). The viewport hit
|
||||
// arrives already projected onto the sheet, so everything
|
||||
// downstream keeps working in paper coordinates — LINE still draws
|
||||
// at the projected paper point.
|
||||
self.vp_snap_frame = None;
|
||||
if edit_cam.is_none()
|
||||
&& !needs_entity
|
||||
&& !is_gathering
|
||||
&& !needs_structure
|
||||
&& !needs_tan
|
||||
{
|
||||
if let Some((vp_hit, frame)) =
|
||||
self.paper_viewport_snap(i, p_full, vp_size, cursor_world)
|
||||
{
|
||||
let merged =
|
||||
crate::snap::merge_snap(self.tabs[i].snap_result, Some(vp_hit), p_full);
|
||||
if merged.is_some_and(|hit| hit.viewport.is_some()) {
|
||||
self.vp_snap_frame = Some(frame);
|
||||
}
|
||||
self.tabs[i].snap_result = merged;
|
||||
}
|
||||
}
|
||||
|
||||
let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| {
|
||||
!command.needs_entity_pick()
|
||||
&& !command.needs_tangent_pick()
|
||||
@@ -2306,6 +2331,10 @@ impl OpenCADStudio {
|
||||
extension_base2: None,
|
||||
extension_origin: None,
|
||||
extension_dir: None,
|
||||
viewport: None,
|
||||
source: None,
|
||||
secondary_source: None,
|
||||
model_point: None,
|
||||
});
|
||||
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||
cmd.set_acquisition_hint(Some(pick.label));
|
||||
@@ -3630,6 +3659,23 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Paper-space snapping through layout viewports. Mirrors
|
||||
// the cursor-move pass: the click recomputes snapping from
|
||||
// scratch, so the viewport query has to run here too. The hit
|
||||
// comes back already projected onto the sheet.
|
||||
let mut click_frame: Option<crate::scene::viewport_ref::ViewportFrame> = None;
|
||||
if edit_cam.is_none() && !needs_entity_click && !needs_tan {
|
||||
if let Some((vp_hit, frame)) =
|
||||
self.paper_viewport_snap(i, p_full, (vw, vh), raw)
|
||||
{
|
||||
let merged = crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full);
|
||||
if merged.is_some_and(|hit| hit.viewport.is_some()) {
|
||||
click_frame = Some(frame);
|
||||
}
|
||||
snap_hit = merged;
|
||||
}
|
||||
}
|
||||
self.pending_click_snap = snap_hit.map(|hit| (hit, click_frame));
|
||||
// Snap runs in model space; the result is already model.
|
||||
let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw);
|
||||
// When no UCS is active clamp to world XY; with a UCS the point is
|
||||
@@ -3957,6 +4003,10 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
if !self.dimension_acquisition_allowed(i, None) {
|
||||
self.finish_command_click(i);
|
||||
return Task::none();
|
||||
}
|
||||
let shift = self.shift_down;
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
|
||||
// Shift-swap state for TRIM/EXTEND (#336).
|
||||
@@ -3964,6 +4014,7 @@ impl OpenCADStudio {
|
||||
c.set_entity_pick_direction(entity_pick_direction);
|
||||
c.on_entity_pick(handle, entity_pick_point)
|
||||
});
|
||||
self.record_dimension_entity_points(i, None, handle, Vec::new());
|
||||
// HATCHEDIT: after pick, inject hatch model data into the command.
|
||||
if self.tabs[i]
|
||||
.active_cmd
|
||||
@@ -4034,11 +4085,39 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
result
|
||||
} else if let Some(result) = {
|
||||
let dimension = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.measures_through_viewports());
|
||||
if dimension {
|
||||
let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0
|
||||
/ (bounds.height as f64).max(1.0);
|
||||
self.try_dimension_viewport_entity_pick(
|
||||
i,
|
||||
pick_wcs,
|
||||
crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} {
|
||||
Some(result)
|
||||
} else if self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|command| command.entity_pick_accepts_points())
|
||||
{
|
||||
let pending = self.pending_click_snap.take();
|
||||
if !self.record_accepted_snap(
|
||||
i,
|
||||
pending.map(|(hit, _)| hit),
|
||||
pending.and_then(|(_, frame)| frame),
|
||||
pick_wcs,
|
||||
) {
|
||||
self.finish_command_click(i);
|
||||
return Task::none();
|
||||
}
|
||||
self.refresh_command_point_pick_context(i);
|
||||
self.tabs[i]
|
||||
.active_cmd
|
||||
@@ -4107,6 +4186,20 @@ impl OpenCADStudio {
|
||||
self.tabs[i].dyn_active = 0;
|
||||
}
|
||||
}
|
||||
// Record what this point step accepted, alongside the point
|
||||
// itself: paper point, model point, viewport, frame and
|
||||
// geometry identity. Commands that don't care keep consuming
|
||||
// `world_pt` exactly as before.
|
||||
let pending = self.pending_click_snap.take();
|
||||
if !self.record_accepted_snap(
|
||||
i,
|
||||
pending.map(|(hit, _)| hit),
|
||||
pending.and_then(|(_, frame)| frame),
|
||||
world_pt,
|
||||
) {
|
||||
self.finish_command_click(i);
|
||||
return Task::none();
|
||||
}
|
||||
self.last_point = Some(world_pt);
|
||||
// The one-shot snap override is spent by this pick —
|
||||
// restore the running osnap configuration (#337).
|
||||
@@ -4142,6 +4235,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_dimension_snaps(i);
|
||||
if let Some(r) = result {
|
||||
let task = self.apply_cmd_result(r);
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
|
||||
@@ -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
Reference in new issue
Block a user