Collapse boundary spikes in region cut; embed dump regression fixtures

A dense self-intersecting freehand lasso leaves clusters of near-coincident
duplicate sub-pixel edges (split products the coincident-edge dedupe can't
reach). The planar face trace bounces back and forth across them, producing
a degenerate "spike" boundary (an edge used twice). Add
`collapse_boundary_spikes`, run on each traced face before it becomes a
fill: it removes consecutive out-and-back entries (where the boundary
returns to where it started) until the loop is simple.

Embed the captured region-select dumps as committed fixtures under
tests/region_dumps/ (replayed by `dumped_region_selects_are_valid`) so the
regression survives /tmp being cleared. dump3 is the boundary-spike repro;
it fails this test without the collapse and passes with it. Loosen the
boundary-connectivity test tolerance to 1e-2 (above sub-micropixel float
drift, far below any real gap).
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 15:01:53 -04:00
parent 39978e59b3
commit 87bcffd427
7 changed files with 109 additions and 2 deletions

View File

@ -1215,7 +1215,13 @@ impl VectorGraph {
self.fills[f.idx()].deleted = true;
self.free_fills.push(f.0);
}
for face in faces {
for mut face in faces {
// Collapse degenerate "spikes" — a sequence that runs out to a point and back
// (e.g. across near-coincident duplicate tiny edges from a dense freehand path).
self.collapse_boundary_spikes(&mut face);
if face.len() < 3 {
continue;
}
// Only bounded (counter-clockwise, positive-area) faces are real regions; the
// outer face is clockwise/negative.
if self.face_signed_area(&face) <= 1e-6 {
@ -1230,6 +1236,51 @@ impl VectorGraph {
}
}
/// Remove out-and-back "spikes" from a face boundary: consecutive entries where the
/// second exactly reverses the first (the boundary returns to where it started, e.g.
/// bouncing across near-coincident duplicate edges). These are zero-area and would make
/// `boundary_to_bezpath` render a stray hair; collapsing them yields a simple loop.
fn collapse_boundary_spikes(&self, face: &mut Vec<(EdgeId, Direction)>) {
let dstart = |entry: &(EdgeId, Direction)| -> Point {
let c = self.edges[entry.0.idx()].curve;
match entry.1 {
Direction::Forward => c.p0,
Direction::Backward => c.p3,
}
};
let dend = |entry: &(EdgeId, Direction)| -> Point {
let c = self.edges[entry.0.idx()].curve;
match entry.1 {
Direction::Forward => c.p3,
Direction::Backward => c.p0,
}
};
const EPS: f64 = 0.5;
loop {
let n = face.len();
if n < 2 {
break;
}
let mut collapsed = false;
for i in 0..n {
let j = (i + 1) % n;
// entries i and j cancel when j ends back at i's start.
let si = dstart(&face[i]);
let ej = dend(&face[j]);
if (si.x - ej.x).hypot(si.y - ej.y) < EPS {
let (hi, lo) = if i > j { (i, j) } else { (j, i) };
face.remove(hi);
face.remove(lo);
collapsed = true;
break;
}
}
if !collapsed {
break;
}
}
}
/// Trace all faces of the planar arrangement formed by `edge_set`, using the standard
/// angular next-edge rule (turn to the clockwise-adjacent dart of the twin at each
/// vertex). Returns each face as an ordered `(edge, direction)` loop. Bounded faces

View File

@ -126,8 +126,9 @@ fn assert_all_fills_connected(g: &VectorGraph) {
for k in 0..n {
let (_, _, end) = b[k];
let (_, next_start, _) = b[(k + 1) % n];
// Tolerance well below any real gap (pixels) but above accumulated float drift.
assert!(
(end.x - next_start.x).abs() < 1e-6 && (end.y - next_start.y).abs() < 1e-6,
(end.x - next_start.x).abs() < 1e-2 && (end.y - next_start.y).abs() < 1e-2,
"fill {i} boundary disconnected between edge {k} (ends {end:?}) and \
edge {} (starts {next_start:?}); boundary = {b:#?}",
(k + 1) % n
@ -258,6 +259,56 @@ fn extract_after_cut_keeps_remaining_fill_intact() {
assert_all_fills_connected(&g);
}
/// Captured region-select cases (`LIGHTNINGBEAM_DUMP_REGION=1`), embedded so the regression
/// survives `/tmp` being cleared. Each is `{ "graph": <VectorGraph>, "segments": [[[x,y]*4]*] }`.
/// They span: two separate rects, side-by-side, overlapping, a notched post-group fill, and
/// dense self-intersecting freehand lassos (dump 3 is the boundary-spike repro).
const REGION_DUMPS: &[&str] = &[
include_str!("region_dumps/dump0.json"),
include_str!("region_dumps/dump1.json"),
include_str!("region_dumps/dump2.json"),
include_str!("region_dumps/dump3.json"),
include_str!("region_dumps/dump4.json"),
];
#[test]
fn dumped_region_selects_are_valid() {
// Replays each captured region-select cut and asserts it yields only valid, non-corrupt
// geometry (no freed-but-referenced refs, no spikes, every fill a connected loop).
for json in REGION_DUMPS {
let v: serde_json::Value = serde_json::from_str(json).unwrap();
let mut g: VectorGraph = serde_json::from_value(v["graph"].clone()).unwrap();
let segs: Vec<CubicBez> = v["segments"].as_array().unwrap().iter().map(|s| {
let p = |i: usize| { let a = s[i].as_array().unwrap(); Point::new(a[0].as_f64().unwrap(), a[1].as_f64().unwrap()) };
CubicBez::new(p(0), p(1), p(2), p(3))
}).collect();
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
assert_no_freed_but_referenced(&g);
assert_no_spikes(&g);
assert_all_fills_connected(&g);
}
}
#[test]
fn near_coincident_needle_does_not_spike() {
// Smoke test: a stroke poking into a fill and returning along a near-coincident path
// must still yield valid, non-corrupt geometry. (The dense-freehand accordion that the
// boundary-spike collapse specifically fixes is only reliably reproduced by the captured
// region dumps; this is a lightweight portable guard for the same family of inputs.)
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
g.insert_stroke(
&[
line(Point::new(50.0, -10.0), Point::new(50.0, 50.0)),
line(Point::new(50.0, 50.0), Point::new(50.0003, -10.0)),
],
None, None, 1.0,
);
g.gc_invisible_edges();
assert_no_spikes(&g);
assert_all_fills_connected(&g);
}
#[test]
fn second_stroke_crossing_first_splits_into_quadrants() {
// A later stroke that crosses an earlier stroke's edge inside a fill triggers an

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long