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
|
|
|
|
|
|
|
|
|
|
|
|
use glam::{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-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"),
|
|
|
|
|
|
(SnapType::Grid, "⊞", "Grid"),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// ── Snap result ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
|
pub struct SnapResult {
|
|
|
|
|
|
pub world: Vec3,
|
|
|
|
|
|
pub screen: Point,
|
|
|
|
|
|
pub snap_type: SnapType,
|
|
|
|
|
|
/// Set when `snap_type == Tangent`; provides entity geometry for TTR/TTT.
|
|
|
|
|
|
pub tangent_obj: Option<TangentObject>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
pub aligned: Vec3,
|
|
|
|
|
|
/// Unit ray direction toward the cursor side (for typed-distance entry).
|
|
|
|
|
|
pub dir: Vec3,
|
|
|
|
|
|
/// The tracking point the ray emanates from.
|
|
|
|
|
|
pub base: Vec3,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
// ── Snapper ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-05 00:35:53 +03:00
|
|
|
|
use rustc_hash::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>,
|
|
|
|
|
|
/// 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).
|
|
|
|
|
|
pub tracking_points: Vec<Vec3>,
|
|
|
|
|
|
/// Last snap world position (for dwell detection).
|
|
|
|
|
|
pub last_snap_world: Option<Vec3>,
|
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-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,
|
|
|
|
|
|
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(),
|
|
|
|
|
|
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-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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn toggle_global(&mut self) {
|
|
|
|
|
|
self.snap_enabled = !self.snap_enabled;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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-05-12 10:36:15 +03:00
|
|
|
|
pub fn update_otrack_dwell(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
snap_world: Option<Vec3>,
|
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-04-08 00:26:54 +03:00
|
|
|
|
if !self.otrack_enabled {
|
|
|
|
|
|
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-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 => {
|
|
|
|
|
|
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-06-23 23:33:21 +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;
|
|
|
|
|
|
// otherwise acquire it (max 4 tracked points).
|
|
|
|
|
|
let existing = self.tracking_points.iter().position(|t| {
|
2026-04-08 00:26:54 +03:00
|
|
|
|
let d = (*t - p).length();
|
|
|
|
|
|
d < self.grid_spacing * 0.1
|
2026-06-17 04:57:17 +03:00
|
|
|
|
});
|
|
|
|
|
|
match existing {
|
|
|
|
|
|
Some(idx) => {
|
|
|
|
|
|
self.tracking_points.remove(idx);
|
|
|
|
|
|
}
|
|
|
|
|
|
None => {
|
|
|
|
|
|
if self.tracking_points.len() >= 4 {
|
|
|
|
|
|
self.tracking_points.remove(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
self.tracking_points.push(p);
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
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-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
|
|
|
|
|
|
/// increment is a candidate so the user can track along POLAR angles.
|
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,
|
|
|
|
|
|
cursor_world: Vec3,
|
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-06-17 05:01:41 +03:00
|
|
|
|
last_point: Option<Vec3>,
|
2026-06-22 11:17:55 +03:00
|
|
|
|
// UCS→world rotation: tracking rays run along the UCS axes, matching
|
|
|
|
|
|
// ortho/polar. Identity = world-aligned rays.
|
|
|
|
|
|
ucs: glam::Mat4,
|
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-06-23 23:33:21 +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-06-17 05:01:41 +03:00
|
|
|
|
let screen_dist = |w: Vec3| {
|
2026-06-23 23:33:21 +03:00
|
|
|
|
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-06-09 23:38:08 +03:00
|
|
|
|
// Candidate angles in [0,180); each ray extends both ways via the
|
|
|
|
|
|
// signed projection `t`, so 0°/90° cover horizontal/vertical.
|
|
|
|
|
|
let mut angles: Vec<f32> = Vec::new();
|
|
|
|
|
|
match polar_step_deg.filter(|s| *s > 1e-3) {
|
|
|
|
|
|
Some(step) => {
|
|
|
|
|
|
let mut a = 0.0_f32;
|
|
|
|
|
|
while a < 180.0 - 1e-3 {
|
|
|
|
|
|
angles.push(a);
|
|
|
|
|
|
a += step;
|
|
|
|
|
|
}
|
2026-04-08 00:26:54 +03:00
|
|
|
|
}
|
2026-06-09 23:38:08 +03:00
|
|
|
|
None => {
|
|
|
|
|
|
angles.push(0.0);
|
|
|
|
|
|
angles.push(90.0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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 {
|
|
|
|
|
|
origin: Vec3,
|
|
|
|
|
|
dir: Vec3,
|
|
|
|
|
|
group: usize,
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut rays: Vec<Ray> = Vec::new();
|
|
|
|
|
|
for (gi, &tp) in self.tracking_points.iter().enumerate() {
|
2026-06-09 23:38:08 +03:00
|
|
|
|
for &adeg in &angles {
|
|
|
|
|
|
let ar = adeg.to_radians();
|
2026-06-17 05:01:41 +03:00
|
|
|
|
rays.push(Ray {
|
|
|
|
|
|
origin: tp,
|
2026-06-22 11:17:55 +03:00
|
|
|
|
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)),
|
2026-06-17 05:01:41 +03:00
|
|
|
|
group: gi,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// OTRACK rays come first; polar rays (appended below) only participate
|
|
|
|
|
|
// in intersection locking, never in single-ray fallback.
|
|
|
|
|
|
let otrack_ray_count = rays.len();
|
|
|
|
|
|
const POLAR_GROUP: usize = usize::MAX;
|
|
|
|
|
|
if let (Some(step), Some(lp)) = (polar_step_deg.filter(|s| *s > 1e-3), last_point) {
|
|
|
|
|
|
let mut a = 0.0_f32;
|
|
|
|
|
|
while a < 180.0 - 1e-3 {
|
|
|
|
|
|
let ar = a.to_radians();
|
|
|
|
|
|
rays.push(Ray {
|
|
|
|
|
|
origin: lp,
|
2026-06-22 11:17:55 +03:00
|
|
|
|
dir: ucs.transform_vector3(Vec3::new(ar.cos(), ar.sin(), 0.0)),
|
2026-06-17 05:01:41 +03:00
|
|
|
|
group: POLAR_GROUP,
|
|
|
|
|
|
});
|
|
|
|
|
|
a += step;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
|
|
|
// Report an OTRACK ray as base/dir for typed-distance entry.
|
|
|
|
|
|
let ot = if rays[i].group != POLAR_GROUP {
|
|
|
|
|
|
&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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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;
|
|
|
|
|
|
let aligned = Vec3::new(
|
|
|
|
|
|
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();
|
|
|
|
|
|
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-03-22 14:56:27 +03:00
|
|
|
|
/// Only runs Tangent snap — used when a command needs object picks via tangent.
|
|
|
|
|
|
pub fn snap_tangent_only(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
cursor_world: Vec3,
|
|
|
|
|
|
cursor_screen: Point,
|
|
|
|
|
|
wires: &[WireModel],
|
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
|
|
|
|
|
|
},
|
|
|
|
|
|
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(),
|
|
|
|
|
|
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-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(
|
|
|
|
|
|
cursor_world,
|
|
|
|
|
|
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.
|
|
|
|
|
|
pub fn snap(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
cursor_world: Vec3,
|
|
|
|
|
|
cursor_screen: Point,
|
|
|
|
|
|
wires: &[WireModel],
|
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> {
|
|
|
|
|
|
if !self.snap_enabled {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
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;
|
|
|
|
|
|
let mut best_d2 = f32::MAX;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
|
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-04-26 12:21:51 +03:00
|
|
|
|
let r = world_snap_r;
|
|
|
|
|
|
cursor_world.x + r >= wire.aabb[0]
|
|
|
|
|
|
&& cursor_world.x - r <= wire.aabb[2]
|
|
|
|
|
|
&& cursor_world.y + r >= wire.aabb[1]
|
|
|
|
|
|
&& cursor_world.y - r <= wire.aabb[3]
|
2026-04-26 11:55:08 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let mut try_pt = |world: Vec3, snap_type: SnapType| {
|
2026-06-23 23:33:21 +03:00
|
|
|
|
let screen = world_to_screen(world, view_rot, eye, bounds);
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
let rank = snap_priority(snap_type);
|
|
|
|
|
|
if rank < best_rank || (rank == best_rank && d2 < best_d2) {
|
|
|
|
|
|
best_rank = rank;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
best_d2 = d2;
|
|
|
|
|
|
best = Some(SnapResult {
|
|
|
|
|
|
world,
|
|
|
|
|
|
screen,
|
|
|
|
|
|
snap_type,
|
|
|
|
|
|
tangent_obj: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ──────
|
|
|
|
|
|
for wire in wires {
|
|
|
|
|
|
for &(world, hint) in &wire.snap_pts {
|
|
|
|
|
|
let snap_type = match hint {
|
|
|
|
|
|
SnapHint::Center => SnapType::Center,
|
|
|
|
|
|
SnapHint::Node => SnapType::Node,
|
|
|
|
|
|
SnapHint::Quadrant => SnapType::Quadrant,
|
|
|
|
|
|
SnapHint::Insertion => SnapType::Insertion,
|
2026-05-29 01:39:02 +03:00
|
|
|
|
SnapHint::Midpoint => SnapType::Midpoint,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
};
|
|
|
|
|
|
if self.is_on(snap_type) {
|
2026-06-23 18:52:42 +03:00
|
|
|
|
try_pt(world.as_vec3(), snap_type);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Endpoint ───────────────────────────────────────────────────────
|
|
|
|
|
|
if self.is_on(SnapType::Endpoint) {
|
|
|
|
|
|
for wire in wires {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if !wire_in_range(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if !wire.key_vertices.is_empty() {
|
|
|
|
|
|
// Use explicit vertices (Line, LwPolyline): every vertex is an endpoint.
|
|
|
|
|
|
for &p in &wire.key_vertices {
|
2026-06-23 22:23:56 +03:00
|
|
|
|
try_pt(
|
|
|
|
|
|
Vec3::new(p[0] as f32, p[1] as f32, p[2] as f32),
|
|
|
|
|
|
SnapType::Endpoint,
|
|
|
|
|
|
);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Tessellated curves (Circle, Arc, Ellipse): only arc endpoints.
|
|
|
|
|
|
if let Some(&p) = wire.points.first() {
|
|
|
|
|
|
try_pt(Vec3::from(p), SnapType::Endpoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
if wire.points.len() > 1 {
|
|
|
|
|
|
if let Some(&p) = wire.points.last() {
|
|
|
|
|
|
try_pt(Vec3::from(p), SnapType::Endpoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Midpoint ───────────────────────────────────────────────────────
|
2026-05-29 01:39:02 +03:00
|
|
|
|
// Only explicit vertex sets (Line, LwPolyline) contribute per-segment
|
|
|
|
|
|
// midpoints. Tessellated curves (Circle, Arc, Ellipse, Spline) emit a
|
|
|
|
|
|
// single `SnapHint::Midpoint` snap_pt where one exists — iterating
|
|
|
|
|
|
// every chord here would otherwise turn a circle's tessellation into
|
|
|
|
|
|
// a haze of false midpoint hits. See #34.
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if self.is_on(SnapType::Midpoint) {
|
|
|
|
|
|
for wire in wires {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if !wire_in_range(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if !wire.key_vertices.is_empty() {
|
|
|
|
|
|
for seg in wire.key_vertices.windows(2) {
|
2026-06-23 22:23:56 +03:00
|
|
|
|
let a = Vec3::new(seg[0][0] as f32, seg[0][1] as f32, seg[0][2] as f32);
|
|
|
|
|
|
let b = Vec3::new(seg[1][0] as f32, seg[1][1] as f32, seg[1][2] as f32);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if a.distance_squared(b) > 1e-12 {
|
|
|
|
|
|
try_pt((a + b) * 0.5, SnapType::Midpoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Nearest — closest point on any segment (clamped) ──────────────
|
|
|
|
|
|
if self.is_on(SnapType::Nearest) {
|
|
|
|
|
|
for wire in wires {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if !wire_in_range(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for seg in wire.points.windows(2) {
|
|
|
|
|
|
let p =
|
|
|
|
|
|
nearest_on_segment(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1]));
|
|
|
|
|
|
try_pt(p, SnapType::Nearest);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-06-18 16:30:37 +03:00
|
|
|
|
let q = self.from_point.unwrap_or(cursor_world);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for wire in wires {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if !wire_in_range(wire) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for seg in wire.points.windows(2) {
|
2026-06-18 16:30:37 +03:00
|
|
|
|
if let Some(foot) = perp_foot(q, Vec3::from(seg[0]), Vec3::from(seg[1])) {
|
2026-03-22 14:56:27 +03:00
|
|
|
|
try_pt(foot, SnapType::Perpendicular);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Intersection — segment-segment intersections ──────────
|
|
|
|
|
|
if self.is_on(SnapType::Intersection) {
|
|
|
|
|
|
for i in 0..wires.len() {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if !wire_in_range(&wires[i]) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for j in (i + 1)..wires.len() {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
if !wire_in_range(&wires[j]) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for seg_a in wires[i].points.windows(2) {
|
2026-05-06 22:42:22 +03:00
|
|
|
|
// S: pre-convert outside inner loop
|
|
|
|
|
|
let a0 = Vec3::from(seg_a[0]);
|
|
|
|
|
|
let a1 = Vec3::from(seg_a[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);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for seg_b in wires[j].points.windows(2) {
|
|
|
|
|
|
let b0 = Vec3::from(seg_b[0]);
|
|
|
|
|
|
let b1 = Vec3::from(seg_b[1]);
|
2026-05-06 22:42:22 +03:00
|
|
|
|
// 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)
|
2026-03-22 14:56:27 +03:00
|
|
|
|
{
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(pt) = seg_intersect_xy(a0, a1, b0, b1) {
|
|
|
|
|
|
try_pt(pt, SnapType::Intersection);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Extension — along the extension of a segment beyond endpoints ──
|
|
|
|
|
|
if self.is_on(SnapType::Extension) {
|
|
|
|
|
|
for wire in wires {
|
|
|
|
|
|
let n = wire.points.len();
|
|
|
|
|
|
if n < 2 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Extend beyond the first point.
|
|
|
|
|
|
{
|
|
|
|
|
|
let p0 = Vec3::from(wire.points[0]);
|
|
|
|
|
|
let p1 = Vec3::from(wire.points[1]);
|
|
|
|
|
|
if let Some(ext) = extension_snap(
|
|
|
|
|
|
cursor_world,
|
|
|
|
|
|
p0,
|
|
|
|
|
|
p0 - p1,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
bounds,
|
2026-06-17 04:56:42 +03:00
|
|
|
|
self.osnap_radius_px,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
) {
|
|
|
|
|
|
try_pt(ext, SnapType::Extension);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Extend beyond the last point.
|
|
|
|
|
|
{
|
|
|
|
|
|
let p_last = Vec3::from(wire.points[n - 1]);
|
|
|
|
|
|
let p_prev = Vec3::from(wire.points[n - 2]);
|
|
|
|
|
|
if let Some(ext) = extension_snap(
|
|
|
|
|
|
cursor_world,
|
|
|
|
|
|
p_last,
|
|
|
|
|
|
p_last - p_prev,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot,
|
|
|
|
|
|
eye,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
bounds,
|
2026-06-17 04:56:42 +03:00
|
|
|
|
self.osnap_radius_px,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
) {
|
|
|
|
|
|
try_pt(ext, SnapType::Extension);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Apparent Intersection — screen-space intersections ─────────────
|
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-03-22 14:56:27 +03:00
|
|
|
|
if self.is_on(SnapType::ApparentIntersection) {
|
2026-05-06 22:42:22 +03:00
|
|
|
|
let screen_pts: Vec<Option<Vec<Point>>> = wires
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.map(|w| {
|
|
|
|
|
|
if !wire_in_range(w) {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(
|
|
|
|
|
|
w.points
|
|
|
|
|
|
.iter()
|
2026-06-23 23:33:21 +03:00
|
|
|
|
.map(|&p| world_to_screen(Vec3::from(p), view_rot, eye, bounds))
|
2026-05-06 22:42:22 +03:00
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for i in 0..wires.len() {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
let Some(ref si) = screen_pts[i] else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
2026-03-22 14:56:27 +03:00
|
|
|
|
for j in (i + 1)..wires.len() {
|
2026-05-12 10:36:15 +03:00
|
|
|
|
let Some(ref sj) = screen_pts[j] else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
2026-05-06 22:42:22 +03:00
|
|
|
|
for (ai, seg_a) in wires[i].points.windows(2).enumerate() {
|
|
|
|
|
|
let sa0 = si[ai];
|
|
|
|
|
|
let sa1 = si[ai + 1];
|
|
|
|
|
|
for (bi, _) in wires[j].points.windows(2).enumerate() {
|
|
|
|
|
|
let sb0 = sj[bi];
|
|
|
|
|
|
let sb1 = sj[bi + 1];
|
2026-03-22 14:56:27 +03:00
|
|
|
|
if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) {
|
|
|
|
|
|
let wa0 = Vec3::from(seg_a[0]);
|
|
|
|
|
|
let wa1 = Vec3::from(seg_a[1]);
|
2026-05-06 22:42:22 +03:00
|
|
|
|
try_pt(wa0 + ta * (wa1 - wa0), SnapType::ApparentIntersection);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Grid ───────────────────────────────────────────────────────────
|
|
|
|
|
|
if self.is_on(SnapType::Grid) {
|
|
|
|
|
|
let s = self.grid_spacing;
|
2026-06-22 11:27:34 +03:00
|
|
|
|
// Round in the UCS grid frame, then map back to world.
|
|
|
|
|
|
let ax = grid_rot.transform_vector3(Vec3::X);
|
|
|
|
|
|
let ay = grid_rot.transform_vector3(Vec3::Y);
|
|
|
|
|
|
let az = grid_rot.transform_vector3(Vec3::Z);
|
|
|
|
|
|
let rel = cursor_world - grid_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;
|
|
|
|
|
|
try_pt(grid_origin + ax * ux + ay * uy + az * uz, SnapType::Grid);
|
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) {
|
|
|
|
|
|
for wire in wires {
|
|
|
|
|
|
for tg in &wire.tangent_geoms {
|
|
|
|
|
|
let (world_pt, d2) = match tg {
|
|
|
|
|
|
TangentGeom::Line { p1, p2 } => {
|
2026-06-23 23:33:21 +03:00
|
|
|
|
let sp0 = world_to_screen(Vec3::from(*p1), view_rot, eye, bounds);
|
|
|
|
|
|
let sp1 = world_to_screen(Vec3::from(*p2), 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-06-23 23:33:21 +03:00
|
|
|
|
let sc = world_to_screen(cv, view_rot, eye, bounds);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let rim = world_to_screen(
|
|
|
|
|
|
Vec3::new(cv.x + radius, cv.y, cv.z),
|
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();
|
|
|
|
|
|
let edge_d = (dc - sr).abs();
|
|
|
|
|
|
// Snap point: point on circle edge facing cursor
|
|
|
|
|
|
let dx = cursor_screen.x - sc.x;
|
|
|
|
|
|
let dy = cursor_screen.y - sc.y;
|
|
|
|
|
|
let dl = (dx * dx + dy * dy).sqrt();
|
|
|
|
|
|
let (nx, ny) = if dl > 1e-6 {
|
|
|
|
|
|
(dx / dl, -dy / dl)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(1.0, 0.0)
|
|
|
|
|
|
};
|
|
|
|
|
|
let w = Vec3::new(cv.x + radius * nx, cv.y, cv.y + radius * ny);
|
|
|
|
|
|
(w, edge_d * edge_d)
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-06-18 16:30:37 +03:00
|
|
|
|
let rank = snap_priority(SnapType::Tangent);
|
|
|
|
|
|
if d2 < radius2 && (rank < best_rank || (rank == best_rank && d2 < best_d2)) {
|
|
|
|
|
|
best_rank = rank;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
best_d2 = d2;
|
2026-06-23 23:33:21 +03:00
|
|
|
|
let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let tangent_obj = match tg {
|
|
|
|
|
|
TangentGeom::Line { p1, p2 } => TangentObject::Line {
|
|
|
|
|
|
p1: Vec3::from(*p1),
|
|
|
|
|
|
p2: Vec3::from(*p2),
|
|
|
|
|
|
},
|
|
|
|
|
|
TangentGeom::Circle { center, radius } => TangentObject::Circle {
|
|
|
|
|
|
center: Vec3::from(*center),
|
|
|
|
|
|
radius: *radius,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
best = Some(SnapResult {
|
|
|
|
|
|
world: world_pt,
|
|
|
|
|
|
screen: screen_pt,
|
|
|
|
|
|
snap_type: SnapType::Tangent,
|
|
|
|
|
|
tangent_obj: Some(tangent_obj),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
best
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
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 ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/// Closest point on segment [p0, p1] to `query`.
|
|
|
|
|
|
fn nearest_on_segment(query: Vec3, p0: Vec3, p1: Vec3) -> Vec3 {
|
|
|
|
|
|
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);
|
|
|
|
|
|
Vec3::new(p0.x + t * d.x, p0.y + t * d.y, p0.z + t * d.z)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Foot of perpendicular from `query` to the line through [p0, p1] (XY plane, unclamped).
|
|
|
|
|
|
/// Returns `None` if the segment is degenerate.
|
|
|
|
|
|
fn perp_foot(query: Vec3, p0: Vec3, p1: Vec3) -> Option<Vec3> {
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(Vec3::new(p0.x + t * d.x, p0.y + t * d.y, p0.z + t * d.z))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// XY-plane segment-segment intersection. Returns `None` if parallel or outside.
|
|
|
|
|
|
fn seg_intersect_xy(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option<Vec3> {
|
|
|
|
|
|
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;
|
|
|
|
|
|
} // parallel
|
|
|
|
|
|
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(Vec3::new(a0.x + t * d1x, a0.y + t * d1y, 0.0))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
fn line_intersect_xy(o1: Vec3, d1: Vec3, o2: Vec3, d2: Vec3) -> Option<Vec3> {
|
|
|
|
|
|
let cross = d1.x * d2.y - d1.y * d2.x;
|
|
|
|
|
|
if cross.abs() < 1e-9 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let ex = o2.x - o1.x;
|
|
|
|
|
|
let ey = o2.y - o1.y;
|
|
|
|
|
|
let t = (ex * d2.y - ey * d2.x) / cross;
|
|
|
|
|
|
Some(Vec3::new(o1.x + d1.x * t, o1.y + d1.y * t, o1.z))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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(
|
|
|
|
|
|
cursor_world: Vec3,
|
|
|
|
|
|
origin: Vec3,
|
|
|
|
|
|
dir: Vec3,
|
2026-06-23 23:33:21 +03:00
|
|
|
|
view_rot: Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
bounds: Rectangle,
|
|
|
|
|
|
radius_px: f32,
|
|
|
|
|
|
) -> Option<Vec3> {
|
|
|
|
|
|
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
|
|
|
|
|
|
let world_pt = Vec3::new(origin.x + t * dir.x, origin.y + t * dir.y, origin.z);
|
2026-06-23 23:33:21 +03:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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).
|
|
|
|
|
|
fn world_to_screen(world: Vec3, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle) -> Point {
|
|
|
|
|
|
let rel = (world.as_dvec3() - eye).as_vec3();
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
|
}
|