forked from 1b-agent/opencadstudio-upstream
5825 lines
231 KiB
Rust
5825 lines
231 KiB
Rust
//! 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 codec::types::Handle;
|
||
use kernel::geom2d::Curve;
|
||
|
||
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 = 25.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,
|
||
/// Area centroid of a closed polyline (#1369).
|
||
GeometricCenter,
|
||
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::GeometricCenter, "⊙", "Geometric 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"),
|
||
];
|
||
|
||
/// Transparent object snap override kind (single-pick override).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SnapOverrideKind {
|
||
Mode(SnapType),
|
||
None,
|
||
}
|
||
|
||
/// Parses a transparent object snap override keyword (e.g. `MID`, `END`, `CEN`, `NON`).
|
||
///
|
||
/// Requires at least 3 letters matching a supported snap mode or alias.
|
||
/// Leading underscores (e.g. `_MID`) and case variations are permitted.
|
||
pub fn parse_snap_override(input: &str) -> Option<SnapOverrideKind> {
|
||
let s = input.trim().trim_start_matches('_').to_ascii_lowercase();
|
||
if s.len() < 3 {
|
||
return None;
|
||
}
|
||
if s.starts_with("end") && "endpoint".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Endpoint));
|
||
}
|
||
if s.starts_with("mid") && "midpoint".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Midpoint));
|
||
}
|
||
if s.starts_with("cen") && ("center".starts_with(&s) || "centre".starts_with(&s)) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Center));
|
||
}
|
||
if (s.starts_with("gce") && "gcen".starts_with(&s))
|
||
|| (s.starts_with("geo")
|
||
&& ("geocenter".starts_with(&s) || "geometriccenter".starts_with(&s)))
|
||
{
|
||
return Some(SnapOverrideKind::Mode(SnapType::GeometricCenter));
|
||
}
|
||
if s.starts_with("nod") && "node".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Node));
|
||
}
|
||
if s.starts_with("qua") && "quadrant".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Quadrant));
|
||
}
|
||
if s.starts_with("int") && "intersection".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Intersection));
|
||
}
|
||
if s.starts_with("ext") && "extension".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Extension));
|
||
}
|
||
if s.starts_with("ins") && ("insertion".starts_with(&s) || "insert".starts_with(&s)) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Insertion));
|
||
}
|
||
if s.starts_with("per") && "perpendicular".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Perpendicular));
|
||
}
|
||
if s.starts_with("tan") && "tangent".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Tangent));
|
||
}
|
||
if s.starts_with("nea") && "nearest".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Nearest));
|
||
}
|
||
if s.starts_with("app")
|
||
&& ("apparent".starts_with(&s)
|
||
|| "apparentintersection".starts_with(&s)
|
||
|| "appint".starts_with(&s))
|
||
{
|
||
return Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection));
|
||
}
|
||
if s.starts_with("par") && "parallel".starts_with(&s) {
|
||
return Some(SnapOverrideKind::Mode(SnapType::Parallel));
|
||
}
|
||
if s.starts_with("non") && "none".starts_with(&s) {
|
||
return Some(SnapOverrideKind::None);
|
||
}
|
||
None
|
||
}
|
||
|
||
// ── 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>>,
|
||
/// Perpendicular direction carried from a point placed by typed distance
|
||
/// along an Extension/OTRACK ray. It survives only until the next point
|
||
/// commit, so the source ray can still drive a perpendicular construction
|
||
/// through the newly placed point.
|
||
post_distance_perp_dir: Option<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<glam::DVec3>,
|
||
/// 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<(DVec3, DVec3)>,
|
||
/// 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<(DVec3, DVec3, 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(),
|
||
post_distance_perp_dir: None,
|
||
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> {
|
||
// A point created by typed distance along Extension/OTRACK carries
|
||
// that source direction for exactly one following point. Perpendicular
|
||
// OSNAP may use it even when F11 tracking itself is off.
|
||
let carried_perp = if self.is_on(SnapType::Perpendicular) {
|
||
self.post_distance_perp_dir.zip(last_point)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if (!self.otrack_enabled || self.tracking_points.is_empty()) && carried_perp.is_none() {
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
// Typed distance along an Extension/OTRACK ray creates a new point
|
||
// that still knows the direction it came from. Offer exactly one
|
||
// perpendicular ray through that new point for the following pick.
|
||
if let Some((dir, origin)) = carried_perp {
|
||
rays.push(Ray {
|
||
origin,
|
||
dir,
|
||
group: self.tracking_points.len(),
|
||
kind: TrackingKind::Perpendicular,
|
||
});
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
/// Carry the direction of a reference ray used by typed-distance entry
|
||
/// into the next point, as its perpendicular construction direction.
|
||
pub fn remember_distance_reference(&mut self, dir: DVec3) {
|
||
let planar = DVec3::new(dir.x, dir.y, 0.0);
|
||
self.post_distance_perp_dir = planar
|
||
.try_normalize()
|
||
.map(|unit| DVec3::new(-unit.y, unit.x, 0.0));
|
||
}
|
||
|
||
/// 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.post_distance_perp_dir = None;
|
||
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: DVec3,
|
||
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: DVec3, b: DVec3| (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: DVec3,
|
||
base: Option<DVec3>,
|
||
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 as f64 * 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, view_rot, eye, bounds);
|
||
let sc = world_to_screen(cursor_world, 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,
|
||
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(),
|
||
post_distance_perp_dir: None,
|
||
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();
|
||
// POINT wires offer only their Node; their glyph strokes stay out of
|
||
// every geometric snap below.
|
||
let mut in_range_nodes: Vec<&WireModel> = Vec::new();
|
||
let unindexed = wires.segments().is_none();
|
||
if unindexed {
|
||
for w in wires.iter() {
|
||
if w.is_node_marker() {
|
||
if wire_in_range(w) {
|
||
in_range_nodes.push(w);
|
||
}
|
||
} else if !w.is_display_only() && 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()
|
||
&& in_range_nodes.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 | SnapHint::ArcCenter => {
|
||
(SnapType::Center, self.is_on(SnapType::Center))
|
||
}
|
||
SnapHint::GeometricCenter => (
|
||
SnapType::GeometricCenter,
|
||
self.is_on(SnapType::GeometricCenter),
|
||
),
|
||
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().chain(in_range_nodes.iter().copied()) {
|
||
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() && !wire.is_node_marker())
|
||
{
|
||
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 if !wire.is_node_marker() {
|
||
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 {
|
||
if let Some(segments) = &local_segments {
|
||
// A curved wire's feet come from its exact geometry once,
|
||
// not from each chord of the tessellation. (#1495)
|
||
let mut exact: HashMap<u32, bool> = HashMap::default();
|
||
for seg in segments {
|
||
let wire = wires.source_wire(seg.wire);
|
||
let handled = *exact.entry(seg.wire).or_insert_with(|| {
|
||
let Some(feet) = wire.and_then(|w| exact_perpendicular_feet(w, q))
|
||
else {
|
||
return false;
|
||
};
|
||
for foot in feet {
|
||
try_pt(
|
||
foot,
|
||
SnapType::Perpendicular,
|
||
wire.and_then(wire_source),
|
||
None,
|
||
);
|
||
}
|
||
true
|
||
});
|
||
if handled {
|
||
continue;
|
||
}
|
||
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);
|
||
if let Some(feet) = exact_perpendicular_feet(wire, q) {
|
||
for foot in feet {
|
||
try_pt(foot, SnapType::Perpendicular, src, None);
|
||
}
|
||
continue;
|
||
}
|
||
for i in 0..wire.points.len().saturating_sub(1) {
|
||
if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) {
|
||
try_pt(foot, SnapType::Perpendicular, 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)
|
||
{
|
||
// Unindexed fallback: also test non-adjacent segments belonging to the
|
||
// same wire. A polyline is one entity but its own segments may cross.
|
||
if local_segments.is_none() {
|
||
for wire in in_range_wires.iter() {
|
||
let src = wire_source(wire);
|
||
let segment_count = wire.points.len().saturating_sub(1);
|
||
|
||
for ai in 0..segment_count {
|
||
let a0 = wp_f64(wire, ai);
|
||
let a1 = wp_f64(wire, 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 (ai + 1)..segment_count {
|
||
let b0 = wp_f64(wire, bi);
|
||
let b1 = wp_f64(wire, bi + 1);
|
||
|
||
// Consecutive polyline segments meet at a normal vertex.
|
||
// That is already an Endpoint snap, not a self-intersection.
|
||
if segments_share_endpoint(a0, a1, b0, b1) {
|
||
continue;
|
||
}
|
||
|
||
// Cheap AABB rejection before the exact 3-D intersection.
|
||
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, src, None);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
let same_wire_neighbours =
|
||
a.wire == b.wire && segments_share_endpoint(a.a, a.b, b.a, b.b);
|
||
|
||
if same_wire_neighbours
|
||
|| 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;
|
||
let primary = wires.source_wire(a.wire).and_then(wire_source);
|
||
|
||
let secondary = if a.wire == b.wire {
|
||
None
|
||
} else {
|
||
wires.source_wire(b.wire).and_then(wire_source)
|
||
};
|
||
|
||
try_pt(pt, SnapType::Intersection, primary, secondary);
|
||
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| {
|
||
let mut line_cursor = 0usize;
|
||
for tg in &wire.tangent_geoms {
|
||
// A tangent point computed from the rubber-band base.
|
||
let mut exact = false;
|
||
let (world_pt, mut d2) = match tg {
|
||
TangentGeom::Line { p1, p2 } => {
|
||
let (lp1, lp2) =
|
||
tangent_line_endpoints(wire, *p1, *p2, &mut line_cursor);
|
||
let sp0 = world_to_screen(lp1, view_rot, eye, bounds);
|
||
let sp1 = world_to_screen(lp2, view_rot, eye, bounds);
|
||
let d2 = dist2_to_segment(cursor_screen, sp0, sp1);
|
||
let t = t_on_segment(cursor_screen, sp0, sp1) as f64;
|
||
let w = lp1 + t * (lp2 - lp1);
|
||
(w, 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 {
|
||
if let Some((t0, t1)) = circle_tangent_points(from.as_vec3(), cv, r)
|
||
{
|
||
let s0 = world_to_screen(t0.as_dvec3(), view_rot, eye, bounds);
|
||
let s1 = world_to_screen(t1.as_dvec3(), view_rot, eye, bounds);
|
||
exact = true;
|
||
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)
|
||
}
|
||
} 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 candidate = self.from_point.and_then(|from| {
|
||
arc_tangent_points(
|
||
from,
|
||
*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))
|
||
})
|
||
});
|
||
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_else(|| DVec3::from_array(*center))
|
||
};
|
||
exact = candidate.is_some();
|
||
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::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, *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))
|
||
})
|
||
});
|
||
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)
|
||
};
|
||
exact = candidate.is_some();
|
||
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 {
|
||
center,
|
||
major_axis,
|
||
normal,
|
||
minor_axis_ratio,
|
||
start_param,
|
||
end_param,
|
||
} => {
|
||
let c = DVec3::from_array(*center);
|
||
let u_vec = DVec3::from_array(*major_axis);
|
||
let a = u_vec.length();
|
||
if a > 1e-9 {
|
||
let u_hat = u_vec / a;
|
||
let n_hat = DVec3::from_array(*normal).normalize_or_zero();
|
||
let v_hat = n_hat.cross(u_hat).normalize_or_zero();
|
||
let b = (a * minor_axis_ratio).max(1e-9);
|
||
|
||
let candidate = self.from_point.and_then(|from| {
|
||
planar_ellipse_tangent_points(
|
||
from,
|
||
c,
|
||
u_vec,
|
||
n_hat,
|
||
*minor_axis_ratio,
|
||
*start_param,
|
||
*end_param,
|
||
)
|
||
.into_iter()
|
||
.min_by(|pt_a, pt_b| {
|
||
let sa = world_to_screen(*pt_a, view_rot, eye, bounds);
|
||
let sb = world_to_screen(*pt_b, view_rot, eye, bounds);
|
||
dist2(sa, cursor_screen)
|
||
.total_cmp(&dist2(sb, cursor_screen))
|
||
})
|
||
});
|
||
|
||
let d_vec = cursor_world - c;
|
||
let u_proj = d_vec.dot(u_hat);
|
||
let v_proj = d_vec.dot(v_hat);
|
||
let t = (v_proj / b).atan2(u_proj / a);
|
||
|
||
let fallback =
|
||
if is_ellipse_param_on_arc(t, *start_param, *end_param) {
|
||
c + u_hat * (a * t.cos()) + v_hat * (b * t.sin())
|
||
} else {
|
||
let p_start = c
|
||
+ u_hat * (a * start_param.cos())
|
||
+ v_hat * (b * start_param.sin());
|
||
let p_end = c
|
||
+ u_hat * (a * end_param.cos())
|
||
+ v_hat * (b * end_param.sin());
|
||
let sa = world_to_screen(p_start, view_rot, eye, bounds);
|
||
let sb = world_to_screen(p_end, view_rot, eye, bounds);
|
||
if dist2(sa, cursor_screen) <= dist2(sb, cursor_screen) {
|
||
p_start
|
||
} else {
|
||
p_end
|
||
}
|
||
};
|
||
|
||
exact = candidate.is_some();
|
||
let world = candidate.unwrap_or(fallback);
|
||
let edge_d2 = if wire.points.len() >= 2 {
|
||
wire.points
|
||
.windows(2)
|
||
.enumerate()
|
||
.filter_map(|(index, _)| {
|
||
let p0 = wp_f64(wire, index);
|
||
let p1 = wp_f64(wire, index + 1);
|
||
(p0.is_finite() && p1.is_finite()).then(|| {
|
||
dist2_to_segment(
|
||
cursor_screen,
|
||
world_to_screen(p0, view_rot, eye, bounds),
|
||
world_to_screen(p1, view_rot, eye, bounds),
|
||
)
|
||
})
|
||
})
|
||
.fold(f32::INFINITY, f32::min)
|
||
} else {
|
||
let screen_pt = world_to_screen(world, view_rot, eye, bounds);
|
||
dist2(screen_pt, cursor_screen)
|
||
};
|
||
(world, edge_d2)
|
||
} else {
|
||
(c, f32::INFINITY)
|
||
}
|
||
}
|
||
};
|
||
let screen_pt = world_to_screen(world_pt, view_rot, eye, bounds);
|
||
// Near the computed tangent point it is a point snap like
|
||
// the others, so the rim-hover Center no longer masks it.
|
||
let point_d2 = dist2(screen_pt, cursor_screen);
|
||
let tier = if exact && point_d2 < radius2 {
|
||
d2 = point_d2;
|
||
0
|
||
} else {
|
||
snap_tier(SnapType::Tangent)
|
||
};
|
||
let sub = snap_priority(SnapType::Tangent);
|
||
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 } => {
|
||
let mut cursor = 0usize;
|
||
let (lp1, lp2) =
|
||
tangent_line_endpoints(wire, *p1, *p2, &mut cursor);
|
||
TangentObject::Line { p1: lp1, p2: lp2 }
|
||
}
|
||
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 {
|
||
center,
|
||
major_axis,
|
||
normal,
|
||
minor_axis_ratio,
|
||
..
|
||
} => TangentObject::Ellipse {
|
||
center: glam::DVec3::from_array(*center),
|
||
major_axis: glam::DVec3::from_array(*major_axis),
|
||
normal: glam::DVec3::from_array(*normal),
|
||
minor_axis_ratio: *minor_axis_ratio,
|
||
},
|
||
};
|
||
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) {
|
||
// A polyline drawn piece by piece keeps its arc centres on one of
|
||
// its wires: the one with the same name that carries them.
|
||
let carries = |w: &WireModel| {
|
||
w.snap_pts
|
||
.iter()
|
||
.any(|(_, hint)| matches!(hint, SnapHint::ArcCenter))
|
||
};
|
||
let arc_centre = |wire: &WireModel, on: (DVec3, DVec3)| -> Option<DVec3> {
|
||
if carries(wire) {
|
||
return arc_segment_center(wire, on);
|
||
}
|
||
let carrier = wires
|
||
.iter()
|
||
.find(|w| !wire.name.is_empty() && w.name == wire.name && carries(w))?;
|
||
arc_segment_center(carrier, on)
|
||
};
|
||
let has_centre = |wire: &WireModel| {
|
||
wire.snap_pts
|
||
.iter()
|
||
.any(|(_, hint)| matches!(hint, SnapHint::Center))
|
||
|| carries(wire)
|
||
|| (!wire.name.is_empty()
|
||
&& wires.iter().any(|w| w.name == wire.name && carries(w)))
|
||
};
|
||
// `on`: the piece of the wire under the cursor, which picks out
|
||
// the arc segment of a polyline whose centre is offered.
|
||
let mut offer = |wire: &WireModel, curve_d2: f32, on: Option<(DVec3, DVec3)>| {
|
||
let Some(center) = wire
|
||
.snap_pts
|
||
.iter()
|
||
.find(|(_, hint)| matches!(hint, SnapHint::Center))
|
||
.map(|&(c, _)| c)
|
||
.or_else(|| arc_centre(wire, on?))
|
||
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, (DVec3, DVec3))> = HashMap::default();
|
||
for segment in segments {
|
||
let Some(wire) = wires.source_wire(segment.wire) else {
|
||
continue;
|
||
};
|
||
if !has_centre(wire) {
|
||
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,
|
||
);
|
||
let piece = (segment.a, segment.b);
|
||
distances
|
||
.entry(segment.wire)
|
||
.and_modify(|best| {
|
||
if distance < best.0 {
|
||
*best = (distance, piece);
|
||
}
|
||
})
|
||
.or_insert((distance, piece));
|
||
}
|
||
for (wire_idx, (distance, piece)) in distances {
|
||
if let Some(wire) = wires.source_wire(wire_idx) {
|
||
offer(wire, distance, Some(piece));
|
||
}
|
||
}
|
||
} else {
|
||
for wire in in_range_wires.iter() {
|
||
let mut curve_d2 = f32::INFINITY;
|
||
let mut piece = None;
|
||
for index in 0..wire.points.len().saturating_sub(1) {
|
||
let (a, b) = (wp_f64(wire, index), wp_f64(wire, index + 1));
|
||
let nearest = nearest_on_segment(cursor_world, a, b);
|
||
let distance = dist2(
|
||
world_to_screen(nearest, view_rot, eye, bounds),
|
||
cursor_screen,
|
||
);
|
||
if distance < curve_d2 {
|
||
curve_d2 = distance;
|
||
piece = Some((a, b));
|
||
}
|
||
}
|
||
offer(wire, curve_d2, piece);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
/// The centre of the polyline arc segment a drawn piece `(a, b)` of `wire`
|
||
/// belongs to: the one its two ends are equally far from. A piece joining two
|
||
/// of the chain's own vertices is a straight segment, which has none.
|
||
fn arc_segment_center(wire: &WireModel, (a, b): (DVec3, DVec3)) -> Option<DVec3> {
|
||
let vertex = |p: DVec3| {
|
||
wire.key_vertices.iter().any(|v| {
|
||
DVec3::from_array(*v).distance_squared(p) <= 1e-18 * p.length_squared().max(1.0)
|
||
})
|
||
};
|
||
if vertex(a) && vertex(b) {
|
||
return None;
|
||
}
|
||
wire.snap_pts
|
||
.iter()
|
||
.filter(|(_, hint)| matches!(hint, SnapHint::ArcCenter))
|
||
.map(|&(c, _)| (c, (c.distance(a) - c.distance(b)).abs()))
|
||
// ponytail: a thousandth of the radius absorbs the drawn points' f32
|
||
// rounding far from the origin; two arcs that close would both fit.
|
||
.filter(|(c, gap)| *gap <= 1e-3 * c.distance(a).max(1e-9))
|
||
.min_by(|x, y| x.1.total_cmp(&y.1))
|
||
.map(|(c, _)| c)
|
||
}
|
||
|
||
// ── 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::GeometricCenter
|
||
| 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 | SnapType::GeometricCenter => 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
|
||
};
|
||
// A chain (polyline) keeps its straight segments even when an arc
|
||
// segment gives it quadrants.
|
||
let is_round = |wire: &WireModel| {
|
||
wire.key_vertices.is_empty()
|
||
&& 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)
|
||
}
|
||
|
||
/// Perpendicular feet from `from` onto a wire's exact curves, or `None` when
|
||
/// the wire has no curved geometry and its segments are already exact.
|
||
fn exact_perpendicular_feet(wire: &WireModel, from: DVec3) -> Option<Vec<DVec3>> {
|
||
let frame = wire_plane(wire)?;
|
||
let curves = curves_in_frame(wire, &frame, 1e-7)?;
|
||
let from = frame.to_2d(from);
|
||
Some(
|
||
curves
|
||
.iter()
|
||
.flat_map(|curve| kernel::geom2d::perpendicular_from(curve, from))
|
||
.map(|foot| frame.to_3d(foot.point))
|
||
.collect(),
|
||
)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// Two segments of the same polyline that merely meet at a shared endpoint
|
||
/// are neighbours, not a self-intersection.
|
||
///
|
||
/// Non-adjacent segments are allowed to intersect even when they belong to
|
||
/// the same WireModel.
|
||
fn segments_share_endpoint(a0: DVec3, a1: DVec3, b0: DVec3, b1: DVec3) -> bool {
|
||
const EPS2: f64 = 1.0e-18;
|
||
|
||
a0.distance_squared(b0) <= EPS2
|
||
|| a0.distance_squared(b1) <= EPS2
|
||
|| a1.distance_squared(b0) <= EPS2
|
||
|| a1.distance_squared(b1) <= EPS2
|
||
}
|
||
/// 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()
|
||
}
|
||
|
||
/// Whether ellipse parameter `t` lies on the arc running counter-clockwise
|
||
/// from `start_param` to `end_param`. A zero or full-turn span is the whole
|
||
/// ellipse.
|
||
fn is_ellipse_param_on_arc(t: f64, start_param: f64, end_param: f64) -> bool {
|
||
use std::f64::consts::TAU;
|
||
let span = end_param - start_param;
|
||
if span.abs() < 1e-9 || span.abs() >= TAU - 1e-9 {
|
||
return true;
|
||
}
|
||
let sweep = span.rem_euclid(TAU);
|
||
let along = (t - start_param).rem_euclid(TAU);
|
||
along <= sweep + 1e-6 || along >= TAU - 1e-6
|
||
}
|
||
|
||
fn planar_ellipse_tangent_points(
|
||
from: DVec3,
|
||
center: DVec3,
|
||
major_axis: DVec3,
|
||
normal: DVec3,
|
||
minor_axis_ratio: f64,
|
||
start_param: f64,
|
||
end_param: f64,
|
||
) -> Vec<DVec3> {
|
||
let a = major_axis.length();
|
||
let b = a * minor_axis_ratio;
|
||
if a < 1e-9 || b < 1e-9 {
|
||
return Vec::new();
|
||
}
|
||
let u_dir = major_axis / a;
|
||
let n_hat = normal.normalize_or_zero();
|
||
let v_dir = n_hat.cross(u_dir).normalize_or_zero();
|
||
|
||
let d = from - center;
|
||
let u0 = d.dot(u_dir);
|
||
let v0 = d.dot(v_dir);
|
||
let u_norm = u0 / a;
|
||
let v_norm = v0 / b;
|
||
let dist = (u_norm * u_norm + v_norm * v_norm).sqrt();
|
||
if dist <= 1.0 + 1e-9 {
|
||
return Vec::new();
|
||
}
|
||
|
||
let theta0 = v_norm.atan2(u_norm);
|
||
let delta = (1.0 / dist).acos();
|
||
let t0 = theta0 + delta;
|
||
let t1 = theta0 - delta;
|
||
|
||
let mut points = Vec::new();
|
||
if is_ellipse_param_on_arc(t0, start_param, end_param) {
|
||
points.push(center + u_dir * (a * t0.cos()) + v_dir * (b * t0.sin()));
|
||
}
|
||
if is_ellipse_param_on_arc(t1, start_param, end_param) {
|
||
points.push(center + u_dir * (a * t1.cos()) + v_dir * (b * t1.sin()));
|
||
}
|
||
points
|
||
}
|
||
|
||
/// 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: DVec3,
|
||
wires: &W,
|
||
view_rot: Mat4,
|
||
eye: glam::DVec3,
|
||
bounds: Rectangle,
|
||
aperture_px: f32,
|
||
) -> Option<(DVec3, DVec3)> {
|
||
let cs = world_to_screen(cursor_world, view_rot, eye, bounds);
|
||
let mut best_d2 = aperture_px * aperture_px;
|
||
let mut best: Option<(DVec3, DVec3)> = 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 = DVec3::new(dx / l, dy / l, 0.0);
|
||
let np = nearest_on_segment(cursor_world, segment.a, segment.b);
|
||
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 = DVec3::new(dx / l, dy / l, 0.0);
|
||
let np = nearest_on_segment(cursor_world, a, b);
|
||
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: DVec3,
|
||
line_pt: DVec3,
|
||
line_dir: DVec3,
|
||
view_rot: Mat4,
|
||
eye: glam::DVec3,
|
||
bounds: Rectangle,
|
||
) -> f32 {
|
||
let sq = world_to_screen(q, view_rot, eye, bounds);
|
||
let s0 = world_to_screen(line_pt, view_rot, eye, bounds);
|
||
let s1 = world_to_screen(line_pt + line_dir, 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 typed_distance_reference_offers_perpendicular_for_next_point() {
|
||
let mut s = Snapper::default();
|
||
s.enabled.insert(SnapType::Perpendicular);
|
||
// This must not depend on F11: the carried construction belongs to
|
||
// Perpendicular OSNAP itself.
|
||
s.otrack_enabled = false;
|
||
s.osnap_radius_px = 10.0;
|
||
|
||
// The first point was placed along a horizontal Extension ray.
|
||
s.remember_distance_reference(DVec3::X);
|
||
let base = DVec3::new(2_000.0, 3_000.0, 0.0);
|
||
|
||
let view_rot = Mat4::from_scale(Vec3::splat(0.0001));
|
||
let eye = DVec3::ZERO;
|
||
let bounds = Rectangle {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1000.0,
|
||
height: 1000.0,
|
||
};
|
||
|
||
// Pull almost vertically from the newly placed point. The preserved
|
||
// horizontal reference must produce a vertical perpendicular guide.
|
||
let cursor = base + DVec3::Y * 5_000.0 + DVec3::X * 0.05;
|
||
let hit = s
|
||
.otrack_snap(
|
||
cursor,
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
None,
|
||
Some(base),
|
||
None,
|
||
false,
|
||
DVec3::X,
|
||
DVec3::Y,
|
||
)
|
||
.expect("carried Extension direction should offer perpendicular");
|
||
|
||
assert_eq!(hit.kind, TrackingKind::Perpendicular);
|
||
assert!((hit.base - base).length() < 1e-12);
|
||
assert!((hit.aligned.x - base.x).abs() < 1e-12);
|
||
assert!((hit.aligned.y - (base.y + 5_000.0)).abs() < 1e-9);
|
||
|
||
// It is deliberately one-step state: the next accepted point clears it.
|
||
s.clear_tracking();
|
||
assert!(s
|
||
.otrack_snap(
|
||
cursor,
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
None,
|
||
Some(base),
|
||
None,
|
||
false,
|
||
DVec3::X,
|
||
DVec3::Y,
|
||
)
|
||
.is_none());
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_ellipse_param_on_arc() {
|
||
use std::f64::consts::{PI, TAU};
|
||
// Full ellipse
|
||
assert!(is_ellipse_param_on_arc(0.0, 0.0, TAU));
|
||
assert!(is_ellipse_param_on_arc(PI, 0.0, TAU));
|
||
assert!(is_ellipse_param_on_arc(PI, 0.0, 0.0));
|
||
assert!(is_ellipse_param_on_arc(0.5, -PI, PI));
|
||
|
||
// Upper half: 0 to PI
|
||
assert!(is_ellipse_param_on_arc(0.0, 0.0, PI));
|
||
assert!(is_ellipse_param_on_arc(PI / 2.0, 0.0, PI));
|
||
assert!(is_ellipse_param_on_arc(PI, 0.0, PI));
|
||
// Over the break
|
||
assert!(!is_ellipse_param_on_arc(3.0 * PI / 2.0, 0.0, PI));
|
||
assert!(!is_ellipse_param_on_arc(7.0 * PI / 4.0, 0.0, PI));
|
||
|
||
// Wrapping arc: 3*PI/2 to PI/2
|
||
let start = 3.0 * PI / 2.0;
|
||
let end = PI / 2.0;
|
||
assert!(is_ellipse_param_on_arc(start, start, end));
|
||
assert!(is_ellipse_param_on_arc(0.0, start, end));
|
||
assert!(is_ellipse_param_on_arc(end, start, end));
|
||
// Over the break (e.g. at PI)
|
||
assert!(!is_ellipse_param_on_arc(PI, start, end));
|
||
assert!(!is_ellipse_param_on_arc(3.0 * PI / 4.0, start, end));
|
||
}
|
||
|
||
#[test]
|
||
fn test_planar_ellipse_tangent_points() {
|
||
use std::f64::consts::{PI, TAU};
|
||
let center = DVec3::ZERO;
|
||
let major = DVec3::new(10.0, 0.0, 0.0);
|
||
let normal = DVec3::Z;
|
||
let ratio = 0.5; // a = 10, b = 5
|
||
|
||
// Point outside at (10, 10, 0)
|
||
let from = DVec3::new(10.0, 10.0, 0.0);
|
||
|
||
// Full ellipse should have 2 tangent points
|
||
let full_pts = planar_ellipse_tangent_points(from, center, major, normal, ratio, 0.0, TAU);
|
||
assert_eq!(full_pts.len(), 2);
|
||
|
||
// Ellipse arc only spanning 0..PI (upper half)
|
||
// Tangent points from (10, 10): one is near (10, 0), one near (0, 5)
|
||
// Upper half includes [0, PI], so any tangent with t in [0, PI] is kept.
|
||
let arc_pts = planar_ellipse_tangent_points(from, center, major, normal, ratio, 0.0, PI);
|
||
for pt in &arc_pts {
|
||
let t = (pt.y / 5.0).atan2(pt.x / 10.0);
|
||
assert!(is_ellipse_param_on_arc(t, 0.0, PI));
|
||
}
|
||
|
||
// Ellipse arc spanning 4.0..5.0 (third quadrant, far away from tangents)
|
||
let empty_pts = planar_ellipse_tangent_points(from, center, major, normal, ratio, 4.0, 5.0);
|
||
assert!(empty_pts.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn tangent_point_beats_rim_center() {
|
||
// TAN and CEN on, the cursor on the circle at the tangent point seen
|
||
// from the base: Tangent wins, not the rim-hover Center (#1768).
|
||
let mut snapper = Snapper::default();
|
||
snapper.snap_enabled = true;
|
||
snapper.enabled = [SnapType::Tangent, SnapType::Center].into_iter().collect();
|
||
snapper.from_point = Some(DVec3::new(100.0, 0.0, 0.0));
|
||
let points = (0..=64)
|
||
.map(|i| {
|
||
let a = i as f32 / 64.0 * std::f32::consts::TAU;
|
||
[50.0 * a.cos(), 50.0 * a.sin(), 0.0]
|
||
})
|
||
.collect();
|
||
let circle = WireModel {
|
||
points,
|
||
aabb: [-50.0, -50.0, 50.0, 50.0],
|
||
snap_pts: vec![(DVec3::ZERO, SnapHint::Center)],
|
||
tangent_geoms: vec![TangentGeom::Circle {
|
||
center: [0.0, 0.0, 0.0],
|
||
radius: 50.0,
|
||
}],
|
||
..Default::default()
|
||
};
|
||
let wires = vec![circle];
|
||
let view_rot = Mat4::from_scale(Vec3::new(0.005, 0.005, 1.0));
|
||
let bounds = Rectangle {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1000.0,
|
||
height: 1000.0,
|
||
};
|
||
let tangent = DVec3::new(25.0, 50.0 * 0.75f64.sqrt(), 0.0);
|
||
let cursor = world_to_screen(tangent, view_rot, DVec3::ZERO, bounds);
|
||
let snap = snapper
|
||
.snap(
|
||
tangent,
|
||
cursor,
|
||
wires.as_slice(),
|
||
view_rot,
|
||
DVec3::ZERO,
|
||
bounds,
|
||
Vec3::ZERO,
|
||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||
None,
|
||
)
|
||
.expect("a snap on the rim");
|
||
assert_eq!(snap.snap_type, SnapType::Tangent);
|
||
assert!((snap.world - tangent).length() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn test_ellipse_arc_tangent_snap_ignores_break() {
|
||
use std::f64::consts::PI;
|
||
let mut snapper = Snapper::default();
|
||
snapper.snap_enabled = true;
|
||
snapper.enabled = [SnapType::Tangent].into_iter().collect();
|
||
|
||
// Upper semi-ellipse: center at (0, 0), a = 100, b = 50, theta from 0 to PI
|
||
let center = [0.0, 0.0, 0.0];
|
||
let major_axis = [100.0, 0.0, 0.0];
|
||
let normal = [0.0, 0.0, 1.0];
|
||
let minor_axis_ratio = 0.5;
|
||
let start_param = 0.0;
|
||
let end_param = PI;
|
||
|
||
// Sample points along the upper semi-ellipse (0..PI)
|
||
let mut points = Vec::new();
|
||
let segments = 32;
|
||
for i in 0..=segments {
|
||
let t = (i as f64 / segments as f64) * PI;
|
||
points.push([(100.0 * t.cos()) as f32, (50.0 * t.sin()) as f32, 0.0]);
|
||
}
|
||
|
||
let ellipse_arc = WireModel {
|
||
points,
|
||
aabb: [-100.0, -50.0, 100.0, 50.0],
|
||
tangent_geoms: vec![TangentGeom::PlanarEllipse {
|
||
center,
|
||
major_axis,
|
||
normal,
|
||
minor_axis_ratio,
|
||
start_param,
|
||
end_param,
|
||
}],
|
||
..Default::default()
|
||
};
|
||
let wires = vec![ellipse_arc];
|
||
|
||
let view_rot = Mat4::from_scale(Vec3::new(0.005, 0.005, 1.0));
|
||
let bounds = Rectangle {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1000.0,
|
||
height: 1000.0,
|
||
};
|
||
let eye = DVec3::ZERO;
|
||
|
||
// 1. Cursor hovering directly over the drawn arc near apex (0, 50, 0)
|
||
let cursor_world_arc = DVec3::new(0.0, 50.0, 0.0);
|
||
let cursor_screen_arc = world_to_screen(cursor_world_arc, view_rot, eye, bounds);
|
||
let res_arc = snapper.snap(
|
||
cursor_world_arc,
|
||
cursor_screen_arc,
|
||
wires.as_slice(),
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
Vec3::ZERO,
|
||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||
None,
|
||
);
|
||
assert!(
|
||
res_arc.is_some(),
|
||
"cursor near valid ellipse arc must snap to tangent"
|
||
);
|
||
let snap = res_arc.unwrap();
|
||
assert_eq!(snap.snap_type, SnapType::Tangent);
|
||
assert!((snap.world.y - 50.0).abs() < 1.0);
|
||
|
||
// 2. Cursor hovering over the break at (0, -50, 0)
|
||
let cursor_world_break = DVec3::new(0.0, -50.0, 0.0);
|
||
let cursor_screen_break = world_to_screen(cursor_world_break, view_rot, eye, bounds);
|
||
let res_break = snapper.snap(
|
||
cursor_world_break,
|
||
cursor_screen_break,
|
||
wires.as_slice(),
|
||
view_rot,
|
||
eye,
|
||
bounds,
|
||
Vec3::ZERO,
|
||
(Vec3::X, Vec3::Y, Vec3::Z),
|
||
None,
|
||
);
|
||
assert!(
|
||
res_break.is_none(),
|
||
"cursor over ellipse arc break must NOT snap to tangent"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_snap_override() {
|
||
use super::{parse_snap_override, SnapOverrideKind, SnapType};
|
||
|
||
// 3-letter prefixes
|
||
assert_eq!(
|
||
parse_snap_override("end"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Endpoint))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("mid"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Midpoint))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("cen"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Center))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("nod"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Node))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("qua"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Quadrant))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("int"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Intersection))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("ext"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Extension))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("ins"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Insertion))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("per"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Perpendicular))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("tan"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Tangent))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("nea"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Nearest))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("app"),
|
||
Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("par"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Parallel))
|
||
);
|
||
assert_eq!(parse_snap_override("non"), Some(SnapOverrideKind::None));
|
||
assert_eq!(
|
||
parse_snap_override("geo"),
|
||
Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("gce"),
|
||
Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))
|
||
);
|
||
|
||
// Case insensitivity & leading underscores
|
||
assert_eq!(
|
||
parse_snap_override("_MID"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Midpoint))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("_Endpoint"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Endpoint))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override(" _NON "),
|
||
Some(SnapOverrideKind::None)
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("CEN"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Center))
|
||
);
|
||
|
||
// Full names and aliases
|
||
assert_eq!(
|
||
parse_snap_override("endpoint"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Endpoint))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("midpoint"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Midpoint))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("center"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Center))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("centre"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Center))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("quadrant"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Quadrant))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("intersection"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Intersection))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("perpendicular"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Perpendicular))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("tangent"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Tangent))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("nearest"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Nearest))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("apparent"),
|
||
Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("apparentintersection"),
|
||
Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("appint"),
|
||
Some(SnapOverrideKind::Mode(SnapType::ApparentIntersection))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("parallel"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Parallel))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("insert"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Insertion))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("insertion"),
|
||
Some(SnapOverrideKind::Mode(SnapType::Insertion))
|
||
);
|
||
assert_eq!(parse_snap_override("none"), Some(SnapOverrideKind::None));
|
||
assert_eq!(
|
||
parse_snap_override("gcen"),
|
||
Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("geometriccenter"),
|
||
Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))
|
||
);
|
||
assert_eq!(
|
||
parse_snap_override("geocenter"),
|
||
Some(SnapOverrideKind::Mode(SnapType::GeometricCenter))
|
||
);
|
||
|
||
// Rejection of strings shorter than 3 chars
|
||
assert_eq!(parse_snap_override("en"), None);
|
||
assert_eq!(parse_snap_override("mi"), None);
|
||
assert_eq!(parse_snap_override("ce"), None);
|
||
assert_eq!(parse_snap_override("p"), None);
|
||
assert_eq!(parse_snap_override("no"), None);
|
||
assert_eq!(parse_snap_override("_m"), None);
|
||
assert_eq!(parse_snap_override(""), None);
|
||
|
||
// Non-matching tokens
|
||
assert_eq!(parse_snap_override("line"), None);
|
||
assert_eq!(parse_snap_override("circle"), None);
|
||
assert_eq!(parse_snap_override("10,20"), None);
|
||
assert_eq!(parse_snap_override("@5<45"), None);
|
||
assert_eq!(parse_snap_override("undo"), None);
|
||
assert_eq!(parse_snap_override("close"), None);
|
||
}
|
||
}
|