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 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/geom.rs b/src/geom.rs index 0c0b959..e73000d 100644 --- a/src/geom.rs +++ b/src/geom.rs @@ -164,7 +164,14 @@ fn force_monotonic(mut cub: CubicBez) -> 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..a8b6dd7 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,17 +225,28 @@ fn next_subsegment(seg: &Segment, out: &BezPath, y1: f64, endpoint: kurbo::Point c } +/// A positioned output segment, possibly perturbed from the original. +/// +/// We perturb output segments in order to strictly enforce ordering. +/// 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 + /// 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, +} + /// Compute positions for all of the output segments. /// /// The orders between the output segments is specified by `order`. The endpoints /// 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. -/// -/// 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, @@ -243,8 +254,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 +266,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 +331,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 +361,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..1fdb66b 100644 --- a/src/segments.rs +++ b/src/segments.rs @@ -40,6 +40,15 @@ 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 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 + /// got split.) + pub(crate) input_segs: SegVec, /// All the entrance heights, of segments, ordered by height. /// This includes horizontal segments. @@ -245,6 +254,9 @@ 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.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()))); @@ -324,6 +336,8 @@ 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.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()))); @@ -332,7 +346,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 +360,9 @@ impl Segments { p3.into(), )); 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()))); @@ -389,6 +407,9 @@ 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.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/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 2a503c4..d63ef22 100644 --- a/src/topology.rs +++ b/src/topology.rs @@ -5,12 +5,13 @@ 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, segments::{NonClosedPath, SegIdx, SegVec, Segments}, sweep::{ SegmentsConnectedAtX, SweepLineBuffers, SweepLineRange, SweepLineRangeBuffers, Sweeper, @@ -907,29 +908,216 @@ 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], - positions: &OutputSegVec<(BezPath, Option)>, + 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.0.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.0.iter().skip(1)); + unfinished = None; } + ret.extend(elems); } 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 { + 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, + 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 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); + + 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) { + 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` @@ -1186,7 +1374,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()); @@ -1797,6 +1985,7 @@ impl std::ops::Index for Contours { #[cfg(test)] mod tests { + use kurbo::BezPath; use proptest::prelude::*; use crate::{ @@ -1986,6 +2175,39 @@ 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); + + 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)); + } + // Checks that all output segments intersect one another only at endpoints. // fn check_intersections(top: &Topology) { // for i in 0..top.winding.inner.len() { diff --git a/tests/snapshots.rs b/tests/snapshots.rs index eeec13e..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"); @@ -415,7 +430,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); @@ -460,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 0000000..d401a50 Binary files /dev/null and b/tests/snapshots/snapshots/output/merge_back.png differ