From 1c52c2ee208a6534d2bdfd1f2c48fbd567b29e1b Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Thu, 27 Nov 2025 15:43:57 -0600 Subject: [PATCH 1/6] Track segment splits --- src/arbitrary.rs | 6 +++-- src/geom.rs | 21 ++++++++++++--- src/position.rs | 66 ++++++++++++++++++++++++++-------------------- src/segments.rs | 14 +++++++++- src/topology.rs | 9 ++++--- tests/snapshots.rs | 3 ++- 6 files changed, 79 insertions(+), 40 deletions(-) diff --git a/src/arbitrary.rs b/src/arbitrary.rs index a5a4a80..f9aba5d 100644 --- a/src/arbitrary.rs +++ b/src/arbitrary.rs @@ -72,10 +72,11 @@ pub fn monotonic_bezier(u: &mut Unstructured<'_>) -> Result Option { } } -pub(crate) fn monotonic_pieces(cub: CubicBez) -> ArrayVec { +/// A y-monotonic piece of a cubic Bezier. +pub(crate) struct MonotonicPiece { + pub start_t: f64, + pub end_t: f64, + pub piece: CubicBez, +} + +pub(crate) fn monotonic_pieces(cub: CubicBez) -> ArrayVec { let mut ret = ArrayVec::new(); let q0 = cub.p1.y - cub.p0.y; let q1 = cub.p2.y - cub.p1.y; @@ -199,7 +206,11 @@ pub(crate) fn monotonic_pieces(cub: CubicBez) -> ArrayVec { if r > 0.0 && r < 1.0 { let piece_before = cub.subsegment(last_r..r); if let Some(c) = force_monotonic(piece_before) { - ret.push(c) + ret.push(MonotonicPiece { + piece: c, + start_t: last_r, + end_t: r, + }); } last_r = r; } @@ -207,7 +218,11 @@ pub(crate) fn monotonic_pieces(cub: CubicBez) -> ArrayVec { let piece_before = cub.subsegment(last_r..1.0); if let Some(c) = force_monotonic(piece_before) { - ret.push(c) + ret.push(MonotonicPiece { + piece: c, + start_t: last_r, + end_t: 1.0, + }); } ret diff --git a/src/position.rs b/src/position.rs index 4d44979..46ddf69 100644 --- a/src/position.rs +++ b/src/position.rs @@ -28,7 +28,7 @@ fn ordered_curves_all_close( segs: &Segments, order: &[SegIdx], output_order: &[OutputSegIdx], - out: &mut OutputSegVec<(BezPath, Option)>, + out: &mut OutputSegVec, mut y0: f64, y1: f64, endpoints: &HalfOutputSegVec, @@ -46,14 +46,14 @@ fn ordered_curves_all_close( let o0 = output_order[0]; let o1 = output_order[1]; - let p0 = bez_end(&out[o0].0); - let p1 = bez_end(&out[o1].0); + let p0 = bez_end(&out[o0].path); + let p1 = bez_end(&out[o1].path); let q0 = endpoints[o0.second_half()]; let q1 = endpoints[o1.second_half()]; if p0.y == y0 && p1.y == y0 { - let c0 = next_subsegment(&segs[s0], &out[o0].0, y1, q0); - let c1 = next_subsegment(&segs[s1], &out[o1].0, y1, q1); + let c0 = next_subsegment(&segs[s0], &out[o0].path, y1, q0); + let c1 = next_subsegment(&segs[s1], &out[o1].path, y1, q1); if transversal_after(c0, c1, y1) { return; @@ -61,8 +61,8 @@ fn ordered_curves_all_close( } if q0.y == y1 && q1.y == y1 { - let c0 = next_subsegment(&segs[s0], &out[o0].0, y1, q0); - let c1 = next_subsegment(&segs[s1], &out[o1].0, y1, q1); + let c0 = next_subsegment(&segs[s0], &out[o0].path, y1, q0); + let c1 = next_subsegment(&segs[s1], &out[o1].path, y1, q1); if transversal_before(c0, c1, y0) { return; @@ -73,15 +73,15 @@ fn ordered_curves_all_close( // Ensure everything in `out` goes up to `y0`. Anything that doesn't go up to `y0` is // an output where we can just copy from the input. for (&seg_idx, &out_idx) in order.iter().zip(output_order) { - let (out_bez, out_copied_idx) = &mut out[out_idx]; - if bez_end_y(out_bez) < y0 { - *out_copied_idx = Some(out_bez.elements().len() - 1); + let out = &mut out[out_idx]; + if bez_end_y(&out.path) < y0 { + out.copied_idx = Some(out.path.elements().len() - 1); let endpoint = endpoints[out_idx.second_half()]; if segs[seg_idx].is_line() { - out_bez.line_to(endpoint) + out.path.line_to(endpoint) } else { - let c = next_subsegment(&segs[seg_idx], out_bez, y0, endpoint); - out_bez.curve_to(c.p1, c.p2, c.p3); + let c = next_subsegment(&segs[seg_idx], &out.path, y0, endpoint); + out.path.curve_to(c.p1, c.p2, c.p3); } } } @@ -110,7 +110,7 @@ fn ordered_curves_all_close( x1_max_so_far = x1_max_so_far.max(x1); out[*out_idx] - .0 + .path .quad_to((x_mid_max_so_far, y_mid), (x1_max_so_far, next_y0)); } approxes.clear(); @@ -225,6 +225,13 @@ fn next_subsegment(seg: &Segment, out: &BezPath, y1: f64, endpoint: kurbo::Point c } +// TODO: docme +#[derive(Default)] +pub struct PositionedOutputSeg { + pub path: BezPath, + pub copied_idx: Option, +} + /// Compute positions for all of the output segments. /// /// The orders between the output segments is specified by `order`. The endpoints @@ -232,6 +239,7 @@ fn next_subsegment(seg: &Segment, out: &BezPath, y1: f64, endpoint: kurbo::Point /// are provided in `endpoints`. For each output segment, we return a Bézier /// path. /// +/// FIXME: update doc /// The `usize` return value tells which segment (if any) in the returned /// path was the one that was "far" from any other paths. This is really /// only interesting for diagnosis/visualization so the API should probably @@ -243,8 +251,8 @@ pub(crate) fn compute_positions( endpoints: &HalfOutputSegVec, scan_order: &ScanLineOrder, accuracy: f64, -) -> OutputSegVec<(BezPath, Option)> { - let mut out = OutputSegVec::<(BezPath, Option)>::with_size(orig_seg_map.len()); +) -> OutputSegVec { + let mut out = OutputSegVec::::with_size(orig_seg_map.len()); // We try to build `out` lazily, by avoiding copying input segments to outputs segments // until they're needed (by copying in one go, we avoid excess subdivisions). That means // we need to separately keep track of how far down we've looked at each output. If @@ -255,12 +263,12 @@ pub(crate) fn compute_positions( let p = endpoints[idx.first_half()]; let q = endpoints[idx.second_half()]; if p.y == q.y { - out[idx].0.move_to(p); - out[idx].0.line_to(q); - out[idx].1 = Some(0); + out[idx].path.move_to(p); + out[idx].path.line_to(q); + out[idx].copied_idx = Some(0); continue; } - out[idx].0.move_to(p); + out[idx].path.move_to(p); queue.push(HeapEntry { y: p.y.into(), idx }); } @@ -320,8 +328,8 @@ pub(crate) fn compute_positions( // we'll hold off on the copying because it might allow us to avoid further // subdivision. if y0 == y1 { - out[idx].1 = Some(out[idx].0.elements().len() - 1); - out[idx].0.line_to(endpoints[idx.second_half()]); + out[idx].copied_idx = Some(out[idx].path.elements().len() - 1); + out[idx].path.line_to(endpoints[idx.second_half()]); } } else { let orig_neighbors = neighbors @@ -350,22 +358,22 @@ pub(crate) fn compute_positions( } for out_idx in out.indices() { - let (out_bez, out_copied_idx) = &mut out[out_idx]; + let out = &mut out[out_idx]; let endpoint = endpoints[out_idx.second_half()]; - if bez_end_y(out_bez) < endpoint.y { + if bez_end_y(&out.path) < endpoint.y { let seg_idx = orig_seg_map[out_idx]; - *out_copied_idx = Some(out_bez.elements().len() - 1); + out.copied_idx = Some(out.path.elements().len() - 1); if segs[seg_idx].is_line() { - out_bez.line_to(endpoint) + out.path.line_to(endpoint) } else { - let c = next_subsegment(&segs[seg_idx], out_bez, endpoint.y, endpoint); - out_bez.curve_to(c.p1, c.p2, c.p3); + let c = next_subsegment(&segs[seg_idx], &out.path, endpoint.y, endpoint); + out.path.curve_to(c.p1, c.p2, c.p3); } } else { // The quadratic approximations don't respect the fixed endpoints, so tidy them // up. Since both the quadratic approximations and the endpoints satisfy // the ordering, this doesn't mess up the ordering. - match out[out_idx].0.elements_mut().last_mut().unwrap() { + match out.path.elements_mut().last_mut().unwrap() { kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) | kurbo::PathEl::QuadTo(_, p) diff --git a/src/segments.rs b/src/segments.rs index 8bdbcd6..9c3b886 100644 --- a/src/segments.rs +++ b/src/segments.rs @@ -40,6 +40,12 @@ pub struct Segments { /// For each segment, stores true if the sweep-line order (small y to big y) /// is the same as the orientation in its original contour. orientation: SegVec, + /// For each segment, stores true if it came from the same input segment as its + /// predecessor (w.r.t. the original orientation). We split input segments at + /// y-critical points because the main algorithm requires monotonic segments. + /// Keeping track of where the splits happened allows us to potentially merge + /// things back at the end. + split_from_predecessor: SegVec<(f64, f64)>, /// All the entrance heights, of segments, ordered by height. /// This includes horizontal segments. @@ -245,6 +251,7 @@ impl Segments { let (a, b, orient) = if p < q { (p, q, true) } else { (q, p, false) }; self.segs.push(Segment::straight(*a, *b)); self.orientation.push(orient); + self.split_from_predecessor.push((0.0, 1.0)); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); @@ -324,6 +331,7 @@ impl Segments { if p0 != p1 { self.segs.push(Segment::straight(p0, p1)); self.orientation.push(orient); + self.split_from_predecessor.push((0.0, 1.0)); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); @@ -332,7 +340,8 @@ impl Segments { _ => { let cubic = to_cubic(seg); let cubics = monotonic_pieces(cubic); - for c in cubics { + for monotonic in cubics { + let c = monotonic.piece; let (p0, p1, p2, p3, orient) = if (c.p0.y, c.p0.x) <= (c.p3.y, c.p3.x) { (c.p0, c.p1, c.p2, c.p3, true) } else { @@ -345,6 +354,8 @@ impl Segments { p3.into(), )); self.orientation.push(orient); + self.split_from_predecessor + .push((monotonic.start_t, monotonic.end_t)); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); @@ -389,6 +400,7 @@ impl Segments { let (a, b, orient) = if p < q { (p, q, true) } else { (q, p, false) }; self.segs.push(Segment::straight(*a, *b)); self.orientation.push(orient); + self.split_from_predecessor.push((0.0, 1.0)); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); diff --git a/src/topology.rs b/src/topology.rs index 2a503c4..7dd72a3 100644 --- a/src/topology.rs +++ b/src/topology.rs @@ -11,6 +11,7 @@ use crate::{ curve::{y_subsegment, Order}, geom::Point, order::ComparisonCache, + position::PositionedOutputSeg, segments::{NonClosedPath, SegIdx, SegVec, Segments}, sweep::{ SegmentsConnectedAtX, SweepLineBuffers, SweepLineRange, SweepLineRangeBuffers, Sweeper, @@ -910,7 +911,7 @@ impl Topology { fn segs_to_path( &self, segs: &[HalfOutputSegIdx], - positions: &OutputSegVec<(BezPath, Option)>, + positions: &OutputSegVec, ) -> BezPath { let mut ret = BezPath::default(); ret.move_to(self.point(segs[0].other_half()).to_kurbo()); @@ -920,9 +921,9 @@ impl Topology { // skip(1) leaves off the initial MoveTo, which is unnecessary // because this path starts where the last one ended. // TODO: avoid the allocation in reverse_subpaths - ret.extend(path.0.reverse_subpaths().iter().skip(1)); + ret.extend(path.path.reverse_subpaths().iter().skip(1)); } else { - ret.extend(path.0.iter().skip(1)); + ret.extend(path.path.iter().skip(1)); } } @@ -1186,7 +1187,7 @@ impl Topology { /// /// TODO: We should allow passing in an "inside" callback and then only do /// positioning for the segments that are on the boundary. - pub fn compute_positions(&self) -> OutputSegVec<(BezPath, Option)> { + pub fn compute_positions(&self) -> OutputSegVec { // TODO: reuse the cache from the sweep-line let mut cmp = ComparisonCache::new(self.eps, self.eps / 2.0); let mut endpoints = HalfOutputSegVec::with_size(self.orig_seg.len()); diff --git a/tests/snapshots.rs b/tests/snapshots.rs index eeec13e..141b5b5 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -415,7 +415,8 @@ fn generate_position_snapshot(path: PathBuf) -> Result<(), Failed> { ..Default::default() }; for out_idx in top.segment_indices() { - let (path, far_idx) = &out_paths[out_idx]; + let path = &out_paths[out_idx].path; + let far_idx = &out_paths[out_idx].copied_idx; for (idx, seg) in path.segments().enumerate() { let skia_seg = skia_kurbo_seg(seg); From 27bbb0e6b8640105a136a4bbf3f19e34248d1a47 Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Sat, 29 Nov 2025 18:27:56 -0600 Subject: [PATCH 2/6] First version that appears to work --- src/curve/mod.rs | 5 +- src/segments.rs | 12 +- ...topology__tests__merge_monotonic_segs.snap | 312 ++++++++++++++++++ src/topology.rs | 165 ++++++++- 4 files changed, 482 insertions(+), 12 deletions(-) create mode 100644 src/snapshots/linesweeper__topology__tests__merge_monotonic_segs.snap diff --git a/src/curve/mod.rs b/src/curve/mod.rs index 8f28c4d..d8a2d64 100644 --- a/src/curve/mod.rs +++ b/src/curve/mod.rs @@ -334,7 +334,10 @@ fn cubic_from_bez_y(c: CubicBez) -> Cubic { /// Find the parameter `t` at which `c` crosses height `y`. pub fn solve_t_for_y(c: CubicBez, y: f64) -> f64 { - debug_assert!(c.p0.y <= y && y <= c.p3.y && c.p0.y < c.p3.y); + debug_assert!( + c.p0.y <= y && y <= c.p3.y && c.p0.y < c.p3.y, + "invalid y ({y}) for curve ({c:?})" + ); if y == c.p0.y { return 0.0; diff --git a/src/segments.rs b/src/segments.rs index 9c3b886..178654c 100644 --- a/src/segments.rs +++ b/src/segments.rs @@ -45,7 +45,11 @@ pub struct Segments { /// y-critical points because the main algorithm requires monotonic segments. /// Keeping track of where the splits happened allows us to potentially merge /// things back at the end. - split_from_predecessor: SegVec<(f64, f64)>, + pub(crate) split_from_predecessor: SegVec<(f64, f64)>, + /// The original input segments, for reconstructing/merging. (TODO: this + /// representation is wasteful, since we only need it for segments that + /// got split.) + pub(crate) input_segs: SegVec, /// All the entrance heights, of segments, ordered by height. /// This includes horizontal segments. @@ -252,6 +256,8 @@ impl Segments { self.segs.push(Segment::straight(*a, *b)); self.orientation.push(orient); self.split_from_predecessor.push((0.0, 1.0)); + self.input_segs + .push(kurbo::PathSeg::Line((p.to_kurbo(), q.to_kurbo()).into())); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); @@ -332,6 +338,7 @@ impl Segments { self.segs.push(Segment::straight(p0, p1)); self.orientation.push(orient); self.split_from_predecessor.push((0.0, 1.0)); + self.input_segs.push(seg); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); @@ -356,6 +363,7 @@ impl Segments { self.orientation.push(orient); self.split_from_predecessor .push((monotonic.start_t, monotonic.end_t)); + self.input_segs.push(seg); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); @@ -401,6 +409,8 @@ impl Segments { self.segs.push(Segment::straight(*a, *b)); self.orientation.push(orient); self.split_from_predecessor.push((0.0, 1.0)); + self.input_segs + .push(kurbo::PathSeg::Line((p.to_kurbo(), q.to_kurbo()).into())); self.contour_prev .push(Some(SegIdx(self.segs.len().saturating_sub(2)))); self.contour_next.push(Some(SegIdx(self.segs.len()))); diff --git a/src/snapshots/linesweeper__topology__tests__merge_monotonic_segs.snap b/src/snapshots/linesweeper__topology__tests__merge_monotonic_segs.snap new file mode 100644 index 0000000..c1e2af0 --- /dev/null +++ b/src/snapshots/linesweeper__topology__tests__merge_monotonic_segs.snap @@ -0,0 +1,312 @@ +--- +source: src/topology.rs +expression: "(top, contours)" +--- +(Topology( + eps: 0.000001, + tag: [ + (), + (), + (), + (), + (), + (), + ], + open_segs: [ + [], + [], + [], + [], + [], + [], + ], + winding: OutputSegVec( + inner: [ + HalfSegmentWindingNumbers( + counter_clockwise: -1, + clockwise: 0, + ), + HalfSegmentWindingNumbers( + counter_clockwise: 0, + clockwise: -1, + ), + HalfSegmentWindingNumbers( + counter_clockwise: -1, + clockwise: 0, + ), + HalfSegmentWindingNumbers( + counter_clockwise: -1, + clockwise: 0, + ), + HalfSegmentWindingNumbers( + counter_clockwise: 0, + clockwise: -1, + ), + HalfSegmentWindingNumbers( + counter_clockwise: -1, + clockwise: 0, + ), + ], + ), + points: PointVec( + inner: [ + Point( + y: -0.2886751345948129, + x: -0.769800358919501, + ), + Point( + y: 0.0, + x: -1.0, + ), + Point( + y: 0.0, + x: 1.0, + ), + Point( + y: 0.2886751345948128, + x: 0.7698003589195009, + ), + Point( + y: 4.0, + x: -2.0, + ), + Point( + y: 4.0, + x: 2.0, + ), + ], + ), + point_idx: HalfOutputSegVec( + start: [ + PointIdx(0), + PointIdx(0), + PointIdx(1), + PointIdx(2), + PointIdx(2), + PointIdx(4), + ], + end: [ + PointIdx(1), + PointIdx(3), + PointIdx(4), + PointIdx(3), + PointIdx(5), + PointIdx(5), + ], + ), + point_neighbors: HalfOutputSegVec( + start: [ + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: false, + ), + ), + ], + end: [ + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: false, + ), + ), + ], + ), + deleted: OutputSegVec( + inner: [ + false, + false, + false, + false, + false, + false, + ], + ), + scan_west: OutputSegVec( + inner: [ + None, + Some(OutputSegIdx(0)), + None, + Some(OutputSegIdx(1)), + Some(OutputSegIdx(3)), + None, + ], + ), + scan_east: OutputSegVec( + inner: [ + None, + None, + Some(OutputSegIdx(1)), + None, + None, + None, + ], + ), + scan_after: [ + (0.2886751345948128, Some(OutputSegIdx(2)), Some(OutputSegIdx(4))), + ], + orig_seg: OutputSegVec( + inner: [ + SegIdx(0), + SegIdx(1), + SegIdx(5), + SegIdx(2), + SegIdx(3), + SegIdx(4), + ], + ), + positively_oriented: OutputSegVec( + inner: [ + false, + true, + false, + false, + true, + false, + ], + ), +), Contours( + contours: [ + Contour( + path: BezPath([ + MoveTo(Point( + x: -1.0, + y: 0.0, + )), + LineTo(Point( + x: -2.0, + y: 4.0, + )), + LineTo(Point( + x: 2.0, + y: 4.0, + )), + LineTo(Point( + x: 1.0, + y: 0.0, + )), + CurveTo(Point( + x: 1.0, + y: 1.0, + ), Point( + x: -1.0, + y: -1.0, + ), Point( + x: -1.0, + y: 0.0, + )), + ClosePath, + ]), + parent: None, + outer: true, + ), + ], +)) diff --git a/src/topology.rs b/src/topology.rs index 7dd72a3..c6e16d6 100644 --- a/src/topology.rs +++ b/src/topology.rs @@ -5,10 +5,10 @@ use std::collections::VecDeque; -use kurbo::{BezPath, Rect, Shape}; +use kurbo::{BezPath, ParamCurve, Rect, Shape}; use crate::{ - curve::{y_subsegment, Order}, + curve::{solve_t_for_y, y_subsegment, Order}, geom::Point, order::ComparisonCache, position::PositionedOutputSeg, @@ -914,23 +914,149 @@ impl Topology { positions: &OutputSegVec, ) -> BezPath { let mut ret = BezPath::default(); - ret.move_to(self.point(segs[0].other_half()).to_kurbo()); - for seg in segs { + let mut unfinished: Option = None; + + // Find a segment that can't be merged into the previous one. + let start_idx = segs + .iter() + .position(|s| self.can_merge(s.other_half(), positions).is_none()) + .unwrap(); + ret.move_to(self.point(segs[start_idx].other_half()).to_kurbo()); + for seg in segs[start_idx..].iter().chain(&segs[..start_idx]) { let path = &positions[seg.idx]; - if seg.is_first_half() { - // skip(1) leaves off the initial MoveTo, which is unnecessary - // because this path starts where the last one ended. - // TODO: avoid the allocation in reverse_subpaths - ret.extend(path.path.reverse_subpaths().iter().skip(1)); + + // TODO: avoid the allocation in reverse_subpaths + let rev_path; + let oriented_path = if seg.is_first_half() { + rev_path = path.path.reverse_subpaths(); + &rev_path + } else { + &path.path + }; + // skip(1) leaves off the initial MoveTo, which is unnecessary + // because this path starts where the last one ended. + let mut elems = oriented_path.iter().skip(1); + if let (Some(our_t), Some(unfinished_t)) = + (self.can_merge(seg.other_half(), positions), unfinished) + { + // Reconstruct part of the input curve + let input = self.segments.input_segs[self.orig_seg[seg.idx]]; + let sub_input = if our_t > unfinished_t { + input.subsegment(unfinished_t..our_t) + } else { + input + .reverse() + .subsegment((1.0 - unfinished_t)..(1.0 - our_t)) + }; + // Maybe we should "fix up" the endpoints of `sub_input` to agree with + // what was in `positions`? It shouldn't matter too much, though: they + // should be close anyway, and any errors won't cause gaps in the path + // because we skip the MoveTo. + // + // unwrap: if unfinished had something, the path must have been non-empty. + *ret.elements_mut().last_mut().unwrap() = sub_input.as_path_el(); + // Skip the first element in this output segment, because we merged it + // to the previous output segment. + elems.next(); + + // If we could merge the beginning of this output segment *and* + // the end, it could be part of a longer merge. Therefore, we + // don't update `unfinished`. + if self.can_merge(*seg, positions).is_none() { + unfinished = None; + } + } else if let Some(t) = self.can_merge(*seg, positions) { + unfinished = Some(t); } else { - ret.extend(path.path.iter().skip(1)); + unfinished = None; } + ret.extend(elems); } + // FIXME: handle the case that the first segment can be merged to the last ret.close_path(); ret } + // Is this intersection point a simple one, where at most two output segments meet? + fn is_simple_point(&self, idx: HalfOutputSegIdx) -> bool { + let nbr = self.point_neighbors[idx].clockwise; + self.point_neighbors[nbr].clockwise == idx + } + + fn can_merge( + &self, + seg: HalfOutputSegIdx, + positions: &OutputSegVec, + ) -> Option { + let pos = &positions[seg.idx]; + let orig_idx = self.orig_seg[seg.idx]; + + // We're currently using y positions on the output segment to figure out + if self.segments[orig_idx].is_horizontal() { + return None; + } + + let extremal_seg_is_copied = if seg.is_first_half() { + pos.copied_idx == Some(0) + } else { + // "- 2" because copied_idx counts segments, not elements + pos.copied_idx == Some(pos.path.elements().len() - 2) + }; + + // Is the curve from `seg` to `seg.other_half()` oriented the same + // way as the input segment? There are two possible changes of orientation: + // the output segment and the input segment could have different + // orientations, and our current contour direction might not agree + // with the output segment's orientation. + let has_orig_orientation = + seg.is_first_half() == self.segments.positively_oriented(orig_idx); + + let start_y = self.point(seg).y; + let end_y = self.point(seg.other_half()).y; + let split_from_pred = self.segments.split_from_predecessor[orig_idx]; + let can_merge = if has_orig_orientation { + let seg_in_input_t = split_from_pred.0; + if (seg_in_input_t > 0.0) + && extremal_seg_is_copied + // Is this out seg the "first" one of the input seg? + && start_y == self.segments.oriented_start(orig_idx).y + { + let t = solve_t_for_y(self.segments[orig_idx].to_kurbo_cubic(), end_y); + Some(self.input_seg_t(seg.idx, t)) + } else { + None + } + } else { + let seg_in_input_t = split_from_pred.1; + if seg_in_input_t < 1.0 + && extremal_seg_is_copied + && start_y == self.segments.oriented_end(orig_idx).y + { + let t = solve_t_for_y(self.segments[orig_idx].to_kurbo_cubic(), end_y); + Some(self.input_seg_t(seg.idx, t)) + } else { + None + } + }; + if self.is_simple_point(seg.other_half()) { + can_merge + } else { + None + } + } + + fn input_seg_t(&self, out_seg: OutputSegIdx, t: f64) -> f64 { + let orig_idx = self.orig_seg[out_seg]; + let split_from_pred = self.segments.split_from_predecessor[orig_idx]; + let ret = if self.segments.positively_oriented(orig_idx) { + split_from_pred.0 + t * (split_from_pred.1 - split_from_pred.0) + } else { + split_from_pred.1 - t * (split_from_pred.1 - split_from_pred.0) + }; + ret.clamp(0.0, 1.0) + } + /// Returns the contours of some set defined by this topology. /// /// The callback function `inside` takes a winding number and returns `true` @@ -1798,6 +1924,7 @@ impl std::ops::Index for Contours { #[cfg(test)] mod tests { + use kurbo::BezPath; use proptest::prelude::*; use crate::{ @@ -1987,6 +2114,24 @@ mod tests { insta::assert_ron_snapshot!((top, contours)); } + #[test] + fn merge_monotonic_segs() { + let mut bez = BezPath::default(); + bez.move_to((-1., 0.)); + // An "N-shaped" curve, so it divides into 3 monotonic pieces. + bez.curve_to((-1., -1.), (1., 1.), (1., 0.)); + // Finish off the shape in a way that doesn't cause any intersections. + bez.line_to((2., 4.)); + bez.line_to((-2., 4.)); + bez.close_path(); + + let top = Topology::from_path(&bez, 1e-6).unwrap(); + let contours = top.contours(|w| w != 0); + + dbg!(&top.segments.split_from_predecessor); + insta::assert_ron_snapshot!((top, contours)); + } + // Checks that all output segments intersect one another only at endpoints. // fn check_intersections(top: &Topology) { // for i in 0..top.winding.inner.len() { From e75c867036a2a577be6a2a2ae71f23bbe1597d05 Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Sun, 30 Nov 2025 19:06:20 -0600 Subject: [PATCH 3/6] Test (and fix) merging when the segment has an intersection --- ..._tests__merge_monotonic_segs_with_cut.snap | 319 ++++++++++++++++++ src/topology.rs | 28 +- tests/snapshots.rs | 90 +++++ .../snapshots/snapshots/output/merge_back.png | Bin 0 -> 8963 bytes 4 files changed, 430 insertions(+), 7 deletions(-) create mode 100644 src/snapshots/linesweeper__topology__tests__merge_monotonic_segs_with_cut.snap create mode 100644 tests/snapshots/snapshots/output/merge_back.png diff --git a/src/snapshots/linesweeper__topology__tests__merge_monotonic_segs_with_cut.snap b/src/snapshots/linesweeper__topology__tests__merge_monotonic_segs_with_cut.snap new file mode 100644 index 0000000..0084460 --- /dev/null +++ b/src/snapshots/linesweeper__topology__tests__merge_monotonic_segs_with_cut.snap @@ -0,0 +1,319 @@ +--- +source: src/topology.rs +expression: "(top, contours)" +--- +(Topology( + eps: 0.000001, + tag: [ + (), + (), + (), + (), + ], + open_segs: [ + [], + [], + [], + [], + ], + winding: OutputSegVec( + inner: [ + HalfSegmentWindingNumbers( + counter_clockwise: -1, + clockwise: 0, + ), + HalfSegmentWindingNumbers( + counter_clockwise: 0, + clockwise: -1, + ), + HalfSegmentWindingNumbers( + counter_clockwise: -1, + clockwise: 0, + ), + HalfSegmentWindingNumbers( + counter_clockwise: 1, + clockwise: 0, + ), + HalfSegmentWindingNumbers( + counter_clockwise: 0, + clockwise: 1, + ), + HalfSegmentWindingNumbers( + counter_clockwise: 0, + clockwise: 1, + ), + ], + ), + points: PointVec( + inner: [ + Point( + y: -0.2886751345948129, + x: -0.769800358919501, + ), + Point( + y: 0.0, + x: -1.0, + ), + Point( + y: 0.0, + x: 0.0000000000000001249000902703301, + ), + Point( + y: 0.0, + x: 1.0, + ), + Point( + y: 0.2886751345948128, + x: 0.7698003589195009, + ), + ], + ), + point_idx: HalfOutputSegVec( + start: [ + PointIdx(0), + PointIdx(0), + PointIdx(1), + PointIdx(2), + PointIdx(2), + PointIdx(3), + ], + end: [ + PointIdx(1), + PointIdx(2), + PointIdx(2), + PointIdx(4), + PointIdx(3), + PointIdx(4), + ], + ), + point_neighbors: HalfOutputSegVec( + start: [ + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(0), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: false, + ), + ), + ], + end: [ + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(4), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(2), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(1), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: false, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: true, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(5), + first_half: true, + ), + ), + PointNeighbors( + clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: false, + ), + counter_clockwise: HalfOutputSegIdx( + idx: OutputSegIdx(3), + first_half: false, + ), + ), + ], + ), + deleted: OutputSegVec( + inner: [ + false, + false, + false, + false, + false, + false, + ], + ), + scan_west: OutputSegVec( + inner: [ + None, + Some(OutputSegIdx(0)), + None, + None, + Some(OutputSegIdx(3)), + Some(OutputSegIdx(3)), + ], + ), + scan_east: OutputSegVec( + inner: [ + None, + None, + None, + None, + None, + None, + ], + ), + scan_after: [], + orig_seg: OutputSegVec( + inner: [ + SegIdx(0), + SegIdx(1), + SegIdx(3), + SegIdx(1), + SegIdx(3), + SegIdx(2), + ], + ), + positively_oriented: OutputSegVec( + inner: [ + false, + true, + false, + true, + false, + false, + ], + ), +), Contours( + contours: [ + Contour( + path: BezPath([ + MoveTo(Point( + x: -1.0, + y: 0.0, + )), + LineTo(Point( + x: 0.0000000000000001249000902703301, + y: 0.0, + )), + CurveTo(Point( + x: -0.5, + y: -0.25, + ), Point( + x: -1.0, + y: -0.5, + ), Point( + x: -1.0, + y: 0.0, + )), + ClosePath, + ]), + parent: None, + outer: true, + ), + Contour( + path: BezPath([ + MoveTo(Point( + x: 0.0000000000000001249000902703301, + y: 0.0, + )), + CurveTo(Point( + x: 0.5, + y: 0.25, + ), Point( + x: 1.0, + y: 0.5, + ), Point( + x: 1.0, + y: 0.0, + )), + LineTo(Point( + x: 0.0000000000000001249000902703301, + y: 0.0, + )), + ClosePath, + ]), + parent: None, + outer: true, + ), + ], +)) diff --git a/src/topology.rs b/src/topology.rs index c6e16d6..4a6b1a5 100644 --- a/src/topology.rs +++ b/src/topology.rs @@ -973,7 +973,6 @@ impl Topology { ret.extend(elems); } - // FIXME: handle the case that the first segment can be merged to the last ret.close_path(); ret } @@ -1004,11 +1003,11 @@ impl Topology { pos.copied_idx == Some(pos.path.elements().len() - 2) }; - // Is the curve from `seg` to `seg.other_half()` oriented the same - // way as the input segment? There are two possible changes of orientation: - // the output segment and the input segment could have different - // orientations, and our current contour direction might not agree - // with the output segment's orientation. + // Is the curve from `seg` to `seg.other_half()` oriented the same way + // as the input segment? There are two possible changes of orientation: + // the segment might have had its orientation switched from the input + // segment, and our current contour direction might not agree with the + // output segment's orientation. let has_orig_orientation = seg.is_first_half() == self.segments.positively_oriented(orig_idx); @@ -1039,7 +1038,7 @@ impl Topology { None } }; - if self.is_simple_point(seg.other_half()) { + if self.is_simple_point(seg) { can_merge } else { None @@ -2128,6 +2127,21 @@ mod tests { let top = Topology::from_path(&bez, 1e-6).unwrap(); let contours = top.contours(|w| w != 0); + insta::assert_ron_snapshot!((top, contours)); + } + + #[test] + fn merge_monotonic_segs_with_cut() { + let mut bez = BezPath::default(); + bez.move_to((-1., 0.)); + // An "N-shaped" curve, so it divides into 3 monotonic pieces. + bez.curve_to((-1., -1.), (1., 1.), (1., 0.)); + // Finish off the shape in a way that cuts through the middle. + bez.close_path(); + + let top = Topology::from_path(&bez, 1e-6).unwrap(); + let contours = top.contours(|w| w != 0); + dbg!(&top.segments.split_from_predecessor); insta::assert_ron_snapshot!((top, contours)); } diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 141b5b5..18d9d42 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -15,6 +15,7 @@ fn main() { let args = Arguments::from_args(); let mut tests = sweep_snapshot_diffs(); tests.extend(position_snapshot_diffs()); + tests.extend(output_snapshot_diffs()); libtest_mimic::run(&args, tests).exit(); } @@ -63,6 +64,20 @@ fn position_snapshot_diffs() -> Vec { .collect() } +fn output_snapshot_diffs() -> Vec { + let ws = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let paths = glob::glob(&format!("{ws}/tests/snapshots/inputs/output/**/*.svg")).unwrap(); + + paths + .into_iter() + .map(|p| { + let p = p.unwrap(); + let name = input_path_base(&p).display().to_string(); + Trial::test(name, || generate_output_snapshot(p)) + }) + .collect() +} + fn input_path_base(input_path: &Path) -> &Path { let ws = std::env::var("CARGO_MANIFEST_DIR").unwrap(); let base = format!("{ws}/tests/snapshots/inputs"); @@ -461,3 +476,78 @@ fn generate_position_snapshot(path: PathBuf) -> Result<(), Failed> { _ => Err("image comparison failed".into()), } } + +fn generate_output_snapshot(path: PathBuf) -> Result<(), Failed> { + let input = std::fs::read_to_string(&path).unwrap(); + let tree = usvg::Tree::from_str(&input, &usvg::Options::default()).unwrap(); + let bezs = linesweeper_util::svg_to_bezpaths(&tree); + let bbox = linesweeper_util::bezier_bounding_box(bezs.iter()); + let bez: BezPath = bezs + .into_iter() + .flat_map(|p| Affine::translate(-bbox.origin().to_vec2()) * p) + .collect(); + + let eps = 1.0; + let top = Topology::from_path(&bez, eps).unwrap(); + let contours = top.contours(|w| w % 2 == 0); + + let pad = 2.0 * eps; + let bbox = top.bounding_box(); + let mut pixmap = Pixmap::new( + (bbox.width() + 2.0 * pad).ceil() as u32, + (bbox.height() + 2.0 * pad).ceil() as u32, + ) + .unwrap(); + let pad_transform = tiny_skia::Transform::from_translate( + (pad - bbox.min_x()) as f32, + (pad - bbox.min_y()) as f32, + ); + + let stroke = tiny_skia::Stroke { + width: 1.0, + ..Default::default() + }; + for contour in contours.contours() { + for seg in contour.path.segments() { + let skia_seg = skia_kurbo_seg(seg); + pixmap.stroke_path( + &skia_seg, + &color(path_color(0)), + &stroke, + pad_transform, + None, + ); + + let p0 = seg.start(); + let p0 = tiny_skia::PathBuilder::from_circle(p0.x as f32, p0.y as f32, 2.0).unwrap(); + let p1 = seg.end(); + let p1 = tiny_skia::PathBuilder::from_circle(p1.x as f32, p1.y as f32, 2.0).unwrap(); + let black = color(tiny_skia::Color::BLACK); + pixmap.fill_path( + &p0, + &black, + tiny_skia::FillRule::Winding, + pad_transform, + None, + ); + pixmap.fill_path( + &p1, + &black, + tiny_skia::FillRule::Winding, + pad_transform, + None, + ); + } + } + let base_path = input_path_base(&path); + let out_path = output_path_for(base_path); + std::fs::create_dir_all(out_path.parent().unwrap()).unwrap(); + pixmap.save_png(&out_path).unwrap(); + + let new_image = kompari::load_image(&out_path)?; + let snapshot = kompari::load_image(&saved_snapshot_path_for(base_path))?; + match kompari::compare_images(&snapshot, &new_image) { + kompari::ImageDifference::None => Ok(()), + _ => Err("image comparison failed".into()), + } +} diff --git a/tests/snapshots/snapshots/output/merge_back.png b/tests/snapshots/snapshots/output/merge_back.png new file mode 100644 index 0000000000000000000000000000000000000000..d401a50e628f4049bdfe1623ab01c4987cf2768f GIT binary patch literal 8963 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 Q4!2O5U$=&~8hIrCAE7-apa1{> literal 0 HcmV?d00001 From 3882c74553e3d25b5a9f591190f2e885d6ae787a Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Sun, 30 Nov 2025 22:58:56 -0600 Subject: [PATCH 4/6] Update docs --- src/position.rs | 17 ++++++++----- src/topology.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/position.rs b/src/position.rs index 46ddf69..e0efafc 100644 --- a/src/position.rs +++ b/src/position.rs @@ -225,10 +225,19 @@ fn next_subsegment(seg: &Segment, out: &BezPath, y1: f64, endpoint: kurbo::Point c } -// TODO: docme +/// A positioned output segment, possibly perturbed from the original. +/// +/// We perturb output segments in order to strictly enforce ordering. +/// A single segment might be expaneded into a longer path. #[derive(Default)] pub struct PositionedOutputSeg { + /// The path, which is guaranteed to start and end at the same points as the + /// output segment did. pub path: BezPath, + /// Sometimes (often, even) a large part of the output segment didn't need + /// to be perturbed. Our sweep-line invariants guarantee that unperturbed + /// part can only be a single contiguous interval: this stores the index + /// of the unperturbed part within `path.segments()`. pub copied_idx: Option, } @@ -238,12 +247,6 @@ pub struct PositionedOutputSeg { /// should have been already computed (in a way that satisfies the order), and /// are provided in `endpoints`. For each output segment, we return a Bézier /// path. -/// -/// FIXME: update doc -/// The `usize` return value tells which segment (if any) in the returned -/// path was the one that was "far" from any other paths. This is really -/// only interesting for diagnosis/visualization so the API should probably -/// be refined somehow to make it optional. (TODO) pub(crate) fn compute_positions( segs: &Segments, orig_seg_map: &OutputSegVec, diff --git a/src/topology.rs b/src/topology.rs index 4a6b1a5..d63ef22 100644 --- a/src/topology.rs +++ b/src/topology.rs @@ -908,6 +908,11 @@ impl Topology { /// The segments in `segs` form a closed path, and each one is the ending half /// of its segment. + /// + /// This basically does two things: + /// - puts the segments in the correct orientation + /// - merges (if possible) segments that were divided because of the + /// monotonicity constraints. fn segs_to_path( &self, segs: &[HalfOutputSegIdx], @@ -977,12 +982,69 @@ impl Topology { ret } - // Is this intersection point a simple one, where at most two output segments meet? + /// Is this intersection point a simple one, where at most two output segments meet? fn is_simple_point(&self, idx: HalfOutputSegIdx) -> bool { - let nbr = self.point_neighbors[idx].clockwise; - self.point_neighbors[nbr].clockwise == idx + self.point_neighbors[idx].clockwise == self.point_neighbors[idx].counter_clockwise } + /// Checks whether this output segment can merge with its neighbor. + /// + /// Imagine you have a single input segment that looks like this: + /// + /// ```text + /// / + /// /-x-\ / + /// / \ / + /// / \-x-/ + /// / + /// ``` + /// + /// `Segments` will turn it into three monotonic pieces (by splitting it at + /// the marked `x`s), but when generating output we try to put the pieces + /// back together into a single segment. We can't always do this, for example + /// if there is an intersection with something else: + /// + /// ```text + /// | + /// /-x-\ + /// / | \ + /// / | \ + /// ``` + /// + /// or a near-intersection that causes some segment to get perturbed. + /// + /// This function returns `Some` for a half-output-segment that can be + /// merged to its neighbor. The value in the `Some` is in the parameter + /// space of the original input segment, and it says how far that merge + /// can go. In pictures, suppose that the curved segment goes from `t=0` + /// at the left to `t=1` at the right. + /// + /// ```text + /// 1 -> <- 2 + /// /-x-\ + /// / \ + /// / \ + /// ``` + /// + /// The `1 -> <- 2` in the diagram assigns names to the two + /// half-output-segments that meet at the `x`. If you call this function + /// with for half-output-segment `1`, it will return `Some(0.0)`, because + /// the output segment ending at `1` can be merged from input parameter + /// `0.0` all the way to the `x`. If you call this function with + /// half-output-segment `1`, it will return `Some(1.0)`. + /// + /// ```text + /// 1 -> <- 2 + /// /-x-\ / + /// / \/ + /// / /\ + /// ``` + /// + /// Now imagine that there is some other intersection preventing + /// the entire half-output-segment `2` from merging. In this case, + /// this function will return something like `Some(0.8)`, because the + /// output segment ending at `2` can be merged with its neighbor + /// ending at `1`, but not all the way to `1.0`. fn can_merge( &self, seg: HalfOutputSegIdx, From 00f2848f77d94082e062134f63644527f449ef38 Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Mon, 1 Dec 2025 10:08:58 -0600 Subject: [PATCH 5/6] Typo --- src/position.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/position.rs b/src/position.rs index e0efafc..a8b6dd7 100644 --- a/src/position.rs +++ b/src/position.rs @@ -228,7 +228,7 @@ fn next_subsegment(seg: &Segment, out: &BezPath, y1: f64, endpoint: kurbo::Point /// A positioned output segment, possibly perturbed from the original. /// /// We perturb output segments in order to strictly enforce ordering. -/// A single segment might be expaneded into a longer path. +/// A single segment might be expanded into a longer path. #[derive(Default)] pub struct PositionedOutputSeg { /// The path, which is guaranteed to start and end at the same points as the From 716bb854d7c64010396231741f09718ebd76228f Mon Sep 17 00:00:00 2001 From: Joe Neeman Date: Mon, 1 Dec 2025 10:16:16 -0600 Subject: [PATCH 6/6] Fix doc --- src/segments.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/segments.rs b/src/segments.rs index 178654c..1fdb66b 100644 --- a/src/segments.rs +++ b/src/segments.rs @@ -40,11 +40,10 @@ pub struct Segments { /// For each segment, stores true if the sweep-line order (small y to big y) /// is the same as the orientation in its original contour. orientation: SegVec, - /// For each segment, stores true if it came from the same input segment as its - /// predecessor (w.r.t. the original orientation). We split input segments at - /// y-critical points because the main algorithm requires monotonic segments. - /// Keeping track of where the splits happened allows us to potentially merge - /// things back at the end. + /// For each segment, stores its parameter range in the original input + /// segment (which may have been split into monotonic sub-segments). Keeping + /// track of where the splits happened allows us to potentially merge things + /// back at the end. pub(crate) split_from_predecessor: SegVec<(f64, f64)>, /// The original input segments, for reconstructing/merging. (TODO: this /// representation is wasteful, since we only need it for segments that