Merge PR #1567 from gianlucafiore/fix/polyline-self-intersections: polyline self-intersection snapping and trimming

This commit is contained in:
Hakan Seven committed 2026-10-02 10:04:11 +03:00
commit 3bf8906ed3
2 files changed
+322 -46

No files matched your search

+236 -43
View File
@@ -1108,6 +1108,161 @@ fn extract_sub_polyline(poly: &LwPolyline, s0: f64, s1: f64) -> Option<LwPolylin
Some(new_poly)
}
/// Kernel curve for one LwPolyline segment.
///
/// The boolean tells whether the kernel curve runs opposite to the polyline's
/// own segment direction. Negative-bulge arcs are represented by the kernel as
/// a positive-sweep arc, so their intersection parameter has to be reversed
/// back to the polyline's local 0..1 parameter.
fn lwpoly_segment_curve(
poly: &LwPolyline,
segment: usize,
) -> Option<(Curve, bool)> {
let n = poly.vertices.len();
if n < 2 {
return None;
}
let a = &poly.vertices[segment % n];
let b = &poly.vertices[(segment + 1) % n];
let p0 = [a.location.x, a.location.y];
let p1 = [b.location.x, b.location.y];
if (p1[0] - p0[0]).hypot(p1[1] - p0[1]) < 1e-12 {
return None;
}
if let Some(arc) = BulgeArc::from_bulge(p0, p1, a.bulge) {
if arc.sweep >= 0.0 {
Some((
Curve::Arc(KernelArc {
centre: arc.center,
radius: arc.radius,
start_angle: arc.start_angle,
end_angle: arc.start_angle + arc.sweep,
}),
false,
))
} else {
Some((
Curve::Arc(KernelArc {
centre: arc.center,
radius: arc.radius,
start_angle: arc.end_angle,
end_angle: arc.end_angle - arc.sweep,
}),
true,
))
}
} else {
Some((
Curve::Line(KernelLine {
start: p0,
end: p1,
}),
false,
))
}
}
/// Global polyline parameters at which non-adjacent segments of the same
/// polyline intersect.
///
/// Segment neighbours are deliberately excluded: their common vertex is a
/// normal polyline vertex, not a self-intersection.
fn lwpoly_self_cut_params(poly: &LwPolyline) -> Vec<f64> {
let n = poly.vertices.len();
if n < 3 {
return Vec::new();
}
let closed = poly.is_closed;
let segment_count = if closed { n } else { n - 1 };
let total = segment_count as f64;
let mut cuts = Vec::new();
for i in 0..segment_count {
let Some((curve_a, reverse_a)) = lwpoly_segment_curve(poly, i) else {
continue;
};
for j in (i + 1)..segment_count {
// Ordinary neighbouring segments meet at their common vertex.
let adjacent = j == i + 1
|| (closed && i == 0 && j + 1 == segment_count);
if adjacent {
continue;
}
let Some((curve_b, reverse_b)) = lwpoly_segment_curve(poly, j) else {
continue;
};
let hits = kernel_intersect(
&curve_a,
&curve_b,
KernelTolerance::new(CUT_TOLERANCE),
);
for hit in hits {
let mut ua = hit.t_a;
let mut ub = hit.t_b;
if reverse_a {
ua = 1.0 - ua;
}
if reverse_b {
ub = 1.0 - ub;
}
if !ua.is_finite()
|| !ub.is_finite()
|| ua < -1e-7
|| ua > 1.0 + 1e-7
|| ub < -1e-7
|| ub > 1.0 + 1e-7
{
continue;
}
ua = ua.clamp(0.0, 1.0);
ub = ub.clamp(0.0, 1.0);
// If both sides only touch at existing vertices, those
// vertices already act as trim limits below.
let a_endpoint = ua <= 1e-7 || ua >= 1.0 - 1e-7;
let b_endpoint = ub <= 1e-7 || ub >= 1.0 - 1e-7;
if a_endpoint && b_endpoint {
continue;
}
let pa = i as f64 + ua;
let pb = j as f64 + ub;
cuts.push(if closed {
pa.rem_euclid(total)
} else {
pa
});
cuts.push(if closed {
pb.rem_euclid(total)
} else {
pb
});
}
}
}
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
cuts
}
/// Trim a clicked LwPolyline: remove the portion containing the click, bounded
/// by the nearest boundary intersections on each side. A closed polyline needs
/// ≥2 cuts and becomes an open polyline (the surviving arc); an open one yields
@@ -1132,22 +1287,46 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
};
// Boundary cuts as global params (segment index + local u).
// External boundary cuts as global params (segment index + local u).
let mut cuts: Vec<f64> = Vec::new();
for i in 0..seg_count {
let p0 = vx(i);
let p1 = vx(i + 1);
let b = seg_bulge(i);
for u in polyline_seg_ts(p0, p1, b, handle, geos) {
let param = i as f64 + u.clamp(0.0, 1.0);
cuts.push(if closed { param.rem_euclid(total) } else { param });
cuts.push(if closed {
param.rem_euclid(total)
} else {
param
});
}
}
cuts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
if cuts.is_empty() {
return None;
// A polyline can also cut itself. These are intersections between
// non-adjacent segments belonging to this same LwPolyline.
cuts.extend(lwpoly_self_cut_params(poly));
// Every polyline vertex is also a valid trim boundary. This allows an entire
// segment between two vertices to be removed even when no external entity
// crosses that particular segment.
if closed {
for i in 0..seg_count {
cuts.push(i as f64);
}
} else {
// 0 and `total` are added explicitly below as the open polyline ends.
for i in 1..seg_count {
cuts.push(i as f64);
}
}
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
// Click param: nearest point on the polyline.
let mut best = (f64::INFINITY, 0.0_f64);
for i in 0..seg_count {
@@ -1251,11 +1430,7 @@ fn trim_lwpolyline(poly: &LwPolyline, cx: f64, cy: f64, geos: &[Geo]) -> Option<
}
}
if out.is_empty() {
None
} else {
Some(out)
}
Some(out)
}
// ── Extend helpers ────────────────────────────────────────────────────────
@@ -1650,6 +1825,11 @@ fn crossing_trim_lwpolyline(
};
let mut removed = Vec::<(f64, f64)>::new();
// Compute the polyline's own non-adjacent segment intersections once.
// These global parameters will later be mapped back into each source segment.
let self_cuts = lwpoly_self_cut_params(poly);
for i in 0..seg_count {
let a = vertex_xy(i);
let b = vertex_xy(i + 1);
@@ -1678,42 +1858,55 @@ fn crossing_trim_lwpolyline(
let Some((inside_lo, inside_hi)) = window_range else {
continue;
};
let cuts = polyline_seg_ts(a, b, bulge, handle, geos);
if cuts.is_empty() {
continue;
// External intersections on this segment.
// External intersections on this segment.
let mut cuts = polyline_seg_ts(a, b, bulge, handle, geos);
// Also include self-intersections belonging to this source segment.
// `lwpoly_self_cut_params()` stores them as global polyline parameters:
// segment_index + local_parameter
//
// Convert the ones belonging to segment `i` back to local 0..1.
let seg_start = i as f64;
let seg_end = seg_start + 1.0;
for global_t in self_cuts.iter().copied() {
if global_t > seg_start + 1e-6
&& global_t < seg_end - 1e-6
{
cuts.push(global_t - seg_start);
}
}
let pick_t = if let Some(ba) = BulgeArc::from_bulge(a, b, bulge) {
let angle = (window.pick[1] - ba.center[1]).atan2(window.pick[0] - ba.center[0]);
let travelled = if ba.sweep >= 0.0 {
(angle - ba.start_angle).rem_euclid(TAU)
} else {
-((ba.start_angle - angle).rem_euclid(TAU))
};
(travelled / ba.sweep).clamp(inside_lo, inside_hi)
} else {
let dx = b[0] - a[0];
let dy = b[1] - a[1];
let len2 = dx * dx + dy * dy;
let projected = if len2 > 1e-12 {
((window.pick[0] - a[0]) * dx + (window.pick[1] - a[1]) * dy) / len2
} else {
(inside_lo + inside_hi) * 0.5
};
projected.clamp(inside_lo, inside_hi)
};
// The source segment endpoints are also valid trim limits.
cuts.push(0.0);
cuts.push(1.0);
let mut bounds = vec![0.0];
bounds.extend(cuts);
bounds.push(1.0);
bounds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
if let Some(span) = bounds
.windows(2)
.find(|span| pick_t >= span[0] - 1e-6 && pick_t <= span[1] + 1e-6)
{
if span[1] - span[0] > 1e-6 {
removed.push((i as f64 + span[0], i as f64 + span[1]));
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
// Remove every trim span that is actually touched by the selection box.
//
// A single source segment may contain several sub-spans when external
// boundaries cross it. Only the sub-spans overlapping the window are removed.
for span in cuts.windows(2) {
let lo = span[0].clamp(0.0, 1.0);
let hi = span[1].clamp(0.0, 1.0);
if hi - lo <= 1e-6 {
continue;
}
// This trim span participates when its parameter interval overlaps the
// portion of the source segment lying inside the crossing rectangle.
let overlaps_window =
hi >= inside_lo - 1e-6 && lo <= inside_hi + 1e-6;
if overlaps_window {
removed.push((
i as f64 + lo,
i as f64 + hi,
));
}
}
}
+86 -3
View File
@@ -1755,6 +1755,53 @@ impl Snapper {
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();
@@ -1789,7 +1836,13 @@ impl Snapper {
if b.min_x() > a.max_x() {
break;
}
if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() {
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))
@@ -1801,11 +1854,23 @@ impl Snapper {
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,
wires.source_wire(a.wire).and_then(wire_source),
wires.source_wire(b.wire).and_then(wire_source),
primary,
secondary,
);
if exact_cursor {
break 'intersection_sweep;
@@ -3151,6 +3216,24 @@ pub(crate) fn exact_curve_intersections(
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