From fae64886eec0cda6c8b190aea1c929440195407c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 7 Oct 2026 08:49:40 +0300 Subject: [PATCH] fix(snap): arc segments of a polyline offer their centre and quadrants Center and Quadrant found nothing on a polyline's bulge segments: the lightweight and heavy polylines carried no snap points for them, and a polyline split into several wires kept its points on one piece whose box the others' never reached. Each arc segment now carries its centre and quadrants, hovering the segment offers its centre, and a wire's box covers its snap points. Fixes #1640 Co-Authored-By: Claude Opus 5.5 --- src/entities/curve.rs | 41 +++++++++++++- src/entities/lwpolyline.rs | 8 ++- src/entities/polyline.rs | 8 ++- src/scene/convert/tess.rs | 17 ++++++ src/scene/model/wire_model.rs | 3 + src/snap.rs | 101 ++++++++++++++++++++++++++-------- 6 files changed, 150 insertions(+), 28 deletions(-) diff --git a/src/entities/curve.rs b/src/entities/curve.rs index 6421370b0..76617d940 100644 --- a/src/entities/curve.rs +++ b/src/entities/curve.rs @@ -484,9 +484,9 @@ pub fn snap_from(curve: &PlanarCurve) -> CurveSnap { // there and let their midpoints be derived; everything else names its own // ends and middle explicitly. // - // The same distinction is why a polyline's arc-segment centres are not - // emitted: a wire carrying a centre is treated as round elsewhere, which - // a polyline with one bulge in it is not. + // The same distinction is why a polyline's arc-segment centres carry + // their own hint: a wire carrying a `Center` is treated as round + // elsewhere, which a polyline with one bulge in it is not. let chain = matches!(curve.curve, Curve::Line(_) | Curve::Polyline(_)); let mut out = CurveSnap::default(); let mut push = |world: [f64; 3], hint: SnapHint| { @@ -499,6 +499,7 @@ pub fn snap_from(curve: &PlanarCurve) -> CurveSnap { SnapKind::Endpoint => push(world, SnapHint::Endpoint), SnapKind::Midpoint if !chain => push(world, SnapHint::Midpoint), SnapKind::Centre if !chain => push(world, SnapHint::Center), + SnapKind::Centre => push(world, SnapHint::ArcCenter), SnapKind::Quadrant => push(world, SnapHint::Quadrant), _ => {} } @@ -506,6 +507,20 @@ pub fn snap_from(curve: &PlanarCurve) -> CurveSnap { out } +/// The centres and quadrants of a polyline's arc segments, for a polyline +/// render that names its vertices itself. +pub fn arc_segment_snaps(entity: &EntityType) -> Vec<(glam::DVec3, SnapHint)> { + entity_curve(entity) + .map(|curve| { + snap_from(&curve) + .snap_pts + .into_iter() + .filter(|(_, hint)| matches!(hint, SnapHint::ArcCenter | SnapHint::Quadrant)) + .collect() + }) + .unwrap_or_default() +} + /// The magnitude the geometry's own coordinates sit at, for scaling a /// relative tolerance. Floored at one so geometry near the origin does not /// end up with a tolerance of nothing. @@ -806,6 +821,26 @@ mod tests { assert!(snap.snap_pts.is_empty(), "{:?}", snap.snap_pts); } + #[test] + fn a_polyline_arc_segment_offers_its_centre_and_quadrants() { + let mut polyline = LwPolylineEnt::default(); + polyline.normal = v3(0.0, 0.0, 1.0); + let mut bend = LwVertex::from_coords(0.0, 0.0); + bend.bulge = 0.9999999999999999; + polyline.vertices = vec![LwVertex::from_coords(-20.0, 0.0), bend, LwVertex::from_coords(20.0, 0.0)]; + let snaps = arc_segment_snaps(&EntityType::LwPolyline(polyline)); + let at = |hint: SnapHint, x: f64, y: f64| { + snaps.iter().any(|(p, h)| { + std::mem::discriminant(h) == std::mem::discriminant(&hint) + && (p.x - x).abs() < 1e-9 + && (p.y - y).abs() < 1e-9 + }) + }; + assert!(at(SnapHint::ArcCenter, 10.0, 0.0), "{snaps:?}"); + assert!(at(SnapHint::Quadrant, 10.0, -10.0), "{snaps:?}"); + assert!(!at(SnapHint::Quadrant, 10.0, 10.0), "{snaps:?}"); + } + #[test] fn a_closed_curve_has_no_ends_to_offer() { let mut circle = CircleEnt::default(); diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index bc1e7b0ee..5340bd542 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -1226,7 +1226,13 @@ fn apply_transform(pline: &mut LwPolyline, t: &EntityTransform) { impl RenderConvertible for LwPolyline { fn to_render(&self, document: &codec::CadDocument) -> Option { - Some(to_render(self, document.header.fill_mode)) + let mut render = to_render(self, document.header.fill_mode); + // Arc segments offer their centres and quadrants. + if self.vertices.iter().any(|v| v.bulge != 0.0) { + let entity = codec::EntityType::LwPolyline(self.clone()); + render.snap_pts.extend(crate::entities::curve::arc_segment_snaps(&entity)); + } + Some(render) } } diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index 061b33e93..fee67b3a5 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -493,7 +493,13 @@ fn centerline_metadata_2d( impl RenderConvertible for Polyline2D { fn to_render(&self, document: &codec::CadDocument) -> Option { - Some(tessellate_polyline2d(self, document.header.fill_mode)) + let mut render = tessellate_polyline2d(self, document.header.fill_mode); + // Arc segments offer their centres and quadrants. + if self.vertices.iter().any(|v| v.bulge != 0.0) { + let entity = codec::EntityType::Polyline2D(self.clone()); + render.snap_pts.extend(crate::entities::curve::arc_segment_snaps(&entity)); + } + Some(render) } } diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index f07a9c024..e91f169b4 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -1766,6 +1766,23 @@ pub(crate) fn set_wire_aabb(w: &mut WireModel, entity_box: [f32; 4]) { for (i, &p) in w.pick_tris.iter().enumerate() { extend(p, w.pick_tris_low.get(i).copied().unwrap_or([0.0; 3])); } + // The entity's snap points ride one wire, which may be only one piece of + // it (a polyline drawn per segment): its box must reach them, or a snap + // on another piece — an arc segment's quadrant — is never tried. + let split = |x: f64| { + let high = x as f32; + [high, (x - high as f64) as f32] + }; + let points = w + .snap_pts + .iter() + .map(|(p, _)| [p.x, p.y]) + .chain(w.key_vertices.iter().map(|p| [p[0], p[1]])) + .collect::>(); + for [x, y] in points { + let ([xh, xl], [yh, yl]) = (split(x), split(y)); + extend([xh, yh, 0.0], [xl, yl, 0.0]); + } w.aabb = out; } diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 4f6c50028..06345140b 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -4,6 +4,9 @@ pub enum SnapHint { /// Geometric center of a circle, arc, or ellipse. Center, + /// Centre of one arc segment of a chain (a polyline). Snaps like + /// `Center`, but does not make the wire count as round. + ArcCenter, /// Area centroid of a closed polyline. GeometricCenter, /// Point entity location. diff --git a/src/snap.rs b/src/snap.rs index 551960bfc..ab59a5f59 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1442,7 +1442,9 @@ impl Snapper { // 3D hints run on the separate 3D master + mode set; everything // else stays on the 2D master + mode set. let (snap_type, on) = match hint { - SnapHint::Center => (SnapType::Center, self.is_on(SnapType::Center)), + SnapHint::Center | SnapHint::ArcCenter => { + (SnapType::Center, self.is_on(SnapType::Center)) + } SnapHint::GeometricCenter => ( SnapType::GeometricCenter, self.is_on(SnapType::GeometricCenter), @@ -2475,12 +2477,35 @@ impl Snapper { // so it can update the candidate state directly. (#152) // 2D master applies (this direct-evaluation pass bypasses `try_pt`). if self.snap_enabled && self.is_on(SnapType::Center) { - let mut offer = |wire: &WireModel, curve_d2: f32| { + // 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 { + 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; }; @@ -2517,16 +2542,12 @@ impl Snapper { } }; if let Some(segments) = &local_segments { - let mut distances: HashMap = HashMap::default(); + let mut distances: HashMap = HashMap::default(); for segment in segments { let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if !wire - .snap_pts - .iter() - .any(|(_, hint)| matches!(hint, SnapHint::Center)) - { + if !has_centre(wire) { continue; } let nearest = nearest_on_segment(cursor_world, segment.a, segment.b); @@ -2534,31 +2555,38 @@ impl Snapper { world_to_screen(nearest, view_rot, eye, bounds), cursor_screen, ); + let piece = (segment.a, segment.b); distances .entry(segment.wire) - .and_modify(|best| *best = best.min(distance)) - .or_insert(distance); + .and_modify(|best| { + if distance < best.0 { + *best = (distance, piece); + } + }) + .or_insert((distance, piece)); } - for (wire_idx, distance) in distances { + for (wire_idx, (distance, piece)) in distances { if let Some(wire) = wires.source_wire(wire_idx) { - offer(wire, distance); + 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 nearest = nearest_on_segment( - cursor_world, - wp_f64(wire, index), - wp_f64(wire, index + 1), - ); - curve_d2 = curve_d2.min(dist2( + 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); + offer(wire, curve_d2, piece); } } } @@ -2589,6 +2617,29 @@ impl Snapper { } } +/// 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 { + 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 @@ -2769,10 +2820,14 @@ fn tracking_dirs_at( 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.snap_pts - .iter() - .any(|(_, hint)| matches!(hint, SnapHint::Quadrant | SnapHint::Center)) + 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 {