diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9cfbe5769..6d97aedca 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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) { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c57bb947c..6290e08ea 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2089,6 +2089,31 @@ impl OpenCADStudio { ) }; + // Paper-space snapping THROUGH layout viewports (PR1). `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() @@ -2376,6 +2401,8 @@ impl OpenCADStudio { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, }); if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { cmd.set_acquisition_hint(Some(pick.label)); @@ -3646,6 +3673,24 @@ impl OpenCADStudio { } } } + // Paper-space snapping through layout viewports (PR1). 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 = 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 @@ -4100,6 +4145,16 @@ impl OpenCADStudio { } } self.last_point = Some(world_pt); + // Record what this point step accepted, alongside the point + // itself (PR1): 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(); + self.record_accepted_snap( + pending.map(|(hit, _)| hit), + pending.and_then(|(_, frame)| frame), + world_pt, + ); // The one-shot snap override is spent by this pick — // restore the running osnap configuration (#337). self.snapper.clear_override(); diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs new file mode 100644 index 000000000..898c55570 --- /dev/null +++ b/src/app/update/viewport_snap.rs @@ -0,0 +1,278 @@ +//! Paper-space snapping THROUGH layout viewports (PR1). +//! +//! In a paper layout with no active viewport, `Scene::hit_test_wires` returns +//! only the paper sheet's own entities — viewport contents are deliberately not +//! interactive, and that must stay true for *selection*. Snapping, however, +//! should see the model geometry a viewport displays, so a paper-space LINE / +//! DIMENSION can pick a real model endpoint and land on the paper pixel where +//! it is drawn. +//! +//! This module adds that as a SEPARATE query. It never changes +//! `hit_test_wires`. For each displayed content viewport under the cursor +//! (top-most first) it runs the EXISTING snap engine against that viewport's +//! resident model wires, using the viewport's own camera and screen rectangle +//! (`Scene::viewport_edit_frame_for` — the same adapter MSPACE editing uses), +//! then maps the accepted model point back onto the sheet with +//! [`ViewportFrame::model_to_paper`]. +//! +//! Performance: no projected geometry is rebuilt per pointer event. The wire +//! set comes from `model_wires_for_viewport_arc`, which is the renderer's own +//! resident, camera-independent `Arc`, and the cursor-local broad phase reuses +//! the shared interaction index built over it. + +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. Consumed by the dimension commands in PR2/PR3. + #[allow(dead_code)] + pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] { + &self.accepted_snaps + } + + /// The most recently accepted snap, if any. + #[allow(dead_code)] + pub(crate) fn last_accepted_snap(&self) -> Option<&AcceptedSnap> { + self.accepted_snaps.last() + } + + /// `true` when any point the active command collected came through a + /// layout viewport. Dimension association inference (which compares a + /// dimension's own definition points against geometry in the *current* + /// space) must not run in that case: the definition points are paper + /// coordinates that correspond to no paper-sheet geometry, and the model + /// geometry they really describe lives at completely different + /// coordinates. PR3 replaces this guard with real viewport-aware + /// associations built from `AcceptedSnap::source`. + pub(crate) fn accepted_snaps_through_viewport(&self) -> bool { + self.accepted_snaps.iter().any(AcceptedSnap::through_viewport) + } + + /// `Scene::infer_dimension_sources`, suppressed when the dimension's points + /// were picked through a layout viewport (PR1). + /// + /// Inference works by looking for geometry near the dimension's own + /// definition points, in whatever space it is handed. A paper-space + /// dimension measured through a viewport has PAPER definition points that + /// happen to sit over projected model geometry, so unguarded inference + /// would either associate it with unrelated paper-sheet geometry at those + /// coordinates, or match model geometry using paper coordinates. Neither is + /// correct, and a wrong association is worse than none. Returning an empty + /// source list makes `attach_dimension_association` a no-op. + /// + /// Deliberately minimal: PR3 replaces this with a real viewport-aware + /// association built from `AcceptedSnap::source`. + pub(crate) fn infer_dimension_sources_guarded( + &self, + tab: usize, + dimension: Handle, + ) -> Vec> { + if self.accepted_snaps_through_viewport() { + return Vec::new(); + } + self.tabs[tab].scene.infer_dimension_sources(dimension) + } + + /// 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, + snap: Option, + frame: Option, + committed: glam::DVec3, + ) { + let 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.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS { + self.accepted_snaps.remove(0); + } + self.accepted_snaps.push(accepted); + } + /// Snap the paper-space cursor to model geometry seen through a layout + /// viewport. + /// + /// * `i` — tab index. + /// * `cursor_canvas` — cursor in canvas pixels. + /// * `canvas` — canvas size in pixels. + /// * `cursor_paper` — the paper-space point under the cursor. + /// + /// Returns the hit expressed in PAPER coordinates (`world` projected onto + /// the sheet, `screen` in canvas pixels) together with the frame it came + /// through, so the caller can merge it with the ordinary paper-sheet snap + /// and later recover the model point. + /// + /// `None` in the Model layout, while a viewport is active (MSPACE already + /// snaps in model space), when snapping is off, or when the cursor is not + /// over a displayed viewport. + 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; + } + + let handles = self.tabs[i].scene.layout_content_viewports(); + // Draw order puts the last-drawn viewport on top; probe top-most first + // so overlapping viewports resolve the same way they render. + for &handle in handles.iter().rev() { + if !self.tabs[i].scene.viewport_displays_content(handle) { + continue; + } + let Some(frame) = self.tabs[i].scene.viewport_frame(handle) else { + continue; // oblique / perspective viewport: unsupported + }; + // Clip first (rectangle + non-rectangular clip boundary), in paper + // coordinates, so a cursor over the cut-away part of a clipped + // viewport sees nothing. + if !self + .tabs[i] + .scene + .viewport_displays_paper_point(handle, cursor_paper.truncate()) + { + continue; + } + 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, + ); + + // Object snap only. The grid belongs to the paper sheet, and the + // rubber-band origin / tracking points are paper-space points, so + // perpendicular and extension feet would be nonsense in model + // space. Save and restore, since `Snapper` is shared 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 = None; + 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 wire set is the whole model, not a clipped copy, so the only + // way a feature outside the visible part could be offered is via + // the aperture reaching past the clip. Reject those: a snap must be + // a real feature the viewport actually displays. + 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, + } +} diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs index eae776d25..1122eee8c 100644 --- a/src/scene/viewport_ref.rs +++ b/src/scene/viewport_ref.rs @@ -148,6 +148,60 @@ impl AcceptedSnap { pub fn through_viewport(&self) -> bool { self.viewport.is_some() } + + /// Build an accepted snap from a [`crate::snap::SnapResult`]. + /// + /// * `frame` `Some`: the snap ran against MODEL geometry through that + /// viewport, so `snap.world` is a model point and the paper point is its + /// projection onto the sheet. + /// * `frame` `None`: the snap ran in the current space directly, so paper + /// and model point coincide. + /// + /// PR1 fills `source` from `SnapResult::source` (the owning entity handle + /// the snap engine attributed the feature to); `block_path` stays empty + /// until PR3 resolves block instance chains. + pub fn from_snap(snap: &crate::snap::SnapResult, frame: Option) -> Self { + let (paper_point, model_point) = match &frame { + Some(frame) => (frame.model_to_paper(snap.world), snap.world), + None => (snap.world, snap.world), + }; + Self { + paper_point, + model_point, + viewport: frame.map(|f| f.viewport), + frame, + source: snap.source.map(|source| SnapSourceRef { + source, + block_path: Vec::new(), + snap_type: snap.snap_type, + }), + } + } + + /// 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 + } } /// The two independent factors that turn a raw measured distance into the diff --git a/src/snap.rs b/src/snap.rs index e2ae67b2f..b388e0f96 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -10,7 +10,8 @@ use iced::{Point, Rectangle}; use cadkernel::geom2d::Curve; -use crate::command::TangentObject; +use crate::command::{DimensionAssociationSource, TangentObject}; +use acadrust::types::Handle; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; @@ -86,6 +87,33 @@ pub struct SnapResult { pub extension_origin: Option, /// Source direction paired with `extension_origin`. pub extension_dir: Option, + /// 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. (PR1) + pub viewport: Option, + /// 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`]. (PR1) + pub source: Option, +} + +impl SnapResult { + /// Handle of the entity the snap landed on, when known. + pub fn source_handle(&self) -> Option { + 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 { + wire.name + .parse::() + .ok() + .map(|v| DimensionAssociationSource::inferred(Handle::new(v))) } /// Object-snap-tracking alignment: the cursor projected onto a ray from an @@ -954,6 +982,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, }) } @@ -1087,6 +1117,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, }); best_rank = snap_tier(SnapType::Grid); best_sub = snap_priority(SnapType::Grid); @@ -1182,7 +1214,9 @@ impl Snapper { return best; } - let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { + let mut try_pt = |world: glam::DVec3, + snap_type: SnapType, + src: Option| { let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1209,12 +1243,16 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: src, }); } }; // ── 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| { let snap_type = match hint { SnapHint::Center => SnapType::Center, SnapHint::Node => SnapType::Node, @@ -1224,23 +1262,24 @@ impl Snapper { SnapHint::Endpoint => SnapType::Endpoint, }; if self.is_on(snap_type) { - try_pt(world, snap_type); + try_pt(world, snap_type, src); } }; if let Some(points) = wires.snap_points() { for point_ref in points { - let Some(&(world, hint)) = wires - .source_wire(point_ref.wire) - .and_then(|wire| wire.snap_pts.get(point_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(point_ref.wire) else { continue; }; - try_snap_hint(world, hint); + let Some(&(world, hint)) = wire.snap_pts.get(point_ref.index as usize) else { + continue; + }; + try_snap_hint(world, hint, wire_source(wire)); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for &(world, hint) in &wire.snap_pts { - try_snap_hint(world, hint); + try_snap_hint(world, hint, src); } } } @@ -1250,13 +1289,17 @@ impl Snapper { if self.is_on(SnapType::Endpoint) { if let Some(vertices) = wires.key_vertices() { for vertex_ref in vertices { - let Some(&point) = wires - .source_wire(vertex_ref.wire) - .and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(vertex_ref.wire) else { continue; }; - try_pt(DVec3::from_array(point), SnapType::Endpoint); + let Some(&point) = wire.key_vertices.get(vertex_ref.index as usize) else { + continue; + }; + try_pt( + DVec3::from_array(point), + SnapType::Endpoint, + wire_source(wire), + ); } // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains @@ -1270,17 +1313,22 @@ impl Snapper { continue; } if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, wire_source(wire)); } 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), + ); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); if !wire.key_vertices.is_empty() { for &point in &wire.key_vertices { - try_pt(DVec3::from_array(point), SnapType::Endpoint); + try_pt(DVec3::from_array(point), SnapType::Endpoint, src); } } else { let closed = wire @@ -1289,10 +1337,14 @@ impl Snapper { .any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); if !closed { if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, src); } 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, + ); } } } @@ -1319,16 +1371,17 @@ impl Snapper { let a = DVec3::from_array(a); let b = DVec3::from_array(b); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint); + try_pt((a + b) * 0.5, SnapType::Midpoint, wire_source(wire)); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for segment in wire.key_vertices.windows(2) { let a = DVec3::from_array(segment[0]); let b = DVec3::from_array(segment[1]); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint); + try_pt((a + b) * 0.5, SnapType::Midpoint, src); } } } @@ -1342,14 +1395,16 @@ impl Snapper { try_pt( nearest_on_segment(cursor_world, seg.a, seg.b), SnapType::Nearest, + wires.source_wire(seg.wire).and_then(wire_source), ); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for i in 0..wire.points.len().saturating_sub(1) { let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); - try_pt(p, SnapType::Nearest); + try_pt(p, SnapType::Nearest, src); } } } @@ -1362,14 +1417,19 @@ impl Snapper { if let Some(segments) = &local_segments { for seg in segments { if let Some(foot) = perp_foot(q, seg.a, seg.b) { - try_pt(foot, SnapType::Perpendicular); + try_pt( + foot, + SnapType::Perpendicular, + wires.source_wire(seg.wire).and_then(wire_source), + ); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for i in 0..wire.points.len().saturating_sub(1) { if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) { - try_pt(foot, SnapType::Perpendicular); + try_pt(foot, SnapType::Perpendicular, src); } } } @@ -1387,12 +1447,17 @@ impl Snapper { ray_segment_intersect_3d(origin, through, segment.a, segment.b) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt( + point, + SnapType::Intersection, + wires.source_wire(segment.wire).and_then(wire_source), + ); } } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for index in 0..wire.points.len().saturating_sub(1) { if let Some(point) = ray_segment_intersect_3d( origin, @@ -1401,7 +1466,7 @@ impl Snapper { wp_f64(wire, index + 1), ) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src); } } } @@ -1483,8 +1548,11 @@ impl Snapper { for (idx, &wire_i) in local_wires.iter().enumerate() { for &wire_j in &local_wires[idx + 1..] { if let Some(points) = exact_curve_intersections(wire_i, wire_j) { + // Two wires meet here; attribute the feature to the + // first. PR3 resolves the true pair. + let src = wire_source(wire_i); for point in points { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src); } resolved_pairs.insert(pair_key(wire_i, wire_j)); } @@ -1515,7 +1583,11 @@ impl Snapper { world_to_screen(pt, view_rot, eye, bounds), cursor_screen, ) <= f32::EPSILON; - try_pt(pt, SnapType::Intersection); + try_pt( + pt, + SnapType::Intersection, + wires.source_wire(a.wire).and_then(wire_source), + ); if exact_cursor { break 'intersection_sweep; } @@ -1535,8 +1607,9 @@ impl Snapper { // Curved pairs are solved exactly (bug #1052); see // `exact_curve_intersections`'s doc comment. if let Some(pts) = exact_curve_intersections(wire_i, wire_j) { + let src = wire_source(wire_i); for pt in pts { - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, src); } continue; } @@ -1560,7 +1633,7 @@ impl Snapper { continue; } if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, wire_source(wire_i)); } } } @@ -1586,7 +1659,7 @@ impl Snapper { bounds, self.osnap_radius_px, ) { - try_pt(ext, SnapType::Extension); + try_pt(ext, SnapType::Extension, None); } } } @@ -1633,7 +1706,7 @@ impl Snapper { // feet (which sit closer to the cursor on their own lines), // or the cursor would snap to a line instead of the crossing. let pt = glam::DVec3::new(a0.x + t1 * d1.x, a0.y + t1 * d1.y, a0.z); - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, None); } } } @@ -1686,6 +1759,7 @@ impl Snapper { try_pt( segment_a.a + ta as f64 * (segment_a.b - segment_a.a), SnapType::ApparentIntersection, + wires.source_wire(segment_a.wire).and_then(wire_source), ); if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { break 'apparent_sweep; @@ -1726,6 +1800,7 @@ impl Snapper { try_pt( wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection, + wire_source(wire_i), ); } } @@ -1947,6 +2022,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), }); } } @@ -2009,6 +2086,8 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), }); } }; @@ -2116,6 +2195,31 @@ fn snap_tier(t: SnapType) -> u8 { /// candidates are effectively equidistant (coincident features) the classic /// sub-priority — so an Endpoint still beats an Intersection sitting on the /// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. +/// Choose between two independently-computed snap results using the SAME +/// priority-then-distance rule the engine applies internally +/// ([`snap_better`]): a higher-priority snap inside the aperture wins even +/// when a lower-priority one is nearer, and distance only breaks ties. +/// +/// Used to merge the ordinary paper-sheet snap with the viewport snap taken +/// through a layout viewport (PR1). Both `screen` fields must be expressed in +/// the same pixel frame. +pub fn merge_snap(a: Option, b: Option, cursor: Point) -> Option { + 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, + } +} + fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { let (bt, bd2, bsub) = best; if tier != bt {