forked from 1b-agent/opencadstudio-upstream
Add `aabb: [f32; 4]` field to WireModel (world-space 2-D bounding box). In tessellate_entity() set each wire's AABB from e.as_entity().bounding_box() via new entity_aabb() helper; Insert sub-entities each get their own AABB. Preview/interim wires and any entity returning a zero-extent default box get UNBOUNDED_AABB so they are never pre-rejected. Replace the chord-sphere heuristic in Snapper::wire_in_range() with a proper AABB vs snap-circle overlap test. The new check is four scalar comparisons and correctly handles closed curves (circles, arcs) which the chord-sphere approach had to special-case. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
725 lines
28 KiB
Rust
725 lines
28 KiB
Rust
//! H7CAD-style object snap (OSNAP) engine.
|
||
//!
|
||
//! Implemented modes:
|
||
//! Endpoint, Midpoint, Center, Node, Quadrant, Intersection,
|
||
//! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent
|
||
|
||
use glam::{Mat4, Vec3};
|
||
use iced::{Point, Rectangle};
|
||
|
||
use crate::command::TangentObject;
|
||
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
|
||
use crate::ui::overlay::CROSSHAIR_ARM;
|
||
|
||
// ── Snap type ─────────────────────────────────────────────────────────────
|
||
|
||
/// Every OSNAP mode — mirrors the H7CAD list.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum SnapType {
|
||
Endpoint,
|
||
Midpoint,
|
||
Center,
|
||
Node,
|
||
Quadrant,
|
||
Intersection,
|
||
Extension,
|
||
Insertion,
|
||
Perpendicular,
|
||
Tangent,
|
||
Nearest,
|
||
ApparentIntersection,
|
||
Parallel,
|
||
Grid,
|
||
}
|
||
|
||
/// 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>,
|
||
}
|
||
|
||
// ── Snapper ───────────────────────────────────────────────────────────────
|
||
|
||
use std::collections::HashSet;
|
||
|
||
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,
|
||
/// Pixel-radius threshold.
|
||
pub snap_radius_px: f32,
|
||
/// 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>,
|
||
/// How many consecutive moves the cursor has been near last_snap_world.
|
||
pub dwell_count: u32,
|
||
}
|
||
|
||
impl Default for Snapper {
|
||
fn default() -> Self {
|
||
let mut enabled = HashSet::new();
|
||
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 {
|
||
snap_enabled: true,
|
||
enabled,
|
||
grid_spacing: 1.0,
|
||
snap_radius_px: CROSSHAIR_ARM,
|
||
otrack_enabled: false,
|
||
tracking_points: Vec::new(),
|
||
last_snap_world: None,
|
||
dwell_count: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/// 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).
|
||
pub fn update_otrack_dwell(&mut self, snap_world: Option<Vec3>, view_proj: glam::Mat4, bounds: iced::Rectangle) {
|
||
if !self.otrack_enabled {
|
||
self.dwell_count = 0;
|
||
self.last_snap_world = None;
|
||
return;
|
||
}
|
||
const DWELL_THRESHOLD: u32 = 4;
|
||
const DWELL_PX: f32 = 8.0;
|
||
|
||
match snap_world {
|
||
None => {
|
||
self.dwell_count = 0;
|
||
self.last_snap_world = None;
|
||
}
|
||
Some(p) => {
|
||
// Convert to screen to measure pixel distance.
|
||
let is_same = if let Some(prev) = self.last_snap_world {
|
||
let dp = world_to_screen(p, view_proj, bounds);
|
||
let dp2 = world_to_screen(prev, view_proj, bounds);
|
||
let dx = dp.x - dp2.x;
|
||
let dy = dp.y - dp2.y;
|
||
(dx * dx + dy * dy).sqrt() < DWELL_PX
|
||
} else {
|
||
false
|
||
};
|
||
if is_same {
|
||
self.dwell_count += 1;
|
||
if self.dwell_count == DWELL_THRESHOLD {
|
||
// Acquire this point (max 4 tracked points).
|
||
if !self.tracking_points.iter().any(|t| {
|
||
let d = (*t - p).length();
|
||
d < self.grid_spacing * 0.1
|
||
}) {
|
||
if self.tracking_points.len() >= 4 {
|
||
self.tracking_points.remove(0);
|
||
}
|
||
self.tracking_points.push(p);
|
||
}
|
||
}
|
||
} else {
|
||
self.dwell_count = 1;
|
||
self.last_snap_world = Some(p);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Given the current cursor world position, check if it aligns with any
|
||
/// tracking point horizontally or vertically. Returns the snapped world
|
||
/// position (and index of the tracking point) if alignment is found within
|
||
/// `snap_radius_px` screen pixels.
|
||
pub fn otrack_snap(
|
||
&self,
|
||
cursor_world: Vec3,
|
||
view_proj: glam::Mat4,
|
||
bounds: iced::Rectangle,
|
||
) -> Option<(Vec3, usize)> {
|
||
if !self.otrack_enabled || self.tracking_points.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let cursor_screen = world_to_screen(cursor_world, view_proj, bounds);
|
||
let r = self.snap_radius_px;
|
||
|
||
for (idx, &tp) in self.tracking_points.iter().enumerate() {
|
||
// Horizontal alignment: cursor.z ≈ tp.z
|
||
let aligned_h = Vec3::new(cursor_world.x, 0.0, tp.z);
|
||
let s = world_to_screen(aligned_h, view_proj, bounds);
|
||
let dy = (s.y - cursor_screen.y).abs();
|
||
if dy < r {
|
||
return Some((aligned_h, idx));
|
||
}
|
||
|
||
// Vertical alignment: cursor.x ≈ tp.x
|
||
let aligned_v = Vec3::new(tp.x, 0.0, cursor_world.z);
|
||
let s = world_to_screen(aligned_v, view_proj, bounds);
|
||
let dx = (s.x - cursor_screen.x).abs();
|
||
if dx < r {
|
||
return Some((aligned_v, idx));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Clear all acquired tracking points (e.g. when command ends).
|
||
pub fn clear_tracking(&mut self) {
|
||
self.tracking_points.clear();
|
||
self.dwell_count = 0;
|
||
self.last_snap_world = None;
|
||
}
|
||
|
||
/// 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],
|
||
view_proj: Mat4,
|
||
bounds: Rectangle,
|
||
) -> Option<SnapResult> {
|
||
let tmp = Snapper {
|
||
snap_enabled: true,
|
||
enabled: {
|
||
let mut s = HashSet::new();
|
||
s.insert(SnapType::Tangent);
|
||
s
|
||
},
|
||
grid_spacing: self.grid_spacing,
|
||
snap_radius_px: self.snap_radius_px,
|
||
otrack_enabled: false,
|
||
tracking_points: Vec::new(),
|
||
last_snap_world: None,
|
||
dwell_count: 0,
|
||
};
|
||
tmp.snap(cursor_world, cursor_screen, wires, view_proj, bounds)
|
||
}
|
||
|
||
/// Find the best snap candidate near the cursor.
|
||
pub fn snap(
|
||
&self,
|
||
cursor_world: Vec3,
|
||
cursor_screen: Point,
|
||
wires: &[WireModel],
|
||
view_proj: Mat4,
|
||
bounds: Rectangle,
|
||
) -> Option<SnapResult> {
|
||
if !self.snap_enabled {
|
||
return None;
|
||
}
|
||
let mut best: Option<SnapResult> = None;
|
||
let mut best_d2 = self.snap_radius_px * self.snap_radius_px;
|
||
|
||
// 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 = {
|
||
let s = view_proj.col(0).x.abs() * bounds.width * 0.5;
|
||
if s > 1e-6 { self.snap_radius_px / s } else { f32::MAX }
|
||
};
|
||
|
||
// 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.
|
||
let wire_in_range = |wire: &WireModel| -> bool {
|
||
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]
|
||
};
|
||
|
||
let mut try_pt = |world: Vec3, snap_type: SnapType| {
|
||
let screen = world_to_screen(world, view_proj, bounds);
|
||
let d2 = dist2(screen, cursor_screen);
|
||
if d2 < best_d2 {
|
||
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,
|
||
};
|
||
if self.is_on(snap_type) {
|
||
try_pt(world, snap_type);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Endpoint ───────────────────────────────────────────────────────
|
||
if self.is_on(SnapType::Endpoint) {
|
||
for wire in wires {
|
||
if !wire_in_range(wire) { continue; }
|
||
if !wire.key_vertices.is_empty() {
|
||
// Use explicit vertices (Line, LwPolyline): every vertex is an endpoint.
|
||
for &p in &wire.key_vertices {
|
||
try_pt(Vec3::from(p), SnapType::Endpoint);
|
||
}
|
||
} 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 ───────────────────────────────────────────────────────
|
||
if self.is_on(SnapType::Midpoint) {
|
||
for wire in wires {
|
||
if !wire_in_range(wire) { continue; }
|
||
if !wire.key_vertices.is_empty() {
|
||
// Use explicit vertices for accurate per-segment midpoints.
|
||
for seg in wire.key_vertices.windows(2) {
|
||
let a = Vec3::from(seg[0]);
|
||
let b = Vec3::from(seg[1]);
|
||
if a.distance_squared(b) > 1e-12 {
|
||
try_pt((a + b) * 0.5, SnapType::Midpoint);
|
||
}
|
||
}
|
||
} else {
|
||
// Tessellated curves: midpoint of whole arc (first↔last midpoint skipped
|
||
// for closed curves; arcs use their own quadrant snaps instead).
|
||
for seg in wire.points.windows(2) {
|
||
let a = Vec3::from(seg[0]);
|
||
let b = Vec3::from(seg[1]);
|
||
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 {
|
||
if !wire_in_range(wire) { continue; }
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Perpendicular — foot of perpendicular from cursor (unclamped) ──
|
||
if self.is_on(SnapType::Perpendicular) {
|
||
for wire in wires {
|
||
if !wire_in_range(wire) { continue; }
|
||
for seg in wire.points.windows(2) {
|
||
if let Some(foot) =
|
||
perp_foot(cursor_world, Vec3::from(seg[0]), Vec3::from(seg[1]))
|
||
{
|
||
try_pt(foot, SnapType::Perpendicular);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Intersection — segment-segment intersections ──────────
|
||
if self.is_on(SnapType::Intersection) {
|
||
let r_world = self.snap_radius_px * 0.5; // rough world-space cull
|
||
for i in 0..wires.len() {
|
||
if !wire_in_range(&wires[i]) { continue; }
|
||
for j in (i + 1)..wires.len() {
|
||
if !wire_in_range(&wires[j]) { continue; }
|
||
for seg_a in wires[i].points.windows(2) {
|
||
for seg_b in wires[j].points.windows(2) {
|
||
let a0 = Vec3::from(seg_a[0]);
|
||
let a1 = Vec3::from(seg_a[1]);
|
||
let b0 = Vec3::from(seg_b[0]);
|
||
let b1 = Vec3::from(seg_b[1]);
|
||
// Quick AABB cull before expensive calculation.
|
||
let seg_mid_a = (a0 + a1) * 0.5;
|
||
let seg_mid_b = (b0 + b1) * 0.5;
|
||
if (seg_mid_a - cursor_world).length_squared()
|
||
> (r_world * 60.0).powi(2)
|
||
&& (seg_mid_b - cursor_world).length_squared()
|
||
> (r_world * 60.0).powi(2)
|
||
{
|
||
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,
|
||
view_proj,
|
||
bounds,
|
||
self.snap_radius_px,
|
||
) {
|
||
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,
|
||
view_proj,
|
||
bounds,
|
||
self.snap_radius_px,
|
||
) {
|
||
try_pt(ext, SnapType::Extension);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Apparent Intersection — screen-space intersections ─────────────
|
||
if self.is_on(SnapType::ApparentIntersection) {
|
||
for i in 0..wires.len() {
|
||
if !wire_in_range(&wires[i]) { continue; }
|
||
for j in (i + 1)..wires.len() {
|
||
if !wire_in_range(&wires[j]) { continue; }
|
||
for seg_a in wires[i].points.windows(2) {
|
||
for seg_b in wires[j].points.windows(2) {
|
||
let sa0 = world_to_screen(Vec3::from(seg_a[0]), view_proj, bounds);
|
||
let sa1 = world_to_screen(Vec3::from(seg_a[1]), view_proj, bounds);
|
||
let sb0 = world_to_screen(Vec3::from(seg_b[0]), view_proj, bounds);
|
||
let sb1 = world_to_screen(Vec3::from(seg_b[1]), view_proj, bounds);
|
||
if let Some((ta, _)) = seg_intersect_2d(sa0, sa1, sb0, sb1) {
|
||
// World position: interpolate along segment A.
|
||
let wa0 = Vec3::from(seg_a[0]);
|
||
let wa1 = Vec3::from(seg_a[1]);
|
||
let world = wa0 + ta * (wa1 - wa0);
|
||
try_pt(world, SnapType::ApparentIntersection);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Grid ───────────────────────────────────────────────────────────
|
||
if self.is_on(SnapType::Grid) {
|
||
let s = self.grid_spacing;
|
||
let gx = (cursor_world.x / s).round() * s;
|
||
let gy = (cursor_world.y / s).round() * s;
|
||
let gz = (cursor_world.z / s).round() * s;
|
||
try_pt(Vec3::new(gx, gy, gz), SnapType::Grid);
|
||
}
|
||
|
||
// ── 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 } => {
|
||
let sp0 = world_to_screen(Vec3::from(*p1), view_proj, bounds);
|
||
let sp1 = world_to_screen(Vec3::from(*p2), view_proj, bounds);
|
||
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);
|
||
let sc = world_to_screen(cv, view_proj, bounds);
|
||
let rim = world_to_screen(
|
||
Vec3::new(cv.x + radius, cv.y, cv.z),
|
||
view_proj,
|
||
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)
|
||
}
|
||
};
|
||
if d2 < best_d2 {
|
||
best_d2 = d2;
|
||
let screen_pt = world_to_screen(world_pt, view_proj, bounds);
|
||
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
|
||
}
|
||
}
|
||
|
||
// ── 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))
|
||
}
|
||
|
||
/// 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,
|
||
view_proj: Mat4,
|
||
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);
|
||
let screen_pt = world_to_screen(world_pt, view_proj, bounds);
|
||
let cursor_screen = world_to_screen(cursor_world, view_proj, bounds);
|
||
if dist2(screen_pt, cursor_screen) > radius_px * radius_px {
|
||
return None;
|
||
}
|
||
Some(world_pt)
|
||
}
|
||
|
||
// ── Projection helpers ────────────────────────────────────────────────────
|
||
|
||
fn world_to_screen(world: Vec3, view_proj: Mat4, bounds: Rectangle) -> Point {
|
||
let ndc = view_proj.project_point3(world);
|
||
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)
|
||
}
|