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 {