Skip to content

Commit 69b680d

Browse files
authored
Address stroke containment bug in polygon fill (#119)
I was using the start point but it really should've been bbox
1 parent cc9a76d commit 69b680d

3 files changed

Lines changed: 133 additions & 48 deletions

File tree

star/src/lower/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,6 @@ mod test {
339339
unit: LengthUnit::In,
340340
}),
341341
],
342-
..Default::default()
343342
};
344343
let json = r#"{"dimensions":[{"number":4.0,"unit":"Mm"},{"number":10.5,"unit":"In"}]}"#;
345344

star/src/turtle/elements/fill.rs

Lines changed: 122 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ fn cross(a: Point<f64>, b: Point<f64>) -> f64 {
1111
/// Signed area of a closed subpath using Green's theorem.
1212
/// A negative area means clockwise winding.
1313
///
14-
///
1514
/// Lines and beziers follow kurbo's [`ParamCurveArea`] formulas;
1615
/// the elliptical arc contribution is a direct integration of the parametric ellipse.
1716
///
@@ -51,23 +50,23 @@ fn signed_area(stroke: &Stroke) -> f64 {
5150
if !stroke.is_closed() {
5251
area += cross(stroke.end_point(), stroke.start_point());
5352
}
54-
area * 0.5
53+
area / 2.
5554
}
5655

5756
/// Returns true if `from` => `to` crosses a ray cast from `point` rightwards.
5857
///
5958
/// Used as the per-segment primitive for ray-casting. The `>= max_y` exclusion on the upper
6059
/// endpoint ensures a shared vertex between two segments is counted only once.
6160
fn edge_crosses_ray(from: Point<f64>, to: Point<f64>, point: Point<f64>) -> bool {
62-
let (min_y, max_y) = if from.y <= to.y {
63-
(from.y, to.y)
64-
} else {
65-
(to.y, from.y)
66-
};
67-
if point.y < min_y || point.y >= max_y {
61+
let (min_y, max_y) = (from.y.min(to.y), from.y.max(to.y));
62+
63+
// >= max_y implicitly encoded here
64+
if !(min_y..max_y).contains(&point.y) {
6865
return false;
6966
}
7067
let t = (point.y - from.y) / (to.y - from.y);
68+
69+
// Ray intersects because the edge x is greater
7170
from.x + t * (to.x - from.x) > point.x
7271
}
7372

@@ -122,73 +121,94 @@ fn stroke_contains_point(stroke: &Stroke, point: Point<f64>) -> bool {
122121
!crossings.is_multiple_of(2)
123122
}
124123

125-
/// Partitions raw SVG subpaths into [`FillPolygon`]s — one per outer contour — with holes
124+
fn stroke_contains_stroke(outer: &Stroke, inner: &Stroke) -> bool {
125+
let outer_bbox = outer.bounding_box();
126+
let inner_bbox = inner.bounding_box();
127+
if !outer_bbox.contains_box(&inner_bbox) {
128+
return false;
129+
}
130+
if !stroke_contains_point(outer, inner.start_point()) {
131+
return false;
132+
}
133+
for cmd in inner.commands() {
134+
if let Some(to) = cmd.end_point()
135+
&& !stroke_contains_point(outer, to)
136+
{
137+
return false;
138+
}
139+
}
140+
true
141+
}
142+
143+
/// Partitions raw SVG subpaths into [`FillPolygon`]s, one per outer contour, with holes
126144
/// assigned to their closest enclosing outer.
127145
///
128-
/// The SVG `fill-rule` is consumed here: for `EvenOdd`, nesting depth determines outer vs. hole
129-
/// (even depth → outer); for `NonZero`, the cumulative signed winding of enclosing subpaths
130-
/// determines it (zero cumulative winding before entering → outer).
146+
/// `fill_rule` is flattened here:
147+
/// - `EvenOdd`: even = outer
148+
/// - `NonZero`: 0 cumulative winding = outer
131149
pub(crate) fn into_fill_polygons(subpaths: Vec<Stroke>, fill_rule: FillRule) -> Vec<FillPolygon> {
132150
if subpaths.is_empty() {
133151
return vec![];
134152
}
135153

136-
let areas: Vec<f64> = subpaths.iter().map(signed_area).collect();
137-
let starts: Vec<Point<f64>> = subpaths.iter().map(|s| s.start_point()).collect();
138-
139-
// For each subpath, the indices of all other subpaths that contain its start point.
140-
let containers: Vec<Vec<usize>> = (0..subpaths.len())
154+
// For each subpath, the indices of all other subpaths that enclose it.
155+
let containers: Vec<Vec<_>> = (0..subpaths.len())
141156
.map(|i| {
142157
(0..subpaths.len())
143158
.filter(|&j| j != i)
144-
.filter(|&j| stroke_contains_point(&subpaths[j], starts[i]))
159+
.filter(|&j| stroke_contains_stroke(&subpaths[j], &subpaths[i]))
145160
.collect()
146161
})
147162
.collect();
148163

149164
// Classify each subpath as outer (contributes filled area) or hole (removes it).
150-
let mut is_outer = vec![false; subpaths.len()];
151-
let mut is_hole = vec![false; subpaths.len()];
165+
let is_outer: Vec<Option<bool>> = match fill_rule {
166+
FillRule::EvenOdd => containers
167+
.iter()
168+
.map(|containing| Some(containing.len().is_multiple_of(2)))
169+
.collect(),
170+
FillRule::NonZero => {
171+
let areas: Vec<f64> = subpaths.iter().map(signed_area).collect();
152172

153-
for i in 0..subpaths.len() {
154-
match fill_rule {
155-
FillRule::EvenOdd => {
156-
if containers[i].len().is_multiple_of(2) {
157-
is_outer[i] = true;
158-
} else {
159-
is_hole[i] = true;
160-
}
161-
}
162-
FillRule::NonZero => {
163-
let cumulative_winding: i32 = containers[i]
164-
.iter()
165-
.map(|&j| if areas[j] > 0.0 { 1i32 } else { -1i32 })
166-
.sum();
173+
containers
174+
.iter()
175+
.zip(areas.iter())
176+
.map(|(container, &area)| {
177+
let cumulative_winding: i32 = container
178+
.iter()
179+
.map(|&j| if areas[j] > 0.0 { 1i32 } else { -1i32 })
180+
.sum();
167181

168-
if cumulative_winding == 0 {
169-
is_outer[i] = true;
170-
} else {
171-
let winding_inside = cumulative_winding + if areas[i] > 0.0 { 1 } else { -1 };
172-
if winding_inside == 0 {
173-
is_hole[i] = true;
182+
if cumulative_winding == 0 {
183+
Some(true)
184+
} else {
185+
let winding_inside = cumulative_winding + if area > 0.0 { 1 } else { -1 };
186+
if winding_inside == 0 {
187+
Some(false)
188+
} else {
189+
// Ignore (why?)
190+
None
191+
}
174192
}
175-
}
176-
}
193+
})
194+
.collect()
177195
}
178-
}
196+
};
179197

180-
// For each outer, collect its direct holes — holes for which this outer is the innermost
198+
// For each outer, collect its immediate holes — holes for which this outer is the innermost
181199
// enclosing outer (no other outer sits between them).
182200
(0..subpaths.len())
183-
.filter(|&i| is_outer[i])
201+
.filter(|&i| is_outer[i] == Some(true))
184202
.map(|i| {
185203
let holes = (0..subpaths.len())
186-
.filter(|&j| is_hole[j] && stroke_contains_point(&subpaths[i], starts[j]))
204+
.filter(|&j| {
205+
is_outer[j] == Some(false) && stroke_contains_stroke(&subpaths[i], &subpaths[j])
206+
})
187207
.filter(|&j| {
188208
// No other outer k is strictly between outer i and hole j.
189209
!containers[j]
190210
.iter()
191-
.any(|&k| k != i && is_outer[k] && containers[k].contains(&i))
211+
.any(|&k| k != i && is_outer[k] == Some(true) && containers[k].contains(&i))
192212
})
193213
.map(|j| subpaths[j].clone())
194214
.collect();
@@ -288,4 +308,59 @@ mod tests {
288308
assert_eq!(polygons[0].outer.start_point(), s0.start_point());
289309
assert_eq!(polygons[0].holes.len(), 0);
290310
}
311+
312+
#[test]
313+
fn test_nonzero_overlapping_not_nested() {
314+
// Subpath 0: CCW square from (0,0) to (10,10)
315+
let s0 = Stroke::new(
316+
Point::new(0.0, 0.0),
317+
vec![
318+
DrawCommand::LineTo {
319+
from: Point::new(0.0, 0.0),
320+
to: Point::new(10.0, 0.0),
321+
},
322+
DrawCommand::LineTo {
323+
from: Point::new(10.0, 0.0),
324+
to: Point::new(10.0, 10.0),
325+
},
326+
DrawCommand::LineTo {
327+
from: Point::new(10.0, 10.0),
328+
to: Point::new(0.0, 10.0),
329+
},
330+
DrawCommand::LineTo {
331+
from: Point::new(0.0, 10.0),
332+
to: Point::new(0.0, 0.0),
333+
},
334+
],
335+
);
336+
337+
// Subpath 1: CCW square from (5,5) to (15,15) - overlapping but not nested
338+
let s1 = Stroke::new(
339+
Point::new(5.0, 5.0),
340+
vec![
341+
DrawCommand::LineTo {
342+
from: Point::new(5.0, 5.0),
343+
to: Point::new(15.0, 5.0),
344+
},
345+
DrawCommand::LineTo {
346+
from: Point::new(15.0, 5.0),
347+
to: Point::new(15.0, 15.0),
348+
},
349+
DrawCommand::LineTo {
350+
from: Point::new(15.0, 15.0),
351+
to: Point::new(5.0, 15.0),
352+
},
353+
DrawCommand::LineTo {
354+
from: Point::new(5.0, 15.0),
355+
to: Point::new(5.0, 5.0),
356+
},
357+
],
358+
);
359+
360+
let polygons = into_fill_polygons(vec![s0.clone(), s1.clone()], FillRule::NonZero);
361+
362+
// Since they overlap but are not nested (neither bbox is inside the other),
363+
// they should be classified as two independent outer contours.
364+
assert_eq!(polygons.len(), 2);
365+
}
291366
}

star/src/turtle/elements/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,15 @@ impl Stroke {
163163
pub fn is_closed(&self) -> bool {
164164
(self.start_point() - self.end_point()).square_length() < f64::EPSILON
165165
}
166+
167+
/// Calculate the bounding box of the stroke.
168+
pub fn bounding_box(&self) -> Box2D<f64> {
169+
let mut bbox = Box2D::new(self.start_point, self.start_point);
170+
for command in &self.commands {
171+
if let Some(b) = command.bounding_box() {
172+
bbox = Box2D::from_points([bbox.min, bbox.max, b.min, b.max]);
173+
}
174+
}
175+
bbox
176+
}
166177
}

0 commit comments

Comments
 (0)