Files
OneCAD-Draft/src/snap.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 07:34:23 +03:00

4597 lines
190 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OpenCADStudio-style object snap (OSNAP) engine.
//!
//! Implemented modes:
//! Endpoint, Midpoint, Center, Node, Quadrant, Intersection,
//! Extension, Insertion, Perpendicular, Nearest, ApparentIntersection, Grid, Tangent
use glam::{DVec3, Mat4, Vec3};
use iced::time::Instant;
use iced::{Point, Rectangle};
use kernel::geom2d::Curve;
use codec::types::Handle;
use crate::command::{DimensionAssociationSource, TangentObject};
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
use crate::scene::pick::interaction_index::WireSource;
const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0;
// ── Snap type ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TrackingKind {
Generic,
Extension,
Perpendicular,
}
/// Every OSNAP mode — mirrors the OpenCADStudio 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,
/// Object acquisition (domain-object pick, e.g. network structure) — orange marker.
ObjectPick,
/// B-rep corner of a 3D solid. Part of the separate 3D object-snap
/// system (`Snapper::snap3d_enabled` + `enabled3d`): the 2D master toggle
/// and mode set never catch it.
Vertex,
/// Centre of a 3D solid B-rep edge. Same separate 3D system as `Vertex`.
EdgeMidpoint,
/// Centre of a 3D solid B-rep face (loop-vertex average; exact for
/// planar faces). Same separate 3D system.
FaceCenter,
/// NURBS knot location on a spline entity. Same separate 3D system.
Knot,
/// Foot of the perpendicular from the command base point onto a solid
/// face. Same separate 3D system.
FacePerpendicular,
/// Nearest point on a solid face to the cursor. Same separate 3D system.
NearestFace,
}
impl SnapType {
/// True for the separate 3D object-snap system (F4 master + `enabled3d`
/// set). The single source of truth — every master-gating check must use
/// this rather than listing variants, so new 3D modes stay independent.
pub fn is_3d(self) -> bool {
matches!(
self,
SnapType::Vertex
| SnapType::EdgeMidpoint
| SnapType::FaceCenter
| SnapType::Knot
| SnapType::FacePerpendicular
| SnapType::NearestFace
)
}
}
/// 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"),
// 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()`.
];
/// 3D object-snap modes: solid B-rep features with their own master toggle
/// (`Snapper::snap3d_enabled`, F4) and mode set (`Snapper::enabled3d`),
/// configured on the Drafting Settings "3D Object Snap" tab. Deliberately
/// absent from [`ALL_SNAP_MODES`] so the 2D Select All / Clear All, the snap
/// popup and `$OSMODE` persistence never touch them.
pub const ALL_3D_SNAP_MODES: &[(SnapType, &str, &str)] = &[
(SnapType::Vertex, "◈", "Vertex"),
(SnapType::EdgeMidpoint, "▽", "Midpoint on edge"),
(SnapType::FaceCenter, "◉", "Center of face"),
(SnapType::Knot, "⬥", "Knot"),
(SnapType::FacePerpendicular, "⟂", "Perpendicular to face"),
(SnapType::NearestFace, "✦", "Nearest to face"),
];
// ── Snap result ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy)]
pub struct SnapResult {
pub world: glam::DVec3,
pub screen: Point,
pub snap_type: SnapType,
/// Set when `snap_type == Tangent`; provides entity geometry for TTR/TTT.
pub tangent_obj: Option<TangentObject>,
/// 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>,
/// 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>,
/// Acquired endpoint used by the Extension guide and typed distance.
pub extension_origin: Option<glam::DVec3>,
/// Source direction paired with `extension_origin`.
pub extension_dir: Option<glam::DVec3>,
/// Layout viewport this snap was seen *through*, when the snap ran against
/// model geometry displayed by a paper-space viewport. `None` for ordinary
/// model-space / paper-sheet snaps. Filled by the paper-space viewport snap
/// query, never by the engine itself.
pub viewport: Option<Handle>,
/// Owning entity of the geometry the snap landed on, when the engine could
/// attribute the feature to a wire. Block sub-entities report the top-level
/// INSERT handle; the block path is resolved later by
/// [`crate::scene::viewport_ref::SnapSourceRef::block_path`].
pub source: Option<DimensionAssociationSource>,
/// Second real object at an intersection, when both objects are known.
pub secondary_source: Option<DimensionAssociationSource>,
/// Original model point when `world` has been projected onto a sheet.
pub model_point: Option<glam::DVec3>,
}
impl SnapResult {
/// Handle of the entity the snap landed on, when known.
pub fn source_handle(&self) -> Option<Handle> {
self.source.map(|s| s.handle)
}
}
/// Entity handle carried by a wire (`WireModel::name` is the decimal handle
/// value). Returns `None` for preview / interim wires with a symbolic name.
#[inline]
pub(crate) fn wire_source(wire: &WireModel) -> Option<DimensionAssociationSource> {
wire.name
.parse::<u64>()
.ok()
.map(|v| DimensionAssociationSource::inferred(Handle::new(v)))
}
/// Object-snap-tracking alignment: the cursor projected onto a ray from an
/// acquired tracking point.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct OtrackHit {
/// Cursor projected onto the tracking ray.
pub aligned: DVec3,
/// Unit ray direction toward the cursor side (for typed-distance entry).
pub dir: DVec3,
/// The tracking point the ray emanates from.
pub base: DVec3,
/// The second ray of an intersection lock, as `(base, outward direction)`.
/// A crossing is the meeting of two tracking vectors, and the user has to
/// see both of them to read what the lock means; `base`/`dir` above carry
/// only the one a typed distance is measured along. `None` for a
/// single-ray alignment, which has no second vector. (#1313)
pub cross: Option<(DVec3, DVec3)>,
pub kind: TrackingKind,
}
// ── Snapper ───────────────────────────────────────────────────────────────
use rustc_hash::{FxHashMap as HashMap, FxHashSet as 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>,
/// 3D object-snap master toggle (F4). Independent of the 2D master:
/// when false, solid B-rep snaps never fire but `enabled3d` is kept.
pub snap3d_enabled: bool,
/// Which 3D snap modes are configured (used when `snap3d_enabled`).
pub enabled3d: HashSet<SnapType>,
/// 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,
/// World-space grid spacing.
pub grid_spacing: f32,
/// User-configurable snap spacing (SNAPUNIT X/Y). Used for grid-snap
/// positions; kept separate from the adaptive `grid_spacing`, which
/// tracks the visible grid step and drives tolerances.
pub snap_spacing_x: f32,
pub snap_spacing_y: f32,
/// Pixel-radius snap aperture, shared by OSNAP, tracking, polar and
/// extension so the catch distance is the same everywhere.
pub osnap_radius_px: f32,
/// Object Snap Tracking on/off (F11).
pub otrack_enabled: bool,
/// Acquired OST points, world space. Tracking is planar: `tracking_dirs_at`
/// builds every direction from X/Y with Z zeroed, and the rays follow.
pub tracking_points: Vec<DVec3>,
/// 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).
pub tracking_dirs: Vec<Vec<DVec3>>,
/// Perpendicular directions through each acquired tracking point.
///
/// Kept separate from `tracking_dirs` because those directions are also
/// consumed by Extension snap and extended-intersection logic. These rays
/// belong only to OTRACK when Perpendicular OSNAP is enabled. (#695)
pub tracking_perp_dirs: Vec<Vec<DVec3>>,
/// Last snap world position (for dwell detection).
pub last_snap_world: Option<DVec3>,
/// 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,
/// Stable observations of the current dwell candidate.
dwell_observations: u8,
/// 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>,
/// 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)>,
/// 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)>,
}
impl Default for Snapper {
fn default() -> Self {
let mut enabled = HashSet::default();
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);
let mut enabled3d = HashSet::default();
enabled3d.insert(SnapType::Vertex);
enabled3d.insert(SnapType::EdgeMidpoint);
enabled3d.insert(SnapType::FaceCenter);
enabled3d.insert(SnapType::Knot);
enabled3d.insert(SnapType::FacePerpendicular);
// Nearest-to-face stays off by default: on dense curved solids it
// would catch nearly every cursor position, masking the discrete
// vertex/edge/face snaps. Opt in via the 3D Object Snap tab.
Self {
snap_enabled: true,
enabled,
snap3d_enabled: true,
enabled3d,
grid_snap_on: false,
grid_spacing: 1.0,
snap_spacing_x: 10.0,
snap_spacing_y: 10.0,
osnap_radius_px: DEFAULT_OSNAP_RADIUS_PX,
otrack_enabled: false,
tracking_points: Vec::new(),
tracking_dirs: Vec::new(),
tracking_perp_dirs: Vec::new(),
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
dwell_observations: 0,
from_point: None,
parallel_ref: None,
parallel_dwell: None,
override_saved: None,
}
}
}
/// Inline stack-allocated collection for up to 16 in-range `WireModel` references,
/// falling back to heap if an aperture contains more than 16 wires.
/// Avoids heap allocations entirely on the interactive drafting and cursor hover paths.
struct InRangeWires<'a> {
stack: [Option<&'a WireModel>; 16],
heap: Vec<&'a WireModel>,
count: usize,
}
impl<'a> InRangeWires<'a> {
#[inline(always)]
fn new() -> Self {
Self {
stack: [None; 16],
heap: Vec::new(),
count: 0,
}
}
#[inline(always)]
fn push(&mut self, wire: &'a WireModel) {
if self.count < 16 {
self.stack[self.count] = Some(wire);
} else {
if self.heap.is_empty() {
self.heap.reserve(16);
for slot in &self.stack {
if let Some(w) = slot {
self.heap.push(*w);
}
}
}
self.heap.push(wire);
}
self.count += 1;
}
#[inline(always)]
fn len(&self) -> usize {
self.count
}
#[inline(always)]
fn is_empty(&self) -> bool {
self.count == 0
}
#[inline(always)]
fn get(&self, idx: usize) -> Option<&'a WireModel> {
if idx >= self.count {
None
} else if self.count <= 16 {
self.stack[idx]
} else {
self.heap.get(idx).copied()
}
}
#[inline(always)]
fn iter(&self) -> InRangeWiresIter<'a, '_> {
InRangeWiresIter {
wires: self,
index: 0,
}
}
}
struct InRangeWiresIter<'a, 'b> {
wires: &'b InRangeWires<'a>,
index: usize,
}
impl<'a, 'b> Iterator for InRangeWiresIter<'a, 'b> {
type Item = &'a WireModel;
#[inline(always)]
fn next(&mut self) -> Option<Self::Item> {
let item = self.wires.get(self.index)?;
self.index += 1;
Some(item)
}
#[inline(always)]
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.wires.count.saturating_sub(self.index);
(remaining, Some(remaining))
}
}
impl<'a, 'b> ExactSizeIterator for InRangeWiresIter<'a, 'b> {}
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)
}
/// 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;
}
/// One-shot "None" override (Snap Overrides ▸ None): the next pick takes
/// the raw cursor point, ignoring every running object snap, after which
/// `clear_override` restores the saved configuration.
pub fn set_override_none(&mut self) {
if self.override_saved.is_none() {
self.override_saved = Some((self.enabled.clone(), self.snap_enabled));
}
self.enabled.clear();
self.snap_enabled = false;
}
/// 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;
}
}
/// 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))
}
/// 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))
}
pub fn toggle_global(&mut self) {
self.snap_enabled = !self.snap_enabled;
}
/// 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;
}
/// 3D object snap on/off (F4) — independent of the 2D object-snap master
/// and mode set. Only gates the solid B-rep snaps (`Vertex`,
/// `EdgeMidpoint`).
pub fn snap3d(&self) -> bool {
self.snap3d_enabled
}
pub fn toggle_snap3d(&mut self) {
self.snap3d_enabled = !self.snap3d_enabled;
}
/// Whether 3D snap mode `t` can fire: master on and mode configured.
pub fn is_on_3d(&self, t: SnapType) -> bool {
self.snap3d_enabled && self.enabled3d.contains(&t)
}
pub fn toggle_3d(&mut self, t: SnapType) {
if !self.enabled3d.remove(&t) {
self.enabled3d.insert(t);
}
}
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 from the current snap result.
pub fn update_otrack_dwell<W: WireSource + ?Sized>(
&mut self,
snap: Option<SnapResult>,
wires: &W,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
now: Instant,
) {
// Extension uses acquired points even when OTRACK is off. (#262)
if !self.tracking_active() {
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
self.dwell_observations = 0;
return;
}
// Acquire only stable geometric snap points for OTRACK. (#716)
let snap_world = snap.and_then(|hit| {
matches!(
hit.snap_type,
SnapType::Endpoint
| SnapType::Midpoint
| SnapType::Center
| SnapType::Node
| SnapType::Quadrant
| SnapType::Intersection
| SnapType::Insertion
| SnapType::ApparentIntersection
)
.then_some(hit.world)
});
// 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;
// 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;
const DWELL_PX: f32 = 8.0;
const MIN_OBSERVATIONS: u8 = 3;
match snap_world {
None => {
// Leaving all geometry: capture the point we were dwelling on if
// it qualified, before the reset loses it.
self.acquire_on_leave(
now,
DWELL_MS,
MIN_OBSERVATIONS,
wires,
endpoints_only,
);
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
self.dwell_observations = 0;
}
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_rot, eye, bounds);
let dp2 = world_to_screen(prev, view_rot, eye, 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_observations = self.dwell_observations.saturating_add(1);
let elapsed = self
.dwell_since
.map_or(0, |t| now.duration_since(t).as_millis());
if !self.dwell_acquired
&& self.dwell_observations >= MIN_OBSERVATIONS
&& elapsed >= DWELL_MS
{
self.dwell_acquired = true;
// Dwelling over an already-acquired point removes it;
// otherwise acquire it.
let existing = self.tracking_points.iter().position(|t| {
let d = (*t - p).length();
d < self.grid_spacing as f64 * 0.1
});
match existing {
Some(idx) => {
self.tracking_points.remove(idx);
if idx < self.tracking_dirs.len() {
self.tracking_dirs.remove(idx);
}
if idx < self.tracking_perp_dirs.len() {
self.tracking_perp_dirs.remove(idx);
}
}
None => self.acquire_tracking_point(p, wires, endpoints_only),
}
}
} else {
// 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.
self.acquire_on_leave(
now,
DWELL_MS,
MIN_OBSERVATIONS,
wires,
endpoints_only,
);
self.last_snap_world = Some(p);
self.dwell_since = Some(now);
self.dwell_acquired = false;
self.dwell_observations = 1;
}
}
}
}
/// Immediately acquire the explicitly engaged grip as a tracking point.
///
/// A grip was deliberately selected by the user, so unlike ordinary cursor
/// acquisition it should not require dwell before OTRACK/Extension can use
/// its original position and incident edge directions.
///
/// `wires` must be the frozen pre-drag geometry. It is used only here to
/// capture directions; it is never added to normal OSNAP candidates.
pub fn acquire_grip_tracking_point<W: WireSource + ?Sized>(
&mut self,
p: DVec3,
wires: &W,
) {
if !self.tracking_active() {
return;
}
// With OTRACK disabled, Extension remains endpoint-only.
let endpoints_only = !self.otrack_enabled;
self.acquire_tracking_point(p, wires, endpoints_only);
}
/// 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).
fn acquire_tracking_point<W: WireSource + ?Sized>(
&mut self,
p: DVec3,
wires: &W,
endpoints_only: bool,
) {
if self
.tracking_points
.iter()
.any(|t| (*t - p).length() < self.grid_spacing as f64 * 0.1)
{
return;
}
// 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, perp_dirs) = tracking_dirs_at(p, wires);
if endpoints_only && dirs.is_empty() {
return;
}
if self.tracking_points.len() >= 4 {
self.tracking_points.remove(0);
if !self.tracking_dirs.is_empty() {
self.tracking_dirs.remove(0);
}
if !self.tracking_perp_dirs.is_empty() {
self.tracking_perp_dirs.remove(0);
}
}
self.tracking_points.push(p);
self.tracking_dirs.push(dirs);
self.tracking_perp_dirs.push(perp_dirs);
}
/// 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).
fn acquire_on_leave<W: WireSource + ?Sized>(
&mut self,
now: Instant,
dwell_ms: u128,
min_observations: u8,
wires: &W,
endpoints_only: bool,
) {
if self.dwell_acquired || self.dwell_observations < min_observations {
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 {
self.acquire_tracking_point(prev, wires, endpoints_only);
}
}
/// Project the cursor onto acquired tracking rays and eligible crossings.
pub fn otrack_snap(
&self,
cursor_world: DVec3,
view_rot: glam::Mat4,
eye: glam::DVec3,
bounds: iced::Rectangle,
polar_step_deg: Option<f32>,
last_point: Option<DVec3>,
required_crossing_ray: Option<(DVec3, DVec3)>,
// 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,
// UCS axes in world space: tracking rays follow the active UCS while
// their world geometry stays in f64.
ucs_x: DVec3,
ucs_y: DVec3,
) -> Option<OtrackHit> {
if !self.otrack_enabled || self.tracking_points.is_empty() {
return None;
}
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
// Use the same aperture as OSNAP so the catch distance is uniform.
let r = self.osnap_radius_px;
let screen_dist = |w: DVec3| {
let s = world_to_screen(w, view_rot, eye, bounds);
((s.x - cursor_screen.x).powi(2) + (s.y - cursor_screen.y).powi(2)).sqrt()
};
// 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];
}
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
};
// 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: DVec3,
dir: DVec3,
group: usize,
kind: TrackingKind,
}
let mut rays: Vec<Ray> = Vec::new();
for (gi, &tp) in self.tracking_points.iter().enumerate() {
for adeg in ray_angles(tp) {
let ar = adeg.to_radians();
rays.push(Ray {
origin: tp,
dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
group: gi,
kind: TrackingKind::Generic,
});
}
// 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,
kind: TrackingKind::Extension,
});
}
}
// Perpendicular tracking ray through the acquired point.
//
// This is intentionally OTRACK-only. `tracking_dirs` continues to contain
// only real edge directions so Extension and extended intersections are not
// polluted by synthetic perpendicular rays. (#695)
if self.is_on(SnapType::Perpendicular) {
if let Some(pdirs) = self.tracking_perp_dirs.get(gi) {
for &d in pdirs {
rays.push(Ray {
origin: tp,
dir: d,
group: gi,
kind: TrackingKind::Perpendicular,
});
}
}
}
// 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;
if len2 > 1e-24 {
let inv = 1.0 / len2.sqrt();
rays.push(Ray {
origin: bp,
dir: DVec3::new(d.x * inv, d.y * inv, 0.0),
group: gi,
kind: TrackingKind::Generic,
});
}
}
}
// 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.
let otrack_ray_count = rays.len();
const POLAR_GROUP: usize = usize::MAX;
const ORTHO_GROUP: usize = usize::MAX - 1;
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();
rays.push(Ray {
origin: lp,
dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
group: POLAR_GROUP,
kind: TrackingKind::Generic,
});
}
}
// 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) {
for &adeg in &[0.0_f64, 90.0] {
let ar = adeg.to_radians();
rays.push(Ray {
origin: lp,
dir: ucs_x * ar.cos() + ucs_y * ar.sin(),
group: ORTHO_GROUP,
kind: TrackingKind::Generic,
});
}
}
// ── 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;
}
if required_crossing_ray.is_some_and(|(origin, dir)| {
!((rays[i].origin == origin && rays[i].dir == dir)
|| (rays[j].origin == origin && rays[j].dir == dir))
}) {
continue;
}
// 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;
}
let Some(x) =
line_intersect_xy(rays[i].origin, rays[i].dir, rays[j].origin, rays[j].dir)
else {
continue;
};
if required_crossing_ray.is_some_and(|(origin, dir)| {
let len2 = dir.x * dir.x + dir.y * dir.y;
let t = ((x.x - origin.x) * dir.x + (x.y - origin.y) * dir.y) / len2;
t < 0.05
}) {
continue;
}
let sd = screen_dist(x);
if sd < r && best_x.as_ref().map_or(true, |(bd, _)| sd < *bd) {
// Report an acquired tracking ray (not an auxiliary
// last_point ray) as base/dir for typed-distance entry.
let (ot, other) = if rays[i].group != POLAR_GROUP
&& rays[i].group != ORTHO_GROUP
{
(&rays[i], &rays[j])
} else {
(&rays[j], &rays[i])
};
// Point each ray the way the crossing lies from its own
// origin, so the guide drawn for it runs through the lock
// rather than away from it.
let outward = |ray: &Ray| {
let t = (x.x - ray.origin.x) * ray.dir.x
+ (x.y - ray.origin.y) * ray.dir.y;
if t >= 0.0 {
ray.dir
} else {
-ray.dir
}
};
let dir_out = outward(ot);
best_x = Some((
sd,
OtrackHit {
aligned: x,
dir: dir_out,
base: ot.origin,
// The vector the reported one crosses. Both are
// drawn, so the intersection reads as one. (#1313)
cross: Some((other.origin, outward(other))),
kind: ot.kind,
}
));
}
}
}
if let Some((_, h)) = best_x {
return Some(h);
}
if required_crossing_ray.is_some() {
return None;
}
// 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;
}
// ── 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 = DVec3::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,
// A single-ray alignment has no second vector.
cross: None,
kind: ray.kind,
},
));
}
}
best.map(|(_, h)| h)
}
/// Clear all acquired tracking points (e.g. when command ends).
pub fn clear_tracking(&mut self) {
self.tracking_points.clear();
self.tracking_dirs.clear();
self.tracking_perp_dirs.clear();
self.parallel_ref = None;
self.parallel_dwell = None;
self.last_snap_world = None;
self.dwell_since = None;
self.dwell_acquired = false;
self.dwell_observations = 0;
}
/// Parallel snap acquisition. When the Parallel object snap is on, hovering
/// 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)
pub fn update_parallel<W: WireSource + ?Sized>(
&mut self,
cursor_world: Vec3,
wires: &W,
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;
let parallel = |a: Vec3, b: Vec3| (a.x * b.x + a.y * b.y).abs() > 0.9998;
let Some((dir, pt)) = nearest_segment(
cursor_world,
wires,
view_rot,
eye,
bounds,
self.osnap_radius_px,
) else {
// 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));
}
}
_ => self.parallel_dwell = Some((dir, pt, now, false)),
}
}
/// 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;
}
let (dir, _) = self.parallel_ref?;
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,
extension_origin: None,
extension_dir: None,
viewport: None,
source: None,
secondary_source: None,
model_point: None,
})
}
/// Only runs Tangent snap — used when a command needs object picks via tangent.
pub fn snap_tangent_only<W: WireSource + ?Sized>(
&self,
cursor_world: Vec3,
cursor_screen: Point,
wires: &W,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Option<SnapResult> {
let tmp = Snapper {
snap_enabled: true,
enabled: {
let mut s = HashSet::default();
s.insert(SnapType::Tangent);
s
},
grid_snap_on: false,
grid_spacing: self.grid_spacing,
snap_spacing_x: self.snap_spacing_x,
snap_spacing_y: self.snap_spacing_y,
snap3d_enabled: false,
enabled3d: HashSet::default(),
osnap_radius_px: self.osnap_radius_px,
otrack_enabled: false,
tracking_points: Vec::new(),
tracking_dirs: Vec::new(),
tracking_perp_dirs: Vec::new(),
last_snap_world: None,
dwell_since: None,
dwell_acquired: false,
dwell_observations: 0,
from_point: None,
parallel_ref: None,
parallel_dwell: None,
override_saved: None,
};
// Tangent-only: Grid is disabled here, so the grid basis is irrelevant.
tmp.snap(
cursor_world.as_dvec3(),
cursor_screen,
wires,
view_rot,
eye,
bounds,
Vec3::ZERO,
(Vec3::X, Vec3::Y, Vec3::Z),
None,
)
}
/// Find the best snap candidate near the cursor.
pub fn snap<W: WireSource + ?Sized>(
&self,
cursor_world: glam::DVec3,
cursor_screen: Point,
wires: &W,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
// Grid origin and live drafting axes, so grid snap lands on the same
// rotated or isometric grid the user sees.
grid_origin: Vec3,
grid_axes: (Vec3, Vec3, Vec3),
construction_ray: Option<(glam::DVec3, glam::DVec3)>,
) -> Option<SnapResult> {
// 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;
let mut best: Option<SnapResult> = None;
let mut best_rank = u8::MAX;
let mut best_sub = u8::MAX;
let mut best_d2 = f32::MAX;
// 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
};
// ── 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.
// Unlike object snap, grid snap LOCKS: like AutoCAD SNAP, every point
// rounds to the grid — it is not gated by the aperture. (Gating it
// would break as soon as the fixed SNAPUNIT spacing differs from the
// adaptive visible-grid step on screen.)
if self.grid_snap_on {
let sx = self.snap_spacing_x as f64;
let sy = self.snap_spacing_y as f64;
// Z has no independent spacing in the dialog; follow X.
let sz = sx;
if sx.abs() > 1e-9 && sy.abs() > 1e-9 && sz.abs() > 1e-9 {
// Round in the UCS grid frame, then map back to world.
let (ax, ay, az) = grid_axes;
let ax = ax.normalize_or(Vec3::X).as_dvec3();
let ay = ay.normalize_or(Vec3::Y).as_dvec3();
let az = az.normalize_or(Vec3::Z).as_dvec3();
let origin = grid_origin.as_dvec3();
let rel = cursor_world - origin;
// The isometric pairs are oblique, so dot products alone are
// not coordinates. Invert their 2×2 Gram matrix before rounding.
let aa = ax.dot(ax);
let ab = ax.dot(ay);
let bb = ay.dot(ay);
let det = aa * bb - ab * ab;
let (ux, uy) = if det.abs() > 1e-9 {
let ra = rel.dot(ax);
let rb = rel.dot(ay);
((ra * bb - rb * ab) / det, (rb * aa - ra * ab) / det)
} else {
(rel.dot(ax), rel.dot(ay))
};
let ux = (ux / sx).round() * sx;
let uy = (uy / sy).round() * sy;
let uz = (rel.dot(az) / sz).round() * sz;
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);
// Lock, don't magnet: any in-pane grid point stands, so the
// cursor jumps grid-to-grid even when the SNAPUNIT spacing
// is far from the adaptive visible-grid step.
if in_bounds(screen) {
best = Some(SnapResult {
world: gp,
screen,
snap_type: SnapType::Grid,
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: None,
secondary_source: None,
model_point: None,
});
best_rank = snap_tier(SnapType::Grid);
best_sub = snap_priority(SnapType::Grid);
best_d2 = d2;
}
}
}
// The 2D object-snap master gates the 2D passes below. With it off,
// the grid result (if any) and the independent 3D system still stand.
// (`try_pt` drops 2D candidates itself when the master is off.)
if !self.snap_enabled && !self.snap3d_enabled {
return best;
}
// 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_rot.col(0).x.abs() * bounds.width * 0.5;
if s > 1e-6 {
self.osnap_radius_px / s
} else {
f32::MAX
}
};
let flat_ortho = view_rot.z_axis.x.abs() < 1e-9
&& view_rot.z_axis.y.abs() < 1e-9
&& (view_rot.w_axis.w - 1.0).abs() < 1e-6;
// 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 {
// In a tilted or perspective view `cursor_world` lies on the active
// construction plane, while the visible vertex may be anywhere on
// the same view ray. A world-XY AABB comparison can therefore reject
// a 3-D solid corner that is directly under the cursor. The scene's
// screen-space interaction index already performs the broad phase
// for those views; small unindexed drawings are cheap enough to let
// the exact screen-aperture checks below decide.
if !flat_ortho {
return true;
}
// 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).
let mag = wire.aabb.iter().fold(0.0f32, |m, c| m.max(c.abs()));
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
};
// 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.
// 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;
let mut in_range_wires = InRangeWires::new();
let unindexed = wires.segments().is_none();
if unindexed {
for w in wires.iter() {
if wire_in_range(w) {
in_range_pts += w.points.len();
in_range_wires.push(w);
}
}
}
let allow_unindexed_pairwise = in_range_pts <= MAX_PAIRWISE_POINTS;
let local_segments = indexed_segments(wires);
// Early out: if no unindexed wires overlap the cursor aperture and
// no persistent tracking points exist, discrete/continuous object snaps
// cannot hit anything. Avoid running all subsequent pass setups and loops.
if unindexed && in_range_wires.is_empty() && self.tracking_points.is_empty() {
return best;
}
let mut try_pt = |world: glam::DVec3,
snap_type: SnapType,
src: Option<DimensionAssociationSource>,
secondary: Option<DimensionAssociationSource>| {
// Masters are per-system: 2D candidates need the 2D master, 3D
// solid candidates answer only to the F4 master.
if !snap_type.is_3d() && !self.snap_enabled {
return;
}
let screen = world_to_screen(world, view_rot, eye, bounds);
if !in_bounds(screen) {
return;
}
let d2 = dist2(screen, cursor_screen);
// `!(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 (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type));
// Coincident 3D features share pixels (top and bottom face
// centres coincide in plan view): break exact ties by eye depth
// so the nearer one wins instead of whichever was evaluated
// first. 2D geometry is coplanar, so this never triggers there.
let depth_tie = snap_type.is_3d()
&& tier == best_rank
&& (d2 - best_d2).abs() <= 1e-4
&& sub == best_sub
&& best.is_some_and(|prev| {
(world - eye).length_squared() < (prev.world - eye).length_squared()
});
if depth_tie
|| snap_better(tier, d2, sub, (best_rank, best_d2, best_sub))
{
best_rank = tier;
best_sub = sub;
best_d2 = d2;
best = Some(SnapResult {
world,
screen,
snap_type,
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: src,
secondary_source: secondary,
model_point: None,
});
}
};
// ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ──────
let mut try_snap_hint = |world: DVec3,
hint: SnapHint,
src: Option<DimensionAssociationSource>| {
// 3D hints run on the separate 3D master + mode set; everything
// else stays on the 2D master + mode set.
let (snap_type, on) = match hint {
SnapHint::Center => (SnapType::Center, self.is_on(SnapType::Center)),
SnapHint::Node => (SnapType::Node, self.is_on(SnapType::Node)),
SnapHint::Quadrant => (SnapType::Quadrant, self.is_on(SnapType::Quadrant)),
SnapHint::Insertion => (SnapType::Insertion, self.is_on(SnapType::Insertion)),
SnapHint::Midpoint => (SnapType::Midpoint, self.is_on(SnapType::Midpoint)),
SnapHint::Endpoint => (SnapType::Endpoint, self.is_on(SnapType::Endpoint)),
SnapHint::Vertex => (SnapType::Vertex, self.is_on_3d(SnapType::Vertex)),
SnapHint::EdgeMidpoint => (
SnapType::EdgeMidpoint,
self.is_on_3d(SnapType::EdgeMidpoint),
),
SnapHint::FaceCenter => (
SnapType::FaceCenter,
self.is_on_3d(SnapType::FaceCenter),
),
SnapHint::Knot => (SnapType::Knot, self.is_on_3d(SnapType::Knot)),
};
if on {
try_pt(world, snap_type, src, None);
}
};
if let Some(points) = wires.snap_points() {
for point_ref in points {
let Some(wire) = wires.source_wire(point_ref.wire) else {
continue;
};
let Some(&(world, hint)) = wire.snap_pts.get(point_ref.index as usize) else {
continue;
};
try_snap_hint(world, hint, wire_source(wire));
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for &(world, hint) in &wire.snap_pts {
try_snap_hint(world, hint, src);
}
}
}
drop(try_snap_hint);
// ── Endpoint ───────────────────────────────────────────────────────
if self.is_on(SnapType::Endpoint) {
if let Some(vertices) = wires.key_vertices() {
for vertex_ref in vertices {
let Some(wire) = wires.source_wire(vertex_ref.wire) else {
continue;
};
let Some(&point) = wire.key_vertices.get(vertex_ref.index as usize) else {
continue;
};
try_pt(
DVec3::from_array(point),
SnapType::Endpoint,
wire_source(wire),
None,
);
}
// 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()) {
let closed = wire
.snap_pts
.iter()
.any(|(_, hint)| matches!(hint, SnapHint::Quadrant));
if closed {
continue;
}
if !wire.points.is_empty() {
try_pt(wp_f64(wire, 0), SnapType::Endpoint, wire_source(wire), None);
}
if wire.points.len() > 1 {
try_pt(
wp_f64(wire, wire.points.len() - 1),
SnapType::Endpoint,
wire_source(wire),
None,
);
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
if !wire.key_vertices.is_empty() {
for &point in &wire.key_vertices {
try_pt(DVec3::from_array(point), SnapType::Endpoint, src, None);
}
} 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, src, None);
}
if wire.points.len() > 1 {
try_pt(
wp_f64(wire, wire.points.len() - 1),
SnapType::Endpoint,
src,
None,
);
}
}
}
}
}
}
// ── Midpoint ───────────────────────────────────────────────────────
// Tessellated curves contribute their pre-baked Midpoint above.
if self.is_on(SnapType::Midpoint) {
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, wire_source(wire), None);
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for segment in wire.key_vertices.windows(2) {
let a = DVec3::from_array(segment[0]);
let b = DVec3::from_array(segment[1]);
if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint, src, None);
}
}
}
}
}
// ── Nearest — closest point on any segment (clamped) ──────────────
if self.is_on(SnapType::Nearest) {
if let Some(segments) = &local_segments {
for seg in segments {
try_pt(
nearest_on_segment(cursor_world, seg.a, seg.b),
SnapType::Nearest,
wires.source_wire(seg.wire).and_then(wire_source),
None,
);
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for i in 0..wire.points.len().saturating_sub(1) {
let p =
nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1));
try_pt(p, SnapType::Nearest, src, None);
}
}
}
}
// ── Perpendicular — foot of perpendicular from the drawing base ──
// Perpendicular requires a drawing base; cursor fallback acts like Nearest. (#716)
if self.is_on(SnapType::Perpendicular) {
if let Some(q) = self.from_point.map(|v| v.as_dvec3()) {
if let Some(segments) = &local_segments {
for seg in segments {
if let Some(foot) = perp_foot(q, seg.a, seg.b) {
try_pt(
foot,
SnapType::Perpendicular,
wires.source_wire(seg.wire).and_then(wire_source),
None,
);
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for i in 0..wire.points.len().saturating_sub(1) {
if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) {
try_pt(foot, SnapType::Perpendicular, src, None);
}
}
}
}
}
}
let mut try_ray_intersections = |origin: glam::DVec3, through: glam::DVec3| {
if (through - origin).length_squared() <= 1e-18 {
return;
}
if let Some(segments) = &local_segments {
for segment in segments {
if let Some(point) =
ray_segment_intersect_3d(origin, through, segment.a, segment.b)
{
if (point - origin).length_squared() > 1e-18 {
try_pt(
point,
SnapType::Intersection,
wires.source_wire(segment.wire).and_then(wire_source),
None,
);
}
}
}
} else {
for wire in in_range_wires.iter() {
let src = wire_source(wire);
for index in 0..wire.points.len().saturating_sub(1) {
if let Some(point) = ray_segment_intersect_3d(
origin,
through,
wp_f64(wire, index),
wp_f64(wire, index + 1),
) {
if (point - origin).length_squared() > 1e-18 {
try_pt(point, SnapType::Intersection, src, None);
}
}
}
}
}
};
// Check engaged extension tracking rays against nearby real geometry.
// OTRACK can align the cursor to an acquired segment extension even when
// Extension OSNAP itself is not the current snap result. Allow Intersection
// to stop that active extension ray where it crosses drawing geometry.
if self.is_on(SnapType::Intersection) && self.otrack_enabled {
for (&origin, dirs) in self.tracking_points.iter().zip(&self.tracking_dirs) {
for &dir in dirs {
// Only test the extension ray while the cursor is actually
// engaged with it, so Intersection does not become Nearest.
if extension_snap(
cursor_world,
origin,
dir,
view_rot,
eye,
bounds,
self.osnap_radius_px,
)
.is_some()
{
try_ray_intersections(origin, origin + dir);
}
}
}
}
if self.is_on(SnapType::Intersection) {
if let Some((origin, through)) = construction_ray {
try_ray_intersections(origin, through);
}
}
// Check engaged perpendicular tracking rays against nearby geometry.
if self.is_on(SnapType::Intersection)
&& self.otrack_enabled
&& self.is_on(SnapType::Perpendicular)
{
for (gi, &origin) in self.tracking_points.iter().enumerate() {
let Some(perp_dirs) = self.tracking_perp_dirs.get(gi) else {
continue;
};
for &dir in perp_dirs {
let rel = cursor_world - origin;
let t = rel.x * dir.x + rel.y * dir.y;
let aligned = glam::DVec3::new(
origin.x + dir.x * t,
origin.y + dir.y * t,
origin.z,
);
let aligned_screen = world_to_screen(aligned, view_rot, eye, bounds);
if dist2(aligned_screen, cursor_screen) > radius2 {
continue;
}
let dir_to_cursor = if t >= 0.0 { dir } else { -dir };
try_ray_intersections(origin, origin + dir_to_cursor);
}
}
}
drop(try_ray_intersections);
// ── Intersection — segment-segment intersections (pairwise, gated) ──
if self.is_on(SnapType::Intersection)
&& (local_segments.is_some() || allow_unindexed_pairwise)
{
if let Some(segments) = &local_segments {
let local_wires: Vec<_> = wires.iter().filter(|wire| wire_in_range(wire)).collect();
let mut resolved_pairs = rustc_hash::FxHashSet::default();
let pair_key = |a: &WireModel, b: &WireModel| {
let a = a as *const WireModel as usize;
let b = b as *const WireModel as usize;
(a.min(b), a.max(b))
};
for (idx, &wire_i) in local_wires.iter().enumerate() {
for &wire_j in &local_wires[idx + 1..] {
if let Some(points) = exact_curve_intersections(wire_i, wire_j) {
// Two wires meet here; attribute the feature to the
// first. Association resolution requires both source paths.
let src = wire_source(wire_i);
for point in points {
try_pt(point, SnapType::Intersection, src, wire_source(wire_j));
}
resolved_pairs.insert(pair_key(wire_i, wire_j));
}
}
}
// 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.
let mut local: Vec<&IndexedSegment> = segments.iter().collect();
local.sort_by(|a, b| a.min_x().total_cmp(&b.min_x()));
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 wires.source_wire(a.wire).zip(wires.source_wire(b.wire))
.is_some_and(|(a, b)| resolved_pairs.contains(&pair_key(a, b))) {
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,
wires.source_wire(a.wire).and_then(wire_source),
wires.source_wire(b.wire).and_then(wire_source),
);
if exact_cursor {
break 'intersection_sweep;
}
}
}
}
}
} else if in_range_wires.len() >= 2 {
for i in 0..in_range_wires.len() {
let Some(wire_i) = in_range_wires.get(i) else {
continue;
};
for j in (i + 1)..in_range_wires.len() {
let Some(wire_j) = in_range_wires.get(j) else {
continue;
};
// Curved pairs are solved exactly (bug #1052); see
// `exact_curve_intersections`'s doc comment.
if let Some(pts) = exact_curve_intersections(wire_i, wire_j) {
let src = wire_source(wire_i);
for pt in pts {
try_pt(pt, SnapType::Intersection, src, wire_source(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,
wire_source(wire_i),
wire_source(wire_j),
);
}
}
}
}
}
}
}
// ── 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.
if self.is_on(SnapType::Extension) && !self.tracking_points.is_empty() {
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, None, None);
}
}
}
// 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.
let filter2 = (self.osnap_radius_px * 2.0).powi(2);
let mut cand: Vec<(glam::DVec3, glam::DVec3)> = Vec::new();
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);
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 {
cand.push((origin, dir));
}
}
}
for i in 0..cand.len() {
let (a0, d1) = cand[i];
for &(b0, d2) in cand.iter().skip(i + 1) {
if (a0 - b0).length_squared() < 1e-18 {
continue;
}
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;
if t1 < 0.05 || t2 < 0.05 {
continue;
}
// 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, None, None);
}
}
}
// ── Apparent Intersection — screen-space intersections (pairwise, gated) ──
// L: pre-project each in-range wire's points to screen once, not once per segment pair.
if self.is_on(SnapType::ApparentIntersection)
&& (local_segments.is_some() || allow_unindexed_pairwise)
{
if let Some(segments) = &local_segments {
// 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
.iter()
.map(|seg| {
let a = world_to_screen(seg.a, view_rot, eye, bounds);
let b = world_to_screen(seg.b, view_rot, eye, bounds);
(
seg,
a,
b,
[a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)],
)
})
.collect();
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,
wires.source_wire(segment_a.wire).and_then(wire_source),
wires.source_wire(segment_b.wire).and_then(wire_source),
);
if dist2(apparent_screen, cursor_screen) <= f32::EPSILON {
break 'apparent_sweep;
}
}
}
}
}
} else if in_range_wires.len() >= 2 {
let screen_pts: Vec<Vec<Point>> = in_range_wires
.iter()
.map(|w| {
(0..w.points.len())
.map(|i| world_to_screen(wp_f64(w, i), view_rot, eye, bounds))
.collect()
})
.collect();
for i in 0..in_range_wires.len() {
let Some(wire_i) = in_range_wires.get(i) else {
continue;
};
let si = &screen_pts[i];
for j in (i + 1)..in_range_wires.len() {
let Some(wire_j) = in_range_wires.get(j) else {
continue;
};
let sj = &screen_pts[j];
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,
wire_source(wire_i),
wire_source(wire_j),
);
}
}
}
}
}
}
}
// ── Tangent ────────────────────────────────────────────────────────
// Operates directly on tangent_geoms geometry — independent of the
// wire.points rendering structure so polyline segments work correctly.
// 2D master applies (the shared `try_pt` gate does not cover this
// direct-evaluation pass).
if self.snap_enabled && self.is_on(SnapType::Tangent) {
let mut eval_tangent = |wire: &WireModel| {
for tg in &wire.tangent_geoms {
let (world_pt, d2) = match tg {
TangentGeom::Line { p1, p2 } => {
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,
);
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.as_dvec3(), d2)
}
TangentGeom::Circle { center, radius } => {
let cv = Vec3::from(*center);
let r = *radius;
let sc = world_to_screen(cv.as_dvec3(), view_rot, eye, bounds);
let rim = world_to_screen(
glam::DVec3::new((cv.x + r) as f64, cv.y as f64, cv.z as f64),
view_rot,
eye,
bounds,
);
let sr = dist2(sc, rim).sqrt();
let dc = dist2(cursor_screen, sc).sqrt();
let edge_d = (dc - sr).abs();
let w = if let Some(from) = self.from_point {
let Some((t0, t1)) = circle_tangent_points(from, cv, r) else {
continue;
};
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
}
} else {
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)
};
Vec3::new(cv.x + r * nx, cv.y + r * ny, cv.z)
};
(w.as_dvec3(), edge_d * edge_d)
}
TangentGeom::Arc {
center,
axis_x,
axis_y,
radius,
start_angle,
end_angle,
} => {
let Some(from) = self.from_point else {
continue;
};
let Some(world) = arc_tangent_points(
from.as_dvec3(),
*center,
*axis_x,
*axis_y,
*radius,
*start_angle,
*end_angle,
)
.into_iter()
.min_by(|a, b| {
let sa = world_to_screen(*a, view_rot, eye, bounds);
let sb = world_to_screen(*b, view_rot, eye, bounds);
dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen))
}) else {
continue;
};
let edge_d2 = wire
.points
.windows(2)
.enumerate()
.filter_map(|(index, _)| {
let a = wp_f64(wire, index);
let b = wp_f64(wire, index + 1);
(a.is_finite() && b.is_finite()).then(|| {
dist2_to_segment(
cursor_screen,
world_to_screen(a, view_rot, eye, bounds),
world_to_screen(b, view_rot, eye, bounds),
)
})
})
.fold(f32::INFINITY, f32::min);
(world, edge_d2)
}
TangentGeom::PlanarCircle {
center,
axis_x,
axis_y,
radius,
} => {
let cv = DVec3::from_array(*center);
let candidate = self.from_point.and_then(|from| {
planar_circle_tangent_points(
from.as_dvec3(),
*center,
*axis_x,
*axis_y,
*radius,
)
.into_iter()
.min_by(|a, b| {
let sa = world_to_screen(*a, view_rot, eye, bounds);
let sb = world_to_screen(*b, view_rot, eye, bounds);
dist2(sa, cursor_screen).total_cmp(&dist2(sb, cursor_screen))
})
});
if self.from_point.is_some() && candidate.is_none() {
continue;
}
let fallback = || {
(0..wire.points.len())
.map(|index| wp_f64(wire, index))
.filter(|point| point.is_finite())
.min_by(|a, b| {
let sa = world_to_screen(*a, view_rot, eye, bounds);
let sb = world_to_screen(*b, view_rot, eye, bounds);
dist2(sa, cursor_screen)
.total_cmp(&dist2(sb, cursor_screen))
})
.unwrap_or(cv)
};
let world = candidate.unwrap_or_else(fallback);
let edge_d2 = wire
.points
.windows(2)
.enumerate()
.filter_map(|(index, _)| {
let a = wp_f64(wire, index);
let b = wp_f64(wire, index + 1);
(a.is_finite() && b.is_finite()).then(|| {
dist2_to_segment(
cursor_screen,
world_to_screen(a, view_rot, eye, bounds),
world_to_screen(b, view_rot, eye, bounds),
)
})
})
.fold(f32::INFINITY, f32::min);
(world, edge_d2)
}
TangentGeom::PlanarEllipse { .. } => continue,
};
let (tier, sub) = (
snap_tier(SnapType::Tangent),
snap_priority(SnapType::Tangent),
);
let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds);
if d2 < radius2
&& in_bounds(screen_pt)
&& snap_better(tier, d2, sub, (best_rank, best_d2, best_sub))
{
best_rank = tier;
best_sub = sub;
best_d2 = d2;
let tangent_obj = match tg {
TangentGeom::Line { p1, p2 } => TangentObject::Line {
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),
},
TangentGeom::Circle { center, radius } => TangentObject::Circle {
center: glam::DVec3::new(
center[0] as f64,
center[1] as f64,
center[2] as f64,
),
radius: *radius as f64,
},
TangentGeom::Arc { center, radius, .. } => TangentObject::Circle {
center: glam::DVec3::from_array(*center),
radius: *radius,
},
TangentGeom::PlanarCircle { center, radius, .. } => {
TangentObject::Circle {
center: glam::DVec3::from_array(*center),
radius: *radius,
}
}
TangentGeom::PlanarEllipse { .. } => unreachable!(),
};
best = Some(SnapResult {
world: world_pt,
screen: screen_pt,
snap_type: SnapType::Tangent,
tangent_obj: Some(tangent_obj),
extension_base: None,
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: wire_source(wire),
secondary_source: None,
model_point: None,
});
}
}
};
if unindexed {
for wire in in_range_wires.iter() {
eval_tangent(wire);
}
} else {
for wire in wires.iter() {
if wire_in_range(wire) {
eval_tangent(wire);
}
}
}
}
// ── 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)
// 2D master applies (this direct-evaluation pass bypasses `try_pt`).
if self.snap_enabled && self.is_on(SnapType::Center) {
let mut offer = |wire: &WireModel, curve_d2: f32| {
let Some(center) = wire
.snap_pts
.iter()
.find(|(_, hint)| matches!(hint, SnapHint::Center))
.map(|&(c, _)| c)
else {
return;
};
let screen = world_to_screen(center, view_rot, eye, bounds);
// 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));
if curve_d2 < radius2
&& in_bounds(screen)
&& snap_better(tier, curve_d2, sub, (best_rank, best_d2, best_sub))
{
best_rank = tier;
best_sub = sub;
best_d2 = curve_d2;
best = Some(SnapResult {
world: center,
screen,
snap_type: SnapType::Center,
tangent_obj: None,
extension_base: None,
extension_base2: None,
extension_origin: None,
extension_dir: None,
viewport: None,
source: wire_source(wire),
secondary_source: None,
model_point: None,
});
}
};
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 in_range_wires.iter() {
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);
}
}
}
// 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)
if let Some(b) = best.as_mut() {
if matches!(b.snap_type, SnapType::Extension | SnapType::Intersection) {
let (b1, b2, ray) = extension_bases_screen(
b.world,
&self.tracking_points,
&self.tracking_dirs,
view_rot,
eye,
bounds,
);
b.extension_base = b1;
b.extension_base2 = b2;
b.extension_origin = ray.map(|(origin, _)| origin);
b.extension_dir = ray.map(|(_, dir)| dir);
}
}
best
}
}
// ── 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.
/// 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.
pub(crate) fn snap_tier(t: SnapType) -> u8 {
match t {
SnapType::Endpoint
| SnapType::Intersection
| SnapType::ApparentIntersection
| SnapType::Midpoint
| SnapType::Center
| SnapType::Node
| SnapType::Quadrant
| SnapType::Insertion
// Solid B-rep snaps are discrete like the 2D ones above.
| SnapType::Vertex
| SnapType::EdgeMidpoint
| SnapType::FaceCenter
| SnapType::Knot => 0,
// The face-continuous modes slot alongside their 2D analogues —
// without these arms the sub-priority fallback would rank them
// below Grid.
SnapType::FacePerpendicular => 9,
SnapType::NearestFace => 13,
other => snap_priority(other),
}
}
/// Merge paper and viewport snaps using the engine's ordering.
/// Both screen positions must use canvas pixels.
pub fn merge_snap(
a: Option<SnapResult>,
b: Option<SnapResult>,
cursor: Point,
) -> Option<SnapResult> {
match (a, b) {
(Some(a), Some(b)) => {
let (at, asub) = (snap_tier(a.snap_type), snap_priority(a.snap_type));
let (bt, bsub) = (snap_tier(b.snap_type), snap_priority(b.snap_type));
let bd2 = dist2(b.screen, cursor);
let ad2 = dist2(a.screen, cursor);
if snap_better(bt, bd2, bsub, (at, ad2, asub)) {
Some(b)
} else {
Some(a)
}
}
(some, None) | (None, some) => some,
}
}
/// 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.
pub(crate) 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
}
pub(crate) 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,
// 3D discretes sort after the 2D ones so a coincident 2D feature wins
// the tie-break; distance still decides first (`snap_better`). The
// face-continuous modes slot alongside their 2D analogues.
SnapType::Vertex => 15,
SnapType::EdgeMidpoint => 16,
SnapType::FaceCenter => 17,
SnapType::Knot => 18,
SnapType::FacePerpendicular => 19,
SnapType::NearestFace => 20,
}
}
// ── Geometric helpers ─────────────────────────────────────────────────────
/// Tracking directions through `p`, in one pass: the alignment ray along each
/// segment that ENDS at `p`, and the perpendicular of each segment that PASSES
/// THROUGH it.
///
/// The two ask different questions of the same segments, and the scan is the
/// expensive part — asking separately would walk the whole drawing twice for
/// every acquisition. Both are deduped by near-parallelism and capped.
///
/// Edge rays let the cursor track a segment's extension rather than only the
/// ortho/polar axes (#219); their sign is kept, because Extension needs to know
/// which way the segment left the corner, so two opposite collinear edges stay
/// distinct. Perpendicular rays are OTRACK's alone and describe an infinite
/// line, so one direction covers both sides (#695).
///
/// Scanned once, at acquisition — not per move. The edge list is empty when `p`
/// is not a segment endpoint (midpoint / centre / node), which is what makes it
/// double as the endpoint test.
fn tracking_dirs_at<W: WireSource + ?Sized>(
p: DVec3,
wires: &W,
) -> (Vec<DVec3>, Vec<DVec3>) {
// 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 edges: Vec<DVec3> = Vec::new();
let mut perps: Vec<DVec3> = Vec::new();
let mut consider = |a: DVec3, b: DVec3| {
if !a.x.is_finite() || !b.x.is_finite() {
return false;
}
let seg_len = {
let seg = b - a;
(seg.x * seg.x + seg.y * seg.y).sqrt()
};
if seg_len < 1e-9 {
return false;
}
let at_a = (a - p).length_squared() < tol2;
let at_b = (b - p).length_squared() < tol2;
if at_a || at_b {
// Store the outward ray from the acquired endpoint.
let seg = if at_a { a - b } else { b - a };
let d = DVec3::new(seg.x / seg_len, seg.y / seg_len, 0.0);
if !edges.iter().any(|e| e.x * d.x + e.y * d.y > 0.99996) {
edges.push(d);
}
}
// A perpendicular needs only that `p` lie on the segment — the whole
// point of #695 is tracking away from a point partway along a line, not
// just from its ends.
if at_a || at_b || (nearest_on_segment(p, a, b) - p).length_squared() <= tol2 {
let seg = b - a;
let perp = DVec3::new(-seg.y / seg_len, seg.x / seg_len, 0.0);
// Opposite perpendiculars describe the same infinite tracking line.
let known = |list: &[DVec3]| {
list.iter()
.any(|d| (d.x * perp.x + d.y * perp.y).abs() > 0.99996)
};
// Also skip a perpendicular that lands on a direction the edge rays
// already cover: at a square corner one segment's perpendicular is
// the other's own ray, and two rays down the same line are two
// things for the cursor to choose between that look identical.
if !known(&perps) && !known(&edges) {
perps.push(perp);
}
}
edges.len() >= 6 && perps.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 (edges, perps);
}
'outer: for wire in wires.iter() {
if is_round(wire) {
continue;
}
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);
if consider(a, b) {
break 'outer;
}
}
}
(edges, perps)
}
#[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)
}
}
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
}
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(),
)
}
/// 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,
)
}
/// Closest point on segment [p0, p1] to `query`.
fn nearest_on_segment(query: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> glam::DVec3 {
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);
glam::DVec3::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: glam::DVec3, p0: glam::DVec3, p1: glam::DVec3) -> Option<glam::DVec3> {
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(glam::DVec3::new(
p0.x + t * d.x,
p0.y + t * d.y,
p0.z + t * d.z,
))
}
/// True 3D intersection of a forward ray and a finite segment.
fn ray_segment_intersect_3d(
ray_origin: glam::DVec3,
ray_through: glam::DVec3,
b0: glam::DVec3,
b1: glam::DVec3,
) -> Option<glam::DVec3> {
let d1x = ray_through.x - ray_origin.x;
let d1y = ray_through.y - ray_origin.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;
}
let ex = b0.x - ray_origin.x;
let ey = b0.y - ray_origin.y;
let t = (ex * d2y - ey * d2x) / cross;
let s = (ex * d1y - ey * d1x) / cross;
if t < 0.0 || s < 0.0 || s > 1.0 {
return None;
}
let za = ray_origin.z + t * (ray_through.z - ray_origin.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;
}
Some(glam::DVec3::new(
ray_origin.x + t * d1x,
ray_origin.y + t * d1y,
0.5 * (za + zb),
))
}
// Coordinates for adapting wire geometry to the kernel intersection solver.
struct WirePlane {
origin: DVec3,
axis_x: DVec3,
axis_y: DVec3,
normal: DVec3,
}
impl WirePlane {
fn to_2d(&self, p: DVec3) -> [f64; 2] {
let rel = p - self.origin;
[rel.dot(self.axis_x), rel.dot(self.axis_y)]
}
/// Projects a *direction*, not a location — for re-expressing another
/// coplanar curve's own axis vector in this plane's 2D basis.
fn dir_to_2d(&self, d: DVec3) -> [f64; 2] {
[d.dot(self.axis_x), d.dot(self.axis_y)]
}
fn to_3d(&self, p: [f64; 2]) -> DVec3 {
self.origin + self.axis_x * p[0] + self.axis_y * p[1]
}
fn contains(&self, p: DVec3, tol: f64) -> bool {
(p - self.origin).dot(self.normal).abs() <= tol
}
}
fn wire_plane(wire: &WireModel) -> Option<WirePlane> {
for geom in &wire.tangent_geoms {
let plane_axes = match geom {
TangentGeom::Circle { center, .. } => Some((
DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64),
DVec3::X,
DVec3::Y,
)),
TangentGeom::PlanarCircle { center, axis_x, axis_y, .. }
| TangentGeom::Arc { center, axis_x, axis_y, .. } => Some((
DVec3::new(center[0], center[1], center[2]),
DVec3::new(axis_x[0], axis_x[1], axis_x[2]),
DVec3::new(axis_y[0], axis_y[1], axis_y[2]),
)),
TangentGeom::PlanarEllipse { center, major_axis, normal, .. } => {
let origin = DVec3::new(center[0], center[1], center[2]);
let n = DVec3::new(normal[0], normal[1], normal[2]).normalize();
let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]);
if major.length() <= 1e-12 {
None
} else {
Some((origin, major.normalize(), n.cross(major.normalize())))
}
}
TangentGeom::Line { .. } => None,
};
if let Some((origin, axis_x, axis_y)) = plane_axes {
return Some(WirePlane { origin, axis_x, axis_y, normal: axis_x.cross(axis_y).normalize() });
}
}
None
}
/// Resolve a `TangentGeom::Line`'s f32 endpoints to the wire's f64
/// `key_vertices`. The two lists are not index-aligned in general — a split
/// polyline keeps every vertex but only its straight geoms, a center mark
/// stores two vertices per segment, a block entry appends per entity — so the
/// vertices are found by value. `cursor` carries the slot of the previous
/// match: the geoms come in vertex order, so the next pair sits at or just
/// after it and the scan is amortised linear over the wire rather than
/// quadratic (a dense contour polyline is checked against every curved wire
/// in the aperture on each cursor move).
fn tangent_line_endpoints(
wire: &WireModel,
p1: [f32; 3],
p2: [f32; 3],
cursor: &mut usize,
) -> (DVec3, DVec3) {
if wire.points.len() == 2 && wire.tangent_geoms.len() == 1 {
return (wp_f64(wire, 0), wp_f64(wire, 1));
}
let matches = |vertex: [f64; 3], point: [f32; 3]| {
(vertex[0] as f32 - point[0]).abs() < 1e-3
&& (vertex[1] as f32 - point[1]).abs() < 1e-3
&& (vertex[2] as f32 - point[2]).abs() < 1e-3
};
let count = wire.key_vertices.len();
if count >= 2 {
for step in 0..count {
let start = (*cursor + step) % count;
let end = (start + 1) % count;
if matches(wire.key_vertices[start], p1) && matches(wire.key_vertices[end], p2) {
*cursor = end;
return (
DVec3::from_array(wire.key_vertices[start]),
DVec3::from_array(wire.key_vertices[end]),
);
}
}
}
(
Vec3::from_array(p1).as_dvec3(),
Vec3::from_array(p2).as_dvec3(),
)
}
fn curves_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Vec<Curve>> {
use kernel::geom2d::{Arc as KArc, Circle as KCircle, Ellipse as KEllipse, EllipseArc as KEllipseArc, Line as KLine};
let mut curves = Vec::new();
if wire.tangent_geoms.is_empty() {
if wire.points.len() != 2 {
return None;
}
let (p0, p1) = (wp_f64(wire, 0), wp_f64(wire, 1));
if !frame.contains(p0, tol) || !frame.contains(p1, tol) {
return None;
}
return Some(vec![Curve::Line(KLine {
start: frame.to_2d(p0),
end: frame.to_2d(p1),
})]);
}
let angle_in_frame = |world_point: DVec3, centre: DVec3| {
let (p2, c2) = (frame.to_2d(world_point), frame.to_2d(centre));
(p2[1] - c2[1]).atan2(p2[0] - c2[0])
};
let mut vertex_cursor = 0usize;
for geom in &wire.tangent_geoms {
match geom {
TangentGeom::Line { p1, p2 } => {
let (p1, p2) = tangent_line_endpoints(wire, *p1, *p2, &mut vertex_cursor);
if !frame.contains(p1, tol) || !frame.contains(p2, tol) {
return None;
}
curves.push(Curve::Line(KLine { start: frame.to_2d(p1), end: frame.to_2d(p2) }));
}
TangentGeom::Circle { center, radius } => {
let c = DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64);
if DVec3::Z.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
return None;
}
curves.push(Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 }));
}
TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => {
let c = DVec3::new(center[0], center[1], center[2]);
let n = DVec3::new(axis_x[0], axis_x[1], axis_x[2]).cross(DVec3::new(axis_y[0], axis_y[1], axis_y[2])).normalize();
if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
return None;
}
curves.push(Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius }));
}
TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => {
let c = DVec3::new(center[0], center[1], center[2]);
let (ax, ay) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2]));
let n = ax.cross(ay).normalize();
if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
return None;
}
let arc = KArc { centre: frame.to_2d(c), radius: *radius, start_angle: *start_angle, end_angle: *end_angle };
let sweep = arc.sweep();
if (sweep - std::f64::consts::TAU).abs() <= 1e-12 {
curves.push(Curve::Circle(KCircle { centre: arc.centre, radius: *radius }));
} else {
let boundary = if n.dot(frame.normal) < 0.0 { *end_angle } else { *start_angle };
let start_angle = angle_in_frame(c + *radius * (boundary.cos() * ax + boundary.sin() * ay), c);
curves.push(Curve::Arc(KArc { start_angle, end_angle: start_angle + sweep, ..arc }));
}
}
TangentGeom::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => {
let c = DVec3::new(center[0], center[1], center[2]);
let ell_normal = DVec3::new(normal[0], normal[1], normal[2]).normalize();
if ell_normal.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
return None;
}
let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]);
let major_radius = major.length();
if major_radius <= 1e-12 {
return None;
}
let minor_radius = major_radius * *minor_axis_ratio;
let dir2d = frame.dir_to_2d(major / major_radius);
let len = (dir2d[0] * dir2d[0] + dir2d[1] * dir2d[1]).sqrt();
if len <= 1e-12 {
return None;
}
let major_axis_2d = [dir2d[0] / len, dir2d[1] / len];
let (start_parameter, end_parameter) = if ell_normal.dot(frame.normal) < 0.0 {
(-*end_param, -*start_param)
} else {
(*start_param, *end_param)
};
curves.push(Curve::Ellipse(KEllipseArc {
ellipse: KEllipse { centre: frame.to_2d(c), major_radius, minor_radius, major_axis: major_axis_2d },
start_parameter,
end_parameter,
}));
}
}
}
Some(curves)
}
pub(crate) fn exact_curve_intersections(
wire_a: &WireModel,
wire_b: &WireModel,
) -> Option<Vec<DVec3>> {
let frame = wire_plane(wire_a).or_else(|| wire_plane(wire_b))?;
const PLANE_TOL: f64 = 1e-7;
let curves_a = curves_in_frame(wire_a, &frame, PLANE_TOL)?;
let curves_b = curves_in_frame(wire_b, &frame, PLANE_TOL)?;
// Two lines have nothing this path can improve on (the segment sweep is
// already exact for a straight pair); avoid the extra work.
let a_all_lines = curves_a.iter().all(|c| matches!(c, Curve::Line(_)));
let b_all_lines = curves_b.iter().all(|c| matches!(c, Curve::Line(_)));
if a_all_lines && b_all_lines {
return None;
}
let tolerance = kernel::geom2d::Tolerance::new(1e-9_f64.max(PLANE_TOL));
let mut points: Vec<DVec3> = Vec::new();
for ca in &curves_a {
for cb in &curves_b {
let crossings = kernel::geom2d::intersect(ca, cb, tolerance);
for c in crossings {
let pt = frame.to_3d(c.point);
if !points.iter().any(|existing| existing.distance_squared(pt) <= 1e-12) {
points.push(pt);
}
}
}
}
Some(points)
}
/// XY-plane segment-segment intersection. Returns `None` if parallel or outside.
/// 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)
fn seg_intersect_3d(
a0: glam::DVec3,
a1: glam::DVec3,
b0: glam::DVec3,
b1: glam::DVec3,
) -> Option<glam::DVec3> {
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 in plan
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;
}
// 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;
}
Some(glam::DVec3::new(
a0.x + t * d1x,
a0.y + t * d1y,
0.5 * (za + zb),
))
}
/// 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: DVec3, d1: DVec3, o2: DVec3, d2: DVec3) -> Option<DVec3> {
let cross = d1.x * d2.y - d1.y * d2.x;
if cross.abs() < 1e-15 {
return None;
}
let ex = o2.x - o1.x;
let ey = o2.y - o1.y;
let t = (ex * d2.y - ey * d2.x) / cross;
Some(DVec3::new(o1.x + d1.x * t, o1.y + d1.y * t, o1.z))
}
/// 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))
}
/// Returns the two external tangent points on an XY circle.
fn circle_tangent_points(p: Vec3, center: Vec3, radius: f32) -> Option<(Vec3, Vec3)> {
let curve = kernel::geom2d::Curve::Circle(kernel::geom2d::Circle {
centre: [center.x as f64, center.y as f64],
radius: radius as f64,
});
let points = kernel::geom2d::tangent_from(&curve, [p.x as f64, p.y as f64]);
let [first, second] = points.as_slice() else {
return None;
};
Some((
Vec3::new(first.point[0] as f32, first.point[1] as f32, center.z),
Vec3::new(second.point[0] as f32, second.point[1] as f32, center.z),
))
}
fn planar_circle_tangent_points(
from: DVec3,
center: [f64; 3],
axis_x: [f64; 3],
axis_y: [f64; 3],
radius: f64,
) -> Vec<DVec3> {
let plane = kernel::space::Plane::from_axes(center, axis_x, axis_y);
let Some(from) = plane.project(from.to_array()) else {
return Vec::new();
};
let curve = kernel::geom2d::Curve::Circle(kernel::geom2d::Circle {
centre: [0.0, 0.0],
radius,
});
kernel::geom2d::tangent_from(&curve, from)
.into_iter()
.map(|point| DVec3::from_array(plane.point_at(point.point)))
.collect()
}
fn arc_tangent_points(
from: DVec3,
center: [f64; 3],
axis_x: [f64; 3],
axis_y: [f64; 3],
radius: f64,
start_angle: f64,
end_angle: f64,
) -> Vec<DVec3> {
let plane = kernel::space::Plane::from_axes(center, axis_x, axis_y);
let Some(from) = plane.project(from.to_array()) else {
return Vec::new();
};
let curve = kernel::geom2d::Curve::Arc(kernel::geom2d::Arc {
centre: [0.0, 0.0],
radius,
start_angle,
end_angle,
});
kernel::geom2d::tangent_from(&curve, from)
.into_iter()
.map(|point| DVec3::from_array(plane.point_at(point.point)))
.collect()
}
/// 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: glam::DVec3,
origin: glam::DVec3,
dir: glam::DVec3,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
radius_px: f32,
) -> Option<glam::DVec3> {
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 = 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);
if dist2(screen_pt, cursor_screen) > radius_px * radius_px {
return None;
}
Some(world_pt)
}
/// 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.
fn extension_bases_screen(
snapped: glam::DVec3,
tracking_points: &[glam::DVec3],
tracking_dirs: &[Vec<glam::DVec3>],
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> (
Option<Point>,
Option<Point>,
Option<(glam::DVec3, glam::DVec3)>,
) {
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, glam::DVec3, Point)> = Vec::new();
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 {
continue;
}
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, dir, base));
}
}
}
// 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();
let mut directions: Vec<glam::DVec3> = Vec::new();
for (_, origin, dir, base) in found {
if origins
.iter()
.any(|o| (*o - origin).length_squared() < 1e-12)
{
continue;
}
origins.push(origin);
directions.push(dir);
if bases[0].is_none() {
bases[0] = Some(base);
} else {
bases[1] = Some(base);
break;
}
}
// `origins` is sorted nearest-fit first, so its head is the endpoint the
// primary guide is drawn from — the one a typed distance measures along.
(
bases[0],
bases[1],
origins.first().copied().zip(directions.first().copied()),
)
}
// ── Projection helpers ────────────────────────────────────────────────────
/// 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: glam::DVec3,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
) -> Point {
let rel = (world - eye).as_vec3();
let ndc = view_rot.project_point3(rel);
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
}
/// Closest point on the 2D triangle (a, b, c) to `p`: the point, its squared
/// distance, and its barycentric weights for (a, b, c) so callers can lift
/// the result back to 3D. Used to land Nearest-to-face on a projected mesh
/// triangle.
pub(crate) fn closest_point_on_tri_2d(
p: [f32; 2],
a: [f32; 2],
b: [f32; 2],
c: [f32; 2],
) -> ([f32; 2], f32, [f32; 3]) {
let close = |q: [f32; 2], w: [f32; 3]| {
let dx = p[0] - q[0];
let dy = p[1] - q[1];
(q, dx * dx + dy * dy, w)
};
// Barycentric region tests (Real-Time Collision Detection §5.1.5).
let ab = [b[0] - a[0], b[1] - a[1]];
let ac = [c[0] - a[0], c[1] - a[1]];
let ap = [p[0] - a[0], p[1] - a[1]];
let d1 = ab[0] * ap[0] + ab[1] * ap[1];
let d2 = ac[0] * ap[0] + ac[1] * ap[1];
if d1 <= 0.0 && d2 <= 0.0 {
return close(a, [1.0, 0.0, 0.0]);
}
let bp = [p[0] - b[0], p[1] - b[1]];
let d3 = ab[0] * bp[0] + ab[1] * bp[1];
let d4 = ac[0] * bp[0] + ac[1] * bp[1];
if d3 >= 0.0 && d4 <= d3 {
return close(b, [0.0, 1.0, 0.0]);
}
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
let v = d1 / (d1 - d3);
return close([a[0] + v * ab[0], a[1] + v * ab[1]], [1.0 - v, v, 0.0]);
}
let cp = [p[0] - c[0], p[1] - c[1]];
let d5 = ab[0] * cp[0] + ab[1] * cp[1];
let d6 = ac[0] * cp[0] + ac[1] * cp[1];
if d6 >= 0.0 && d5 <= d6 {
return close(c, [0.0, 0.0, 1.0]);
}
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
let w = d2 / (d2 - d6);
return close([a[0] + w * ac[0], a[1] + w * ac[1]], [1.0 - w, 0.0, w]);
}
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
return close(
[b[0] + w * (c[0] - b[0]), b[1] + w * (c[1] - b[1])],
[0.0, 1.0 - w, w],
);
}
// Inside: barycentric interpolation.
let denom = 1.0 / (va + vb + vc);
let v = vb * denom;
let w = vc * denom;
close(
[a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w],
[1.0 - v - w, v, w],
)
}
/// Foot of the perpendicular from `base` onto the triangle (a, b, c):
/// `None` for degenerate triangles, a base already in the face plane
/// (perpendicular undefined), or a foot landing outside the triangle.
pub(crate) fn foot_on_triangle(
base: glam::DVec3,
a: glam::DVec3,
b: glam::DVec3,
c: glam::DVec3,
) -> Option<glam::DVec3> {
let ab = b - a;
let ac = c - a;
let n = ab.cross(ac);
let n2 = n.length_squared();
if !(n2 > 1e-24) {
return None;
}
let dist = (base - a).dot(n) / n2.sqrt();
if dist.abs() <= 1e-9 {
return None;
}
let foot = base - n * ((base - a).dot(n) / n2);
// Inside test via barycentric areas (tolerant sliver at the rim).
let v0 = c - a;
let v1 = b - a;
let v2 = foot - a;
let d00 = v0.dot(v0);
let d01 = v0.dot(v1);
let d11 = v1.dot(v1);
let d20 = v2.dot(v0);
let d21 = v2.dot(v1);
let denom = d00 * d11 - d01 * d01;
if denom.abs() <= 1e-24 {
return None;
}
let v = (d11 * d20 - d01 * d21) / denom;
let w = (d00 * d21 - d01 * d20) / denom;
if v >= -1e-9 && w >= -1e-9 && v + w <= 1.0 + 1e-9 {
Some(foot)
} else {
None
}
}
/// 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)
fn nearest_segment<W: WireSource + ?Sized>(
cursor_world: Vec3,
wires: &W,
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
aperture_px: f32,
) -> Option<(Vec3, Vec3)> {
let cs = world_to_screen(cursor_world.as_dvec3(), view_rot, eye, bounds);
let mut best_d2 = aperture_px * aperture_px;
let mut best: Option<(Vec3, Vec3)> = None;
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() {
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;
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));
}
}
}
}
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
}
/// 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)
}
#[cfg(test)]
mod ext_tests {
use super::*;
#[test]
fn closest_point_on_triangle_2d_handles_inside_edge_and_vertex() {
let (a, b, c) = ([0.0f32, 0.0], [10.0, 0.0], [0.0, 10.0]);
// Interior → itself, with barycentric weights for (a, b, c).
let (q, d2, w) = closest_point_on_tri_2d([2.0, 2.0], a, b, c);
assert!((q[0] - 2.0).abs() < 1e-6 && (q[1] - 2.0).abs() < 1e-6);
assert!(d2 < 1e-12);
assert!((w[0] - 0.6).abs() < 1e-6 && (w[1] - 0.2).abs() < 1e-6 && (w[2] - 0.2).abs() < 1e-6);
// Outside near an edge → foot on the edge.
let (q, d2, w) = closest_point_on_tri_2d([5.0, -3.0], a, b, c);
assert!((q[0] - 5.0).abs() < 1e-6 && q[1].abs() < 1e-6);
assert!((d2 - 9.0).abs() < 1e-6);
assert!(w[2].abs() < 1e-6, "edge AB carries no C weight: {w:?}");
// Outside near a vertex → the vertex.
let (q, d2, _) = closest_point_on_tri_2d([-4.0, -3.0], a, b, c);
assert!(q[0].abs() < 1e-6 && q[1].abs() < 1e-6);
assert!((d2 - 25.0).abs() < 1e-6);
}
#[test]
fn foot_on_triangle_needs_off_plane_base_and_inside_landing() {
let (a, b, c) = (
DVec3::new(0.0, 0.0, 5.0),
DVec3::new(10.0, 0.0, 5.0),
DVec3::new(0.0, 10.0, 5.0),
);
// Base below the face interior → foot straight above it.
let foot = foot_on_triangle(DVec3::new(2.0, 3.0, 0.0), a, b, c)
.expect("interior foot");
assert!((foot - DVec3::new(2.0, 3.0, 5.0)).length() < 1e-9);
// Base outside the triangle's span → the foot misses it.
assert!(foot_on_triangle(DVec3::new(9.0, 9.0, 0.0), a, b, c).is_none());
// Base already in the face plane → perpendicular undefined.
assert!(foot_on_triangle(DVec3::new(2.0, 3.0, 5.0), a, b, c).is_none());
}
#[test]
fn snap3d_master_and_set_gate_independently_of_2d() {
let mut s = Snapper::default();
// Defaults: master on; every 3D mode configured except
// Nearest-to-face, which would mask the discrete snaps.
assert!(s.is_on_3d(SnapType::Vertex));
assert!(s.is_on_3d(SnapType::EdgeMidpoint));
assert!(!s.is_on_3d(SnapType::NearestFace));
// The 2D master has no say over 3D modes.
s.snap_enabled = false;
assert!(s.is_on_3d(SnapType::Vertex));
// Per-mode toggle.
s.toggle_3d(SnapType::Vertex);
assert!(!s.is_on_3d(SnapType::Vertex));
assert!(s.is_on_3d(SnapType::EdgeMidpoint));
// Master toggle gates the whole 3D system, keeping the set.
s.toggle_snap3d();
assert!(!s.is_on_3d(SnapType::EdgeMidpoint));
assert!(s.enabled3d.contains(&SnapType::EdgeMidpoint));
s.toggle_snap3d();
assert!(s.is_on_3d(SnapType::EdgeMidpoint));
// 3D modes never leak into the 2D set.
assert!(!s.is_on(SnapType::Vertex));
assert!(!s.is_on(SnapType::EdgeMidpoint));
}
#[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());
}
#[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 {
text_verts: Vec::new(),
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).
s.acquire_tracking_point(DVec3::new(5.0, 0.0, 0.0), &wires, true);
assert!(s.tracking_points.is_empty());
// The genuine endpoint is acquired.
s.acquire_tracking_point(DVec3::new(10.0, 0.0, 0.0), &wires, true);
assert_eq!(s.tracking_points.len(), 1);
// OTRACK (endpoints_only = false) still acquires any snap point.
s.acquire_tracking_point(DVec3::new(5.0, 0.0, 0.0), &wires, false);
assert_eq!(s.tracking_points.len(), 2);
}
#[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.
let corner = DVec3::new(8_000.0, 6_000.0, 0.0);
s.tracking_points.push(corner);
s.tracking_dirs.push(Vec::new());
let base = DVec3::new(0.0, 0.0, 0.0);
// A plain orthographic camera: world * 0.0001 → NDC.
let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
let eye = glam::DVec3::ZERO;
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 1000.0,
height: 1000.0,
};
// 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.
let dir = (corner - base).normalize();
let perp = DVec3::new(-dir.y, dir.x, 0.0);
let cursor = base + dir * 5_000.0 + perp * 0.05;
let hit = s
.otrack_snap(
cursor,
view_rot,
eye,
bounds,
None,
Some(base),
None,
false,
DVec3::X,
DVec3::Y,
)
.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;
assert!((off.length() - 5_000.0).abs() < 1e-10);
assert!(
cross.abs() < 1e-12,
"aligned point off the base→corner line: {:?}",
hit.aligned
);
assert!(
(hit.base - base).length() < 1e-12,
"ray base is not the command base"
);
// Without a base point the alignment does not exist (nothing to join).
let none = s.otrack_snap(
cursor,
view_rot,
eye,
bounds,
None,
None,
None,
false,
DVec3::X,
DVec3::Y,
);
assert!(none.is_none(), "no base point → no base→corner alignment");
}
/// #1313: an intersection lock must report both of the vectors it is the
/// crossing of, so the overlay can draw both. Reporting only the one a
/// typed distance runs along leaves the user with a single guide and no
/// sign of what the point actually is.
#[test]
fn intersection_lock_reports_both_crossing_vectors() {
let mut s = Snapper::default();
s.otrack_enabled = true;
s.osnap_radius_px = 10.0;
// Two acquired corners. With no polar step each offers a horizontal and
// a vertical ray, so their rays cross at (10, 0) and at (0, 5).
let first = DVec3::new(0.0, 0.0, 0.0);
let second = DVec3::new(10.0, 5.0, 0.0);
for corner in [first, second] {
s.tracking_points.push(corner);
s.tracking_dirs.push(Vec::new());
}
let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
let eye = glam::DVec3::ZERO;
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 1000.0,
height: 1000.0,
};
// Cursor a hair off the crossing of the first corner's horizontal ray
// and the second corner's vertical one.
let crossing = DVec3::new(10.0, 0.0, 0.0);
let hit = s
.otrack_snap(
crossing + DVec3::new(0.02, 0.02, 0.0),
view_rot,
eye,
bounds,
None,
None,
None,
false,
DVec3::X,
DVec3::Y,
)
.expect("the two rays cross inside the aperture");
assert!(
(hit.aligned - crossing).length() < 1e-9,
"locked off the crossing: {:?}",
hit.aligned
);
let (cross_base, cross_dir) = hit.cross.expect("a crossing reports its second vector");
let bases = [hit.base, cross_base];
for corner in [first, second] {
assert!(
bases.iter().any(|b| (*b - corner).length() < 1e-9),
"{corner:?} is not one of the two reported vectors: {bases:?}"
);
}
// Each vector runs from its own corner through the crossing, pointing
// at it — the guides are drawn along these.
for (base, dir) in [(hit.base, hit.dir), (cross_base, cross_dir)] {
let off = crossing - base;
assert!(
(off.x * dir.y - off.y * dir.x).abs() < 1e-9,
"the crossing is off the vector from {base:?} along {dir:?}"
);
assert!(
off.dot(dir) > 0.0,
"vector from {base:?} points away from the crossing"
);
}
}
/// The second vector belongs to a crossing alone: a plain single-ray
/// alignment has nothing to cross, and must not draw a second guide.
#[test]
fn single_ray_alignment_reports_no_crossing_vector() {
let mut s = Snapper::default();
s.otrack_enabled = true;
s.osnap_radius_px = 10.0;
let corner = DVec3::new(10.0, 5.0, 0.0);
s.tracking_points.push(corner);
s.tracking_dirs.push(Vec::new());
let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
let eye = glam::DVec3::ZERO;
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 1000.0,
height: 1000.0,
};
let hit = s
.otrack_snap(
DVec3::new(60.0, 5.02, 0.0),
view_rot,
eye,
bounds,
None,
None,
None,
false,
DVec3::X,
DVec3::Y,
)
.expect("the corner's horizontal ray catches the cursor");
assert!(
hit.cross.is_none(),
"a single-ray alignment reported a crossing vector: {:?}",
hit.cross
);
}
#[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());
}
#[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");
assert!(
(hi.z - 5.0).abs() < 1e-9,
"z should be the true height, got {}",
hi.z
);
}
#[test]
fn exact_curve_intersections_matches_the_3_4_5_report() {
let c1 = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 4.0,
}],
..Default::default()
};
let c2 = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [3.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
}],
..Default::default()
};
let pts = exact_curve_intersections(&c1, &c2).expect("two overlapping circles must intersect");
assert_eq!(pts.len(), 2, "two distinct circles crossing at two points");
let upper = pts.iter().copied().find(|p| p.y > 0.0).expect("an upper intersection");
assert!((upper - DVec3::new(0.0, 4.0, 0.0)).length() < 1e-9, "expected exactly (0,4,0), got {upper:?}");
for p in &pts {
assert!(((*p - DVec3::ZERO).length() - 4.0).abs() < 1e-9, "must be exactly radius 4 from c1's centre, got {p:?}");
assert!(((*p - DVec3::new(3.0, 0.0, 0.0)).length() - 5.0).abs() < 1e-9, "must be exactly radius 5 from c2's centre, got {p:?}");
}
}
#[test]
fn exact_curve_intersections_respects_an_arcs_own_sweep() {
let arc = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 4.0,
start_angle: 0.0,
end_angle: std::f64::consts::FRAC_PI_2,
}],
..Default::default()
};
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [3.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
}],
..Default::default()
};
// Full-circle math gives (0,4,0) [on the 0..90° arc] and (0,-4,0)
// [not on it].
let pts = exact_curve_intersections(&arc, &circle).expect("the circles still cross");
assert_eq!(pts.len(), 1, "only the point on the arc's own sweep");
assert!((pts[0] - DVec3::new(0.0, 4.0, 0.0)).length() < 1e-9);
}
#[test]
fn exact_curve_intersections_is_none_for_non_coplanar_circles() {
let flat = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 4.0,
}],
..Default::default()
};
let tilted = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 0.0, 1.0],
radius: 4.0,
}],
..Default::default()
};
assert!(
exact_curve_intersections(&flat, &tilted).is_none(),
"a genuinely-3D pair must fall back to the ordinary sweep, not guess a plane"
);
}
#[test]
fn exact_curve_intersections_resolves_disjoint_circles() {
let near = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 1.0 }],
..Default::default()
};
let far = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle { center: [100.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 1.0 }],
..Default::default()
};
assert!(exact_curve_intersections(&near, &far).unwrap().is_empty());
}
#[test]
fn exact_curve_intersections_handles_two_arcs_in_differently_rotated_frames() {
// Arc A: quarter circle 0..90°, axis_x along world +X.
let arc_a = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 4.0,
start_angle: 0.0,
end_angle: std::f64::consts::FRAC_PI_2,
}],
..Default::default()
};
let arc_b = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [3.0, 0.0, 0.0],
axis_x: [0.0, 1.0, 0.0],
axis_y: [-1.0, 0.0, 0.0],
radius: 5.0,
start_angle: std::f64::consts::FRAC_PI_2,
end_angle: 3.0 * std::f64::consts::FRAC_PI_2,
}],
..Default::default()
};
assert!(exact_curve_intersections(&arc_a, &arc_b).unwrap().is_empty());
// Flip A to cover the lower-right quadrant (270..360°) instead: now
// (0,-4,0) is on both A's and B's own sweep, independently checked
// in each one's own (differently rotated) frame.
let arc_a_lower = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 4.0,
start_angle: 3.0 * std::f64::consts::FRAC_PI_2,
end_angle: std::f64::consts::TAU,
}],
..Default::default()
};
let pts = exact_curve_intersections(&arc_a_lower, &arc_b).expect("both sweeps cover (0,-4,0)");
assert_eq!(pts.len(), 1);
assert!((pts[0] - DVec3::new(0.0, -4.0, 0.0)).length() < 1e-9, "got {:?}", pts[0]);
}
fn plain_line(a: [f64; 3], b: [f64; 3]) -> WireModel {
WireModel { points: vec![[a[0] as f32, a[1] as f32, a[2] as f32], [b[0] as f32, b[1] as f32, b[2] as f32]], points_low: vec![], ..Default::default() }
}
#[test]
fn exact_curve_intersections_handles_a_line_against_a_circle() {
let line = plain_line([-10.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 5.0 }],
..Default::default()
};
let pts = exact_curve_intersections(&line, &circle).expect("a diameter line crosses its circle twice");
assert_eq!(pts.len(), 2);
let mut xs: Vec<f64> = pts.iter().map(|p| p.x).collect();
xs.sort_by(f64::total_cmp);
assert!((xs[0] - -5.0).abs() < 1e-9 && (xs[1] - 5.0).abs() < 1e-9, "expected x = -5 and +5, got {xs:?}");
for p in &pts {
assert!(p.y.abs() < 1e-9);
}
}
#[test]
fn exact_curve_intersections_handles_a_line_against_an_arcs_own_sweep() {
let arc = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 4.0,
start_angle: 0.0,
end_angle: std::f64::consts::FRAC_PI_2,
}],
..Default::default()
};
let line = plain_line([0.0, -10.0, 0.0], [0.0, 10.0, 0.0]);
let pts = exact_curve_intersections(&arc, &line).expect("the line crosses the arc's own sweep");
assert_eq!(pts.len(), 1);
assert!((pts[0] - DVec3::new(0.0, 4.0, 0.0)).length() < 1e-9);
}
#[test]
fn exact_curve_intersections_handles_a_line_against_an_ellipse() {
// x^2/16 + y^2/4 = 1 -- a vertical line at x=0 crosses it exactly at
// y = +-2.
let ellipse = WireModel {
tangent_geoms: vec![TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [4.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::TAU,
}],
..Default::default()
};
let line = plain_line([0.0, -10.0, 0.0], [0.0, 10.0, 0.0]);
let pts = exact_curve_intersections(&ellipse, &line).expect("the line crosses the ellipse");
assert_eq!(pts.len(), 2);
for p in &pts {
assert!(p.x.abs() < 1e-9);
assert!((p.y.abs() - 2.0).abs() < 1e-9, "expected y = +-2, got {p:?}");
}
}
#[test]
fn exact_curve_intersections_handles_a_circle_against_a_full_ellipse() {
// x^2/25 + y^2/9 = 1 against a radius-3 circle at (6,0,0): computed
// independently (not via this module's own math) as crossing at
// exactly x = 3.75, y = +-sqrt(63)/4.
let ellipse = WireModel {
tangent_geoms: vec![TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [5.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.6,
start_param: 0.0,
end_param: std::f64::consts::TAU,
}],
..Default::default()
};
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle { center: [6.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 3.0 }],
..Default::default()
};
let pts = exact_curve_intersections(&ellipse, &circle).expect("the circle crosses the ellipse");
assert_eq!(pts.len(), 2);
let expected_y = (63.0_f64).sqrt() / 4.0;
for p in &pts {
assert!((p.x - 3.75).abs() < 1e-6, "expected x=3.75, got {p:?}");
assert!((p.y.abs() - expected_y).abs() < 1e-6, "expected y=+-{expected_y}, got {p:?}");
}
}
#[test]
fn exact_curve_intersections_ellipse_arc_respects_normal_direction() {
// Full ellipse x^2/25 + y^2/9 = 1, and a radius-3 "ellipse" (ratio
// 1.0, i.e. a circle) at (6,0,0) — same pair as the full-ellipse
// test above, crossing at (3.75, +-1.9843...).
let ellipse = WireModel {
tangent_geoms: vec![TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [5.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.6,
start_param: 0.0,
end_param: std::f64::consts::TAU,
}],
..Default::default()
};
let deg = std::f64::consts::PI / 180.0;
let make_arc = |normal_z: f64| WireModel {
tangent_geoms: vec![TangentGeom::PlanarEllipse {
center: [6.0, 0.0, 0.0],
major_axis: [3.0, 0.0, 0.0],
normal: [0.0, 0.0, normal_z],
minor_axis_ratio: 1.0,
start_param: 100.0 * deg,
end_param: 170.0 * deg,
}],
..Default::default()
};
// normal=+Z: independently computed, param range [100,170]deg holds
// only the UPPER crossing's own angle (~138.59deg under +Z).
let plus = exact_curve_intersections(&ellipse, &make_arc(1.0)).expect("normal=+Z arc crosses the ellipse");
assert_eq!(plus.len(), 1);
assert!(plus[0].y > 0.0, "expected the upper point, got {:?}", plus[0]);
assert!((plus[0] - DVec3::new(3.75, (63.0_f64).sqrt() / 4.0, 0.0)).length() < 1e-6);
// normal=-Z, SAME numeric [100,170]deg range: independently computed
// to hold only the LOWER crossing's own angle under this flipped
// convention (~138.59deg under -Z maps to the lower world point).
let minus = exact_curve_intersections(&ellipse, &make_arc(-1.0)).expect("normal=-Z arc crosses the ellipse");
assert_eq!(minus.len(), 1);
assert!(minus[0].y < 0.0, "expected the lower point (normal flip must change which physical arc this is), got {:?}", minus[0]);
assert!((minus[0] - DVec3::new(3.75, -(63.0_f64).sqrt() / 4.0, 0.0)).length() < 1e-6);
}
#[test]
fn exact_curve_intersections_arc_respects_normal_direction_against_an_ellipse_frame() {
let ellipse = WireModel {
tangent_geoms: vec![TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [5.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.6,
start_param: 0.0,
end_param: std::f64::consts::TAU,
}],
..Default::default()
};
let deg = std::f64::consts::PI / 180.0;
// normal=-Z arc: axis_x/axis_y chosen so axis_x x axis_y = (0,0,-1),
// mirroring the ellipse test's make_arc(-1.0) exactly.
let arc_minus_z = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [6.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, -1.0, 0.0],
radius: 3.0,
start_angle: 100.0 * deg,
end_angle: 170.0 * deg,
}],
..Default::default()
};
let pts = exact_curve_intersections(&ellipse, &arc_minus_z).expect("the arc crosses the ellipse");
assert_eq!(pts.len(), 1);
assert!(pts[0].y < 0.0, "expected the lower point (normal flip must change which physical arc this is), got {:?}", pts[0]);
assert!((pts[0] - DVec3::new(3.75, -(63.0_f64).sqrt() / 4.0, 0.0)).length() < 1e-6);
}
#[test]
fn indexed_intersection_uses_true_curves_even_when_chords_miss_the_aperture() {
use crate::scene::pick::interaction_index::InteractionIndex;
use std::sync::Arc;
let circle = |name: &str, x: f64, radius: f64| {
let points = (0..=48).map(|i| {
let angle = i as f64 * std::f64::consts::TAU / 48.0 + if x == 0.0 { 0.03 } else { 0.0 };
[(x + radius * angle.cos()) as f32, (radius * angle.sin()) as f32, 0.0]
}).collect();
let mut wire = WireModel::solid(name.to_owned(), points, [1.0; 4], false);
wire.aabb = [(x - radius) as f32, -radius as f32, (x + radius) as f32, radius as f32];
wire.tangent_geoms = vec![TangentGeom::PlanarCircle {
center: [x, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius,
}];
wire
};
let wires = Arc::new(vec![circle("1", 0.0, 4.0), circle("2", 3.0, 5.0)]);
let index = InteractionIndex::build(&wires);
let candidates = index.query_xy(Arc::clone(&wires), [-0.0001, 3.9999, 0.0001, 4.0001]);
assert_eq!(candidates.len(), 2);
assert!(candidates.segments().unwrap().iter().all(|segment| segment.wire == 1));
let point = DVec3::new(0.0, 4.0, 0.0);
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Intersection].into_iter().collect();
let result = snapper.snap(
point, Point::new(500.0, 500.0), &candidates,
Mat4::from_scale(Vec3::splat(100.0)), point,
Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 },
Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
).expect("the true intersection remains available at high zoom");
assert!((result.world - point).length() < 1e-9);
}
#[test]
fn line_circle_intersection_retains_low_coordinate_bits() {
let origin = 1_000_000_000.0;
let a = [origin - 10.0, origin, 0.0];
let b = [origin + 10.0, origin, 0.0];
let points = [a, b].map(|point| point.map(|value| value as f32));
let low = [a, b].into_iter().zip(points).map(|(point, high)| {
std::array::from_fn(|axis| (point[axis] - high[axis] as f64) as f32)
}).collect();
let line = WireModel {
points: points.to_vec(), points_low: low,
tangent_geoms: vec![TangentGeom::Line { p1: points[0], p2: points[1] }],
..Default::default()
};
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [origin, origin, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 3.0,
}], ..Default::default()
};
let points = exact_curve_intersections(&line, &circle).unwrap();
assert_eq!(points.len(), 2);
assert!(points.iter().all(|point| (point.x - origin).abs() == 3.0 && point.y == origin));
}
#[test]
fn test_unindexed_snap_endpoint_and_midpoint_accuracy() {
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Endpoint, SnapType::Midpoint].into_iter().collect();
let line = WireModel {
points: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]],
key_vertices: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]],
aabb: [0.0, 0.0, 100.0, 100.0],
tangent_geoms: vec![TangentGeom::Line { p1: [0.0, 0.0, 0.0], p2: [100.0, 100.0, 0.0] }],
..Default::default()
};
let wires = vec![line];
let view_rot = Mat4::IDENTITY;
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
// Test Endpoint snap near (0, 0) with eye at (0, 0, 500)
let eye_origin = DVec3::new(0.0, 0.0, 500.0);
let cursor_world = DVec3::new(0.01, 0.01, 0.0);
let cursor_screen = world_to_screen(cursor_world, view_rot, eye_origin, bounds);
let res = snapper.snap(
cursor_world, cursor_screen, wires.as_slice(),
view_rot, eye_origin, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
).expect("should snap to endpoint");
assert_eq!(res.snap_type, SnapType::Endpoint);
assert_eq!(res.world, DVec3::new(0.0, 0.0, 0.0));
// Test Midpoint snap near (50, 50) with eye at (50, 50, 500)
let eye_mid = DVec3::new(50.0, 50.0, 500.0);
let cursor_world = DVec3::new(50.01, 50.01, 0.0);
let cursor_screen = world_to_screen(cursor_world, view_rot, eye_mid, bounds);
let res = snapper.snap(
cursor_world, cursor_screen, wires.as_slice(),
view_rot, eye_mid, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
).expect("should snap to midpoint");
assert_eq!(res.snap_type, SnapType::Midpoint);
assert_eq!(res.world, DVec3::new(50.0, 50.0, 0.0));
}
#[test]
fn test_unindexed_snap_empty_space_early_out() {
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enable_all();
let line = WireModel {
points: vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0]],
key_vertices: vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0]],
aabb: [0.0, 0.0, 10.0, 10.0],
..Default::default()
};
let wires = vec![line];
let view_rot = Mat4::IDENTITY;
let eye = DVec3::new(500.0, 500.0, 500.0);
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
// Cursor in far whitespace (5000, 5000)
let cursor_world = DVec3::new(5000.0, 5000.0, 0.0);
let cursor_screen = Point::new(500.0, 500.0);
let res = snapper.snap(
cursor_world, cursor_screen, wires.as_slice(),
view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
);
assert!(res.is_none(), "whitespace cursor must return None");
}
#[test]
fn test_unindexed_snap_intersection_pair() {
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Intersection].into_iter().collect();
let line1 = WireModel {
points: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]],
key_vertices: vec![[0.0, 0.0, 0.0], [100.0, 100.0, 0.0]],
aabb: [0.0, 0.0, 100.0, 100.0],
..Default::default()
};
let line2 = WireModel {
points: vec![[0.0, 100.0, 0.0], [100.0, 0.0, 0.0]],
key_vertices: vec![[0.0, 100.0, 0.0], [100.0, 0.0, 0.0]],
aabb: [0.0, 0.0, 100.0, 100.0],
..Default::default()
};
let wires = vec![line1, line2];
let view_rot = Mat4::IDENTITY;
let eye = DVec3::new(50.0, 50.0, 500.0);
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
// Cursor near intersection (50, 50)
let cursor_world = DVec3::new(50.01, 50.01, 0.0);
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
let res = snapper.snap(
cursor_world, cursor_screen, wires.as_slice(),
view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
).expect("should snap to intersection");
assert_eq!(res.snap_type, SnapType::Intersection);
assert!((res.world - DVec3::new(50.0, 50.0, 0.0)).length() < 1e-6);
}
#[test]
fn test_unindexed_snap_dense_cluster_fallback() {
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Endpoint].into_iter().collect();
// Generate 25 lines passing through (0, 0) to force > 16 wires in aperture
let mut wires = Vec::new();
for i in 0..25 {
let angle = (i as f64) * 0.1;
let p1 = [0.0f32, 0.0, 0.0];
let p2 = [(angle.cos() * 50.0) as f32, (angle.sin() * 50.0) as f32, 0.0];
let p1_f64 = [0.0f64, 0.0, 0.0];
let p2_f64 = [angle.cos() * 50.0, angle.sin() * 50.0, 0.0];
wires.push(WireModel {
points: vec![p1, p2],
key_vertices: vec![p1_f64, p2_f64],
aabb: [p1[0].min(p2[0]), p1[1].min(p2[1]), p1[0].max(p2[0]), p1[1].max(p2[1])],
..Default::default()
});
}
let view_rot = Mat4::IDENTITY;
let eye = DVec3::new(0.0, 0.0, 500.0);
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
let cursor_world = DVec3::new(0.01, 0.01, 0.0);
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
let res = snapper.snap(
cursor_world, cursor_screen, wires.as_slice(),
view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
).expect("should snap to endpoint even with > 16 wires in aperture");
assert_eq!(res.snap_type, SnapType::Endpoint);
assert_eq!(res.world, DVec3::ZERO);
}
#[test]
fn grid_snap_locks_beyond_aperture_with_independent_xy() {
let mut s = Snapper::default();
s.grid_snap_on = true;
s.snap_enabled = false; // grid only
s.snap_spacing_x = 1.0;
s.snap_spacing_y = 0.5;
s.osnap_radius_px = 15.0;
let view_rot = Mat4::IDENTITY;
let eye = DVec3::new(0.0, 0.0, 500.0);
let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 };
let wires: Vec<WireModel> = Vec::new();
// 500 px per world unit: the cursor sits ~160 px from the nearest
// grid corner — far outside the 15 px aperture — yet SNAP must still
// lock like AutoCAD instead of magnetizing only when near a point.
let cursor_world = DVec3::new(0.32, 0.47, 500.0);
let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds);
let res = s.snap(
cursor_world, cursor_screen, &wires,
view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
).expect("grid snap must lock even outside the aperture");
assert_eq!(res.snap_type, SnapType::Grid);
assert!((res.world.x - 0.0).abs() < 1e-9, "x rounds to 0: {:?}", res.world);
assert!((res.world.y - 0.5).abs() < 1e-9, "y rounds to 0.5: {:?}", res.world);
}
#[test]
fn exact_curve_intersections_handles_multisegment_polyline_against_circle_at_vertex() {
let polyline = WireModel {
key_vertices: vec![[-10.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 10.0, 0.0]],
tangent_geoms: vec![
TangentGeom::Line { p1: [-10.0, 0.0, 0.0], p2: [0.0, 0.0, 0.0] },
TangentGeom::Line { p1: [0.0, 0.0, 0.0], p2: [0.0, 10.0, 0.0] },
],
..Default::default()
};
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [0.0, 5.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
}],
..Default::default()
};
let pts = exact_curve_intersections(&polyline, &circle)
.expect("polyline must analytically intersect circle");
assert_eq!(pts.len(), 2, "expected intersections at (0,0) and (0,10), got {pts:?}");
let mut ys: Vec<f64> = pts.iter().map(|p| p.y).collect();
ys.sort_by(f64::total_cmp);
assert!((ys[0] - 0.0).abs() < 1e-9, "expected corner intersection at y=0, got {}", ys[0]);
assert!((ys[1] - 10.0).abs() < 1e-9, "expected endpoint intersection at y=10, got {}", ys[1]);
for p in &pts {
assert!(p.x.abs() < 1e-9, "expected x=0, got {}", p.x);
assert!(p.z.abs() < 1e-9, "expected z=0, got {}", p.z);
}
}
#[test]
fn exact_curve_intersections_handles_polyline_with_bulge_arc_against_circle() {
let polyline = WireModel {
tangent_geoms: vec![
TangentGeom::Line { p1: [-10.0, 0.0, 0.0], p2: [0.0, 0.0, 0.0] },
TangentGeom::Arc {
center: [0.0, 4.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 4.0,
start_angle: -std::f64::consts::FRAC_PI_2,
end_angle: std::f64::consts::FRAC_PI_2,
},
],
..Default::default()
};
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 5.0,
}],
..Default::default()
};
let pts = exact_curve_intersections(&polyline, &circle)
.expect("polyline with arc must intersect circle");
assert!(!pts.is_empty());
let line_pt = pts.iter().find(|p| (p.x - -5.0).abs() < 1e-9 && p.y.abs() < 1e-9);
assert!(line_pt.is_some(), "expected line crossing at (-5, 0, 0), got {pts:?}");
}
fn xy_circle(cx: f64, cy: f64, radius: f64) -> WireModel {
WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [cx, cy, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius,
}],
..Default::default()
}
}
fn line_geom(a: [f64; 3], b: [f64; 3]) -> TangentGeom {
TangentGeom::Line {
p1: [a[0] as f32, a[1] as f32, a[2] as f32],
p2: [b[0] as f32, b[1] as f32, b[2] as f32],
}
}
/// A center mark (`entities/line.rs`) stores two `key_vertices` per
/// segment plus the centre, so index `i` of `tangent_geoms` does not name
/// vertex `i`; the endpoints must be found by value.
#[test]
fn exact_curve_intersections_handles_center_mark_vertex_layout() {
let segments: [([f64; 3], [f64; 3]); 4] = [
([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]),
([0.0, -1.0, 0.0], [0.0, 1.0, 0.0]),
([2.0, 0.0, 0.0], [7.0, 0.0, 0.0]),
([0.0, 2.0, 0.0], [0.0, 7.0, 0.0]),
];
let mut mark = WireModel::default();
for (a, b) in segments {
mark.key_vertices.push(a);
mark.key_vertices.push(b);
mark.tangent_geoms.push(line_geom(a, b));
}
mark.key_vertices.push([0.0, 0.0, 0.0]);
let mut pts = exact_curve_intersections(&mark, &xy_circle(0.0, 0.0, 5.0))
.expect("the extension lines cross the circle");
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
assert_eq!(pts.len(), 2, "only the two extension lines reach r=5: {pts:?}");
assert!((pts[0] - DVec3::new(0.0, 5.0, 0.0)).length() < 1e-9, "{pts:?}");
assert!((pts[1] - DVec3::new(5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}");
}
/// A block entry (`cache/block_cache.rs`) appends each entity's
/// `key_vertices` and `tangent_geoms` in turn; the seam between two
/// entities must not be read as a segment.
#[test]
fn exact_curve_intersections_handles_block_entry_vertex_layout() {
let (a0, a1) = ([-10.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
let (b0, b1) = ([20.0, -10.0, 0.0], [20.0, 10.0, 0.0]);
let block = WireModel {
key_vertices: vec![a0, a1, b0, b1],
tangent_geoms: vec![
line_geom(a0, a1),
line_geom(b0, b1),
TangentGeom::PlanarCircle {
center: [30.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 1.0,
},
],
..Default::default()
};
let mut pts = exact_curve_intersections(&block, &xy_circle(0.0, 0.0, 5.0))
.expect("the first line crosses the circle");
pts.sort_by(|a, b| a.x.total_cmp(&b.x));
assert_eq!(pts.len(), 2, "the seam a1->b0 is not a segment: {pts:?}");
assert!((pts[0] - DVec3::new(-5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}");
assert!((pts[1] - DVec3::new(5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}");
}
/// The closing segment of a closed polyline runs from the last vertex
/// back to the first; it must still resolve to f64 vertices rather than
/// the f32 geom endpoints, which drift by centimetres at UTM scale.
#[test]
fn exact_curve_intersections_closing_segment_keeps_f64_vertices() {
let (ox, oy) = (500_000.123456, 4_000_000.654321);
let v = [
[ox + 10.0, oy, 0.0],
[ox + 10.0, oy + 10.0, 0.0],
[ox, oy + 10.0, 0.0],
[ox, oy, 0.0],
];
let square = WireModel {
key_vertices: v.to_vec(),
tangent_geoms: (0..4).map(|i| line_geom(v[i], v[(i + 1) % 4])).collect(),
..Default::default()
};
// Centred on the closing edge x = ox, so both crossings sit on it.
let pts = exact_curve_intersections(&square, &xy_circle(ox, oy + 5.0, 1.0))
.expect("the circle crosses the closing edge");
assert_eq!(pts.len(), 2, "{pts:?}");
for p in &pts {
assert!((p.x - ox).abs() < 1e-9, "closing edge fell back to f32 endpoints: {p:?}");
}
}
/// The by-value vertex lookup must stay linear over the wire: each geom's
/// pair is expected at the cursor left by the previous match.
#[test]
fn tangent_line_endpoints_cursor_walks_the_wire_once() {
let n = 64;
let verts: Vec<[f64; 3]> = (0..=n).map(|i| [i as f64, (i % 3) as f64, 0.0]).collect();
let wire = WireModel {
key_vertices: verts.clone(),
tangent_geoms: (0..n).map(|i| line_geom(verts[i], verts[i + 1])).collect(),
..Default::default()
};
let mut cursor = 0usize;
for (i, geom) in wire.tangent_geoms.iter().enumerate() {
let TangentGeom::Line { p1, p2 } = geom else { unreachable!() };
let (a, b) = tangent_line_endpoints(&wire, *p1, *p2, &mut cursor);
assert_eq!(a, DVec3::from_array(verts[i]));
assert_eq!(b, DVec3::from_array(verts[i + 1]));
assert_eq!(cursor, i + 1, "cursor must land on the segment's end vertex");
}
}
}