forked from 1b-agent/opencadstudio-upstream
feat(snap): snap paper space through layout viewports
Paper-space commands could not pick model geometry displayed by a layout viewport: `hit_test_wires` deliberately excludes viewport content in PSPACE, and that has to stay true for selection. This adds the capability as a separate query rather than by changing those semantics. `OpenCADStudio::paper_viewport_snap` walks the current layout's displayed content viewports top-most first, skips those whose display is off or whose clip boundary excludes the cursor, and for each one runs the EXISTING snap engine against that viewport's model wires using the viewport's own camera and screen rectangle (`viewport_edit_frame_for`). The accepted model point is mapped back onto the sheet with `ViewportFrame::model_to_paper`, so everything downstream keeps working in paper coordinates — LINE still draws at the projected paper point. Grid snap and the paper-space rubber-band origin are suppressed for that pass; results outside the clip are rejected so no snap lands on a clip cut. Locked viewports snap like unlocked ones. No projected geometry is rebuilt per pointer event: the wire set is the renderer's resident per-viewport Arc (`model_wires_for_viewport_arc`) and the broad phase reuses the shared interaction index over it. The result is merged with the ordinary paper-sheet snap by `snap::merge_snap`, which applies the engine's own priority-then-distance rule, in both the cursor-move and the click paths. `SnapResult` gains Copy fields `viewport: Option<Handle>` and `source: Option<DimensionAssociationSource>`; the engine fills `source` from the owning wire wherever it knows it. `AcceptedSnap::from_snap` converts a result plus frame into the shared PR2/PR3 contract type. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
53576fabdf
commit
6def465ba5
5 files changed
+525
-33
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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<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
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Option<Handle>> {
|
||||
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<SnapResult>,
|
||||
frame: Option<ViewportFrame>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<ViewportFrame>) -> 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
|
||||
|
||||
+137
-33
@@ -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<glam::DVec3>,
|
||||
/// Source direction paired with `extension_origin`.
|
||||
pub extension_dir: Option<glam::DVec3>,
|
||||
/// Layout viewport this snap was seen *through*, when the snap ran against
|
||||
/// model geometry displayed by a paper-space viewport. `None` for ordinary
|
||||
/// model-space / paper-sheet snaps. Filled by the paper-space viewport snap
|
||||
/// query, never by the engine itself. (PR1)
|
||||
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`]. (PR1)
|
||||
pub source: Option<DimensionAssociationSource>,
|
||||
}
|
||||
|
||||
impl SnapResult {
|
||||
/// Handle of the entity the snap landed on, when known.
|
||||
pub fn source_handle(&self) -> Option<Handle> {
|
||||
self.source.map(|s| s.handle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity handle carried by a wire (`WireModel::name` is the decimal handle
|
||||
/// value). Returns `None` for preview / interim wires with a symbolic name.
|
||||
#[inline]
|
||||
pub(crate) fn wire_source(wire: &WireModel) -> Option<DimensionAssociationSource> {
|
||||
wire.name
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|v| DimensionAssociationSource::inferred(Handle::new(v)))
|
||||
}
|
||||
|
||||
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
|
||||
@@ -954,6 +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<DimensionAssociationSource>| {
|
||||
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<DimensionAssociationSource>| {
|
||||
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<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,
|
||||
}
|
||||
}
|
||||
|
||||
fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool {
|
||||
let (bt, bd2, bsub) = best;
|
||||
if tier != bt {
|
||||
|
||||
Reference in new issue
Block a user