Fix #1770: Shape Builder merges leave no spur or split outline when shared edges differ by rounding

The Shape Builder merges faces by uniting their contours, and the edges
two faces share cancel out. The sweep can give two faces copies of a
shared edge whose ends lie a few times its tolerance apart, with the
difference left as pieces shorter than the tolerance. The union could
then keep the space between the copies: a hairline that joined a hole
as a spur, or a seam that cut a petal tip off the ring's outer outline
as an outline of its own. On Apple Silicon, turning one petal of the
rosette test by -180 degrees instead of 180 leaves a spur.

A Shape Builder action unites the picked faces first, and then what is
left of each shape. When two copies of a shared edge differ in the
first of these unions that unites the faces' contours, each point of
the faces' contours that lies within 16 times the sweep's tolerance of
a point kept before it now moves onto that point before that union and
each later one, with the handles next to it. A piece left within that
distance of one point is dropped. The planar map joins the ends of
copies of an edge within the same distance. Copies that share their
ends leave at most a hairline contour, which the merge drops as a
sliver. When the copies in that union all match exactly, the unions
take the contours unchanged. Only the Shape Builder's merges and
deletions unite faces this way, so Pathfinder output does not change.
This commit is contained in:
dotCooCoo
2026-10-16 02:21:46 +07:00
committed by bflatastic
parent ac4d69032c
commit 7960fa722f
3 changed files with 288 additions and 10 deletions
+22 -3
View File
@@ -1127,8 +1127,9 @@ fn image_trace_views_draw_on_screen_only_and_expand_to_the_result() {
/// #569: merging the regions of a rosette of unfilled ellipses (each off the centre, petals
/// overlapping) merges every region the drags touch into one shape whose outline is only the
/// merged area's border: no edge between merged regions stays in, as a stray line or a spur.
#[test]
fn shape_builder_merges_rosette_rings_cleanly() {
/// Petal `k` turns about the center by 45° × `k`, or by each angle `turns` lists for it in turn.
#[track_caller]
fn rosette_rings_merge_cleanly(turns: &[(u32, &[f64])]) {
let mut s = session();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
let c = [400.0, 320.0];
@@ -1137,7 +1138,10 @@ fn shape_builder_merges_rosette_rings_cleanly() {
let id =
s.execute("shape.ellipse", &json!({"x": c[0] - 60.0, "y": c[1] - 172.0, "width": 120, "height": 324})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("object.rotate", &json!({"angle": 45.0 * f64::from(k), "origin": c})).unwrap();
let angles = turns.iter().find(|t| t.0 == k).map_or_else(|| vec![45.0 * f64::from(k)], |t| t.1.to_vec());
for angle in angles {
s.execute("object.rotate", &json!({"angle": angle, "origin": c})).unwrap();
}
petals.push(id);
}
s.execute("select.set", &json!({"ids": petals})).unwrap();
@@ -1187,3 +1191,18 @@ fn shape_builder_merges_rosette_rings_cleanly() {
let clean = vectorcraft_pathops::normalize(&ring, rule);
assert!((ring.length() - clean.length()).abs() < 1.0, "{} pt of outline, {} without inner edges", ring.length(), clean.length());
}
#[test]
fn shape_builder_merges_rosette_rings_cleanly() {
rosette_rings_merge_cleanly(&[]);
}
/// #1770: the rosette merges as cleanly with a petal turned by an equivalent angle (-180° for 180°,
/// -855° for 225°, -45° for 315°) or in two steps, which moves its outline by a rounding error.
#[test]
fn shape_builder_merges_rosette_rings_cleanly_however_a_petal_turns() {
rosette_rings_merge_cleanly(&[(4, &[-180.0])]);
rosette_rings_merge_cleanly(&[(5, &[-855.0])]);
rosette_rings_merge_cleanly(&[(7, &[-45.0])]);
rosette_rings_merge_cleanly(&[(4, &[100.0, 80.0])]);
}