Merge pull request #1301: Add associative viewport dimensions

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

No files matched your search

+1
View File
@@ -95,6 +95,7 @@ mod file;
mod style;
mod util;
mod viewport;
mod viewport_snap;
impl OpenCADStudio {
pub(in crate::app) fn reset_modal_geometry(&mut self) {
+94
View File
@@ -2017,6 +2017,31 @@ impl OpenCADStudio {
)
};
// Paper-space snapping through layout viewports. `edit_cam`
// is None here, so `cursor_world` is the paper point and the sheet
// fills the canvas (pane-local == canvas pixels). The viewport hit
// arrives already projected onto the sheet, so everything
// downstream keeps working in paper coordinates — LINE still draws
// at the projected paper point.
self.vp_snap_frame = None;
if edit_cam.is_none()
&& !needs_entity
&& !is_gathering
&& !needs_structure
&& !needs_tan
{
if let Some((vp_hit, frame)) =
self.paper_viewport_snap(i, p_full, vp_size, cursor_world)
{
let merged =
crate::snap::merge_snap(self.tabs[i].snap_result, Some(vp_hit), p_full);
if merged.is_some_and(|hit| hit.viewport.is_some()) {
self.vp_snap_frame = Some(frame);
}
self.tabs[i].snap_result = merged;
}
}
let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| {
!command.needs_entity_pick()
&& !command.needs_tangent_pick()
@@ -2306,6 +2331,10 @@ impl OpenCADStudio {
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: None,
secondary_source: None,
model_point: None,
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(Some(pick.label));
@@ -3630,6 +3659,23 @@ impl OpenCADStudio {
}
}
}
// Paper-space snapping through layout viewports. Mirrors
// the cursor-move pass: the click recomputes snapping from
// scratch, so the viewport query has to run here too. The hit
// comes back already projected onto the sheet.
let mut click_frame: Option<crate::scene::viewport_ref::ViewportFrame> = None;
if edit_cam.is_none() && !needs_entity_click && !needs_tan {
if let Some((vp_hit, frame)) =
self.paper_viewport_snap(i, p_full, (vw, vh), raw)
{
let merged = crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full);
if merged.is_some_and(|hit| hit.viewport.is_some()) {
click_frame = Some(frame);
}
snap_hit = merged;
}
}
self.pending_click_snap = snap_hit.map(|hit| (hit, click_frame));
// Snap runs in model space; the result is already model.
let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw);
// When no UCS is active clamp to world XY; with a UCS the point is
@@ -3957,6 +4003,10 @@ impl OpenCADStudio {
}
}
if !self.dimension_acquisition_allowed(i, None) {
self.finish_command_click(i);
return Task::none();
}
let shift = self.shift_down;
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
// Shift-swap state for TRIM/EXTEND (#336).
@@ -3964,6 +4014,7 @@ impl OpenCADStudio {
c.set_entity_pick_direction(entity_pick_direction);
c.on_entity_pick(handle, entity_pick_point)
});
self.record_dimension_entity_points(i, None, handle, Vec::new());
// HATCHEDIT: after pick, inject hatch model data into the command.
if self.tabs[i]
.active_cmd
@@ -4034,11 +4085,39 @@ impl OpenCADStudio {
}
}
result
} else if let Some(result) = {
let dimension = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.measures_through_viewports());
if dimension {
let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0
/ (bounds.height as f64).max(1.0);
self.try_dimension_viewport_entity_pick(
i,
pick_wcs,
crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px,
)
} else {
None
}
} {
Some(result)
} else if self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.entity_pick_accepts_points())
{
let pending = self.pending_click_snap.take();
if !self.record_accepted_snap(
i,
pending.map(|(hit, _)| hit),
pending.and_then(|(_, frame)| frame),
pick_wcs,
) {
self.finish_command_click(i);
return Task::none();
}
self.refresh_command_point_pick_context(i);
self.tabs[i]
.active_cmd
@@ -4107,6 +4186,20 @@ impl OpenCADStudio {
self.tabs[i].dyn_active = 0;
}
}
// Record what this point step accepted, alongside the point
// itself: paper point, model point, viewport, frame and
// geometry identity. Commands that don't care keep consuming
// `world_pt` exactly as before.
let pending = self.pending_click_snap.take();
if !self.record_accepted_snap(
i,
pending.map(|(hit, _)| hit),
pending.and_then(|(_, frame)| frame),
world_pt,
) {
self.finish_command_click(i);
return Task::none();
}
self.last_point = Some(world_pt);
// The one-shot snap override is spent by this pick —
// restore the running osnap configuration (#337).
@@ -4142,6 +4235,7 @@ impl OpenCADStudio {
}
};
self.sync_dimension_snaps(i);
if let Some(r) = result {
let task = self.apply_cmd_result(r);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
+299
View File
@@ -0,0 +1,299 @@
//! Snap to displayed model geometry while a paper-space command is active.
//! Viewport queries use the renderer's resident wires and interaction index,
//! then project the accepted model point onto the sheet. Ordinary selection
//! continues to use paper-space hit testing.
use iced::Point;
use acadrust::types::Handle;
use crate::app::OpenCADStudio;
use crate::scene::viewport_ref::{AcceptedSnap, ViewportFrame};
use crate::snap::{SnapResult, SnapType};
/// Bound on the retained accepted-snap history. A command's point steps are a
/// handful at most; the cap only stops a long polyline from growing forever.
const MAX_ACCEPTED_SNAPS: usize = 64;
impl OpenCADStudio {
/// Snaps accepted by the active command's point steps, oldest first, in
/// step order.
pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] {
&self.accepted_snaps
}
/// Infer direct sources only when the measurement uses the active space.
/// Projected model points must retain their acquired source identities;
/// proximity on the sheet could bind them to unrelated paper geometry.
pub(crate) fn infer_dimension_sources_guarded(
&self,
tab: usize,
dimension: Handle,
) -> Vec<Option<Handle>> {
if !self.dimension_association_allowed(tab) {
return Vec::new();
}
self.tabs[tab].scene.infer_dimension_sources(dimension)
}
/// Whether the dimension can use ordinary active-space associations.
/// Placement clicks are excluded by the measurement classification.
pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool {
matches!(
self.dimension_measure_space(tab),
crate::app::dim_viewport::DimensionMeasureSpace::Direct
)
}
/// Forget the accepted snaps of a finished / abandoned command.
pub(crate) fn clear_accepted_snaps(&mut self) {
self.accepted_snaps.clear();
}
/// Record the snap a point step just accepted. `snap` is the displayed snap
/// result (already in the current space's coordinates) or `None` for a free
/// / typed point; `committed` is the point the command actually received
/// after ortho / polar / axis-lock / dynamic-input resolution.
pub(crate) fn record_accepted_snap(
&mut self,
tab: usize,
snap: Option<SnapResult>,
frame: Option<ViewportFrame>,
committed: glam::DVec3,
) -> bool {
let mut accepted = match snap {
// A viewport snap keeps its frame only when the hit really came
// through it, so a paper-sheet snap never claims a model point.
Some(hit) => {
let frame = hit.viewport.and(frame);
AcceptedSnap::from_snap(&hit, frame).with_paper_point(committed)
}
None => AcceptedSnap::free(committed),
};
if self.tabs[tab]
.active_cmd
.as_ref()
.is_some_and(|cmd| cmd.measures_through_viewports())
{
if self.tabs[tab]
.active_cmd
.as_ref()
.is_some_and(|cmd| cmd.dimension_placement_pending())
{
return true;
}
if !self.dimension_acquisition_allowed(tab, accepted.viewport) {
return false;
}
}
self.resolve_source_block_path(tab, &mut accepted);
self.push_accepted_snap(accepted);
true
}
/// Resolve the instance path once, when a feature is accepted. Failure
/// drops source identity so a failed descent cannot attach to the insert's
/// origin. Ambiguous intersections within one batched block remain unlinked.
fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) {
let resolve = |source: &mut crate::scene::viewport_ref::SnapSourceRef| {
if !source.block_path.is_empty() {
return true;
}
if !matches!(
self.tabs[tab]
.scene
.document
.get_entity(source.source.handle),
Some(acadrust::EntityType::Insert(_))
) {
return true;
}
if source.snap_type == SnapType::Insertion {
let Some(acadrust::EntityType::Insert(insert)) = self.tabs[tab]
.scene
.document
.get_entity(source.source.handle)
else {
return false;
};
let origin = glam::DVec3::new(
insert.insert_point.x,
insert.insert_point.y,
insert.insert_point.z,
);
return origin.distance(accepted.model_point) < 1e-5;
}
let Some((entity, path)) = self.tabs[tab].scene.resolve_measurable_feature(
source.source.handle,
accepted.model_point,
accepted.viewport,
Some(source.snap_type),
) else {
return false;
};
let distance = crate::scene::viewport_dimension_pick::feature_pick_distance(
&entity,
accepted.model_point,
Some(source.snap_type),
);
if !distance.is_some_and(|distance| distance < 1e-5) {
return false;
}
source.source =
crate::command::DimensionAssociationSource::inferred(entity.common().handle);
source.block_path = path;
true
};
let Some(source) = accepted.source.as_mut() else {
return;
};
if !resolve(source) {
accepted.source = None;
return;
}
if let Some(other) = source.intersection.as_mut() {
if !resolve(other)
|| (other.source.handle == source.source.handle
&& other.block_path == source.block_path)
{
source.intersection = None;
}
}
}
/// Retain a bounded history for point and object picks.
pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) {
if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS {
self.accepted_snaps.remove(0);
}
self.accepted_snaps.push(accepted);
}
/// Query visible viewport geometry with a canvas-pixel cursor and sheet point.
/// Returns paper coordinates and canvas pixels, plus the source viewport frame.
pub(in crate::app) fn paper_viewport_snap(
&mut self,
i: usize,
cursor_canvas: Point,
canvas: (f32, f32),
cursor_paper: glam::DVec3,
) -> Option<(SnapResult, ViewportFrame)> {
if !self.snapper.snap_enabled {
return None;
}
{
let scene = &self.tabs[i].scene;
if scene.current_layout == "Model" || scene.active_viewport.is_some() {
return None;
}
}
if canvas.0 < 1.0 || canvas.1 < 1.0 {
return None;
}
for frame in self.tabs[i]
.scene
.viewport_frames_at_paper_point(cursor_paper)
{
let handle = frame.viewport;
let Some((cam, rect)) = self.tabs[i].scene.viewport_edit_frame_for(handle, canvas)
else {
continue;
};
if rect.width < 1.0 || rect.height < 1.0 {
continue;
}
let bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: rect.width,
height: rect.height,
};
let local = Point::new(cursor_canvas.x - rect.x, cursor_canvas.y - rect.y);
let view_rot = cam.view_proj_rte(bounds);
let eye = cam.eye();
// The cursor in MODEL space, through the frame — exactly the point
// the viewport camera projects back onto `local`.
let model_cursor = frame.paper_to_model(cursor_paper);
let wires = self.tabs[i]
.scene
.model_wires_for_viewport_arc(handle, bounds.height);
let candidates = self.tabs[i].scene.interaction_candidates_near(
wires,
model_cursor,
view_rot,
eye,
bounds,
self.snapper.osnap_radius_px,
);
// The grid belongs to the sheet. Translate the construction
// anchor for perpendicular/tangent snaps into this viewport's
// model coordinates, then restore the shared snapper state.
let saved_grid = self.snapper.grid_snap_on;
let saved_from = self.snapper.from_point;
self.snapper.grid_snap_on = false;
self.snapper.from_point =
saved_from.map(|p| frame.paper_to_model(p.as_dvec3()).as_vec3());
let hit = self.snapper.snap(
model_cursor,
local,
&candidates,
view_rot,
eye,
bounds,
glam::Vec3::ZERO,
(glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z),
None,
);
self.snapper.grid_snap_on = saved_grid;
self.snapper.from_point = saved_from;
let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else {
continue;
};
// The snap aperture can reach outside the visible clip boundary.
let paper = frame.model_to_paper(hit.world);
if !self.tabs[i]
.scene
.viewport_displays_paper_point(handle, paper.truncate())
{
continue;
}
return Some((project_hit_to_paper(hit, &frame, rect, handle), frame));
}
None
}
}
/// Re-express a snap taken in a viewport's model space as a paper-space result:
/// the point projects onto the sheet through `frame`, and the pane-local pixel
/// positions shift by the viewport's screen origin so they share the canvas
/// frame with the ordinary paper snap.
fn project_hit_to_paper(
hit: SnapResult,
frame: &ViewportFrame,
rect: iced::Rectangle,
viewport: Handle,
) -> SnapResult {
let to_canvas = |p: Point| Point::new(p.x + rect.x, p.y + rect.y);
SnapResult {
world: frame.model_to_paper(hit.world),
screen: to_canvas(hit.screen),
snap_type: hit.snap_type,
// Tangent geometry is model-space and would be consumed as paper-space
// by TTR/TTT; drop it rather than hand over a wrong circle.
tangent_obj: None,
extension_base: hit.extension_base.map(to_canvas),
extension_base2: hit.extension_base2.map(to_canvas),
extension_origin: hit.extension_origin.map(|p| frame.model_to_paper(p)),
extension_dir: hit.extension_dir.map(|d| {
let mapped = frame.model_to_paper_dir(d.truncate());
glam::DVec3::new(mapped.x, mapped.y, d.z)
}),
viewport: Some(viewport),
source: hit.source,
secondary_source: hit.secondary_source,
model_point: Some(hit.world),
}
}