fix(snap): correct snap symbols + drop bogus midpoints on circles (#34)

Two bugs from #34:

1. Hovering a circle showed the Center snap as a diamond instead of
   the conventional circle outline.
2. Hovering a circle also showed Midpoint hits (triangle marker)
   even though a closed curve has no midpoint.

(2) came from a "tessellated curve" fallback in the Midpoint snap
path that produced a midpoint for every chord of the polyline
approximation. Drop that fallback — only entities with explicit
`key_vertices` (Line, LwPolyline) contribute per-segment midpoints
now. Curves that DO have a single well-defined midpoint (arcs, and
later splines) declare it explicitly via a new
`SnapHint::Midpoint`; `arc.rs` emits one at the arc-length centre.

(1) — and the broader "everything past Endpoint / Midpoint / Grid
was a diamond" inconsistency — fixed by giving every `SnapType` a
distinct marker in `SelectionCanvas::draw`:

- Center: hollow circle
- Node: circle with inscribed X
- Quadrant: diamond (unchanged)
- Intersection: X
- Apparent Intersection: X inside a square
- Insertion: two overlapping rectangles (tag glyph)
- Perpendicular: right-angle hook
- Tangent: circle with a bar across the top
- Nearest: hourglass / bowtie
- Extension: three dots
- Parallel: two parallel diagonal bars
- Endpoint / Midpoint / Grid: unchanged

Closes #34
This commit is contained in:
Hakan Seven committed 2026-05-29 01:39:02 +03:00
1 parent 89fcb21b46
commit 0ec6d77cb1
4 files changed
+162 -28

No files matched your search

+6 -2
View File
@@ -40,6 +40,10 @@ fn to_truck(arc: &Arc) -> TruckEntity {
};
let cv = Vec3::new(cwx as f32, cwy as f32, cwz as f32);
// Arc-length centre — one well-defined midpoint snap. Circles and
// ellipses (closed curves) deliberately don't emit this; see #34.
let mid_pt_3 = arc_pt(mid_a);
let mv = Vec3::new(mid_pt_3.x as f32, mid_pt_3.y as f32, mid_pt_3.z as f32);
let tangent = TangentGeom::Circle {
center: [cwx as f32, cwy as f32, cwz as f32],
radius: r as f32,
@@ -73,7 +77,7 @@ fn to_truck(arc: &Arc) -> TruckEntity {
pts.push([pe.x + t * nx, pe.y + t * ny, pe.z + t * nz]);
return TruckEntity {
object: TruckObject::Lines(pts),
snap_pts: vec![(cv, SnapHint::Center)],
snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)],
tangent_geoms: vec![tangent],
key_vertices: vec![],
fill_tris: vec![],
@@ -88,7 +92,7 @@ fn to_truck(arc: &Arc) -> TruckEntity {
let edge = builder::circle_arc(&v_start, &v_end, p_mid);
TruckEntity {
object: TruckObject::Curve(edge),
snap_pts: vec![(cv, SnapHint::Center)],
snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)],
tangent_geoms: vec![tangent],
key_vertices: vec![],
fill_tris: vec![],
+4
View File
@@ -10,6 +10,10 @@ pub enum SnapHint {
Quadrant,
/// Insertion point of text or block.
Insertion,
/// Midpoint of a curve that has one well-defined midpoint (an arc's
/// arc-length centre, a spline's `t = 0.5`). Lines / polylines do
/// not use this — their midpoints are derived from `key_vertices`.
Midpoint,
}
/// Geometric primitive used by the tangent-snap engine.
+6 -11
View File
@@ -334,6 +334,7 @@ impl Snapper {
SnapHint::Node => SnapType::Node,
SnapHint::Quadrant => SnapType::Quadrant,
SnapHint::Insertion => SnapType::Insertion,
SnapHint::Midpoint => SnapType::Midpoint,
};
if self.is_on(snap_type) {
try_pt(world, snap_type);
@@ -367,13 +368,17 @@ impl Snapper {
}
// ── Midpoint ───────────────────────────────────────────────────────
// Only explicit vertex sets (Line, LwPolyline) contribute per-segment
// midpoints. Tessellated curves (Circle, Arc, Ellipse, Spline) emit a
// single `SnapHint::Midpoint` snap_pt where one exists — iterating
// every chord here would otherwise turn a circle's tessellation into
// a haze of false midpoint hits. See #34.
if self.is_on(SnapType::Midpoint) {
for wire in wires {
if !wire_in_range(wire) {
continue;
}
if !wire.key_vertices.is_empty() {
// Use explicit vertices for accurate per-segment midpoints.
for seg in wire.key_vertices.windows(2) {
let a = Vec3::from(seg[0]);
let b = Vec3::from(seg[1]);
@@ -381,16 +386,6 @@ impl Snapper {
try_pt((a + b) * 0.5, SnapType::Midpoint);
}
}
} else {
// Tessellated curves: midpoint of whole arc (first↔last midpoint skipped
// for closed curves; arcs use their own quadrant snaps instead).
for seg in wire.points.windows(2) {
let a = Vec3::from(seg[0]);
let b = Vec3::from(seg[1]);
if a.distance_squared(b) > 1e-12 {
try_pt((a + b) * 0.5, SnapType::Midpoint);
}
}
}
}
}
+146 -15
View File
@@ -489,10 +489,10 @@ impl canvas::Program<Message> for SelectionCanvas {
};
match snap_type {
SnapType::Endpoint => {
let half = 5.0_f32;
let h = 5.0_f32;
let rect = canvas::Path::rectangle(
Point::new(sp.x - half, sp.y - half),
Size::new(half * 2.0, half * 2.0),
Point::new(sp.x - h, sp.y - h),
Size::new(h * 2.0, h * 2.0),
);
frame.stroke(&rect, stroke);
}
@@ -506,6 +506,149 @@ impl canvas::Program<Message> for SelectionCanvas {
});
frame.stroke(&path, stroke);
}
SnapType::Center => {
let r = 5.5_f32;
let path = canvas::Path::circle(sp, r);
frame.stroke(&path, stroke);
}
SnapType::Node => {
// Circle with an inscribed X.
let r = 5.5_f32;
let cpath = canvas::Path::circle(sp, r);
frame.stroke(&cpath, stroke.clone());
let d = r * std::f32::consts::FRAC_1_SQRT_2;
let x1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - d, sp.y - d));
b.line_to(Point::new(sp.x + d, sp.y + d));
});
let x2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - d, sp.y + d));
b.line_to(Point::new(sp.x + d, sp.y - d));
});
frame.stroke(&x1, stroke.clone());
frame.stroke(&x2, stroke);
}
SnapType::Quadrant => {
let r = 6.0_f32;
let path = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y));
b.line_to(Point::new(sp.x, sp.y + r));
b.line_to(Point::new(sp.x - r, sp.y));
b.close();
});
frame.stroke(&path, stroke);
}
SnapType::Intersection => {
let r = 5.0_f32;
let p1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y + r));
});
let p2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y + r));
b.line_to(Point::new(sp.x + r, sp.y - r));
});
frame.stroke(&p1, stroke.clone());
frame.stroke(&p2, stroke);
}
SnapType::ApparentIntersection => {
// X like Intersection, framed by a small square so the
// two are visually distinguishable.
let r = 5.0_f32;
let rect = canvas::Path::rectangle(
Point::new(sp.x - r, sp.y - r),
Size::new(r * 2.0, r * 2.0),
);
frame.stroke(&rect, stroke.clone());
let xr = r - 1.5;
let p1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - xr, sp.y - xr));
b.line_to(Point::new(sp.x + xr, sp.y + xr));
});
let p2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - xr, sp.y + xr));
b.line_to(Point::new(sp.x + xr, sp.y - xr));
});
frame.stroke(&p1, stroke.clone());
frame.stroke(&p2, stroke);
}
SnapType::Insertion => {
// Two overlapping rectangles (a small "tag" glyph).
let r = 5.0_f32;
let inner = canvas::Path::rectangle(
Point::new(sp.x - r * 0.5, sp.y - r),
Size::new(r, r * 2.0),
);
let outer = canvas::Path::rectangle(
Point::new(sp.x - r, sp.y - r * 0.5),
Size::new(r * 2.0, r),
);
frame.stroke(&outer, stroke.clone());
frame.stroke(&inner, stroke);
}
SnapType::Perpendicular => {
// Right-angle hook in the lower-left quadrant.
let r = 6.0_f32;
let p = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x - r, sp.y + r));
b.line_to(Point::new(sp.x + r, sp.y + r));
});
let foot = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y));
b.line_to(Point::new(sp.x, sp.y));
b.line_to(Point::new(sp.x, sp.y + r));
});
frame.stroke(&p, stroke.clone());
frame.stroke(&foot, stroke);
}
SnapType::Tangent => {
// Circle with a tangent bar across the top.
let r = 5.5_f32;
let c = canvas::Path::circle(sp, r);
frame.stroke(&c, stroke.clone());
let bar = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y - r));
});
frame.stroke(&bar, stroke);
}
SnapType::Nearest => {
// Bowtie / hourglass — two opposed triangles meeting at sp.
let r = 5.5_f32;
let path = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y - r));
b.line_to(Point::new(sp.x - r, sp.y + r));
b.line_to(Point::new(sp.x + r, sp.y + r));
b.close();
});
frame.stroke(&path, stroke);
}
SnapType::Extension => {
// Three dots strung along a tracked direction.
let r = 1.4_f32;
for k in [-7.0_f32, 0.0, 7.0] {
let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r);
frame.fill(&dot, yellow);
}
}
SnapType::Parallel => {
// Two short parallel diagonal bars.
let r = 6.0_f32;
let off = 3.0_f32;
let b1 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r - off, sp.y + r));
b.line_to(Point::new(sp.x + r - off, sp.y - r));
});
let b2 = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x - r + off, sp.y + r));
b.line_to(Point::new(sp.x + r + off, sp.y - r));
});
frame.stroke(&b1, stroke.clone());
frame.stroke(&b2, stroke);
}
SnapType::Grid => {
let arm = 4.0_f32;
let h = canvas::Path::new(|b| {
@@ -519,18 +662,6 @@ impl canvas::Program<Message> for SelectionCanvas {
frame.stroke(&h, stroke.clone());
frame.stroke(&v, stroke);
}
// Other snap types use the same diamond marker.
_ => {
let r = 5.0_f32;
let path = canvas::Path::new(|b| {
b.move_to(Point::new(sp.x, sp.y - r));
b.line_to(Point::new(sp.x + r, sp.y));
b.line_to(Point::new(sp.x, sp.y + r));
b.line_to(Point::new(sp.x - r, sp.y));
b.close();
});
frame.stroke(&path, stroke);
}
}
}