2026-05-23 17:16:15 +03:00
|
|
|
|
//! OpenCADStudio-style object snap (OSNAP) engine.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
//!
|
|
|
|
|
|
//! Implemented modes:
|
|
|
|
|
|
//! Endpoint, Midpoint, Center, Node, Quadrant, Intersection,
|
|
|
|
|
|
//! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent
|
|
|
|
|
|
|
2026-07-24 17:56:06 +03:00
|
|
|
|
use glam::{DVec3, Mat4, Vec3};
|
2026-06-17 04:59:06 +03:00
|
|
|
|
use iced::time::Instant;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
use iced::{Point, Rectangle};
|
|
|
|
|
|
|
|
|
|
|
|
use crate::command::TangentObject;
|
2026-06-16 11:33:46 +03:00
|
|
|
|
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
|
2026-07-24 20:49:05 +03:00
|
|
|
|
use crate::scene::pick::interaction_index::WireSource;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
use crate::ui::overlay::CROSSHAIR_ARM;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Snap type ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-05-23 17:16:15 +03:00
|
|
|
|
/// Every OSNAP mode — mirrors the OpenCADStudio list.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
|
|
|
|
pub enum SnapType {
|
|
|
|
|
|
Endpoint,
|
|
|
|
|
|
Midpoint,
|
|
|
|
|
|
Center,
|
|
|
|
|
|
Node,
|
|
|
|
|
|
Quadrant,
|
|
|
|
|
|
Intersection,
|
|
|
|
|
|
Extension,
|
|
|
|
|
|
Insertion,
|
|
|
|
|
|
Perpendicular,
|
|
|
|
|
|
Tangent,
|
|
|
|
|
|
Nearest,
|
|
|
|
|
|
ApparentIntersection,
|
|
|
|
|
|
Parallel,
|
|
|
|
|
|
Grid,
|
2026-06-18 21:28:07 +03:00
|
|
|
|
/// Object acquisition (domain-object pick, e.g. network structure) — orange marker.
|
2026-06-09 13:41:21 -04:00
|
|
|
|
ObjectPick,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Ordered list used by the popup and snap engine.
|
|
|
|
|
|
pub const ALL_SNAP_MODES: &[(SnapType, &str, &str)] = &[
|
|
|
|
|
|
(SnapType::Endpoint, "◻", "Endpoint"),
|
|
|
|
|
|
(SnapType::Midpoint, "△", "Midpoint"),
|
|
|
|
|
|
(SnapType::Center, "◯", "Center"),
|
|
|
|
|
|
(SnapType::Node, "◆", "Node"),
|
|
|
|
|
|
(SnapType::Quadrant, "◇", "Quadrant"),
|
|
|
|
|
|
(SnapType::Intersection, "✕", "Intersection"),
|
|
|
|
|
|
(SnapType::Extension, "—", "Extension"),
|
|
|
|
|
|
(SnapType::Insertion, "⊾", "Insertion"),
|
|
|
|
|
|
(SnapType::Perpendicular, "⊥", "Perpendicular"),
|
|
|
|
|
|
(SnapType::Tangent, "⌒", "Tangent"),
|
|
|
|
|
|
(SnapType::Nearest, "✧", "Nearest"),
|
|
|
|
|
|
(SnapType::ApparentIntersection, "✗", "Apparent Intersection"),
|
|
|
|
|
|
(SnapType::Parallel, "∥", "Parallel"),
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
// NOTE: Grid is intentionally NOT an object-snap mode. Grid snap is a
|
|
|
|
|
|
// separate system (`Snapper::grid_snap_on`) so object snap never catches a
|
|
|
|
|
|
// grid point; it is toggled on its own and handled directly in `snap()`.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ── Snap result ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
|
pub struct SnapResult {
|
2026-06-24 03:32:11 +03:00
|
|
|
|
pub world: glam::DVec3,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub screen: Point,
|
|
|
|
|
|
pub snap_type: SnapType,
|
|
|
|
|
|
/// Set when `snap_type == Tangent`; provides entity geometry for TTR/TTT.
|
|
|
|
|
|
pub tangent_obj: Option<TangentObject>,
|
2026-07-01 19:39:21 +03:00
|
|
|
|
/// Screen position of the endpoint an Extension snap extends from, so the
|
|
|
|
|
|
/// overlay can draw the dashed extension guide line back to it. `None` for
|
|
|
|
|
|
/// every other snap type. (#238)
|
|
|
|
|
|
pub extension_base: Option<Point>,
|
2026-07-03 17:46:08 +03:00
|
|
|
|
/// Second extension-guide base, set only for an extended intersection
|
|
|
|
|
|
/// (`snap_type == Intersection` where two extension lines cross): the
|
|
|
|
|
|
/// overlay draws a dashed guide from each base to the crossing so both
|
|
|
|
|
|
/// contributing extensions stay visible. `None` otherwise. (#247, #259)
|
|
|
|
|
|
pub extension_base2: Option<Point>,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 23:38:08 +03:00
|
|
|
|
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
|
|
|
|
|
|
/// acquired tracking point.
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
|
pub struct OtrackHit {
|
|
|
|
|
|
/// Cursor projected onto the tracking ray.
|
2026-07-24 17:56:06 +03:00
|
|
|
|
pub aligned: DVec3,
|
2026-06-09 23:38:08 +03:00
|
|
|
|
/// Unit ray direction toward the cursor side (for typed-distance entry).
|
2026-07-24 17:56:06 +03:00
|
|
|
|
pub dir: DVec3,
|
2026-06-09 23:38:08 +03:00
|
|
|
|
/// The tracking point the ray emanates from.
|
2026-07-24 17:56:06 +03:00
|
|
|
|
pub base: DVec3,
|
2026-06-09 23:38:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
// ── Snapper ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-24 22:00:40 +03:00
|
|
|
|
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
|
|
pub struct Snapper {
|
|
|
|
|
|
/// Global snap on/off toggle. When false, all snapping is bypassed
|
|
|
|
|
|
/// but the `enabled` set is preserved so it can be restored.
|
|
|
|
|
|
pub snap_enabled: bool,
|
|
|
|
|
|
/// Which snap modes are configured (used when `snap_enabled` is true).
|
|
|
|
|
|
pub enabled: HashSet<SnapType>,
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
/// Grid snap on/off — a system fully separate from object snap. When on,
|
|
|
|
|
|
/// `snap()` can pick the nearest grid corner; object snap never does.
|
|
|
|
|
|
pub grid_snap_on: bool,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// World-space grid spacing.
|
|
|
|
|
|
pub grid_spacing: f32,
|
2026-06-17 05:25:11 +03:00
|
|
|
|
/// Pixel-radius snap aperture, shared by OSNAP, tracking, polar and
|
|
|
|
|
|
/// extension so the catch distance is the same everywhere.
|
2026-06-17 04:56:42 +03:00
|
|
|
|
pub osnap_radius_px: f32,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
/// Object Snap Tracking on/off (F11).
|
|
|
|
|
|
pub otrack_enabled: bool,
|
|
|
|
|
|
/// Acquired OST points (world XZ, Y=0 plane).
|
2026-07-24 17:56:06 +03:00
|
|
|
|
pub tracking_points: Vec<DVec3>,
|
2026-07-03 20:34:20 +03:00
|
|
|
|
/// Edge directions at each acquired point (parallel to `tracking_points`):
|
|
|
|
|
|
/// the line direction of every wire segment meeting at that corner, so
|
|
|
|
|
|
/// OTRACK can offer an alignment ray along a segment's extension, not only
|
|
|
|
|
|
/// the ortho/polar axes. Pulling the cursor along an acquired corner's edge
|
|
|
|
|
|
/// then locks to that line (#219). Empty for a point that is not a segment
|
|
|
|
|
|
/// endpoint (e.g. a midpoint or centre acquisition).
|
2026-07-24 17:56:06 +03:00
|
|
|
|
pub tracking_dirs: Vec<Vec<DVec3>>,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
/// Last snap world position (for dwell detection).
|
2026-07-24 17:56:06 +03:00
|
|
|
|
pub last_snap_world: Option<DVec3>,
|
2026-06-17 04:59:06 +03:00
|
|
|
|
/// When the cursor first rested near `last_snap_world`.
|
|
|
|
|
|
pub dwell_since: Option<Instant>,
|
|
|
|
|
|
/// Whether the current dwell already acquired/removed a point (fire once).
|
|
|
|
|
|
pub dwell_acquired: bool,
|
2026-06-18 16:30:37 +03:00
|
|
|
|
/// The point the in-progress command is drawing *from* (the rubber-band
|
|
|
|
|
|
/// origin), if any. Perpendicular snap drops its foot from here so the new
|
|
|
|
|
|
/// segment is genuinely perpendicular to the target — without it, perp
|
|
|
|
|
|
/// would just give the nearest point on the line. Set before each `snap`.
|
|
|
|
|
|
pub from_point: Option<Vec3>,
|
2026-07-05 14:50:33 +03:00
|
|
|
|
/// Parallel snap: the acquired reference as (unit direction, a point on the
|
|
|
|
|
|
/// line). When the cursor's direction from the command's start point runs
|
|
|
|
|
|
/// parallel to this, the point locks onto that parallel line; the point half
|
|
|
|
|
|
/// marks the reference on screen. Works with only the Parallel object snap
|
|
|
|
|
|
/// on — independent of OTRACK (#277).
|
|
|
|
|
|
pub parallel_ref: Option<(Vec3, Vec3)>,
|
|
|
|
|
|
/// Dwell state for acquiring/removing `parallel_ref`: the candidate line
|
|
|
|
|
|
/// direction + point under the cursor, when it was first hovered, and
|
|
|
|
|
|
/// whether this dwell has already fired (so it acquires/toggles once).
|
|
|
|
|
|
parallel_dwell: Option<(Vec3, Vec3, Instant, bool)>,
|
2026-07-22 12:27:27 +03:00
|
|
|
|
/// One-shot snap override (Shift+RMB menu): the (enabled set, snap on)
|
|
|
|
|
|
/// pair saved when the override engaged, restored when it is consumed by
|
|
|
|
|
|
/// the next point pick or cancelled. While `Some`, `enabled` holds only
|
|
|
|
|
|
/// the override mode and `snap_enabled` is forced on — the override works
|
|
|
|
|
|
/// even with running osnap off (#337).
|
|
|
|
|
|
override_saved: Option<(HashSet<SnapType>, bool)>,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Default for Snapper {
|
|
|
|
|
|
fn default() -> Self {
|
2026-06-05 00:35:53 +03:00
|
|
|
|
let mut enabled = HashSet::default();
|
2026-03-22 14:56:27 +03:00
|
|
|
|
enabled.insert(SnapType::Endpoint);
|
|
|
|
|
|
enabled.insert(SnapType::Midpoint);
|
|
|
|
|
|
enabled.insert(SnapType::Center);
|
|
|
|
|
|
enabled.insert(SnapType::Node);
|
|
|
|
|
|
enabled.insert(SnapType::Quadrant);
|
|
|
|
|
|
enabled.insert(SnapType::Intersection);
|
|
|
|
|
|
enabled.insert(SnapType::Nearest);
|
|
|
|
|
|
Self {
|
2026-05-01 21:22:01 +03:00
|
|
|
|
snap_enabled: false,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
enabled,
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
grid_snap_on: false,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
grid_spacing: 1.0,
|
2026-06-17 04:56:42 +03:00
|
|
|
|
osnap_radius_px: CROSSHAIR_ARM * 0.25,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
otrack_enabled: false,
|
|
|
|
|
|
tracking_points: Vec::new(),
|
2026-07-03 20:34:20 +03:00
|
|
|
|
tracking_dirs: Vec::new(),
|
2026-04-08 00:26:54 +03:00
|
|
|
|
last_snap_world: None,
|
2026-06-17 04:59:06 +03:00
|
|
|
|
dwell_since: None,
|
|
|
|
|
|
dwell_acquired: false,
|
2026-06-18 16:30:37 +03:00
|
|
|
|
from_point: None,
|
2026-07-05 13:24:43 +03:00
|
|
|
|
parallel_ref: None,
|
|
|
|
|
|
parallel_dwell: None,
|
2026-07-22 12:27:27 +03:00
|
|
|
|
override_saved: None,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Snapper {
|
|
|
|
|
|
/// True when snap is globally on AND at least one mode is configured.
|
|
|
|
|
|
pub fn is_active(&self) -> bool {
|
|
|
|
|
|
self.snap_enabled && !self.enabled.is_empty()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn is_on(&self, t: SnapType) -> bool {
|
|
|
|
|
|
self.enabled.contains(&t)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 12:27:27 +03:00
|
|
|
|
/// Engage a one-shot snap override (Shift+RMB menu): only `t` snaps, even
|
|
|
|
|
|
/// with running osnap off, until `clear_override` restores the saved
|
|
|
|
|
|
/// configuration. Re-picking while active replaces the mode but keeps the
|
|
|
|
|
|
/// original saved state.
|
|
|
|
|
|
pub fn set_override(&mut self, t: SnapType) {
|
|
|
|
|
|
if self.override_saved.is_none() {
|
|
|
|
|
|
self.override_saved = Some((self.enabled.clone(), self.snap_enabled));
|
|
|
|
|
|
}
|
|
|
|
|
|
self.enabled = std::iter::once(t).collect();
|
|
|
|
|
|
self.snap_enabled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Restore the pre-override snap configuration. No-op when inactive.
|
|
|
|
|
|
pub fn clear_override(&mut self) {
|
|
|
|
|
|
if let Some((enabled, on)) = self.override_saved.take() {
|
|
|
|
|
|
self.enabled = enabled;
|
|
|
|
|
|
self.snap_enabled = on;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:30:12 +03:00
|
|
|
|
/// Whether temporary tracking points are being acquired and drawn: OTRACK
|
|
|
|
|
|
/// on, or the Extension object snap on. Extension tracks a segment's line
|
|
|
|
|
|
/// only from an acquired endpoint, and works independently of OTRACK's
|
|
|
|
|
|
/// on/off state (#262).
|
|
|
|
|
|
pub fn tracking_active(&self) -> bool {
|
|
|
|
|
|
self.otrack_enabled || (self.snap_enabled && self.is_on(SnapType::Extension))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 13:24:43 +03:00
|
|
|
|
/// Whether an alignment guide may be on screen — tracking (above) OR a
|
|
|
|
|
|
/// Parallel lock. Used to gate the guide-line projection; Parallel draws its
|
|
|
|
|
|
/// alignment guide without acquiring tracking points. (#277)
|
|
|
|
|
|
pub fn alignment_active(&self) -> bool {
|
|
|
|
|
|
self.tracking_active() || (self.snap_enabled && self.is_on(SnapType::Parallel))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub fn toggle_global(&mut self) {
|
|
|
|
|
|
self.snap_enabled = !self.snap_enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
/// Grid snap on/off — independent of the object-snap master and mode set.
|
|
|
|
|
|
pub fn grid_snap(&self) -> bool {
|
|
|
|
|
|
self.grid_snap_on
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn toggle_grid_snap(&mut self) {
|
|
|
|
|
|
self.grid_snap_on = !self.grid_snap_on;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub fn toggle(&mut self, t: SnapType) {
|
|
|
|
|
|
if !self.enabled.remove(&t) {
|
|
|
|
|
|
self.enabled.insert(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn all_on(&self) -> bool {
|
|
|
|
|
|
ALL_SNAP_MODES
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.all(|(t, _, _)| self.enabled.contains(t))
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn none_on(&self) -> bool {
|
|
|
|
|
|
self.enabled.is_empty()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn enable_all(&mut self) {
|
|
|
|
|
|
for &(t, _, _) in ALL_SNAP_MODES {
|
|
|
|
|
|
self.enabled.insert(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn disable_all(&mut self) {
|
|
|
|
|
|
self.enabled.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 00:26:54 +03:00
|
|
|
|
/// Update dwell tracking and possibly acquire a new OST point.
|
|
|
|
|
|
/// Should be called on every ViewportMove when snap is active.
|
|
|
|
|
|
/// `snap_world` is the current snap result world point (if any).
|
2026-07-24 20:49:05 +03:00
|
|
|
|
pub fn update_otrack_dwell<W: WireSource + ?Sized>(
|
2026-05-12 10:36:15 +03:00
|
|
|
|
&mut self,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
snap_world: Option<DVec3>,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
wires: &W,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
bounds: iced::Rectangle,
|
2026-06-17 04:59:06 +03:00
|
|
|
|
now: Instant,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
) {
|
2026-07-04 23:30:12 +03:00
|
|
|
|
// Temporary tracking points are acquired when OTRACK is on, OR when the
|
|
|
|
|
|
// Extension object snap is on: Extension tracks a segment's line only
|
|
|
|
|
|
// from an endpoint the user has acquired, independently of OTRACK's
|
|
|
|
|
|
// on/off state (#262).
|
|
|
|
|
|
if !self.tracking_active() {
|
2026-04-08 00:26:54 +03:00
|
|
|
|
self.last_snap_world = None;
|
2026-06-17 04:59:06 +03:00
|
|
|
|
self.dwell_since = None;
|
|
|
|
|
|
self.dwell_acquired = false;
|
2026-04-08 00:26:54 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-04 23:42:40 +03:00
|
|
|
|
// With OTRACK off, acquisition is Extension-driven, and Extension tracks
|
|
|
|
|
|
// a line only from a real segment endpoint — so acquire endpoints only.
|
|
|
|
|
|
// This stops a paused cursor on an extension foot (or a midpoint/centre)
|
|
|
|
|
|
// from being acquired and evicting, through the 4-point cap, the very
|
|
|
|
|
|
// endpoint the user acquired — the reason the marker vanished after a
|
|
|
|
|
|
// few pauses (#262). OTRACK keeps acquiring any snap point.
|
|
|
|
|
|
let endpoints_only = !self.otrack_enabled;
|
2026-06-17 04:59:06 +03:00
|
|
|
|
// The cursor must rest near a snap point for this long before it is
|
|
|
|
|
|
// acquired, so that brushing past snap points while moving the mouse
|
|
|
|
|
|
// does not create accidental tracking points.
|
|
|
|
|
|
const DWELL_MS: u128 = 250;
|
2026-04-08 00:26:54 +03:00
|
|
|
|
const DWELL_PX: f32 = 8.0;
|
|
|
|
|
|
|
|
|
|
|
|
match snap_world {
|
|
|
|
|
|
None => {
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// Leaving all geometry: capture the point we were dwelling on if
|
|
|
|
|
|
// it qualified, before the reset loses it.
|
2026-07-04 23:42:40 +03:00
|
|
|
|
self.acquire_on_leave(now, DWELL_MS, wires, endpoints_only);
|
2026-04-08 00:26:54 +03:00
|
|
|
|
self.last_snap_world = None;
|
2026-06-17 04:59:06 +03:00
|
|
|
|
self.dwell_since = None;
|
|
|
|
|
|
self.dwell_acquired = false;
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
Some(p) => {
|
|
|
|
|
|
// Convert to screen to measure pixel distance.
|
|
|
|
|
|
let is_same = if let Some(prev) = self.last_snap_world {
|
2026-07-24 17:56:06 +03:00
|
|
|
|
let dp = world_to_screen(p, view_rot, eye, bounds);
|
|
|
|
|
|
let dp2 = world_to_screen(prev, view_rot, eye, bounds);
|
2026-04-08 00:26:54 +03:00
|
|
|
|
let dx = dp.x - dp2.x;
|
|
|
|
|
|
let dy = dp.y - dp2.y;
|
|
|
|
|
|
(dx * dx + dy * dy).sqrt() < DWELL_PX
|
|
|
|
|
|
} else {
|
|
|
|
|
|
false
|
|
|
|
|
|
};
|
|
|
|
|
|
if is_same {
|
2026-06-17 04:59:06 +03:00
|
|
|
|
let elapsed = self
|
|
|
|
|
|
.dwell_since
|
|
|
|
|
|
.map_or(0, |t| now.duration_since(t).as_millis());
|
|
|
|
|
|
if !self.dwell_acquired && elapsed >= DWELL_MS {
|
|
|
|
|
|
self.dwell_acquired = true;
|
2026-06-17 04:57:17 +03:00
|
|
|
|
// Dwelling over an already-acquired point removes it;
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// otherwise acquire it.
|
2026-06-17 04:57:17 +03:00
|
|
|
|
let existing = self.tracking_points.iter().position(|t| {
|
2026-04-08 00:26:54 +03:00
|
|
|
|
let d = (*t - p).length();
|
2026-07-24 17:56:06 +03:00
|
|
|
|
d < self.grid_spacing as f64 * 0.1
|
2026-06-17 04:57:17 +03:00
|
|
|
|
});
|
|
|
|
|
|
match existing {
|
|
|
|
|
|
Some(idx) => {
|
|
|
|
|
|
self.tracking_points.remove(idx);
|
2026-07-03 20:34:20 +03:00
|
|
|
|
if idx < self.tracking_dirs.len() {
|
|
|
|
|
|
self.tracking_dirs.remove(idx);
|
2026-06-17 04:57:17 +03:00
|
|
|
|
}
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
2026-07-04 23:42:40 +03:00
|
|
|
|
None => self.acquire_tracking_point(p, wires, endpoints_only),
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// Moved to a different snap point: capture the previous one
|
|
|
|
|
|
// first if it was dwelt on long enough, so a pause-then-drag
|
|
|
|
|
|
// gesture reliably acquires it even without in-place events.
|
2026-07-04 23:42:40 +03:00
|
|
|
|
self.acquire_on_leave(now, DWELL_MS, wires, endpoints_only);
|
2026-04-08 00:26:54 +03:00
|
|
|
|
self.last_snap_world = Some(p);
|
2026-06-17 04:59:06 +03:00
|
|
|
|
self.dwell_since = Some(now);
|
|
|
|
|
|
self.dwell_acquired = false;
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:34:20 +03:00
|
|
|
|
/// Add `p` as a tracking point (capturing its corner edge directions) unless
|
|
|
|
|
|
/// it is already tracked; drops the oldest when the 4-point cap is reached.
|
|
|
|
|
|
/// Edge directions are scanned once here, at acquisition, so OTRACK can align
|
|
|
|
|
|
/// to a segment's extension without rescanning geometry per move (#219).
|
2026-07-24 20:49:05 +03:00
|
|
|
|
fn acquire_tracking_point<W: WireSource + ?Sized>(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
p: DVec3,
|
|
|
|
|
|
wires: &W,
|
|
|
|
|
|
endpoints_only: bool,
|
|
|
|
|
|
) {
|
2026-07-03 20:34:20 +03:00
|
|
|
|
if self
|
|
|
|
|
|
.tracking_points
|
|
|
|
|
|
.iter()
|
2026-07-24 17:56:06 +03:00
|
|
|
|
.any(|t| (*t - p).length() < self.grid_spacing as f64 * 0.1)
|
2026-07-03 20:34:20 +03:00
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-04 23:42:40 +03:00
|
|
|
|
// Edge directions double as an endpoint test: a point with no incident
|
|
|
|
|
|
// segment — a midpoint, centre, intersection or extension foot — has
|
|
|
|
|
|
// none. Extension-driven acquisition (#262) keeps only endpoints.
|
|
|
|
|
|
let dirs = edge_dirs_at(p, wires);
|
|
|
|
|
|
if endpoints_only && dirs.is_empty() {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-03 20:34:20 +03:00
|
|
|
|
if self.tracking_points.len() >= 4 {
|
|
|
|
|
|
self.tracking_points.remove(0);
|
|
|
|
|
|
if !self.tracking_dirs.is_empty() {
|
|
|
|
|
|
self.tracking_dirs.remove(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.tracking_points.push(p);
|
2026-07-04 23:42:40 +03:00
|
|
|
|
self.tracking_dirs.push(dirs);
|
2026-07-03 20:34:20 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// If the cursor dwelt on a snap point long enough but the in-place check
|
|
|
|
|
|
/// never fired (a perfectly still cursor emits no move events, so the timer
|
|
|
|
|
|
/// is only re-examined once the cursor moves off), acquire it now as the
|
|
|
|
|
|
/// cursor leaves. This makes "pause on a corner, then drag along its edge"
|
|
|
|
|
|
/// reliably capture the corner (#219).
|
2026-07-24 20:49:05 +03:00
|
|
|
|
fn acquire_on_leave<W: WireSource + ?Sized>(
|
2026-07-04 23:42:40 +03:00
|
|
|
|
&mut self,
|
|
|
|
|
|
now: Instant,
|
|
|
|
|
|
dwell_ms: u128,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
wires: &W,
|
2026-07-04 23:42:40 +03:00
|
|
|
|
endpoints_only: bool,
|
|
|
|
|
|
) {
|
2026-07-03 20:34:20 +03:00
|
|
|
|
if self.dwell_acquired {
|
|
|
|
|
|
return; // already handled by the in-place branch
|
|
|
|
|
|
}
|
|
|
|
|
|
let Some(prev) = self.last_snap_world else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
let elapsed = self
|
|
|
|
|
|
.dwell_since
|
|
|
|
|
|
.map_or(0, |t| now.duration_since(t).as_millis());
|
|
|
|
|
|
if elapsed >= dwell_ms {
|
2026-07-04 23:42:40 +03:00
|
|
|
|
self.acquire_tracking_point(prev, wires, endpoints_only);
|
2026-07-03 20:34:20 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 23:38:08 +03:00
|
|
|
|
/// Project the cursor onto a tracking ray emanating from one of the
|
|
|
|
|
|
/// acquired tracking points, in the XY plane. Without `polar_step_deg` the
|
|
|
|
|
|
/// rays are horizontal / vertical (0° / 90°); with it, every polar
|
2026-07-03 20:34:20 +03:00
|
|
|
|
/// increment is a candidate so the user can track along POLAR angles. Each
|
|
|
|
|
|
/// acquired corner also contributes a ray along its own edge directions, so
|
|
|
|
|
|
/// pulling the cursor along a segment's extension locks to that line (#219).
|
2026-06-17 05:01:41 +03:00
|
|
|
|
///
|
|
|
|
|
|
/// When the cursor sits near the crossing of two active vectors from
|
|
|
|
|
|
/// different origins the intersection point wins, so the cursor locks onto
|
|
|
|
|
|
/// the exact crossing rather than a free point along one vector:
|
|
|
|
|
|
/// * two OTRACK vectors from different tracking points (#112), and
|
|
|
|
|
|
/// * a POLAR vector from `last_point` crossing an OTRACK vector (#111).
|
|
|
|
|
|
///
|
2026-06-09 23:38:08 +03:00
|
|
|
|
/// Returns the aligned point, the unit ray direction (pointing toward the
|
2026-06-17 05:01:41 +03:00
|
|
|
|
/// cursor side, used for typed-distance entry), and the originating point.
|
2026-04-08 00:26:54 +03:00
|
|
|
|
pub fn otrack_snap(
|
|
|
|
|
|
&self,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
cursor_world: DVec3,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
bounds: iced::Rectangle,
|
2026-06-09 23:38:08 +03:00
|
|
|
|
polar_step_deg: Option<f32>,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
last_point: Option<DVec3>,
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// Ortho on: the axis from `last_point` is a hard lock. Only crossings of
|
|
|
|
|
|
// an acquired ray with that axis lock; single tracking rays are
|
|
|
|
|
|
// suppressed so the cursor can't leave the ortho axis. (#218)
|
|
|
|
|
|
ortho: bool,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
// UCS axes in world space: tracking rays follow the active UCS while
|
|
|
|
|
|
// their world geometry stays in f64.
|
|
|
|
|
|
ucs_x: DVec3,
|
|
|
|
|
|
ucs_y: DVec3,
|
2026-06-09 23:38:08 +03:00
|
|
|
|
) -> Option<OtrackHit> {
|
2026-04-08 00:26:54 +03:00
|
|
|
|
if !self.otrack_enabled || self.tracking_points.is_empty() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 17:56:06 +03:00
|
|
|
|
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
|
2026-06-17 05:25:11 +03:00
|
|
|
|
// Use the same aperture as OSNAP so the catch distance is uniform.
|
|
|
|
|
|
let r = self.osnap_radius_px;
|
2026-07-24 17:56:06 +03:00
|
|
|
|
let screen_dist = |w: DVec3| {
|
|
|
|
|
|
let s = world_to_screen(w, view_rot, eye, bounds);
|
2026-06-17 05:01:41 +03:00
|
|
|
|
((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt()
|
|
|
|
|
|
};
|
2026-04-08 00:26:54 +03:00
|
|
|
|
|
2026-07-24 20:49:05 +03:00
|
|
|
|
// Only rays near the cursor direction can fall inside the aperture.
|
|
|
|
|
|
// Quantize that direction and keep its two neighbours; enumerating all
|
|
|
|
|
|
// 180 rays at a 1° polar increment multiplied every tracking-point and
|
|
|
|
|
|
// ray-intersection check for no observable benefit.
|
|
|
|
|
|
let ray_angles = |origin: DVec3| -> Vec<f64> {
|
|
|
|
|
|
let Some(step) = polar_step_deg.filter(|s| *s > 1e-3) else {
|
|
|
|
|
|
return vec![0.0, 90.0];
|
|
|
|
|
|
};
|
|
|
|
|
|
let d = cursor_world - origin;
|
|
|
|
|
|
let ux = d.dot(ucs_x);
|
|
|
|
|
|
let uy = d.dot(ucs_y);
|
|
|
|
|
|
if ux * ux + uy * uy < 1e-24 {
|
|
|
|
|
|
return vec![0.0];
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let step = step as f64;
|
|
|
|
|
|
let angle = uy.atan2(ux).to_degrees().rem_euclid(180.0);
|
|
|
|
|
|
let nearest = (angle / step).round() * step;
|
|
|
|
|
|
let mut out = vec![
|
|
|
|
|
|
(nearest - step).rem_euclid(180.0),
|
|
|
|
|
|
nearest.rem_euclid(180.0),
|
|
|
|
|
|
(nearest + step).rem_euclid(180.0),
|
|
|
|
|
|
];
|
|
|
|
|
|
out.sort_by(f64::total_cmp);
|
|
|
|
|
|
out.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
|
|
|
|
|
out
|
|
|
|
|
|
};
|
2026-04-08 00:26:54 +03:00
|
|
|
|
|
2026-06-17 05:01:41 +03:00
|
|
|
|
// Build candidate rays tagged by origin group so two rays sharing an
|
|
|
|
|
|
// origin (a parallel pencil that only meets at that origin) are never
|
|
|
|
|
|
// intersected with each other.
|
|
|
|
|
|
struct Ray {
|
2026-07-24 17:56:06 +03:00
|
|
|
|
origin: DVec3,
|
|
|
|
|
|
dir: DVec3,
|
2026-06-17 05:01:41 +03:00
|
|
|
|
group: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut rays: Vec<Ray> = Vec::new();
|
|
|
|
|
|
for (gi, &tp) in self.tracking_points.iter().enumerate() {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
for adeg in ray_angles(tp) {
|
2026-06-09 23:38:08 +03:00
|
|
|
|
let ar = adeg.to_radians();
|
2026-06-17 05:01:41 +03:00
|
|
|
|
rays.push(Ray {
|
|
|
|
|
|
origin: tp,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
|
2026-06-17 05:01:41 +03:00
|
|
|
|
group: gi,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// Extension rays along the corner's own edges (world-space geometry
|
|
|
|
|
|
// directions — already oriented, no UCS rotation). Included in the
|
|
|
|
|
|
// single-ray set so pulling the cursor along a segment's extension
|
|
|
|
|
|
// locks to it. (#219)
|
|
|
|
|
|
if let Some(edirs) = self.tracking_dirs.get(gi) {
|
|
|
|
|
|
for &d in edirs {
|
|
|
|
|
|
rays.push(Ray {
|
|
|
|
|
|
origin: tp,
|
|
|
|
|
|
dir: d,
|
|
|
|
|
|
group: gi,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-08 10:34:31 +03:00
|
|
|
|
// Ray from the command's base point toward this acquired corner:
|
|
|
|
|
|
// once the first point is placed, the cursor can lock onto the line
|
|
|
|
|
|
// joining that base point and an existing corner — the direction
|
|
|
|
|
|
// *between* them, not only the ortho/polar axes or the corner's own
|
|
|
|
|
|
// edges. Grouped with the corner so it never self-intersects with
|
|
|
|
|
|
// that corner's other rays (they meet only at the corner).
|
|
|
|
|
|
if let Some(bp) = last_point {
|
|
|
|
|
|
let d = tp - bp;
|
|
|
|
|
|
let len2 = d.x * d.x + d.y * d.y;
|
2026-07-24 17:56:06 +03:00
|
|
|
|
if len2 > 1e-24 {
|
2026-07-08 10:34:31 +03:00
|
|
|
|
let inv = 1.0 / len2.sqrt();
|
|
|
|
|
|
rays.push(Ray {
|
|
|
|
|
|
origin: bp,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
dir: DVec3::new(d.x * inv, d.y * inv, 0.0),
|
2026-07-08 10:34:31 +03:00
|
|
|
|
group: gi,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-17 05:01:41 +03:00
|
|
|
|
}
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// OTRACK rays come first; the auxiliary rays appended below (polar from
|
|
|
|
|
|
// last_point, ortho axis from last_point) only participate in
|
|
|
|
|
|
// intersection locking, never in single-ray fallback.
|
2026-06-17 05:01:41 +03:00
|
|
|
|
let otrack_ray_count = rays.len();
|
|
|
|
|
|
const POLAR_GROUP: usize = usize::MAX;
|
2026-07-03 20:34:20 +03:00
|
|
|
|
const ORTHO_GROUP: usize = usize::MAX - 1;
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if let (Some(_), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) {
|
|
|
|
|
|
for angle in ray_angles(lp) {
|
|
|
|
|
|
let ar = angle.to_radians();
|
2026-06-17 05:01:41 +03:00
|
|
|
|
rays.push(Ray {
|
|
|
|
|
|
origin: lp,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
|
2026-06-17 05:01:41 +03:00
|
|
|
|
group: POLAR_GROUP,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// Ortho axis rays from `last_point`, so a tracking ray crossing the
|
|
|
|
|
|
// ortho axis locks on-axis (the useful corner-finding case). (#218)
|
|
|
|
|
|
let ortho_lock = ortho && last_point.is_some();
|
|
|
|
|
|
if let (true, Some(lp)) = (ortho_lock, last_point) {
|
2026-07-24 17:56:06 +03:00
|
|
|
|
for &adeg in &[0.0_f64, 90.0] {
|
2026-07-03 20:34:20 +03:00
|
|
|
|
let ar = adeg.to_radians();
|
|
|
|
|
|
rays.push(Ray {
|
|
|
|
|
|
origin: lp,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
|
2026-07-03 20:34:20 +03:00
|
|
|
|
group: ORTHO_GROUP,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-17 05:01:41 +03:00
|
|
|
|
|
|
|
|
|
|
// ── Intersection lock — crossing of two vectors from distinct origins.
|
|
|
|
|
|
let mut best_x: Option<(f32, OtrackHit)> = None;
|
|
|
|
|
|
for i in 0..rays.len() {
|
|
|
|
|
|
for j in (i + 1)..rays.len() {
|
|
|
|
|
|
if rays[i].group == rays[j].group {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// Under an ortho lock only crossings that involve the ortho axis
|
|
|
|
|
|
// are valid — every other crossing lies off it. (#218)
|
|
|
|
|
|
if ortho_lock && rays[i].group != ORTHO_GROUP && rays[j].group != ORTHO_GROUP {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-17 05:01:41 +03:00
|
|
|
|
let Some(x) =
|
|
|
|
|
|
line_intersect_xy(rays[i].origin, rays[i].dir, rays[j].origin, rays[j].dir)
|
|
|
|
|
|
else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
let sd = screen_dist(x);
|
|
|
|
|
|
if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) {
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// Report an acquired tracking ray (not an auxiliary
|
|
|
|
|
|
// last_point ray) as base/dir for typed-distance entry.
|
|
|
|
|
|
let ot = if rays[i].group != POLAR_GROUP && rays[i].group != ORTHO_GROUP {
|
2026-06-17 05:01:41 +03:00
|
|
|
|
&rays[i]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
&rays[j]
|
|
|
|
|
|
};
|
|
|
|
|
|
let t = (x.x - ot.origin.x) * ot.dir.x + (x.y - ot.origin.y) * ot.dir.y;
|
|
|
|
|
|
let dir_out = if t >= 0.0 { ot.dir } else { -ot.dir };
|
|
|
|
|
|
best_x = Some((
|
2026-06-09 23:38:08 +03:00
|
|
|
|
sd,
|
|
|
|
|
|
OtrackHit {
|
2026-06-17 05:01:41 +03:00
|
|
|
|
aligned: x,
|
2026-06-09 23:38:08 +03:00
|
|
|
|
dir: dir_out,
|
2026-06-17 05:01:41 +03:00
|
|
|
|
base: ot.origin,
|
2026-06-09 23:38:08 +03:00
|
|
|
|
},
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-17 05:01:41 +03:00
|
|
|
|
if let Some((_, h)) = best_x {
|
|
|
|
|
|
return Some(h);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:34:20 +03:00
|
|
|
|
// With Ortho on and a base point, the axis is a hard lock: no single
|
|
|
|
|
|
// tracking ray may pull the cursor off it. Only crossings with the
|
|
|
|
|
|
// ortho axis (handled above) lock; otherwise defer to the caller's
|
|
|
|
|
|
// ortho constraint. (#218)
|
|
|
|
|
|
if ortho_lock {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 05:01:41 +03:00
|
|
|
|
// ── Single-ray alignment (OTRACK rays only) ──
|
|
|
|
|
|
let mut best: Option<(f32, OtrackHit)> = None;
|
|
|
|
|
|
for ray in rays.iter().take(otrack_ray_count) {
|
|
|
|
|
|
let t = (cursor_world.x - ray.origin.x) * ray.dir.x
|
|
|
|
|
|
+ (cursor_world.y - ray.origin.y) * ray.dir.y;
|
2026-07-24 17:56:06 +03:00
|
|
|
|
let aligned = DVec3::new(
|
2026-06-17 05:01:41 +03:00
|
|
|
|
ray.origin.x + ray.dir.x * t,
|
|
|
|
|
|
ray.origin.y + ray.dir.y * t,
|
|
|
|
|
|
ray.origin.z,
|
|
|
|
|
|
);
|
|
|
|
|
|
let sd = screen_dist(aligned);
|
|
|
|
|
|
if sd < r && best.as_ref().map_or(true, |(bd, _)| sd < *bd) {
|
|
|
|
|
|
let dir_out = if t >= 0.0 { ray.dir } else { -ray.dir };
|
|
|
|
|
|
best = Some((
|
|
|
|
|
|
sd,
|
|
|
|
|
|
OtrackHit {
|
|
|
|
|
|
aligned,
|
|
|
|
|
|
dir: dir_out,
|
|
|
|
|
|
base: ray.origin,
|
|
|
|
|
|
},
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-09 23:38:08 +03:00
|
|
|
|
best.map(|(_, h)| h)
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Clear all acquired tracking points (e.g. when command ends).
|
|
|
|
|
|
pub fn clear_tracking(&mut self) {
|
|
|
|
|
|
self.tracking_points.clear();
|
2026-07-03 20:34:20 +03:00
|
|
|
|
self.tracking_dirs.clear();
|
2026-07-05 13:24:43 +03:00
|
|
|
|
self.parallel_ref = None;
|
|
|
|
|
|
self.parallel_dwell = None;
|
2026-04-08 00:26:54 +03:00
|
|
|
|
self.last_snap_world = None;
|
2026-06-17 04:59:06 +03:00
|
|
|
|
self.dwell_since = None;
|
|
|
|
|
|
self.dwell_acquired = false;
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 13:24:43 +03:00
|
|
|
|
/// Parallel snap acquisition. When the Parallel object snap is on, hovering
|
2026-07-05 14:50:33 +03:00
|
|
|
|
/// a line (or polyline segment) for a short dwell acquires it as the parallel
|
|
|
|
|
|
/// reference (its direction + a point on it, which marks it on screen); the
|
|
|
|
|
|
/// reference persists once the cursor moves off so the user can then draw
|
|
|
|
|
|
/// parallel to it. Dwelling on the SAME reference line again removes it
|
|
|
|
|
|
/// (toggle). Curves (circle/arc/ellipse) are ignored — "parallel to a curve"
|
|
|
|
|
|
/// is undefined. Call on every viewport move. (#277)
|
2026-07-24 20:49:05 +03:00
|
|
|
|
pub fn update_parallel<W: WireSource + ?Sized>(
|
2026-07-05 13:24:43 +03:00
|
|
|
|
&mut self,
|
|
|
|
|
|
cursor_world: Vec3,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
wires: &W,
|
2026-07-05 13:24:43 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
|
|
|
|
|
bounds: iced::Rectangle,
|
|
|
|
|
|
now: Instant,
|
|
|
|
|
|
) {
|
|
|
|
|
|
if !(self.snap_enabled && self.is_on(SnapType::Parallel)) {
|
|
|
|
|
|
self.parallel_ref = None;
|
|
|
|
|
|
self.parallel_dwell = None;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const PAR_DWELL_MS: u128 = 150;
|
2026-07-05 14:50:33 +03:00
|
|
|
|
let parallel = |a: Vec3, b: Vec3| (a.x * b.x + a.y * b.y).abs() > 0.9998;
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let Some((dir, pt)) = nearest_segment(
|
|
|
|
|
|
cursor_world,
|
|
|
|
|
|
wires,
|
|
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
|
|
|
|
|
bounds,
|
|
|
|
|
|
self.osnap_radius_px,
|
|
|
|
|
|
) else {
|
2026-07-05 14:50:33 +03:00
|
|
|
|
// Off all lines: drop the in-progress candidate, keep the reference.
|
|
|
|
|
|
self.parallel_dwell = None;
|
|
|
|
|
|
return;
|
|
|
|
|
|
};
|
|
|
|
|
|
// Restart the dwell when the hovered line changes (different direction,
|
|
|
|
|
|
// or a parallel line far from the candidate's point on screen).
|
|
|
|
|
|
let same_candidate = self.parallel_dwell.map_or(false, |(cd, cp, _, _)| {
|
|
|
|
|
|
parallel(cd, dir)
|
|
|
|
|
|
&& screen_perp_dist(pt, cp, cd, view_rot, eye, bounds) < self.osnap_radius_px
|
|
|
|
|
|
});
|
|
|
|
|
|
match self.parallel_dwell {
|
|
|
|
|
|
Some((cd, cp, since, fired)) if same_candidate => {
|
|
|
|
|
|
if !fired && now.duration_since(since).as_millis() >= PAR_DWELL_MS {
|
|
|
|
|
|
// Dwelt long enough: acquire this line, or remove it if it is
|
|
|
|
|
|
// already the reference (hovering it a second time toggles).
|
|
|
|
|
|
let is_ref = self.parallel_ref.map_or(false, |(rd, rp)| {
|
|
|
|
|
|
parallel(rd, dir)
|
|
|
|
|
|
&& screen_perp_dist(pt, rp, rd, view_rot, eye, bounds)
|
|
|
|
|
|
< self.osnap_radius_px
|
|
|
|
|
|
});
|
|
|
|
|
|
self.parallel_ref = if is_ref { None } else { Some((dir, pt)) };
|
|
|
|
|
|
self.parallel_dwell = Some((cd, cp, since, true));
|
2026-07-05 13:24:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-05 14:50:33 +03:00
|
|
|
|
_ => self.parallel_dwell = Some((dir, pt, now, false)),
|
2026-07-05 13:24:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Parallel lock: if the cursor's direction from `base` runs parallel to the
|
|
|
|
|
|
/// acquired reference, snap the point onto the line through `base` parallel
|
|
|
|
|
|
/// to the reference (locking when the cursor sits within the snap aperture
|
|
|
|
|
|
/// of that line). Independent of OTRACK. (#277)
|
|
|
|
|
|
pub fn parallel_snap(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
cursor_world: Vec3,
|
|
|
|
|
|
base: Option<Vec3>,
|
|
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
|
|
|
|
|
bounds: iced::Rectangle,
|
|
|
|
|
|
) -> Option<SnapResult> {
|
|
|
|
|
|
if !(self.snap_enabled && self.is_on(SnapType::Parallel)) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-07-05 14:50:33 +03:00
|
|
|
|
let (dir, _) = self.parallel_ref?;
|
2026-07-05 13:24:43 +03:00
|
|
|
|
let base = base?;
|
|
|
|
|
|
let d = cursor_world - base;
|
|
|
|
|
|
// Need a bit of travel from the base, and the cursor must be pulling
|
|
|
|
|
|
// roughly along the reference (not backward-only noise near the base).
|
|
|
|
|
|
if (d.x * d.x + d.y * d.y).sqrt() < self.grid_spacing * 0.01 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let t = d.x * dir.x + d.y * dir.y;
|
|
|
|
|
|
let locked = base + dir * t;
|
|
|
|
|
|
let sl = world_to_screen(locked.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
let sc = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
if dist2(sl, sc) > self.osnap_radius_px * self.osnap_radius_px {
|
|
|
|
|
|
return None; // cursor not near the parallel line — don't lock
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(SnapResult {
|
|
|
|
|
|
world: locked.as_dvec3(),
|
|
|
|
|
|
screen: sl,
|
|
|
|
|
|
snap_type: SnapType::Parallel,
|
|
|
|
|
|
tangent_obj: None,
|
|
|
|
|
|
extension_base: None,
|
|
|
|
|
|
extension_base2: None,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// Only runs Tangent snap — used when a command needs object picks via tangent.
|
2026-07-24 20:49:05 +03:00
|
|
|
|
pub fn snap_tangent_only<W: WireSource + ?Sized>(
|
2026-03-22 14:56:27 +03:00
|
|
|
|
&self,
|
|
|
|
|
|
cursor_world: Vec3,
|
|
|
|
|
|
cursor_screen: Point,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
wires: &W,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
) -> Option<SnapResult> {
|
|
|
|
|
|
let tmp = Snapper {
|
|
|
|
|
|
snap_enabled: true,
|
|
|
|
|
|
enabled: {
|
2026-06-05 00:35:53 +03:00
|
|
|
|
let mut s = HashSet::default();
|
2026-03-22 14:56:27 +03:00
|
|
|
|
s.insert(SnapType::Tangent);
|
|
|
|
|
|
s
|
|
|
|
|
|
},
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
grid_snap_on: false,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
grid_spacing: self.grid_spacing,
|
2026-06-17 04:56:42 +03:00
|
|
|
|
osnap_radius_px: self.osnap_radius_px,
|
2026-04-08 00:26:54 +03:00
|
|
|
|
otrack_enabled: false,
|
|
|
|
|
|
tracking_points: Vec::new(),
|
2026-07-03 20:34:20 +03:00
|
|
|
|
tracking_dirs: Vec::new(),
|
2026-04-08 00:26:54 +03:00
|
|
|
|
last_snap_world: None,
|
2026-06-17 04:59:06 +03:00
|
|
|
|
dwell_since: None,
|
|
|
|
|
|
dwell_acquired: false,
|
2026-06-18 16:30:37 +03:00
|
|
|
|
from_point: None,
|
2026-07-05 13:24:43 +03:00
|
|
|
|
parallel_ref: None,
|
|
|
|
|
|
parallel_dwell: None,
|
2026-07-22 12:27:27 +03:00
|
|
|
|
override_saved: None,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
};
|
2026-06-22 11:27:34 +03:00
|
|
|
|
// Tangent-only: Grid is disabled here, so the grid basis is irrelevant.
|
|
|
|
|
|
tmp.snap(
|
2026-06-24 04:26:05 +03:00
|
|
|
|
cursor_world.as_dvec3(),
|
2026-06-22 11:27:34 +03:00
|
|
|
|
cursor_screen,
|
|
|
|
|
|
wires,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
2026-06-22 11:27:34 +03:00
|
|
|
|
bounds,
|
|
|
|
|
|
Vec3::ZERO,
|
|
|
|
|
|
Mat4::IDENTITY,
|
|
|
|
|
|
)
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Find the best snap candidate near the cursor.
|
2026-07-24 20:49:05 +03:00
|
|
|
|
pub fn snap<W: WireSource + ?Sized>(
|
2026-03-22 14:56:27 +03:00
|
|
|
|
&self,
|
2026-06-24 04:26:05 +03:00
|
|
|
|
cursor_world: glam::DVec3,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
cursor_screen: Point,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
wires: &W,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
bounds: Rectangle,
|
2026-06-22 11:27:34 +03:00
|
|
|
|
// Grid origin (render/wire space) and UCS→world rotation, so grid snap
|
|
|
|
|
|
// lands on the UCS grid the user sees. `(ZERO, IDENTITY)` = world grid.
|
|
|
|
|
|
grid_origin: Vec3,
|
|
|
|
|
|
grid_rot: Mat4,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
) -> Option<SnapResult> {
|
2026-06-18 16:30:37 +03:00
|
|
|
|
// Object-snap selection is priority-then-distance, NOT nearest-wins.
|
|
|
|
|
|
// "Continuous" snaps (Nearest, Perpendicular, …) sit on the geometry
|
|
|
|
|
|
// and are therefore almost always closer to the cursor than a discrete
|
|
|
|
|
|
// Endpoint/Midpoint/Center, so a pure-distance pick would let them mask
|
|
|
|
|
|
// every other enabled snap. Instead a higher-priority snap inside the
|
|
|
|
|
|
// snap circle wins even when a lower-priority one is closer; distance
|
|
|
|
|
|
// only breaks ties within the same priority. See #118.
|
|
|
|
|
|
let radius2 = self.osnap_radius_px * self.osnap_radius_px;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let mut best: Option<SnapResult> = None;
|
2026-06-18 16:30:37 +03:00
|
|
|
|
let mut best_rank = u8::MAX;
|
2026-07-21 11:35:30 +03:00
|
|
|
|
let mut best_sub = u8::MAX;
|
2026-06-18 16:30:37 +03:00
|
|
|
|
let mut best_d2 = f32::MAX;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
2026-06-24 17:45:33 +03:00
|
|
|
|
// Reject candidates projecting outside the pane rectangle. The GPU
|
|
|
|
|
|
// scissors viewport content to exactly `bounds`, but the hit-test wire
|
|
|
|
|
|
// set reaches past it (the cull keeps a margin and lines run beyond the
|
|
|
|
|
|
// rect), so without this a snap could land on geometry clipped out of
|
|
|
|
|
|
// the viewport. `bounds` is the full canvas in model space, so this is a
|
|
|
|
|
|
// no-op there.
|
|
|
|
|
|
let in_bounds = |s: Point| -> bool {
|
|
|
|
|
|
s.x >= 0.0 && s.x <= bounds.width && s.y >= 0.0 && s.y <= bounds.height
|
|
|
|
|
|
};
|
|
|
|
|
|
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
// ── Grid snap — a SEPARATE system from object snap ───────────────────
|
|
|
|
|
|
// Grid snap has its own toggle (`grid_snap_on`) and is independent of
|
|
|
|
|
|
// the object-snap master (`snap_enabled`) and the object-snap mode set.
|
|
|
|
|
|
// Object snaps therefore NEVER catch grid points; only when grid snap is
|
|
|
|
|
|
// on can a grid corner be picked. It is evaluated first and at the
|
|
|
|
|
|
// lowest priority, so any object snap inside the aperture overrides it.
|
|
|
|
|
|
if self.grid_snap_on {
|
|
|
|
|
|
let s = self.grid_spacing as f64;
|
|
|
|
|
|
if s.abs() > 1e-9 {
|
|
|
|
|
|
// Round in the UCS grid frame, then map back to world.
|
|
|
|
|
|
let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3();
|
|
|
|
|
|
let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3();
|
|
|
|
|
|
let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3();
|
|
|
|
|
|
let origin = grid_origin.as_dvec3();
|
|
|
|
|
|
let rel = cursor_world - origin;
|
|
|
|
|
|
let ux = (rel.dot(ax) / s).round() * s;
|
|
|
|
|
|
let uy = (rel.dot(ay) / s).round() * s;
|
|
|
|
|
|
let uz = (rel.dot(az) / s).round() * s;
|
|
|
|
|
|
let gp = origin + ax * ux + ay * uy + az * uz;
|
|
|
|
|
|
let screen = world_to_screen(gp, view_rot, eye, bounds);
|
|
|
|
|
|
let d2 = dist2(screen, cursor_screen);
|
2026-06-24 17:45:33 +03:00
|
|
|
|
if d2 < radius2 && in_bounds(screen) {
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
best = Some(SnapResult {
|
|
|
|
|
|
world: gp,
|
|
|
|
|
|
screen,
|
|
|
|
|
|
snap_type: SnapType::Grid,
|
|
|
|
|
|
tangent_obj: None,
|
2026-07-01 19:39:21 +03:00
|
|
|
|
extension_base: None,
|
2026-07-03 17:46:08 +03:00
|
|
|
|
extension_base2: None,
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
});
|
2026-07-21 11:35:30 +03:00
|
|
|
|
best_rank = snap_tier(SnapType::Grid);
|
|
|
|
|
|
best_sub = snap_priority(SnapType::Grid);
|
feat(viewport): unify in-viewport editing on the model-space path
Replace the leaky paper-projection approach to MSPACE editing with one
coordinate adapter that routes all interaction through the viewport's own
camera — the exact camera+rect the GPU draws the content with.
- Add Scene::viewport_edit_frame(canvas) -> (camera_for_viewport,
viewport_screen_rect): the unified adapter. Inside a floating viewport
the viewport is the active pane, so snap/pick/grip/preview/DYN run in
model space and land on the pixels the GPU draws. Tracks pan/zoom/twist
exactly (twist handled by camera_from_view); crop is pixel-preserving so
full-rect projection + cursor offset matches.
- Route snap-move, click-pick, grip-drag, grip hit-test, grip render,
hover (HoverDwellTick), selection (single/box/poly/cycle) and command
entity-pick through it via pick_view / cursor_model_point helpers and an
edit_cam-aware "active pane = viewport rect" override, so the existing
tile offset plumbing just works. Fixes wrong-object hover/selection and
stale snap after pan/zoom.
- DYN: project anchors with the viewport camera and shift to canvas space
so the guide/labels sit correctly (also fixes a latent tiled-model bug).
- Separate grid snap from object snap entirely: new Snapper.grid_snap_on
flag, Grid removed from the osnap mode set, evaluated independently of
the osnap master. Object snap never catches grid; grid corners are
picked only when grid snap is on, at lowest priority. Default off.
- grip.rs: project_rte + grips_to_screen_rte / find_hit_grip_rte taking
(view_rot, eye) so grips project through the composed view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 17:06:59 +03:00
|
|
|
|
best_d2 = d2;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Object snaps are gated by the object-snap master toggle. With it off
|
|
|
|
|
|
// only the grid result (if any) stands.
|
|
|
|
|
|
if !self.snap_enabled {
|
|
|
|
|
|
return best;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 11:55:08 +03:00
|
|
|
|
// World-space snap radius — derived from the view scale so wires whose
|
|
|
|
|
|
// entire extent is clearly outside the snap circle can be skipped cheaply
|
|
|
|
|
|
// before projecting any of their vertices to screen space.
|
|
|
|
|
|
// view_proj col-0 x = 2*zoom / viewport_width for an orthographic camera,
|
|
|
|
|
|
// so scale_x * (width/2) = pixels per world unit.
|
|
|
|
|
|
let world_snap_r = {
|
2026-06-23 23:33:21 +03:00
|
|
|
|
let s = view_rot.col(0).x.abs() * bounds.width * 0.5;
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if s > 1e-6 {
|
2026-06-17 04:56:42 +03:00
|
|
|
|
self.osnap_radius_px / s
|
2026-05-12 10:36:15 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
f32::MAX
|
|
|
|
|
|
}
|
2026-04-26 11:55:08 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-26 12:21:51 +03:00
|
|
|
|
// Returns false when the wire's AABB does not overlap the snap circle —
|
|
|
|
|
|
// safe to skip all vertex work for this wire.
|
|
|
|
|
|
// UNBOUNDED_AABB (±infinity) passes through automatically without a
|
|
|
|
|
|
// special-case branch because the arithmetic is exact for infinities.
|
2026-04-26 11:55:08 +03:00
|
|
|
|
let wire_in_range = |wire: &WireModel| -> bool {
|
2026-06-24 04:26:05 +03:00
|
|
|
|
// The AABB is stored in f32, so at UTM-scale coordinates each bound
|
|
|
|
|
|
// is quantized by up to ~1 ulp (≈ coord × 2⁻²³ ≈ 0.7 m at 5.7e6).
|
|
|
|
|
|
// When zoomed in hard the snap radius shrinks below that, so the
|
|
|
|
|
|
// raw f32 bound can wrongly exclude a wire the cursor is on. Pad the
|
|
|
|
|
|
// test by the bound's own quantization so the cull never rejects a
|
|
|
|
|
|
// genuinely in-range wire (it only ever over-includes, which the
|
|
|
|
|
|
// per-vertex screen test below then rejects precisely).
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let mag = wire.aabb.iter().fold(0.0f32, |m, c| m.max(c.abs()));
|
2026-06-24 04:26:05 +03:00
|
|
|
|
let pad = (mag * f32::EPSILON * 2.0) as f64;
|
|
|
|
|
|
let r = world_snap_r as f64 + pad;
|
|
|
|
|
|
cursor_world.x + r >= wire.aabb[0] as f64
|
|
|
|
|
|
&& cursor_world.x - r <= wire.aabb[2] as f64
|
|
|
|
|
|
&& cursor_world.y + r >= wire.aabb[1] as f64
|
|
|
|
|
|
&& cursor_world.y - r <= wire.aabb[3] as f64
|
2026-04-26 11:55:08 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-08 18:57:10 +02:00
|
|
|
|
// The per-wire snaps below skip out-of-aperture wires via `wire_in_range`,
|
|
|
|
|
|
// so they stay O(n) and snap at any zoom. Intersection / ApparentIntersection
|
|
|
|
|
|
// instead compare pairs of in-range segments — O(k²) — which hangs when the
|
|
|
|
|
|
// aperture spans the whole drawing. Count the in-range wires once and gate
|
|
|
|
|
|
// only those pairwise passes; the single-wire snaps always run.
|
2026-07-09 01:00:51 +03:00
|
|
|
|
// Intersection / ApparentIntersection compare every in-range segment
|
|
|
|
|
|
// against every other — O(total in-range segments²). Gate on the
|
|
|
|
|
|
// in-range *point* (≈ segment) count, not the wire count: a handful of
|
|
|
|
|
|
// curved wires, each hundreds of tessellated points, blows the
|
|
|
|
|
|
// quadratic up even though the wire count looks modest. (A dense survey
|
|
|
|
|
|
// cursor cell held ~1k wires / ~240k points → 15 s pre-gate.)
|
|
|
|
|
|
const MAX_PAIRWISE_POINTS: usize = 3_000;
|
|
|
|
|
|
let mut in_range_pts = 0usize;
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if wires.segments().is_none() {
|
|
|
|
|
|
for w in wires.iter().filter(|w| wire_in_range(w)) {
|
|
|
|
|
|
in_range_pts += w.points.len();
|
|
|
|
|
|
if in_range_pts > MAX_PAIRWISE_POINTS {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-07-09 01:00:51 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 22:00:40 +03:00
|
|
|
|
let allow_unindexed_pairwise = in_range_pts <= MAX_PAIRWISE_POINTS;
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let local_segments = indexed_segments(wires);
|
2026-07-08 18:57:10 +02:00
|
|
|
|
|
2026-06-24 03:32:11 +03:00
|
|
|
|
let mut try_pt = |world: glam::DVec3, snap_type: SnapType| {
|
2026-06-23 23:33:21 +03:00
|
|
|
|
let screen = world_to_screen(world, view_rot, eye, bounds);
|
2026-06-24 17:45:33 +03:00
|
|
|
|
if !in_bounds(screen) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let d2 = dist2(screen, cursor_screen);
|
2026-06-18 16:30:37 +03:00
|
|
|
|
// `!(d2 < radius2)` (not `d2 >= radius2`) so a NaN distance from
|
|
|
|
|
|
// degenerate geometry is rejected: with priority selection a NaN
|
|
|
|
|
|
// would otherwise pass the gate and be chosen on rank alone,
|
|
|
|
|
|
// feeding a NaN snap point to the renderer. (#118)
|
|
|
|
|
|
if !(d2 < radius2) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-21 11:35:30 +03:00
|
|
|
|
let (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type));
|
|
|
|
|
|
if snap_better(tier, d2, sub, (best_rank, best_d2, best_sub)) {
|
|
|
|
|
|
best_rank = tier;
|
|
|
|
|
|
best_sub = sub;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
best_d2 = d2;
|
|
|
|
|
|
best = Some(SnapResult {
|
|
|
|
|
|
world,
|
|
|
|
|
|
screen,
|
|
|
|
|
|
snap_type,
|
|
|
|
|
|
tangent_obj: None,
|
2026-07-01 19:39:21 +03:00
|
|
|
|
extension_base: None,
|
2026-07-03 17:46:08 +03:00
|
|
|
|
extension_base2: None,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ──────
|
2026-07-24 22:00:40 +03:00
|
|
|
|
let mut try_snap_hint = |world: DVec3, hint: SnapHint| {
|
|
|
|
|
|
let snap_type = match hint {
|
|
|
|
|
|
SnapHint::Center => SnapType::Center,
|
|
|
|
|
|
SnapHint::Node => SnapType::Node,
|
|
|
|
|
|
SnapHint::Quadrant => SnapType::Quadrant,
|
|
|
|
|
|
SnapHint::Insertion => SnapType::Insertion,
|
|
|
|
|
|
SnapHint::Midpoint => SnapType::Midpoint,
|
|
|
|
|
|
};
|
|
|
|
|
|
if self.is_on(snap_type) {
|
|
|
|
|
|
try_pt(world, snap_type);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
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 {
|
|
|
|
|
|
continue;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
};
|
2026-07-24 22:00:40 +03:00
|
|
|
|
try_snap_hint(world, hint);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for wire in wires.iter() {
|
|
|
|
|
|
for &(world, hint) in &wire.snap_pts {
|
|
|
|
|
|
try_snap_hint(world, hint);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 22:00:40 +03:00
|
|
|
|
drop(try_snap_hint);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
|
|
|
|
|
// ── Endpoint ───────────────────────────────────────────────────────
|
|
|
|
|
|
if self.is_on(SnapType::Endpoint) {
|
2026-07-24 22:00:40 +03:00
|
|
|
|
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 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
try_pt(DVec3::from_array(point), SnapType::Endpoint);
|
2026-05-12 10:36:15 +03:00
|
|
|
|
}
|
2026-07-24 22:00:40 +03:00
|
|
|
|
// Tessellated open curves have no key-vertex set. Their only
|
|
|
|
|
|
// endpoints are first/last, so testing candidate wires remains
|
|
|
|
|
|
// O(local wires), independent of tessellation density.
|
|
|
|
|
|
for wire in wires.iter().filter(|wire| wire.key_vertices.is_empty()) {
|
2026-07-05 11:58:44 +03:00
|
|
|
|
let closed = wire
|
|
|
|
|
|
.snap_pts
|
|
|
|
|
|
.iter()
|
2026-07-24 22:00:40 +03:00
|
|
|
|
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
|
|
|
|
|
|
if closed {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !wire.points.is_empty() {
|
|
|
|
|
|
try_pt(wp_f64(wire, 0), SnapType::Endpoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
if wire.points.len() > 1 {
|
|
|
|
|
|
try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for wire in wires.iter() {
|
|
|
|
|
|
if !wire_in_range(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if !wire.key_vertices.is_empty() {
|
|
|
|
|
|
for &point in &wire.key_vertices {
|
|
|
|
|
|
try_pt(DVec3::from_array(point), SnapType::Endpoint);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
2026-07-24 22:00:40 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
let closed = wire
|
|
|
|
|
|
.snap_pts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
|
|
|
|
|
|
if !closed {
|
|
|
|
|
|
if !wire.points.is_empty() {
|
|
|
|
|
|
try_pt(wp_f64(wire, 0), SnapType::Endpoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
if wire.points.len() > 1 {
|
|
|
|
|
|
try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint);
|
2026-07-05 11:58:44 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Midpoint ───────────────────────────────────────────────────────
|
2026-07-24 22:00:40 +03:00
|
|
|
|
// Tessellated curves contribute their pre-baked Midpoint above.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if self.is_on(SnapType::Midpoint) {
|
2026-07-24 22:00:40 +03:00
|
|
|
|
if let Some(segments) = wires.key_segments() {
|
|
|
|
|
|
for segment_ref in segments {
|
|
|
|
|
|
let Some(wire) = wires.source_wire(segment_ref.wire) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
let start = segment_ref.start as usize;
|
|
|
|
|
|
let Some((&a, &b)) = wire
|
|
|
|
|
|
.key_vertices
|
|
|
|
|
|
.get(start)
|
|
|
|
|
|
.zip(wire.key_vertices.get(start + 1))
|
|
|
|
|
|
else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
|
}
|
2026-07-24 22:00:40 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
for wire in wires.iter().filter(|wire| wire_in_range(wire)) {
|
|
|
|
|
|
for segment in wire.key_vertices.windows(2) {
|
|
|
|
|
|
let a = DVec3::from_array(segment[0]);
|
|
|
|
|
|
let b = DVec3::from_array(segment[1]);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if a.distance_squared(b) > 1e-12 {
|
2026-06-24 04:26:05 +03:00
|
|
|
|
try_pt((a + b) * 0.5, SnapType::Midpoint);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Nearest — closest point on any segment (clamped) ──────────────
|
|
|
|
|
|
if self.is_on(SnapType::Nearest) {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if let Some(segments) = &local_segments {
|
|
|
|
|
|
for seg in segments {
|
|
|
|
|
|
try_pt(
|
|
|
|
|
|
nearest_on_segment(cursor_world, seg.a, seg.b),
|
|
|
|
|
|
SnapType::Nearest,
|
|
|
|
|
|
);
|
2026-05-12 10:36:15 +03:00
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
for wire in wires.iter() {
|
|
|
|
|
|
if !wire_in_range(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 16:30:37 +03:00
|
|
|
|
// ── Perpendicular — foot of perpendicular from the drawing base ──
|
|
|
|
|
|
// Drop the foot from the point the command is drawing *from* (so the
|
|
|
|
|
|
// new segment is truly perpendicular to the target). Only when there
|
|
|
|
|
|
// is no base point — e.g. picking the very first point — does it fall
|
|
|
|
|
|
// back to the cursor (a plain nearest-on-line). The candidate is gated
|
|
|
|
|
|
// on its screen distance to the cursor like every other snap, so it
|
|
|
|
|
|
// offers when the cursor is near the perpendicular foot. (#118)
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if self.is_on(SnapType::Perpendicular) {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let q = self
|
|
|
|
|
|
.from_point
|
|
|
|
|
|
.map(|v| v.as_dvec3())
|
|
|
|
|
|
.unwrap_or(cursor_world);
|
|
|
|
|
|
if let Some(segments) = &local_segments {
|
|
|
|
|
|
for seg in segments {
|
|
|
|
|
|
if let Some(foot) = perp_foot(q, seg.a, seg.b) {
|
2026-06-24 04:26:05 +03:00
|
|
|
|
try_pt(foot, SnapType::Perpendicular);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
for wire in wires.iter() {
|
|
|
|
|
|
if !wire_in_range(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 18:57:10 +02:00
|
|
|
|
// ── Intersection — segment-segment intersections (pairwise, gated) ──
|
2026-07-24 22:00:40 +03:00
|
|
|
|
if self.is_on(SnapType::Intersection)
|
|
|
|
|
|
&& (local_segments.is_some() || allow_unindexed_pairwise)
|
|
|
|
|
|
{
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if let Some(segments) = &local_segments {
|
2026-07-24 22:00:40 +03:00
|
|
|
|
// Exact cursor-local sweep: never discard a valid intersection
|
|
|
|
|
|
// in dense geometry. Min-X ordering plus Y overlap avoids
|
|
|
|
|
|
// comparing segment pairs whose bounds cannot meet.
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let mut local: Vec<&IndexedSegment> = segments.iter().collect();
|
|
|
|
|
|
local.sort_by(|a, b| a.min_x().total_cmp(&b.min_x()));
|
2026-07-24 22:00:40 +03:00
|
|
|
|
if indexed_sweep_within_budget(&local) {
|
|
|
|
|
|
'intersection_sweep: for i in 0..local.len() {
|
|
|
|
|
|
let a = local[i];
|
|
|
|
|
|
for &b in local.iter().skip(i + 1) {
|
|
|
|
|
|
if b.min_x() > a.max_x() {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) {
|
|
|
|
|
|
let exact_cursor = dist2(
|
|
|
|
|
|
world_to_screen(pt, view_rot, eye, bounds),
|
|
|
|
|
|
cursor_screen,
|
|
|
|
|
|
) <= f32::EPSILON;
|
|
|
|
|
|
try_pt(pt, SnapType::Intersection);
|
|
|
|
|
|
if exact_cursor {
|
|
|
|
|
|
break 'intersection_sweep;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-12 10:36:15 +03:00
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
for i in 0..wires.len() {
|
|
|
|
|
|
let Some(wire_i) = wires.get(i) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
if !wire_in_range(wire_i) {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
for j in (i + 1)..wires.len() {
|
|
|
|
|
|
let Some(wire_j) = wires.get(j) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
if !wire_in_range(wire_j) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
for ai in 0..wire_i.points.len().saturating_sub(1) {
|
|
|
|
|
|
// S: pre-convert outside inner loop
|
|
|
|
|
|
let a0 = wp_f64(wire_i, ai);
|
|
|
|
|
|
let a1 = wp_f64(wire_i, ai + 1);
|
|
|
|
|
|
let a_min_x = a0.x.min(a1.x);
|
|
|
|
|
|
let a_max_x = a0.x.max(a1.x);
|
|
|
|
|
|
let a_min_y = a0.y.min(a1.y);
|
|
|
|
|
|
let a_max_y = a0.y.max(a1.y);
|
|
|
|
|
|
for bi in 0..wire_j.points.len().saturating_sub(1) {
|
|
|
|
|
|
let b0 = wp_f64(wire_j, bi);
|
|
|
|
|
|
let b1 = wp_f64(wire_j, bi + 1);
|
|
|
|
|
|
// O: tight per-segment AABB overlap cull
|
|
|
|
|
|
if a_max_x < b0.x.min(b1.x)
|
|
|
|
|
|
|| a_min_x > b0.x.max(b1.x)
|
|
|
|
|
|
|| a_max_y < b0.y.min(b1.y)
|
|
|
|
|
|
|| a_min_y > b0.y.max(b1.y)
|
|
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) {
|
|
|
|
|
|
try_pt(pt, SnapType::Intersection);
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 20:49:05 +03:00
|
|
|
|
// ── Extension — persistent rays captured at endpoint acquisition ──
|
|
|
|
|
|
// The source geometry may be far outside the cursor's spatial query by
|
|
|
|
|
|
// the time its extension is used. Keep the outward incident directions
|
|
|
|
|
|
// captured with each tracking point instead of rescanning either the
|
|
|
|
|
|
// whole drawing or only the now-local candidates.
|
2026-07-04 23:30:12 +03:00
|
|
|
|
if self.is_on(SnapType::Extension) && !self.tracking_points.is_empty() {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) {
|
|
|
|
|
|
for &dir in dirs {
|
|
|
|
|
|
if let Some(ext) = extension_snap(
|
|
|
|
|
|
cursor_world,
|
|
|
|
|
|
origin,
|
|
|
|
|
|
dir,
|
|
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
|
|
|
|
|
bounds,
|
|
|
|
|
|
self.osnap_radius_px,
|
|
|
|
|
|
) {
|
|
|
|
|
|
try_pt(ext, SnapType::Extension);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-03 17:46:08 +03:00
|
|
|
|
|
2026-07-24 20:49:05 +03:00
|
|
|
|
// Extended intersection: intersect only outward rays whose infinite
|
|
|
|
|
|
// lines pass near the cursor. At most four acquired points × six
|
|
|
|
|
|
// directions participate, independent of drawing density.
|
2026-07-03 17:46:08 +03:00
|
|
|
|
let filter2 = (self.osnap_radius_px * 2.0).powi(2);
|
|
|
|
|
|
let mut cand: Vec<(glam::DVec3, glam::DVec3)> = Vec::new();
|
2026-07-24 20:49:05 +03:00
|
|
|
|
for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) {
|
|
|
|
|
|
for &dir in dirs {
|
|
|
|
|
|
let s0 = world_to_screen(origin, view_rot, eye, bounds);
|
|
|
|
|
|
let s1 = world_to_screen(origin + dir, view_rot, eye, bounds);
|
2026-07-03 17:46:08 +03:00
|
|
|
|
let ex = s1.x - s0.x;
|
|
|
|
|
|
let ey = s1.y - s0.y;
|
|
|
|
|
|
let l2 = ex * ex + ey * ey;
|
|
|
|
|
|
if l2 < 1e-6 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Perpendicular screen distance² from the cursor to the line.
|
|
|
|
|
|
let cross = ex * (cursor_screen.y - s0.y) - ey * (cursor_screen.x - s0.x);
|
|
|
|
|
|
if cross * cross / l2 <= filter2 {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
cand.push((origin, dir));
|
2026-07-03 17:46:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for i in 0..cand.len() {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let (a0, d1) = cand[i];
|
|
|
|
|
|
for &(b0, d2) in cand.iter().skip(i + 1) {
|
|
|
|
|
|
if (a0 - b0).length_squared() < 1e-18 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-03 17:46:08 +03:00
|
|
|
|
let denom = d1.x * d2.y - d1.y * d2.x;
|
|
|
|
|
|
if denom.abs() < 1e-12 {
|
|
|
|
|
|
continue; // parallel
|
|
|
|
|
|
}
|
|
|
|
|
|
let t1 = ((b0.x - a0.x) * d2.y - (b0.y - a0.y) * d2.x) / denom;
|
|
|
|
|
|
let t2 = ((b0.x - a0.x) * d1.y - (b0.y - a0.y) * d1.x) / denom;
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if t1 < 0.05 || t2 < 0.05 {
|
|
|
|
|
|
continue;
|
2026-07-03 17:46:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
// Emit as an Intersection, not an Extension: the crossing is
|
|
|
|
|
|
// a distinct point and must outrank the per-segment extension
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 18:57:10 +02:00
|
|
|
|
// ── Apparent Intersection — screen-space intersections (pairwise, gated) ──
|
2026-05-06 22:42:22 +03:00
|
|
|
|
// L: pre-project each in-range wire's points to screen once, not once per segment pair.
|
2026-07-24 22:00:40 +03:00
|
|
|
|
if self.is_on(SnapType::ApparentIntersection)
|
|
|
|
|
|
&& (local_segments.is_some() || allow_unindexed_pairwise)
|
|
|
|
|
|
{
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if let Some(segments) = &local_segments {
|
2026-07-24 22:00:40 +03:00
|
|
|
|
// Project once, then run an exact screen-AABB sweep. Unlike the
|
|
|
|
|
|
// previous 512-item cap this cannot drop the true apparent
|
|
|
|
|
|
// intersection in a dense aperture.
|
|
|
|
|
|
let mut projected: Vec<(&IndexedSegment, Point, Point, [f32; 4])> = segments
|
2026-07-24 20:49:05 +03:00
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|seg| {
|
2026-07-24 22:00:40 +03:00
|
|
|
|
let a = world_to_screen(seg.a, view_rot, eye, bounds);
|
|
|
|
|
|
let b = world_to_screen(seg.b, view_rot, eye, bounds);
|
2026-07-24 20:49:05 +03:00
|
|
|
|
(
|
2026-07-24 22:00:40 +03:00
|
|
|
|
seg,
|
|
|
|
|
|
a,
|
|
|
|
|
|
b,
|
|
|
|
|
|
[a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)],
|
2026-07-24 20:49:05 +03:00
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
2026-07-24 22:00:40 +03:00
|
|
|
|
projected.sort_by(|a, b| a.3[0].total_cmp(&b.3[0]));
|
|
|
|
|
|
if projected_sweep_within_budget(&projected) {
|
|
|
|
|
|
'apparent_sweep: for i in 0..projected.len() {
|
|
|
|
|
|
let (segment_a, screen_a0, screen_a1, aabb_a) = projected[i];
|
|
|
|
|
|
for &(segment_b, screen_b0, screen_b1, aabb_b) in
|
|
|
|
|
|
projected.iter().skip(i + 1)
|
|
|
|
|
|
{
|
|
|
|
|
|
if aabb_b[0] > aabb_a[2] {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if segment_a.wire == segment_b.wire
|
|
|
|
|
|
|| aabb_a[3] < aabb_b[1]
|
|
|
|
|
|
|| aabb_a[1] > aabb_b[3]
|
|
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some((ta, _)) =
|
|
|
|
|
|
seg_intersect_2d(screen_a0, screen_a1, screen_b0, screen_b1)
|
|
|
|
|
|
{
|
|
|
|
|
|
let apparent_screen = Point::new(
|
|
|
|
|
|
screen_a0.x + ta * (screen_a1.x - screen_a0.x),
|
|
|
|
|
|
screen_a0.y + ta * (screen_a1.y - screen_a0.y),
|
|
|
|
|
|
);
|
|
|
|
|
|
try_pt(
|
|
|
|
|
|
segment_a.a + ta as f64 * (segment_a.b - segment_a.a),
|
|
|
|
|
|
SnapType::ApparentIntersection,
|
|
|
|
|
|
);
|
|
|
|
|
|
if dist2(apparent_screen, cursor_screen) <= f32::EPSILON {
|
|
|
|
|
|
break 'apparent_sweep;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
}
|
2026-05-06 22:42:22 +03:00
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let screen_pts: Vec<Option<Vec<Point>>> = wires
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|w| {
|
|
|
|
|
|
if !wire_in_range(w) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(
|
|
|
|
|
|
(0..w.points.len())
|
|
|
|
|
|
.map(|i| world_to_screen(wp_f64(w, i), view_rot, eye, bounds))
|
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
|
|
for i in 0..wires.len() {
|
|
|
|
|
|
let Some(ref si) = screen_pts[i] else {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
continue;
|
|
|
|
|
|
};
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let Some(wire_i) = wires.get(i) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
for j in (i + 1)..wires.len() {
|
|
|
|
|
|
let Some(ref sj) = screen_pts[j] else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
let Some(wire_j) = wires.get(j) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
for ai in 0..wire_i.points.len().saturating_sub(1) {
|
|
|
|
|
|
let sa0 = si[ai];
|
|
|
|
|
|
let sa1 = si[ai + 1];
|
|
|
|
|
|
for bi in 0..wire_j.points.len().saturating_sub(1) {
|
|
|
|
|
|
let sb0 = sj[bi];
|
|
|
|
|
|
let sb1 = sj[bi + 1];
|
|
|
|
|
|
if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) {
|
|
|
|
|
|
let wa0 = wp_f64(wire_i, ai);
|
|
|
|
|
|
let wa1 = wp_f64(wire_i, ai + 1);
|
|
|
|
|
|
try_pt(
|
|
|
|
|
|
wa0 + ta as f64 * (wa1 - wa0),
|
|
|
|
|
|
SnapType::ApparentIntersection,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Tangent ────────────────────────────────────────────────────────
|
|
|
|
|
|
// Operates directly on tangent_geoms geometry — independent of the
|
|
|
|
|
|
// wire.points rendering structure so polyline segments work correctly.
|
|
|
|
|
|
if self.is_on(SnapType::Tangent) {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
for wire in wires.iter() {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for tg in &wire.tangent_geoms {
|
|
|
|
|
|
let (world_pt, d2) = match tg {
|
|
|
|
|
|
TangentGeom::Line { p1, p2 } => {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let sp0 = world_to_screen(
|
|
|
|
|
|
glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64),
|
|
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
|
|
|
|
|
bounds,
|
|
|
|
|
|
);
|
|
|
|
|
|
let sp1 = world_to_screen(
|
|
|
|
|
|
glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64),
|
|
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
|
|
|
|
|
bounds,
|
|
|
|
|
|
);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let d2 = dist2_to_segment(cursor_screen, sp0, sp1);
|
|
|
|
|
|
let t = t_on_segment(cursor_screen, sp0, sp1);
|
|
|
|
|
|
let w = Vec3::from(*p1) + t * (Vec3::from(*p2) - Vec3::from(*p1));
|
|
|
|
|
|
(w, d2)
|
|
|
|
|
|
}
|
|
|
|
|
|
TangentGeom::Circle { center, radius } => {
|
|
|
|
|
|
let cv = Vec3::from(*center);
|
2026-07-05 12:49:36 +03:00
|
|
|
|
let r = *radius;
|
2026-06-24 03:32:11 +03:00
|
|
|
|
let sc = world_to_screen(cv.as_dvec3(), view_rot, eye, bounds);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let rim = world_to_screen(
|
2026-07-05 12:49:36 +03:00
|
|
|
|
glam::DVec3::new((cv.x + r) as f64, cv.y as f64, cv.z as f64),
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
bounds,
|
|
|
|
|
|
);
|
|
|
|
|
|
let sr = dist2(sc, rim).sqrt();
|
|
|
|
|
|
let dc = dist2(cursor_screen, sc).sqrt();
|
2026-07-05 12:49:36 +03:00
|
|
|
|
// Trigger by proximity to the circle EDGE (hovering
|
|
|
|
|
|
// the circle), independent of where the tangent point
|
|
|
|
|
|
// lands.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let edge_d = (dc - sr).abs();
|
2026-07-05 12:49:36 +03:00
|
|
|
|
// A TRUE tangent: from the point the command draws
|
|
|
|
|
|
// from (P), the tangent point T on the circle has the
|
|
|
|
|
|
// radius CT perpendicular to PT, so T lies at the
|
|
|
|
|
|
// half-angle acos(r/|CP|) either side of the C→P
|
|
|
|
|
|
// direction. Two solutions — take the one nearer the
|
|
|
|
|
|
// cursor. Without P (a deferred tangent picked first)
|
|
|
|
|
|
// or with P inside the circle, fall back to the circle
|
|
|
|
|
|
// point facing the cursor. Previously this always used
|
|
|
|
|
|
// the facing point, so Tangent behaved like Nearest
|
|
|
|
|
|
// (#274).
|
|
|
|
|
|
let w = self
|
|
|
|
|
|
.from_point
|
|
|
|
|
|
.and_then(|p| circle_tangent_points(p, cv, r))
|
|
|
|
|
|
.map(|(t0, t1)| {
|
|
|
|
|
|
// Two tangents — take the one nearer the cursor.
|
|
|
|
|
|
let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
if dist2(s0, cursor_screen) <= dist2(s1, cursor_screen) {
|
|
|
|
|
|
t0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
t1
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
|
let dx = cursor_screen.x - sc.x;
|
|
|
|
|
|
let dy = cursor_screen.y - sc.y;
|
|
|
|
|
|
let dl = (dx * dx + dy * dy).sqrt();
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let (nx, ny) = if dl > 1e-6 {
|
|
|
|
|
|
(dx / dl, -dy / dl)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(1.0, 0.0)
|
|
|
|
|
|
};
|
2026-07-05 12:49:36 +03:00
|
|
|
|
Vec3::new(cv.x + r * nx, cv.y + r * ny, cv.z)
|
|
|
|
|
|
});
|
2026-03-22 14:56:27 +03:00
|
|
|
|
(w, edge_d * edge_d)
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let (tier, sub) = (
|
|
|
|
|
|
snap_tier(SnapType::Tangent),
|
|
|
|
|
|
snap_priority(SnapType::Tangent),
|
|
|
|
|
|
);
|
2026-06-24 17:45:33 +03:00
|
|
|
|
let screen_pt = world_to_screen(world_pt.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
if d2 < radius2
|
|
|
|
|
|
&& in_bounds(screen_pt)
|
2026-07-21 11:35:30 +03:00
|
|
|
|
&& snap_better(tier, d2, sub, (best_rank, best_d2, best_sub))
|
2026-06-24 17:45:33 +03:00
|
|
|
|
{
|
2026-07-21 11:35:30 +03:00
|
|
|
|
best_rank = tier;
|
|
|
|
|
|
best_sub = sub;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
best_d2 = d2;
|
|
|
|
|
|
let tangent_obj = match tg {
|
|
|
|
|
|
TangentGeom::Line { p1, p2 } => TangentObject::Line {
|
2026-06-24 03:32:11 +03:00
|
|
|
|
p1: glam::DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64),
|
|
|
|
|
|
p2: glam::DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
},
|
|
|
|
|
|
TangentGeom::Circle { center, radius } => TangentObject::Circle {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
center: glam::DVec3::new(
|
|
|
|
|
|
center[0] as f64,
|
|
|
|
|
|
center[1] as f64,
|
|
|
|
|
|
center[2] as f64,
|
|
|
|
|
|
),
|
2026-06-24 03:32:11 +03:00
|
|
|
|
radius: *radius as f64,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
best = Some(SnapResult {
|
2026-06-24 03:32:11 +03:00
|
|
|
|
world: world_pt.as_dvec3(),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
screen: screen_pt,
|
|
|
|
|
|
snap_type: SnapType::Tangent,
|
|
|
|
|
|
tangent_obj: Some(tangent_obj),
|
2026-07-01 19:39:21 +03:00
|
|
|
|
extension_base: None,
|
2026-07-03 17:46:08 +03:00
|
|
|
|
extension_base2: None,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 17:01:23 +03:00
|
|
|
|
// ── Center via curve proximity ─────────────────────────────────────
|
|
|
|
|
|
// A circle/arc/ellipse's centre is offset from its curve — for an arc
|
|
|
|
|
|
// it usually sits in empty space well off the geometry — so gating the
|
|
|
|
|
|
// Center snap purely on the cursor's distance to the centre *point*
|
|
|
|
|
|
// (the pre-baked pass above) means hovering the curve, the natural
|
|
|
|
|
|
// gesture, never offers it. Mirror running-osnap behaviour: when the
|
|
|
|
|
|
// cursor is near such a curve, offer its centre, ranked by how close
|
|
|
|
|
|
// the cursor is to the curve. Runs here, after `try_pt`'s borrow ends,
|
|
|
|
|
|
// so it can update the candidate state directly. (#152)
|
|
|
|
|
|
if self.is_on(SnapType::Center) {
|
2026-07-24 22:00:40 +03:00
|
|
|
|
let mut offer = |wire: &WireModel, curve_d2: f32| {
|
2026-06-25 17:01:23 +03:00
|
|
|
|
let Some(center) = wire
|
|
|
|
|
|
.snap_pts
|
|
|
|
|
|
.iter()
|
2026-07-24 22:00:40 +03:00
|
|
|
|
.find(|(_, hint)| matches!(hint, SnapHint::Center))
|
2026-06-25 17:01:23 +03:00
|
|
|
|
.map(|&(c, _)| c)
|
|
|
|
|
|
else {
|
2026-07-24 22:00:40 +03:00
|
|
|
|
return;
|
2026-06-25 17:01:23 +03:00
|
|
|
|
};
|
|
|
|
|
|
let screen = world_to_screen(center, view_rot, eye, bounds);
|
2026-07-21 11:35:30 +03:00
|
|
|
|
// Rim-hover Center: the cursor is on the CURVE, not near the
|
|
|
|
|
|
// centre, so its distance metric (curve_d2 ≈ 0 anywhere on the
|
|
|
|
|
|
// rim) must not compete inside the discrete tier — it would
|
|
|
|
|
|
// mask the circle's own Quadrants at every rim position
|
|
|
|
|
|
// (#420). Tier 1 sits below every discrete point snap and
|
|
|
|
|
|
// above the continuous ones; the pre-baked Center point (true
|
|
|
|
|
|
// cursor-to-centre distance) stays in tier 0.
|
|
|
|
|
|
let (tier, sub) = (1u8, snap_priority(SnapType::Center));
|
2026-06-25 17:01:23 +03:00
|
|
|
|
if curve_d2 < radius2
|
|
|
|
|
|
&& in_bounds(screen)
|
2026-07-21 11:35:30 +03:00
|
|
|
|
&& snap_better(tier, curve_d2, sub, (best_rank, best_d2, best_sub))
|
2026-06-25 17:01:23 +03:00
|
|
|
|
{
|
2026-07-21 11:35:30 +03:00
|
|
|
|
best_rank = tier;
|
|
|
|
|
|
best_sub = sub;
|
2026-06-25 17:01:23 +03:00
|
|
|
|
best_d2 = curve_d2;
|
|
|
|
|
|
best = Some(SnapResult {
|
|
|
|
|
|
world: center,
|
|
|
|
|
|
screen,
|
|
|
|
|
|
snap_type: SnapType::Center,
|
|
|
|
|
|
tangent_obj: None,
|
2026-07-01 19:39:21 +03:00
|
|
|
|
extension_base: None,
|
2026-07-03 17:46:08 +03:00
|
|
|
|
extension_base2: None,
|
2026-06-25 17:01:23 +03:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-07-24 22:00:40 +03:00
|
|
|
|
};
|
|
|
|
|
|
if let Some(segments) = &local_segments {
|
|
|
|
|
|
let mut distances: HashMap<u32, f32> = HashMap::default();
|
|
|
|
|
|
for segment in segments {
|
|
|
|
|
|
let Some(wire) = wires.source_wire(segment.wire) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
if !wire
|
|
|
|
|
|
.snap_pts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|(_, hint)| matches!(hint, SnapHint::Center))
|
|
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let nearest = nearest_on_segment(cursor_world, segment.a, segment.b);
|
|
|
|
|
|
let distance = dist2(
|
|
|
|
|
|
world_to_screen(nearest, view_rot, eye, bounds),
|
|
|
|
|
|
cursor_screen,
|
|
|
|
|
|
);
|
|
|
|
|
|
distances
|
|
|
|
|
|
.entry(segment.wire)
|
|
|
|
|
|
.and_modify(|best| *best = best.min(distance))
|
|
|
|
|
|
.or_insert(distance);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (wire_idx, distance) in distances {
|
|
|
|
|
|
if let Some(wire) = wires.source_wire(wire_idx) {
|
|
|
|
|
|
offer(wire, distance);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for wire in wires.iter().filter(|wire| wire_in_range(wire)) {
|
|
|
|
|
|
let mut curve_d2 = f32::INFINITY;
|
|
|
|
|
|
for index in 0..wire.points.len().saturating_sub(1) {
|
|
|
|
|
|
let nearest = nearest_on_segment(
|
|
|
|
|
|
cursor_world,
|
|
|
|
|
|
wp_f64(wire, index),
|
|
|
|
|
|
wp_f64(wire, index + 1),
|
|
|
|
|
|
);
|
|
|
|
|
|
curve_d2 = curve_d2.min(dist2(
|
|
|
|
|
|
world_to_screen(nearest, view_rot, eye, bounds),
|
|
|
|
|
|
cursor_screen,
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
offer(wire, curve_d2);
|
|
|
|
|
|
}
|
2026-06-25 17:01:23 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 17:46:08 +03:00
|
|
|
|
// If an Extension snap or an extended intersection won, re-find the
|
|
|
|
|
|
// endpoint(s) whose ray(s) it lies on so the overlay can draw the dashed
|
|
|
|
|
|
// guide line(s) back to them. An Extension yields one base; an extended
|
|
|
|
|
|
// intersection yields both crossing extensions. A genuine on-segment
|
|
|
|
|
|
// intersection yields none, so its guides simply don't draw. (#238, #247, #259)
|
2026-07-01 19:39:21 +03:00
|
|
|
|
if let Some(b) = best.as_mut() {
|
2026-07-03 17:46:08 +03:00
|
|
|
|
if matches!(b.snap_type, SnapType::Extension | SnapType::Intersection) {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let (b1, b2) = extension_bases_screen(
|
|
|
|
|
|
b.world,
|
|
|
|
|
|
&self.tracking_points,
|
|
|
|
|
|
&self.tracking_dirs,
|
|
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
|
|
|
|
|
bounds,
|
|
|
|
|
|
);
|
2026-07-03 17:46:08 +03:00
|
|
|
|
b.extension_base = b1;
|
|
|
|
|
|
b.extension_base2 = b2;
|
2026-07-01 19:39:21 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
best
|
|
|
|
|
|
}
|
2026-08-06 23:35:58 -03:00
|
|
|
|
pub fn extension_input_ray(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
snapped: glam::DVec3,
|
|
|
|
|
|
) -> Option<(glam::DVec3, glam::DVec3)> {
|
|
|
|
|
|
let mut best: Option<(f64, glam::DVec3, glam::DVec3)> = None;
|
|
|
|
|
|
|
|
|
|
|
|
for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) {
|
|
|
|
|
|
for &dir in dirs {
|
|
|
|
|
|
let len2 = dir.x * dir.x + dir.y * dir.y;
|
|
|
|
|
|
if len2 < 1e-12 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let dx = snapped.x - origin.x;
|
|
|
|
|
|
let dy = snapped.y - origin.y;
|
|
|
|
|
|
|
|
|
|
|
|
let t = (dx * dir.x + dy * dir.y) / len2;
|
|
|
|
|
|
|
|
|
|
|
|
// Extension only exists beyond the acquired endpoint.
|
|
|
|
|
|
if t < 0.05 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let projected = glam::DVec3::new(
|
|
|
|
|
|
origin.x + dir.x * t,
|
|
|
|
|
|
origin.y + dir.y * t,
|
|
|
|
|
|
origin.z,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
let ex = snapped.x - projected.x;
|
|
|
|
|
|
let ey = snapped.y - projected.y;
|
|
|
|
|
|
let err2 = ex * ex + ey * ey;
|
|
|
|
|
|
|
|
|
|
|
|
let len = len2.sqrt();
|
|
|
|
|
|
let unit_dir =
|
|
|
|
|
|
glam::DVec3::new(dir.x / len, dir.y / len, 0.0);
|
|
|
|
|
|
|
2026-08-07 11:22:04 +03:00
|
|
|
|
// The ray has to actually pass through the snapped point. Only
|
|
|
|
|
|
// four acquisitions are kept and the oldest is dropped as new
|
|
|
|
|
|
// ones come in, so by the time a typed distance arrives the ray
|
|
|
|
|
|
// that produced this snap may be gone — without this the nearest
|
|
|
|
|
|
// surviving ray wins by default and the distance flies off along
|
|
|
|
|
|
// it. Failing here instead leaves the caller on plain point input.
|
|
|
|
|
|
//
|
|
|
|
|
|
// `extension_snap` builds the point by exact f64 projection onto
|
|
|
|
|
|
// this same ray, so a match sits on it to rounding: the tolerance
|
|
|
|
|
|
// only has to clear f64 noise, and scaling it with the coordinate
|
|
|
|
|
|
// keeps that true at UTM magnitudes. (`extension_bases_screen`
|
|
|
|
|
|
// answers the same question in screen pixels because it also has
|
|
|
|
|
|
// to catch a *crossing* base, which is a looser fit than this.)
|
|
|
|
|
|
let tol = 1e-9_f64.max(2e-12 * origin.x.abs().max(origin.y.abs()));
|
|
|
|
|
|
if err2 > tol * tol {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-08-06 23:35:58 -03:00
|
|
|
|
if best.as_ref().map_or(true, |(best_err, _, _)| err2 < *best_err) {
|
|
|
|
|
|
best = Some((err2, origin, unit_dir));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
best.map(|(_, origin, dir)| (origin, dir))
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 16:30:37 +03:00
|
|
|
|
// ── Object-snap priority ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Selection priority for an object snap — lower wins. Discrete snaps that
|
|
|
|
|
|
/// land on a specific feature (Endpoint, Midpoint, Center, …) outrank the
|
|
|
|
|
|
/// "continuous" snaps (Perpendicular, Tangent, Nearest) that can sit anywhere
|
|
|
|
|
|
/// along the geometry, so enabling a continuous snap can't suppress the
|
|
|
|
|
|
/// discrete ones the user also turned on. Mirrors the usual CAD running-osnap
|
|
|
|
|
|
/// precedence. See #118.
|
2026-07-21 11:35:30 +03:00
|
|
|
|
/// Priority TIER for candidate selection. All discrete point snaps
|
|
|
|
|
|
/// (Endpoint … Insertion) share one tier so the cursor's nearest feature wins
|
|
|
|
|
|
/// among them — a circle's Center must not mask its Quadrants just by rank
|
|
|
|
|
|
/// (#420). Continuous snaps keep their individual lower tiers, preserving the
|
|
|
|
|
|
/// #118 guarantee that they never suppress a discrete snap in the aperture.
|
|
|
|
|
|
fn snap_tier(t: SnapType) -> u8 {
|
|
|
|
|
|
match t {
|
|
|
|
|
|
SnapType::Endpoint
|
|
|
|
|
|
| SnapType::Intersection
|
|
|
|
|
|
| SnapType::ApparentIntersection
|
|
|
|
|
|
| SnapType::Midpoint
|
|
|
|
|
|
| SnapType::Center
|
|
|
|
|
|
| SnapType::Node
|
|
|
|
|
|
| SnapType::Quadrant
|
|
|
|
|
|
| SnapType::Insertion => 0,
|
|
|
|
|
|
other => snap_priority(other),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Candidate ordering: tier first, then cursor distance, and only when two
|
|
|
|
|
|
/// 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.
|
|
|
|
|
|
fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool {
|
|
|
|
|
|
let (bt, bd2, bsub) = best;
|
|
|
|
|
|
if tier != bt {
|
|
|
|
|
|
return tier < bt;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (d2 - bd2).abs() > 4.0 {
|
|
|
|
|
|
return d2 < bd2;
|
|
|
|
|
|
}
|
|
|
|
|
|
sub < bsub
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 16:30:37 +03:00
|
|
|
|
fn snap_priority(t: SnapType) -> u8 {
|
|
|
|
|
|
match t {
|
|
|
|
|
|
SnapType::Endpoint => 0,
|
|
|
|
|
|
SnapType::Intersection => 1,
|
|
|
|
|
|
SnapType::ApparentIntersection => 2,
|
|
|
|
|
|
SnapType::Midpoint => 3,
|
|
|
|
|
|
SnapType::Center => 4,
|
|
|
|
|
|
SnapType::Node => 5,
|
|
|
|
|
|
SnapType::Quadrant => 6,
|
|
|
|
|
|
SnapType::Insertion => 7,
|
|
|
|
|
|
SnapType::ObjectPick => 8,
|
|
|
|
|
|
SnapType::Perpendicular => 9,
|
|
|
|
|
|
SnapType::Tangent => 10,
|
|
|
|
|
|
SnapType::Parallel => 11,
|
|
|
|
|
|
SnapType::Extension => 12,
|
|
|
|
|
|
SnapType::Nearest => 13,
|
|
|
|
|
|
SnapType::Grid => 14,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
// ── Geometric helpers ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-03 20:34:20 +03:00
|
|
|
|
/// Line directions of every wire segment that has an endpoint at `p` (an
|
|
|
|
|
|
/// acquired corner), deduped by near-parallelism and capped. OTRACK offers an
|
|
|
|
|
|
/// alignment ray along each so the cursor can track a segment's extension, not
|
|
|
|
|
|
/// just the ortho/polar axes (#219). Scanned once, at acquisition — not per
|
|
|
|
|
|
/// move. Empty when `p` is not a segment endpoint (midpoint / centre / node).
|
2026-07-24 20:49:05 +03:00
|
|
|
|
fn edge_dirs_at<W: WireSource + ?Sized>(p: DVec3, wires: &W) -> Vec<DVec3> {
|
2026-07-24 17:56:06 +03:00
|
|
|
|
// Acquired points and reconstructed wire vertices are both f64. Keep a
|
|
|
|
|
|
// small scale-aware window for double-single reconstruction residuals
|
|
|
|
|
|
// without allowing unrelated UTM-scale vertices to match.
|
|
|
|
|
|
let tol = 1e-8_f64.max(2e-12 * p.x.abs().max(p.y.abs()));
|
|
|
|
|
|
let tol2 = tol * tol;
|
|
|
|
|
|
let mut dirs: Vec<DVec3> = Vec::new();
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let mut consider = |a: DVec3, b: DVec3| {
|
|
|
|
|
|
if !a.x.is_finite() || !b.x.is_finite() {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let at_a = (a - p).length_squared() < tol2;
|
|
|
|
|
|
let at_b = (b - p).length_squared() < tol2;
|
|
|
|
|
|
if !at_a && !at_b {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Store the outward ray from the acquired endpoint. Unlike OTRACK's
|
|
|
|
|
|
// infinite alignment line, Extension needs the sign, so opposite
|
|
|
|
|
|
// collinear incident edges remain distinct.
|
|
|
|
|
|
let seg = if at_a { a - b } else { b - a };
|
|
|
|
|
|
let l = (seg.x * seg.x + seg.y * seg.y).sqrt();
|
|
|
|
|
|
if l < 1e-9 {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let d = DVec3::new(seg.x / l, seg.y / l, 0.0);
|
|
|
|
|
|
if dirs.iter().any(|e| e.x * d.x + e.y * d.y > 0.99996) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
dirs.push(d);
|
|
|
|
|
|
dirs.len() >= 6
|
|
|
|
|
|
};
|
|
|
|
|
|
let is_round = |wire: &WireModel| {
|
|
|
|
|
|
wire.snap_pts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant | SnapHint::Center))
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some(segments) = indexed_segments(wires) {
|
|
|
|
|
|
for segment in segments {
|
|
|
|
|
|
if wires.source_wire(segment.wire).is_some_and(&is_round) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if consider(segment.a, segment.b) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return dirs;
|
|
|
|
|
|
}
|
|
|
|
|
|
'outer: for wire in wires.iter() {
|
|
|
|
|
|
if is_round(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-03 20:34:20 +03:00
|
|
|
|
let n = wire.points.len();
|
|
|
|
|
|
if n < 2 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
for i in 0..n - 1 {
|
|
|
|
|
|
let a = wp_f64(wire, i);
|
|
|
|
|
|
let b = wp_f64(wire, i + 1);
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if consider(a, b) {
|
2026-07-03 20:34:20 +03:00
|
|
|
|
break 'outer;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
dirs
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 20:49:05 +03:00
|
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
|
struct IndexedSegment {
|
|
|
|
|
|
wire: u32,
|
|
|
|
|
|
a: DVec3,
|
|
|
|
|
|
b: DVec3,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl IndexedSegment {
|
|
|
|
|
|
fn min_x(self) -> f64 {
|
|
|
|
|
|
self.a.x.min(self.b.x)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn max_x(self) -> f64 {
|
|
|
|
|
|
self.a.x.max(self.b.x)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn min_y(self) -> f64 {
|
|
|
|
|
|
self.a.y.min(self.b.y)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn max_y(self) -> f64 {
|
|
|
|
|
|
self.a.y.max(self.b.y)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 22:00:40 +03:00
|
|
|
|
const MAX_INDEXED_PAIRWISE_WORK: usize = 500_000;
|
|
|
|
|
|
|
|
|
|
|
|
fn indexed_sweep_within_budget(segments: &[&IndexedSegment]) -> bool {
|
|
|
|
|
|
let mut work = 0usize;
|
|
|
|
|
|
for (index, &a) in segments.iter().enumerate() {
|
|
|
|
|
|
for &b in segments.iter().skip(index + 1) {
|
|
|
|
|
|
if b.min_x() > a.max_x() {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
work += 1;
|
|
|
|
|
|
if work > MAX_INDEXED_PAIRWISE_WORK {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn projected_sweep_within_budget(segments: &[(&IndexedSegment, Point, Point, [f32; 4])]) -> bool {
|
|
|
|
|
|
let mut work = 0usize;
|
|
|
|
|
|
for (index, &(a, _, _, aabb_a)) in segments.iter().enumerate() {
|
|
|
|
|
|
for &(b, _, _, aabb_b) in segments.iter().skip(index + 1) {
|
|
|
|
|
|
if aabb_b[0] > aabb_a[2] {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
work += 1;
|
|
|
|
|
|
if work > MAX_INDEXED_PAIRWISE_WORK {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.wire == b.wire || aabb_a[3] < aabb_b[1] || aabb_a[1] > aabb_b[3] {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 20:49:05 +03:00
|
|
|
|
fn indexed_segments<W: WireSource + ?Sized>(wires: &W) -> Option<Vec<IndexedSegment>> {
|
|
|
|
|
|
let refs = wires.segments()?;
|
|
|
|
|
|
Some(
|
|
|
|
|
|
refs.iter()
|
|
|
|
|
|
.filter_map(|seg| {
|
|
|
|
|
|
let wire = wires.source_wire(seg.wire)?;
|
|
|
|
|
|
let start = seg.start as usize;
|
|
|
|
|
|
if start + 1 >= wire.points.len() {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let a = wp_f64(wire, start);
|
|
|
|
|
|
let b = wp_f64(wire, start + 1);
|
|
|
|
|
|
(a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite())
|
|
|
|
|
|
.then_some(IndexedSegment {
|
|
|
|
|
|
wire: seg.wire,
|
|
|
|
|
|
a,
|
|
|
|
|
|
b,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect(),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 04:26:05 +03:00
|
|
|
|
/// Reconstruct the absolute f64 position of wire vertex `i` from its
|
|
|
|
|
|
/// double-single high/low pair. At UTM-scale coordinates the `points` (high)
|
|
|
|
|
|
/// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so
|
|
|
|
|
|
/// computed snaps (nearest/perp/intersection/extension) land on the geometry.
|
|
|
|
|
|
#[inline]
|
|
|
|
|
|
fn wp_f64(wire: &WireModel, i: usize) -> glam::DVec3 {
|
|
|
|
|
|
let h = wire.points[i];
|
|
|
|
|
|
let l = wire.points_low.get(i).copied().unwrap_or([0.0; 3]);
|
|
|
|
|
|
glam::DVec3::new(
|
|
|
|
|
|
h[0] as f64 + l[0] as f64,
|
|
|
|
|
|
h[1] as f64 + l[1] as f64,
|
|
|
|
|
|
h[2] as f64 + l[2] as f64,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// Closest point on segment [p0, p1] to `query`.
|
2026-06-24 04:26:05 +03:00
|
|
|
|
fn nearest_on_segment(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> glam::DVec3 {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let d = p1 - p0;
|
|
|
|
|
|
let len2 = d.x * d.x + d.y * d.y;
|
|
|
|
|
|
if len2 < 1e-12 {
|
|
|
|
|
|
return p0;
|
|
|
|
|
|
}
|
|
|
|
|
|
let t = ((query.x - p0.x) * d.x + (query.y - p0.y) * d.y) / len2;
|
|
|
|
|
|
let t = t.clamp(0.0, 1.0);
|
2026-06-24 04:26:05 +03:00
|
|
|
|
glam::DVec3::new(p0.x + t * d.x, p0.y + t * d.y, p0.z + t * d.z)
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Foot of perpendicular from `query` to the line through [p0, p1] (XY plane, unclamped).
|
|
|
|
|
|
/// Returns `None` if the segment is degenerate.
|
2026-06-24 04:26:05 +03:00
|
|
|
|
fn perp_foot(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option<glam::DVec3> {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let d = p1 - p0;
|
|
|
|
|
|
let len2 = d.x * d.x + d.y * d.y;
|
|
|
|
|
|
if len2 < 1e-12 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let t = ((query.x - p0.x) * d.x + (query.y - p0.y) * d.y) / len2;
|
|
|
|
|
|
// Reject if the foot is far outside the segment (more than 2× segment length).
|
|
|
|
|
|
if t < -1.0 || t > 2.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
Some(glam::DVec3::new(
|
|
|
|
|
|
p0.x + t * d.x,
|
|
|
|
|
|
p0.y + t * d.y,
|
|
|
|
|
|
p0.z + t * d.z,
|
|
|
|
|
|
))
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// XY-plane segment-segment intersection. Returns `None` if parallel or outside.
|
2026-07-10 13:05:48 +03:00
|
|
|
|
/// True 3D intersection of two segments: the point where their plan (XY)
|
|
|
|
|
|
/// projections cross **and** both segments are at the same height there. Returns
|
|
|
|
|
|
/// `None` when parallel in plan, out of range, or the segments only *appear* to
|
|
|
|
|
|
/// cross in plan because they sit at different Z — a real Intersection requires
|
|
|
|
|
|
/// the objects to actually meet, so that case is left to Apparent Intersection
|
|
|
|
|
|
/// (the view-space crossing). (#335)
|
2026-07-24 20:49:05 +03:00
|
|
|
|
fn seg_intersect_3d(
|
|
|
|
|
|
a0: glam::DVec3,
|
|
|
|
|
|
a1: glam::DVec3,
|
|
|
|
|
|
b0: glam::DVec3,
|
|
|
|
|
|
b1: glam::DVec3,
|
|
|
|
|
|
) -> Option<glam::DVec3> {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let d1x = a1.x - a0.x;
|
|
|
|
|
|
let d1y = a1.y - a0.y;
|
|
|
|
|
|
let d2x = b1.x - b0.x;
|
|
|
|
|
|
let d2y = b1.y - b0.y;
|
|
|
|
|
|
let cross = d1x * d2y - d1y * d2x;
|
|
|
|
|
|
if cross.abs() < 1e-9 {
|
|
|
|
|
|
return None;
|
2026-07-10 13:05:48 +03:00
|
|
|
|
} // parallel in plan
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let ex = b0.x - a0.x;
|
|
|
|
|
|
let ey = b0.y - a0.y;
|
|
|
|
|
|
let t = (ex * d2y - ey * d2x) / cross;
|
|
|
|
|
|
let s = (ex * d1y - ey * d1x) / cross;
|
|
|
|
|
|
if t < 0.0 || t > 1.0 || s < 0.0 || s > 1.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-07-10 13:05:48 +03:00
|
|
|
|
// The plans cross; the segments truly meet only if they are at the same
|
|
|
|
|
|
// height at that crossing. Different Z ⇒ they merely overlap in plan, which
|
|
|
|
|
|
// is an apparent (view-dependent) intersection, not a real one.
|
|
|
|
|
|
let za = a0.z + t * (a1.z - a0.z);
|
|
|
|
|
|
let zb = b0.z + s * (b1.z - b0.z);
|
|
|
|
|
|
let tol = 1e-6_f64.max(1e-9 * za.abs().max(zb.abs()));
|
|
|
|
|
|
if (za - zb).abs() > tol {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
Some(glam::DVec3::new(
|
|
|
|
|
|
a0.x + t * d1x,
|
|
|
|
|
|
a0.y + t * d1y,
|
|
|
|
|
|
0.5 * (za + zb),
|
|
|
|
|
|
))
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 05:01:41 +03:00
|
|
|
|
/// Intersection of two infinite lines in the XY plane, each given by an origin
|
|
|
|
|
|
/// and a direction. Returns `None` when the lines are parallel.
|
2026-07-24 17:56:06 +03:00
|
|
|
|
fn line_intersect_xy(o1: DVec3, d1: DVec3, o2: DVec3, d2: DVec3) -> Option<DVec3> {
|
2026-06-17 05:01:41 +03:00
|
|
|
|
let cross = d1.x * d2.y - d1.y * d2.x;
|
2026-07-24 17:56:06 +03:00
|
|
|
|
if cross.abs() < 1e-15 {
|
2026-06-17 05:01:41 +03:00
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let ex = o2.x - o1.x;
|
|
|
|
|
|
let ey = o2.y - o1.y;
|
|
|
|
|
|
let t = (ex * d2.y - ey * d2.x) / cross;
|
2026-07-24 17:56:06 +03:00
|
|
|
|
Some(DVec3::new(o1.x + d1.x * t, o1.y + d1.y * t, o1.z))
|
2026-06-17 05:01:41 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// Screen-space 2D segment intersection. Returns `(t, s)` parameters if found.
|
|
|
|
|
|
fn seg_intersect_2d(a0: Point, a1: Point, b0: Point, b1: Point) -> Option<(f32, f32)> {
|
|
|
|
|
|
let d1x = a1.x - a0.x;
|
|
|
|
|
|
let d1y = a1.y - a0.y;
|
|
|
|
|
|
let d2x = b1.x - b0.x;
|
|
|
|
|
|
let d2y = b1.y - b0.y;
|
|
|
|
|
|
let cross = d1x * d2y - d1y * d2x;
|
|
|
|
|
|
if cross.abs() < 1e-6 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let ex = b0.x - a0.x;
|
|
|
|
|
|
let ey = b0.y - a0.y;
|
|
|
|
|
|
let t = (ex * d2y - ey * d2x) / cross;
|
|
|
|
|
|
let s = (ex * d1y - ey * d1x) / cross;
|
|
|
|
|
|
if t < 0.0 || t > 1.0 || s < 0.0 || s > 1.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some((t, s))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 12:49:36 +03:00
|
|
|
|
/// The two points on a circle (`center`, `radius`, in its XY plane) where a
|
|
|
|
|
|
/// line drawn from `p` touches tangentially, or `None` when `p` is inside or on
|
|
|
|
|
|
/// the circle (no external tangent). Each tangent point's radius is
|
|
|
|
|
|
/// perpendicular to the line from `p`, so it sits at the half-angle
|
|
|
|
|
|
/// `acos(radius / |center→p|)` on either side of the center→p direction. This
|
|
|
|
|
|
/// is the real Tangent osnap; snapping to the circle point merely facing the
|
|
|
|
|
|
/// cursor made Tangent behave like Nearest (#274).
|
|
|
|
|
|
fn circle_tangent_points(p: Vec3, center: Vec3, radius: f32) -> Option<(Vec3, Vec3)> {
|
|
|
|
|
|
let vx = p.x - center.x;
|
|
|
|
|
|
let vy = p.y - center.y;
|
|
|
|
|
|
let d = (vx * vx + vy * vy).sqrt();
|
|
|
|
|
|
if d <= radius + 1e-6 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let base = vy.atan2(vx);
|
|
|
|
|
|
let off = (radius / d).acos();
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let at = |a: f32| {
|
|
|
|
|
|
Vec3::new(
|
|
|
|
|
|
center.x + radius * a.cos(),
|
|
|
|
|
|
center.y + radius * a.sin(),
|
|
|
|
|
|
center.z,
|
|
|
|
|
|
)
|
|
|
|
|
|
};
|
2026-07-05 12:49:36 +03:00
|
|
|
|
Some((at(base + off), at(base - off)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// Snap to the extension of a ray beyond `origin` in `dir` direction.
|
|
|
|
|
|
/// Returns `None` if the cursor is not near the extension line.
|
|
|
|
|
|
fn extension_snap(
|
2026-06-24 04:26:05 +03:00
|
|
|
|
cursor_world: glam::DVec3,
|
|
|
|
|
|
origin: glam::DVec3,
|
|
|
|
|
|
dir: glam::DVec3,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot: Mat4,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
eye: glam::DVec3,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
radius_px: f32,
|
2026-06-24 04:26:05 +03:00
|
|
|
|
) -> Option<glam::DVec3> {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let len2 = dir.x * dir.x + dir.y * dir.y;
|
|
|
|
|
|
if len2 < 1e-12 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let t = ((cursor_world.x - origin.x) * dir.x + (cursor_world.y - origin.y) * dir.y) / len2;
|
|
|
|
|
|
if t < 0.05 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
} // only beyond the endpoint
|
2026-06-24 04:26:05 +03:00
|
|
|
|
let world_pt = glam::DVec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z);
|
|
|
|
|
|
let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds);
|
|
|
|
|
|
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if dist2(screen_pt, cursor_screen) > radius_px * radius_px {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(world_pt)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 20:49:05 +03:00
|
|
|
|
/// Find acquired endpoint(s) whose outward ray contains `snapped`, returning
|
|
|
|
|
|
/// their screen positions for Extension guides. The captured ray set persists
|
|
|
|
|
|
/// after its source geometry leaves the cursor-local spatial query.
|
2026-07-03 17:46:08 +03:00
|
|
|
|
fn extension_bases_screen(
|
2026-07-01 19:39:21 +03:00
|
|
|
|
snapped: glam::DVec3,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
tracking_points: &[glam::DVec3],
|
|
|
|
|
|
tracking_dirs: &[Vec<glam::DVec3>],
|
2026-07-01 19:39:21 +03:00
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
|
|
|
|
|
bounds: Rectangle,
|
2026-07-03 17:46:08 +03:00
|
|
|
|
) -> (Option<Point>, Option<Point>) {
|
|
|
|
|
|
let snapped_screen = world_to_screen(snapped, view_rot, eye, bounds);
|
|
|
|
|
|
// Collect qualifying endpoints, off-ray distance measured in screen space so
|
|
|
|
|
|
// the tolerance stays scale-independent at UTM coordinates (a world² test
|
|
|
|
|
|
// would reject the crossing base once coordinates reach ~1e7).
|
|
|
|
|
|
let mut found: Vec<(f32, glam::DVec3, Point)> = Vec::new();
|
2026-07-24 20:49:05 +03:00
|
|
|
|
for (&origin, dirs) in tracking_points.iter().zip(tracking_dirs) {
|
|
|
|
|
|
for &dir in dirs {
|
|
|
|
|
|
let len2 = dir.x * dir.x + dir.y * dir.y;
|
|
|
|
|
|
if len2 < 1e-12 {
|
2026-07-01 19:39:21 +03:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-24 20:49:05 +03:00
|
|
|
|
let t = ((snapped.x - origin.x) * dir.x + (snapped.y - origin.y) * dir.y) / len2;
|
|
|
|
|
|
if t < 0.05 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let on = glam::DVec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z);
|
|
|
|
|
|
let off = dist2(world_to_screen(on, view_rot, eye, bounds), snapped_screen);
|
|
|
|
|
|
if off <= 4.0 {
|
|
|
|
|
|
let base = world_to_screen(origin, view_rot, eye, bounds);
|
|
|
|
|
|
found.push((off, origin, base));
|
2026-07-01 19:39:21 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-03 17:46:08 +03:00
|
|
|
|
// Nearest-fit first, then keep up to two with distinct origins (collinear
|
|
|
|
|
|
// segments sharing an endpoint must not draw the same guide twice).
|
|
|
|
|
|
found.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
|
|
let mut bases: [Option<Point>; 2] = [None, None];
|
|
|
|
|
|
let mut origins: Vec<glam::DVec3> = Vec::new();
|
|
|
|
|
|
for (_, origin, base) in found {
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if origins
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|o| (*o - origin).length_squared() < 1e-12)
|
|
|
|
|
|
{
|
2026-07-03 17:46:08 +03:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
origins.push(origin);
|
|
|
|
|
|
if bases[0].is_none() {
|
|
|
|
|
|
bases[0] = Some(base);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
bases[1] = Some(base);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
(bases[0], bases[1])
|
2026-07-01 19:39:21 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
// ── Projection helpers ────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-23 23:33:21 +03:00
|
|
|
|
/// Project a world point to screen relative-to-eye: subtract the f64 eye first
|
|
|
|
|
|
/// so the result is precise at UTM-scale absolute coordinates (a full
|
|
|
|
|
|
/// view-projection with a ~1e7 translation cancels catastrophically in f32).
|
|
|
|
|
|
/// `view_rot` is the rotation-only view-projection (Camera::view_proj_rte).
|
2026-07-24 20:49:05 +03:00
|
|
|
|
fn world_to_screen(
|
|
|
|
|
|
world: glam::DVec3,
|
|
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
) -> Point {
|
2026-06-24 03:32:11 +03:00
|
|
|
|
let rel = (world - eye).as_vec3();
|
2026-06-23 23:33:21 +03:00
|
|
|
|
let ndc = view_rot.project_point3(rel);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
Point::new(
|
|
|
|
|
|
(ndc.x + 1.0) * 0.5 * bounds.width,
|
|
|
|
|
|
(1.0 - ndc.y) * 0.5 * bounds.height,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[inline]
|
|
|
|
|
|
fn dist2(a: Point, b: Point) -> f32 {
|
|
|
|
|
|
let dx = a.x - b.x;
|
|
|
|
|
|
let dy = a.y - b.y;
|
|
|
|
|
|
dx * dx + dy * dy
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 14:50:33 +03:00
|
|
|
|
/// The nearest line / polyline segment under the cursor as (unit direction,
|
|
|
|
|
|
/// world point on it), within `aperture_px` in screen space, or None.
|
|
|
|
|
|
/// Tessellated curves (circle / arc / ellipse) are skipped — they carry a
|
|
|
|
|
|
/// Center snap hint and "parallel to a curve" is meaningless. Used to acquire
|
|
|
|
|
|
/// the Parallel-snap reference. (#277)
|
2026-07-24 20:49:05 +03:00
|
|
|
|
fn nearest_segment<W: WireSource + ?Sized>(
|
2026-07-05 13:24:43 +03:00
|
|
|
|
cursor_world: Vec3,
|
2026-07-24 20:49:05 +03:00
|
|
|
|
wires: &W,
|
2026-07-05 13:24:43 +03:00
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
aperture_px: f32,
|
2026-07-05 14:50:33 +03:00
|
|
|
|
) -> Option<(Vec3, Vec3)> {
|
2026-07-05 13:24:43 +03:00
|
|
|
|
let cs = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
let mut best_d2 = aperture_px * aperture_px;
|
2026-07-05 14:50:33 +03:00
|
|
|
|
let mut best: Option<(Vec3, Vec3)> = None;
|
2026-07-24 20:49:05 +03:00
|
|
|
|
if let Some(segments) = indexed_segments(wires) {
|
|
|
|
|
|
for segment in segments {
|
|
|
|
|
|
let Some(wire) = wires.source_wire(segment.wire) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
if wire
|
|
|
|
|
|
.snap_pts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|(_, h)| matches!(h, SnapHint::Center))
|
|
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let sa = world_to_screen(segment.a, view_rot, eye, bounds);
|
|
|
|
|
|
let sb = world_to_screen(segment.b, view_rot, eye, bounds);
|
|
|
|
|
|
let d2 = dist2_to_segment(cs, sa, sb);
|
|
|
|
|
|
if d2 < best_d2 {
|
|
|
|
|
|
let dx = segment.b.x - segment.a.x;
|
|
|
|
|
|
let dy = segment.b.y - segment.a.y;
|
|
|
|
|
|
let l = (dx * dx + dy * dy).sqrt();
|
|
|
|
|
|
if l > 1e-9 {
|
|
|
|
|
|
best_d2 = d2;
|
|
|
|
|
|
let dir = Vec3::new((dx / l) as f32, (dy / l) as f32, 0.0);
|
|
|
|
|
|
let np =
|
|
|
|
|
|
nearest_on_segment(cursor_world.as_dvec3(), segment.a, segment.b).as_vec3();
|
|
|
|
|
|
best = Some((dir, np));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return best;
|
|
|
|
|
|
}
|
|
|
|
|
|
for wire in wires.iter() {
|
2026-07-05 13:24:43 +03:00
|
|
|
|
if wire
|
|
|
|
|
|
.snap_pts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|(_, h)| matches!(h, SnapHint::Center))
|
|
|
|
|
|
{
|
|
|
|
|
|
continue; // circle / arc / ellipse — no meaningful parallel
|
|
|
|
|
|
}
|
|
|
|
|
|
for i in 0..wire.points.len().saturating_sub(1) {
|
|
|
|
|
|
let a = wp_f64(wire, i);
|
|
|
|
|
|
let b = wp_f64(wire, i + 1);
|
|
|
|
|
|
if !a.x.is_finite() || !b.x.is_finite() {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let sa = world_to_screen(a, view_rot, eye, bounds);
|
|
|
|
|
|
let sb = world_to_screen(b, view_rot, eye, bounds);
|
|
|
|
|
|
let d2 = dist2_to_segment(cs, sa, sb);
|
|
|
|
|
|
if d2 < best_d2 {
|
|
|
|
|
|
let dx = b.x - a.x;
|
|
|
|
|
|
let dy = b.y - a.y;
|
|
|
|
|
|
let l = (dx * dx + dy * dy).sqrt();
|
|
|
|
|
|
if l > 1e-9 {
|
|
|
|
|
|
best_d2 = d2;
|
2026-07-05 14:50:33 +03:00
|
|
|
|
let dir = Vec3::new((dx / l) as f32, (dy / l) as f32, 0.0);
|
|
|
|
|
|
let np = nearest_on_segment(cursor_world.as_dvec3(), a, b).as_vec3();
|
|
|
|
|
|
best = Some((dir, np));
|
2026-07-05 13:24:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-05 14:50:33 +03:00
|
|
|
|
best
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Perpendicular screen-space distance (px) from world point `q` to the infinite
|
|
|
|
|
|
/// line through `line_pt` along `line_dir`. Used to tell whether a hovered line
|
|
|
|
|
|
/// is the acquired parallel reference (same line) regardless of zoom. (#277)
|
|
|
|
|
|
fn screen_perp_dist(
|
|
|
|
|
|
q: Vec3,
|
|
|
|
|
|
line_pt: Vec3,
|
|
|
|
|
|
line_dir: Vec3,
|
|
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
|
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
) -> f32 {
|
|
|
|
|
|
let sq = world_to_screen(q.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
let s0 = world_to_screen(line_pt.as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
let s1 = world_to_screen((line_pt + line_dir).as_dvec3(), view_rot, eye, bounds);
|
|
|
|
|
|
let ex = s1.x - s0.x;
|
|
|
|
|
|
let ey = s1.y - s0.y;
|
|
|
|
|
|
let l = (ex * ex + ey * ey).sqrt();
|
|
|
|
|
|
if l < 1e-6 {
|
|
|
|
|
|
return dist2(sq, s0).sqrt();
|
|
|
|
|
|
}
|
|
|
|
|
|
(ex * (sq.y - s0.y) - ey * (sq.x - s0.x)).abs() / l
|
2026-07-05 13:24:43 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
/// Squared distance from point p to line segment [a, b] in screen space.
|
|
|
|
|
|
fn dist2_to_segment(p: Point, a: Point, b: Point) -> f32 {
|
|
|
|
|
|
let dx = b.x - a.x;
|
|
|
|
|
|
let dy = b.y - a.y;
|
|
|
|
|
|
let len2 = dx * dx + dy * dy;
|
|
|
|
|
|
if len2 < 1e-6 {
|
|
|
|
|
|
let ex = p.x - a.x;
|
|
|
|
|
|
let ey = p.y - a.y;
|
|
|
|
|
|
return ex * ex + ey * ey;
|
|
|
|
|
|
}
|
|
|
|
|
|
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
|
|
|
|
|
let t = t.clamp(0.0, 1.0);
|
|
|
|
|
|
let nx = a.x + t * dx - p.x;
|
|
|
|
|
|
let ny = a.y + t * dy - p.y;
|
|
|
|
|
|
nx * nx + ny * ny
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Parameter t ∈ [0,1] of the closest point on segment [a,b] to p.
|
|
|
|
|
|
fn t_on_segment(p: Point, a: Point, b: Point) -> f32 {
|
|
|
|
|
|
let dx = b.x - a.x;
|
|
|
|
|
|
let dy = b.y - a.y;
|
|
|
|
|
|
let len2 = dx * dx + dy * dy;
|
|
|
|
|
|
if len2 < 1e-6 {
|
|
|
|
|
|
return 0.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
(((p.x - a.x) * dx + (p.y - a.y) * dy) / len2).clamp(0.0, 1.0)
|
|
|
|
|
|
}
|
2026-07-04 23:30:12 +03:00
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod ext_tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn tracking_active_covers_otrack_and_extension() {
|
|
|
|
|
|
let mut s = Snapper::default();
|
|
|
|
|
|
// OTRACK off, Extension not enabled → no acquisition.
|
|
|
|
|
|
s.snap_enabled = true;
|
|
|
|
|
|
s.otrack_enabled = false;
|
|
|
|
|
|
assert!(!s.tracking_active());
|
|
|
|
|
|
// Extension on with the snap master on → acquire, independent of OTRACK.
|
|
|
|
|
|
s.enabled.insert(SnapType::Extension);
|
|
|
|
|
|
assert!(s.tracking_active());
|
|
|
|
|
|
// Extension is gated by the snap master.
|
|
|
|
|
|
s.snap_enabled = false;
|
|
|
|
|
|
assert!(!s.tracking_active());
|
|
|
|
|
|
// OTRACK acquires regardless of the object-snap master.
|
|
|
|
|
|
s.enabled.remove(&SnapType::Extension);
|
|
|
|
|
|
s.otrack_enabled = true;
|
|
|
|
|
|
assert!(s.tracking_active());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 23:42:40 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn extension_acquisition_keeps_only_endpoints() {
|
|
|
|
|
|
let mut s = Snapper::default();
|
|
|
|
|
|
// A single line segment (0,0)-(10,0): its endpoints are vertices, its
|
|
|
|
|
|
// midpoint and any extension foot are not.
|
|
|
|
|
|
let wire = WireModel {
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
text_verts: Vec::new(),
|
2026-07-04 23:42:40 +03:00
|
|
|
|
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
};
|
|
|
|
|
|
let wires = [wire];
|
|
|
|
|
|
|
|
|
|
|
|
// Extension-driven acquisition (endpoints_only): a midpoint — like an
|
|
|
|
|
|
// extension foot the cursor paused on — is ignored, so it can't fill the
|
|
|
|
|
|
// buffer and evict the real endpoint (#262).
|
2026-07-24 17:56:06 +03:00
|
|
|
|
s.acquire_tracking_point(DVec3::new(5.0, 0.0, 0.0), &wires, true);
|
2026-07-04 23:42:40 +03:00
|
|
|
|
assert!(s.tracking_points.is_empty());
|
|
|
|
|
|
// The genuine endpoint is acquired.
|
2026-07-24 17:56:06 +03:00
|
|
|
|
s.acquire_tracking_point(DVec3::new(10.0, 0.0, 0.0), &wires, true);
|
2026-07-04 23:42:40 +03:00
|
|
|
|
assert_eq!(s.tracking_points.len(), 1);
|
|
|
|
|
|
// OTRACK (endpoints_only = false) still acquires any snap point.
|
2026-07-24 17:56:06 +03:00
|
|
|
|
s.acquire_tracking_point(DVec3::new(5.0, 0.0, 0.0), &wires, false);
|
2026-07-04 23:42:40 +03:00
|
|
|
|
assert_eq!(s.tracking_points.len(), 2);
|
|
|
|
|
|
}
|
2026-07-05 12:49:36 +03:00
|
|
|
|
|
2026-07-08 10:34:31 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn base_to_corner_direction_is_tracked() {
|
|
|
|
|
|
let mut s = Snapper::default();
|
|
|
|
|
|
s.otrack_enabled = true;
|
|
|
|
|
|
s.osnap_radius_px = 10.0;
|
|
|
|
|
|
// An acquired corner of an existing line, and the base point (first
|
|
|
|
|
|
// point) of the line currently being drawn.
|
2026-07-24 17:56:06 +03:00
|
|
|
|
let corner = DVec3::new(8_000.0, 6_000.0, 0.0);
|
2026-07-08 10:34:31 +03:00
|
|
|
|
s.tracking_points.push(corner);
|
|
|
|
|
|
s.tracking_dirs.push(Vec::new());
|
2026-07-24 17:56:06 +03:00
|
|
|
|
let base = DVec3::new(0.0, 0.0, 0.0);
|
2026-07-08 10:34:31 +03:00
|
|
|
|
|
2026-07-24 17:56:06 +03:00
|
|
|
|
// A plain orthographic camera: world * 0.0001 → NDC.
|
|
|
|
|
|
let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
|
2026-07-08 10:34:31 +03:00
|
|
|
|
let eye = glam::DVec3::ZERO;
|
|
|
|
|
|
let bounds = Rectangle {
|
|
|
|
|
|
x: 0.0,
|
|
|
|
|
|
y: 0.0,
|
|
|
|
|
|
width: 1000.0,
|
|
|
|
|
|
height: 1000.0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-24 17:56:06 +03:00
|
|
|
|
// Cursor 5000 units along the base→corner ray (3-4-5 direction), nudged
|
|
|
|
|
|
// a hair off it. The OTRACK result must retain that exact distance.
|
2026-07-08 10:34:31 +03:00
|
|
|
|
let dir = (corner - base).normalize();
|
2026-07-24 17:56:06 +03:00
|
|
|
|
let perp = DVec3::new(-dir.y, dir.x, 0.0);
|
|
|
|
|
|
let cursor = base + dir * 5_000.0 + perp * 0.05;
|
2026-07-08 10:34:31 +03:00
|
|
|
|
|
|
|
|
|
|
let hit = s
|
|
|
|
|
|
.otrack_snap(
|
|
|
|
|
|
cursor,
|
|
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
|
|
|
|
|
bounds,
|
|
|
|
|
|
None,
|
|
|
|
|
|
Some(base),
|
|
|
|
|
|
false,
|
2026-07-24 17:56:06 +03:00
|
|
|
|
DVec3::X,
|
|
|
|
|
|
DVec3::Y,
|
2026-07-08 10:34:31 +03:00
|
|
|
|
)
|
|
|
|
|
|
.expect("base→corner alignment should lock");
|
|
|
|
|
|
// The aligned point lies on the base→corner line, and the reported base
|
|
|
|
|
|
// is the command's base point (so typed-distance runs from there).
|
|
|
|
|
|
let off = hit.aligned - base;
|
|
|
|
|
|
let cross = off.x * dir.y - off.y * dir.x;
|
2026-07-24 17:56:06 +03:00
|
|
|
|
assert!((off.length() - 5_000.0).abs() < 1e-10);
|
2026-07-08 10:34:31 +03:00
|
|
|
|
assert!(
|
2026-07-24 17:56:06 +03:00
|
|
|
|
cross.abs() < 1e-12,
|
2026-07-08 10:34:31 +03:00
|
|
|
|
"aligned point off the base→corner line: {:?}",
|
|
|
|
|
|
hit.aligned
|
|
|
|
|
|
);
|
2026-07-24 20:49:05 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
(hit.base - base).length() < 1e-12,
|
|
|
|
|
|
"ray base is not the command base"
|
|
|
|
|
|
);
|
2026-07-08 10:34:31 +03:00
|
|
|
|
|
|
|
|
|
|
// Without a base point the alignment does not exist (nothing to join).
|
|
|
|
|
|
let none = s.otrack_snap(
|
2026-07-24 17:56:06 +03:00
|
|
|
|
cursor,
|
|
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
|
|
|
|
|
bounds,
|
|
|
|
|
|
None,
|
|
|
|
|
|
None,
|
|
|
|
|
|
false,
|
|
|
|
|
|
DVec3::X,
|
|
|
|
|
|
DVec3::Y,
|
2026-07-08 10:34:31 +03:00
|
|
|
|
);
|
|
|
|
|
|
assert!(none.is_none(), "no base point → no base→corner alignment");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 12:49:36 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn tangent_points_are_perpendicular_to_the_radius() {
|
|
|
|
|
|
let c = Vec3::new(0.0, 0.0, 0.0);
|
|
|
|
|
|
let p = Vec3::new(10.0, 0.0, 0.0);
|
|
|
|
|
|
let (t0, t1) = circle_tangent_points(p, c, 5.0).expect("external tangents exist");
|
|
|
|
|
|
for t in [t0, t1] {
|
|
|
|
|
|
// On the circle...
|
|
|
|
|
|
assert!(((t - c).length() - 5.0).abs() < 1e-3, "{t:?} off circle");
|
|
|
|
|
|
// ...and the radius C→T is perpendicular to the line P→T — the
|
|
|
|
|
|
// defining property of a tangent (not just the nearest point).
|
|
|
|
|
|
let ct = t - c;
|
|
|
|
|
|
let pt = t - p;
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
(ct.x * pt.x + ct.y * pt.y).abs() < 1e-3,
|
|
|
|
|
|
"radius not perpendicular to the line at {t:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Known geometry: acos(5/10) = 60°, so the tangents are at (2.5, ±4.330).
|
|
|
|
|
|
assert!((t0.x - 2.5).abs() < 1e-2 && (t0.y.abs() - 4.330).abs() < 1e-2);
|
|
|
|
|
|
// A point inside the circle has no external tangent.
|
|
|
|
|
|
assert!(circle_tangent_points(Vec3::new(1.0, 0.0, 0.0), c, 5.0).is_none());
|
|
|
|
|
|
}
|
2026-07-10 13:05:48 +03:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn intersection_requires_matching_z_not_just_plan_crossing() {
|
|
|
|
|
|
use glam::DVec3;
|
|
|
|
|
|
// Two segments whose plans cross at the origin, both at z = 0: a real
|
|
|
|
|
|
// 3D intersection.
|
|
|
|
|
|
let hit = seg_intersect_3d(
|
|
|
|
|
|
DVec3::new(-1.0, 0.0, 0.0),
|
|
|
|
|
|
DVec3::new(1.0, 0.0, 0.0),
|
|
|
|
|
|
DVec3::new(0.0, -1.0, 0.0),
|
|
|
|
|
|
DVec3::new(0.0, 1.0, 0.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("coplanar crossing is a real intersection");
|
|
|
|
|
|
assert!(hit.x.abs() < 1e-9 && hit.y.abs() < 1e-9 && hit.z.abs() < 1e-9);
|
|
|
|
|
|
|
|
|
|
|
|
// Same plan crossing, but the second segment sits at z = 5: the lines
|
|
|
|
|
|
// only *appear* to cross in top view — not a real intersection (#335).
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
seg_intersect_3d(
|
|
|
|
|
|
DVec3::new(-1.0, 0.0, 0.0),
|
|
|
|
|
|
DVec3::new(1.0, 0.0, 0.0),
|
|
|
|
|
|
DVec3::new(0.0, -1.0, 5.0),
|
|
|
|
|
|
DVec3::new(0.0, 1.0, 5.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
.is_none(),
|
|
|
|
|
|
"different-Z plan crossing must not be a real Intersection"
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// A real 3D crossing above the ground plane: both segments pass through
|
|
|
|
|
|
// (0,0,5), so it snaps there at the true height.
|
|
|
|
|
|
let hi = seg_intersect_3d(
|
|
|
|
|
|
DVec3::new(-1.0, 0.0, 5.0),
|
|
|
|
|
|
DVec3::new(1.0, 0.0, 5.0),
|
|
|
|
|
|
DVec3::new(0.0, -1.0, 5.0),
|
|
|
|
|
|
DVec3::new(0.0, 1.0, 5.0),
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("coplanar crossing at z=5");
|
2026-07-24 20:49:05 +03:00
|
|
|
|
assert!(
|
|
|
|
|
|
(hi.z - 5.0).abs() < 1e-9,
|
|
|
|
|
|
"z should be the true height, got {}",
|
|
|
|
|
|
hi.z
|
|
|
|
|
|
);
|
2026-07-10 13:05:48 +03:00
|
|
|
|
}
|
2026-07-04 23:30:12 +03:00
|
|
|
|
}
|