diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/convex_hull.rs b/node-graph/libraries/vector-types/src/vector/algorithms/convex_hull.rs index 38fcfb9638..c55c5d27f0 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/convex_hull.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/convex_hull.rs @@ -52,8 +52,17 @@ impl ConvexArc { enum SampleTag { /// A sample on an arc at local parameter `t`. Arc { arc: usize, t: f64 }, - /// A standalone candidate point: a line endpoint, a floating anchor, or an extreme of degenerate geometry. - Point, + /// A standalone candidate point (indexing into the candidate point list): a line endpoint, a + /// floating anchor, or an extreme of degenerate geometry. + Point { candidate: usize }, +} + +/// A standalone candidate position, remembering every input segment location it came from so pocket +/// extraction can locate it along its subpath. Floating anchors carry no marks. +struct CandidatePoint { + position: Point, + /// `(segment index, parameter)` locations on the input that coincide with this position. + marks: Vec<(usize, f64)>, } /// A unique candidate position, carrying every sample that landed exactly on it (e.g. the shared @@ -79,7 +88,15 @@ enum Contact { /// touch at a single point (its final extent is determined by refinement). Arc { arc: usize, t_in: f64, t_out: f64 }, /// A single point the boundary bends around: a corner anchor, line endpoint, or floating point. - Point { position: Point }, + /// The marks record where this point sits on the input segments (empty for floating anchors). + Point { position: Point, marks: Vec<(usize, f64)> }, +} + +/// The result of the shared hull pipeline: either a trivial degenerate output, or the refined cyclic +/// contact sequence ready for boundary emission. +enum HullStructure { + Degenerate(BezPath), + Contacts { arcs: Vec, contacts: Vec, distance_epsilon: f64 }, } /// Computes the convex hull of a collection of path segments plus free-floating points, returned as a @@ -87,58 +104,85 @@ enum Contact { /// of the original segments), connected by straight tangent lines. Returns an empty path for empty input, /// and a degenerate path (a single anchor, or a single straight segment) for point-like or collinear input. pub fn convex_hull_of_geometry(segments: &[PathSeg], loose_points: &[Point]) -> BezPath { + match compute_hull_structure(segments, loose_points, false) { + HullStructure::Degenerate(path) => path, + HullStructure::Contacts { arcs, contacts, distance_epsilon } => emit_hull_path(&contacts, &arcs, segments, distance_epsilon), + } +} + +/// Runs the shared hull pipeline: normalization, sampling, the polygonal hull of the samples, contact +/// extraction, and tangency refinement. `keep_collinear` keeps hull vertices lying on straight hull +/// edges, which partial hulls need for locating pockets. +fn compute_hull_structure(segments: &[PathSeg], loose_points: &[Point], keep_collinear: bool) -> HullStructure { // Stage 1: normalize the input into curvature-monotone arcs and standalone candidate points. - let (mut arcs, mut points) = normalize_geometry(segments, loose_points); + let (mut arcs, candidates) = normalize_geometry(segments, loose_points); // Establish the overall scale so tolerances can be relative to the input's size - let scale = geometry_scale(&arcs, &points); + let scale = geometry_scale(&arcs, &candidates); let distance_epsilon = (scale * 1e-9).max(f64::MIN_POSITIVE); assign_sample_counts(&mut arcs, scale); - points.sort_by(|a, b| (a.x, a.y).partial_cmp(&(b.x, b.y)).unwrap_or(std::cmp::Ordering::Equal)); - points.dedup(); - // Trivial inputs that cannot form a polygonal hull if arcs.is_empty() { - match points.len() { - 0 => return BezPath::new(), - 1 => return BezPath::from_vec(vec![PathEl::MoveTo(points[0])]), + match candidates.len() { + 0 => return HullStructure::Degenerate(BezPath::new()), + 1 => return HullStructure::Degenerate(BezPath::from_vec(vec![PathEl::MoveTo(candidates[0].position)])), _ => {} } } // Stage 2: sample all candidate geometry and take the polygonal hull of the samples. - let vertices = collect_hull_vertices(&arcs, &points); - let hull = monotone_chain(&vertices); + let vertices = collect_hull_vertices(&arcs, &candidates); + let hull = monotone_chain(&vertices, keep_collinear); match hull.len() { - 0 => return BezPath::new(), - 1 => return BezPath::from_vec(vec![PathEl::MoveTo(vertices[hull[0]].position)]), + 0 => return HullStructure::Degenerate(BezPath::new()), + 1 => return HullStructure::Degenerate(BezPath::from_vec(vec![PathEl::MoveTo(vertices[hull[0]].position)])), 2 => { // All input geometry is collinear, so the hull degenerates to a straight segment let (a, b) = (vertices[hull[0]].position, vertices[hull[1]].position); - return BezPath::from_vec(vec![PathEl::MoveTo(a), PathEl::LineTo(b), PathEl::ClosePath]); + return HullStructure::Degenerate(BezPath::from_vec(vec![PathEl::MoveTo(a), PathEl::LineTo(b), PathEl::ClosePath])); } _ => {} } // Read off the cyclic sequence of arc ranges and corner points forming the boundary let labels = label_hull_edges(&vertices, &hull, &arcs); - let mut contacts = extract_contacts(&vertices, &hull, &labels); + let mut contacts = extract_contacts(&vertices, &hull, &labels, &candidates, &arcs); // Stage 3: refine every transition between contacts to an exact tangency. refine_transitions(&mut contacts, &arcs, distance_epsilon); - // Stage 4: emit the boundary as original-geometry cuts joined by bridge lines. - emit_hull_path(&contacts, &arcs, segments, distance_epsilon) + HullStructure::Contacts { arcs, contacts, distance_epsilon } } /// Splits the input into curvature-monotone arcs and standalone candidate points. /// Curved segments are split at inflections and cusps; lines and degenerate or collinear curves are -/// reduced to the extreme points of the straight line they trace. -fn normalize_geometry(segments: &[PathSeg], loose_points: &[Point]) -> (Vec, Vec) { +/// reduced to the extreme points of the straight line they trace. Candidate points landing on the +/// same position are merged, accumulating every input location they came from. +fn normalize_geometry(segments: &[PathSeg], loose_points: &[Point]) -> (Vec, Vec) { + use std::collections::HashMap; + let mut arcs = Vec::new(); - let mut points: Vec = loose_points.iter().copied().filter(|point| point.is_finite()).collect(); + let mut candidates: Vec = Vec::new(); + let mut candidate_by_position: HashMap<(u64, u64), usize> = HashMap::new(); + let mut add_point = |position: Point, mark: Option<(usize, f64)>| { + if !position.is_finite() { + return; + } + let key = (position.x.to_bits(), position.y.to_bits()); + let index = *candidate_by_position.entry(key).or_insert_with(|| { + candidates.push(CandidatePoint { position, marks: Vec::new() }); + candidates.len() - 1 + }); + if let Some(mark) = mark { + candidates[index].marks.push(mark); + } + }; + + for &point in loose_points { + add_point(point, None); + } for (source, segment) in segments.iter().enumerate() { let cubic = segment.to_cubic(); @@ -147,8 +191,8 @@ fn normalize_geometry(segments: &[PathSeg], loose_points: &[Point]) -> (Vec (Vec (Vec Option { } /// The interior parametric extremes of a straight-line cubic (where the curve reverses direction along -/// its line and overshoots past its endpoints). -fn straight_curve_interior_extremes(cubic: &CubicBez, direction: Vec2) -> impl Iterator + '_ { +/// its line and overshoots past its endpoints), as `(parameter, position)` pairs. +fn straight_curve_interior_extremes(cubic: &CubicBez, direction: Vec2) -> impl Iterator + '_ { let derivative = cubic.deriv(); let (d0, d1, d2) = (derivative.p0.to_vec2(), derivative.p1.to_vec2(), derivative.p2.to_vec2()); @@ -243,11 +289,11 @@ fn straight_curve_interior_extremes(cubic: &CubicBez, direction: Vec2) -> impl I solve_quadratic(a, 2. * (b - a), a - 2. * b + c) .into_iter() .filter(|t| (PARAM_EPSILON..1. - PARAM_EPSILON).contains(t)) - .map(|t| cubic.eval(t)) + .map(|t| (t, cubic.eval(t))) } /// The overall size of the input, used to make tolerances scale-relative. -fn geometry_scale(arcs: &[ConvexArc], points: &[Point]) -> f64 { +fn geometry_scale(arcs: &[ConvexArc], candidates: &[CandidatePoint]) -> f64 { let mut min = Point::new(f64::INFINITY, f64::INFINITY); let mut max = Point::new(f64::NEG_INFINITY, f64::NEG_INFINITY); let mut include = |point: Point| { @@ -260,8 +306,8 @@ fn geometry_scale(arcs: &[ConvexArc], points: &[Point]) -> f64 { include(point); } } - for &point in points { - include(point); + for candidate in candidates { + include(candidate.position); } if min.x > max.x { 0. } else { (max - min).hypot() } @@ -279,7 +325,7 @@ fn assign_sample_counts(arcs: &mut [ConvexArc], scale: f64) { /// Samples every arc and merges samples landing on bit-identical positions into shared vertices, so /// junction anchors carry the tags of both adjoining arcs. -fn collect_hull_vertices(arcs: &[ConvexArc], points: &[Point]) -> Vec { +fn collect_hull_vertices(arcs: &[ConvexArc], candidates: &[CandidatePoint]) -> Vec { use std::collections::HashMap; let mut vertices: Vec = Vec::new(); @@ -308,16 +354,18 @@ fn collect_hull_vertices(arcs: &[ConvexArc], points: &[Point]) -> Vec Vec { +/// counterclockwise order (positive signed area). With `keep_collinear`, vertices lying on a hull edge +/// are kept as hull vertices (partial hulls need them: they carry the contacts pockets attach to); +/// otherwise they are dropped. +fn monotone_chain(vertices: &[HullVertex], keep_collinear: bool) -> Vec { let mut order: Vec = (0..vertices.len()).collect(); order.sort_by(|&a, &b| { let (pa, pb) = (vertices[a].position, vertices[b].position); @@ -332,28 +380,33 @@ fn monotone_chain(vertices: &[HullVertex]) -> Vec { let (po, pa, pb) = (vertices[o].position, vertices[a].position, vertices[b].position); (pa - po).cross(pb - po) }; + let pops = |cross_value: f64| if keep_collinear { cross_value < 0. } else { cross_value <= 0. }; let mut hull: Vec = Vec::with_capacity(order.len() + 1); // Lower hull for &index in &order { - while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], index) <= 0. { + while hull.len() >= 2 && pops(cross(hull[hull.len() - 2], hull[hull.len() - 1], index)) { hull.pop(); } hull.push(index); } - // Upper hull + // Upper hull, continuing from the rightmost vertex already on the stack let lower_len = hull.len() + 1; - for &index in order.iter().rev() { - while hull.len() >= lower_len && cross(hull[hull.len() - 2], hull[hull.len() - 1], index) <= 0. { + for &index in order.iter().rev().skip(1) { + while hull.len() >= lower_len && pops(cross(hull[hull.len() - 2], hull[hull.len() - 1], index)) { hull.pop(); } hull.push(index); } - // The final vertex repeats the first + // The final vertex repeats the first, and keeping collinear vertices can duplicate the seams hull.pop(); + hull.dedup(); + if hull.len() > 1 && hull.first() == hull.last() { + hull.pop(); + } hull } @@ -398,7 +451,7 @@ fn continues(a: EdgeLabel, b: EdgeLabel) -> bool { /// Groups the labeled hull edges into the cyclic sequence of boundary contacts: maximal arc ranges, /// and the corner points standing alone between bridges. -fn extract_contacts(vertices: &[HullVertex], hull: &[usize], labels: &[EdgeLabel]) -> Vec { +fn extract_contacts(vertices: &[HullVertex], hull: &[usize], labels: &[EdgeLabel], candidates: &[CandidatePoint], arcs: &[ConvexArc]) -> Vec { let edge_count = labels.len(); // Rotate to start at an edge that does not continue its predecessor, so no arc chain wraps around @@ -414,7 +467,18 @@ fn extract_contacts(vertices: &[HullVertex], hull: &[usize], labels: &[EdgeLabel }); match interior_tag { Some((arc, t)) => Contact::Arc { arc, t_in: t, t_out: t }, - None => Contact::Point { position: vertex.position }, + None => { + // Collect every input location this corner coincides with: endpoint tags of adjoining + // arcs, and the locations of any merged standalone candidate point + let mut marks = Vec::new(); + for tag in &vertex.tags { + match *tag { + SampleTag::Arc { arc, t } => marks.push((arcs[arc].source, arcs[arc].to_source_t(t))), + SampleTag::Point { candidate } => marks.extend(candidates[candidate].marks.iter().copied()), + } + } + Contact::Point { position: vertex.position, marks } + } } }; @@ -495,12 +559,12 @@ fn refine_transitions(contacts: &mut [Contact], arcs: &[ConvexArc], distance_eps let out_end = match contacts[i] { Contact::Arc { arc, t_out, .. } if t_out > 0. && t_out < 1. => BridgeEnd::Free { arc, t: t_out }, Contact::Arc { arc, t_out, .. } => BridgeEnd::Fixed(arcs[arc].cubic.eval(t_out)), - Contact::Point { position } => BridgeEnd::Fixed(position), + Contact::Point { position, .. } => BridgeEnd::Fixed(position), }; let in_end = match contacts[j] { Contact::Arc { arc, t_in, .. } if t_in > 0. && t_in < 1. => BridgeEnd::Free { arc, t: t_in }, Contact::Arc { arc, t_in, .. } => BridgeEnd::Fixed(arcs[arc].cubic.eval(t_in)), - Contact::Point { position } => BridgeEnd::Fixed(position), + Contact::Point { position, .. } => BridgeEnd::Fixed(position), }; let (refined_out, refined_in) = match (out_end, in_end) { @@ -561,17 +625,38 @@ fn refine_transitions(contacts: &mut [Contact], arcs: &[ConvexArc], distance_eps } } +/// Cuts the given parameter range out of a source segment and appends it to the path, preserving the +/// segment's exact control points (and kind) when the whole segment lies on the boundary. A reversed +/// range walks the segment backward. +fn push_source_cut(path: &mut BezPath, segments: &[PathSeg], source: usize, t0: f64, t1: f64) { + let source = &segments[source]; + + let piece = if t0 == 0. && t1 == 1. { + *source + } else if t0 == 1. && t1 == 0. { + source.reverse() + } else { + source.subsegment(t0..t1) + }; + + match piece { + PathSeg::Line(line) => path.line_to(line.p1), + PathSeg::Quad(quad) => path.quad_to(quad.p1, quad.p2), + PathSeg::Cubic(cubic) => path.curve_to(cubic.p1, cubic.p2, cubic.p3), + } +} + /// Builds the final closed path: each arc contact becomes a cut of its original source segment /// (preserving the input's exact geometry and segment kind), and consecutive contacts are joined by /// straight bridge lines wherever their endpoints do not already coincide. fn emit_hull_path(contacts: &[Contact], arcs: &[ConvexArc], segments: &[PathSeg], distance_epsilon: f64) -> BezPath { let contact_in_position = |contact: &Contact| match contact { &Contact::Arc { arc, t_in, .. } => arcs[arc].cubic.eval(t_in), - Contact::Point { position } => *position, + Contact::Point { position, .. } => *position, }; let contact_out_position = |contact: &Contact| match contact { &Contact::Arc { arc, t_out, .. } => arcs[arc].cubic.eval(t_out), - Contact::Point { position } => *position, + Contact::Point { position, .. } => *position, }; let mut path = BezPath::new(); @@ -584,24 +669,7 @@ fn emit_hull_path(contacts: &[Contact], arcs: &[ConvexArc], segments: &[PathSeg] && (t_out - t_in).abs() > PARAM_EPSILON { let arc = &arcs[arc]; - let source = &segments[arc.source]; - let (source_in, source_out) = (arc.to_source_t(t_in), arc.to_source_t(t_out)); - - // Cut the range out of the source segment, preserving its exact control points when the - // whole segment lies on the hull - let piece = if source_in == 0. && source_out == 1. { - *source - } else if source_in == 1. && source_out == 0. { - source.reverse() - } else { - source.subsegment(source_in..source_out) - }; - - match piece { - PathSeg::Line(line) => path.line_to(line.p1), - PathSeg::Quad(quad) => path.quad_to(quad.p1, quad.p2), - PathSeg::Cubic(cubic) => path.curve_to(cubic.p1, cubic.p2, cubic.p3), - } + push_source_cut(&mut path, segments, arc.source, arc.to_source_t(t_in), arc.to_source_t(t_out)); } // The bridge line to the next contact, unless the two already meet at a shared anchor. The @@ -618,6 +686,559 @@ fn emit_hull_path(contacts: &[Contact], arcs: &[ConvexArc], segments: &[PathSeg] path } +/// One subpath of the input geometry: a contiguous run of the segment list, and whether the run closes +/// back on itself. Partial hulls use this to walk the boundary stretch a bridge spans. +pub struct SubpathRun { + pub segments: std::ops::Range, + pub closed: bool, +} + +/// Samples taken along each pocket cut for the flattening sweep. +const POCKET_SAMPLES_PER_CUT: usize = 16; +/// Relative turning below which a polyline vertex is treated as straight. +const TURN_EPSILON: f64 = 1e-9; + +/// A traversal-ordered cut of an input segment forming part of a pocket walk (`t0 > t1` walks the +/// segment backward). +#[derive(Clone, Copy)] +struct PocketCut { + source: usize, + t0: f64, + t1: f64, +} + +impl PocketCut { + /// Map a local parameter along this cut's traversal to a parameter on its source segment. + fn to_source_t(self, u: f64) -> f64 { + self.t0 + u * (self.t1 - self.t0) + } + + fn piece(self, segments: &[PathSeg]) -> PathSeg { + segments[self.source].subsegment(self.t0..self.t1) + } +} + +/// One piece of a flattened pocket boundary: a kept cut of original geometry, or a straight chord +/// covering a dent. +enum PocketPiece { + Cut { source: usize, t0: f64, t1: f64 }, + Chord(Point), +} + +/// The signed angle from `a` to `b` in radians (counterclockwise positive). +fn signed_angle(a: Vec2, b: Vec2) -> f64 { + a.cross(b).atan2(a.dot(b)) +} + +/// Whether two segments cross at a single interior point (touching at endpoints does not count). +fn segments_properly_cross(a1: Point, a2: Point, b1: Point, b2: Point) -> bool { + let d1 = (a2 - a1).cross(b1 - a1); + let d2 = (a2 - a1).cross(b2 - a1); + let d3 = (b2 - b1).cross(a1 - b1); + let d4 = (b2 - b1).cross(a2 - b1); + d1 * d2 < 0. && d3 * d4 < 0. +} + +/// Computes the convex hull of the given geometry, but keeps (rather than bridging over) every +/// concavity whose boundary turns backward by more than `max_concavity` radians. Bridges between +/// separate subpaths always apply, so disjoint islands are connected and interior geometry (such as +/// holes) never survives. At `max_concavity = 0` every dent is kept; as it grows, shallower dents are +/// flattened first, and at infinity the result is the plain convex hull. +pub fn partial_convex_hull_of_geometry(segments: &[PathSeg], runs: &[SubpathRun], loose_points: &[Point], max_concavity: f64) -> BezPath { + if !max_concavity.is_finite() { + return convex_hull_of_geometry(segments, loose_points); + } + + match compute_hull_structure(segments, loose_points, true) { + HullStructure::Degenerate(path) => path, + HullStructure::Contacts { arcs, contacts, distance_epsilon } => emit_partial_hull_path(&contacts, &arcs, segments, runs, max_concavity, distance_epsilon), + } +} + +/// The `(segment, source parameter)` locations where a contact begins or ends on the boundary. +fn contact_marks(contact: &Contact, arcs: &[ConvexArc], departure: bool) -> Vec<(usize, f64)> { + match contact { + &Contact::Arc { arc, t_in, t_out } => { + let t = if departure { t_out } else { t_in }; + vec![(arcs[arc].source, arcs[arc].to_source_t(t))] + } + Contact::Point { marks, .. } => marks.clone(), + } +} + +/// The shared inputs of pocket lookup: the refined hull structure plus the subpath bookkeeping. +struct PocketContext<'a> { + contacts: &'a [Contact], + arcs: &'a [ConvexArc], + segments: &'a [PathSeg], + runs: &'a [SubpathRun], + /// Which run each segment belongs to. + segment_run: &'a [Option], + /// Per run: the `(contact index, low, high)` boundary intervals each contact occupies. + occupied: &'a [Vec<(usize, f64, f64)>], +} + +/// Locates the stretch of original boundary a bridge spans: the gap along the subpath between the two +/// contacts it connects, free of any other contact. Returns `None` for bridges between separate +/// subpaths or to floating points, which therefore always apply. +fn pocket_for_bridge(context: &PocketContext, from_contact: usize, to_contact: usize, bridge_from: Point, bridge_to: Point) -> Option> { + const POSITION_EPSILON: f64 = 1e-9; + + let bridge_length = bridge_from.distance(bridge_to); + if bridge_length <= f64::MIN_POSITIVE.max(1e-12) { + return None; + } + let bridge_direction = (bridge_to - bridge_from) / bridge_length; + + let out_marks = contact_marks(&context.contacts[from_contact], context.arcs, true); + let in_marks = contact_marks(&context.contacts[to_contact], context.arcs, false); + + for &(segment_a, t_a) in &out_marks { + for &(segment_b, t_b) in &in_marks { + let Some(run_index) = context.segment_run.get(segment_a).copied().flatten() else { continue }; + if context.segment_run.get(segment_b).copied().flatten() != Some(run_index) { + continue; + } + let run = &context.runs[run_index]; + let cycle = (run.segments.end - run.segments.start) as f64; + let position_a = (segment_a - run.segments.start) as f64 + t_a; + let position_b = (segment_b - run.segments.start) as f64 + t_b; + + // Walk forward (ascending positions) or backward, wrapping only on closed runs + for forward in [true, false] { + let walk = |from: f64, to: f64| { + let raw = if forward { to - from } else { from - to }; + if run.closed { raw.rem_euclid(cycle) } else { raw } + }; + let length = walk(position_a, position_b); + if !length.is_finite() || length <= POSITION_EPSILON || length >= cycle - POSITION_EPSILON { + continue; + } + + // The gap must contain no other contact + let relative = |position: f64| { + let raw = if forward { position - position_a } else { position_a - position }; + if run.closed { raw.rem_euclid(cycle) } else { raw } + }; + let dirty = context.occupied[run_index].iter().any(|&(contact_index, low, high)| { + if contact_index == from_contact || contact_index == to_contact { + return false; + } + [low, high].into_iter().any(|position| { + let r = relative(position); + r > POSITION_EPSILON && r < length - POSITION_EPSILON + }) + }); + if dirty { + continue; + } + + let Some(cuts) = build_pocket_cuts(run, segment_a, t_a, segment_b, t_b, forward) else { continue }; + if cuts.is_empty() { + continue; + } + + // The pocket must lie on the interior side of the bridge (left of a counterclockwise hull) + let middle = cuts[cuts.len() / 2].piece(context.segments).eval(0.5); + if bridge_direction.cross(middle - bridge_from) < -bridge_length * 1e-9 { + continue; + } + + return Some(cuts); + } + } + } + + None +} + +/// Builds the traversal-ordered cut list from `(segment_a, t_a)` to `(segment_b, t_b)` along the run. +fn build_pocket_cuts(run: &SubpathRun, segment_a: usize, t_a: f64, segment_b: usize, t_b: f64, forward: bool) -> Option> { + let run_length = run.segments.end - run.segments.start; + let mut cuts = Vec::new(); + let mut push = |source: usize, t0: f64, t1: f64| { + if (t1 - t0).abs() > PARAM_EPSILON { + cuts.push(PocketCut { source, t0, t1 }); + } + }; + + let step = |segment: usize| { + let local = segment - run.segments.start; + let next = if forward { (local + 1) % run_length } else { (local + run_length - 1) % run_length }; + run.segments.start + next + }; + let (enter, exit) = if forward { (0., 1.) } else { (1., 0.) }; + + if segment_a == segment_b && ((forward && t_b >= t_a) || (!forward && t_b <= t_a)) { + push(segment_a, t_a, t_b); + return Some(cuts); + } + + push(segment_a, t_a, exit); + let mut segment = step(segment_a); + let mut guard = 0; + while segment != segment_b { + push(segment, enter, exit); + segment = step(segment); + guard += 1; + if guard > run_length { + return None; + } + } + push(segment_b, enter, t_b); + + Some(cuts) +} + +/// A sample along a pocket walk, tagged with the cut and local parameter it came from. +struct PocketSample { + position: Point, + cut: usize, + u: f64, +} + +fn sample_pocket(cuts: &[PocketCut], segments: &[PathSeg]) -> Vec { + // Keep the total sweep size bounded for very long pockets + let per_cut = (600 / cuts.len().max(1)).clamp(6, POCKET_SAMPLES_PER_CUT); + + let mut samples: Vec = Vec::new(); + for (cut_index, cut) in cuts.iter().enumerate() { + let piece = cut.piece(segments); + for k in 0..=per_cut { + let u = k as f64 / per_cut as f64; + let position = piece.eval(u); + if !position.is_finite() { + continue; + } + // Cut junctions coincide, so skip the duplicated first sample of subsequent cuts + if let Some(last) = samples.last() + && last.position == position + { + continue; + } + samples.push(PocketSample { position, cut: cut_index, u }); + } + } + samples +} + +/// Flattens every dent of the pocket whose bypassed boundary turns backward by at most +/// `max_concavity`, returning the resulting boundary as kept cuts of original geometry joined by +/// straight chords. Deeper dents (and anything the flattening chord cannot validly cover) survive. +/// +/// The sweep repeatedly deletes the reflex sample vertex with the smallest bypassed backward turning, +/// as long as the bypassing chord stays on the empty side of the boundary and within the angle budget. +/// Nested dents resolve naturally: shallow dents inside a deep one flatten while the deep one survives. +fn flatten_pocket(cuts: &[PocketCut], segments: &[PathSeg], bridge_from: Point, bridge_to: Point, max_concavity: f64, distance_epsilon: f64) -> Vec { + let samples = sample_pocket(cuts, segments); + let count = samples.len(); + if count < 3 { + return vec![PocketPiece::Chord(bridge_to)]; + } + + // Backward (clockwise) turning accumulated along the original sample polyline. `cumulative[i]` is + // the total up to and including vertex `i`, so an exclusive interval's content is a difference. + let mut cumulative = vec![0.; count]; + for i in 1..count - 1 { + let before = (samples[i].position - samples[i - 1].position).normalize(); + let after = (samples[i + 1].position - samples[i].position).normalize(); + let turn = if before.is_finite() && after.is_finite() { signed_angle(before, after) } else { 0. }; + cumulative[i] = cumulative[i - 1] + (-turn).max(0.); + } + cumulative[count - 1] = cumulative[count - 2]; + + let bypassed_turning = |a: usize, b: usize| if b > a + 1 { cumulative[b - 1] - cumulative[a] } else { 0. }; + + let scale = samples.iter().map(|s| (s.position - samples[0].position).hypot()).fold(bridge_from.distance(bridge_to), f64::max); + let side_tolerance = scale * 1e-9; + + // Whether a chord from kept vertex `a` to kept vertex `b` validly covers everything between them: + // the bypassed boundary must sit on the interior side of the chord (never cutting into a bump), + // and the chord must not cross the surviving boundary elsewhere + let chord_valid = |kept_indices: &[usize], a: usize, b: usize| { + let (pa, pb) = (samples[a].position, samples[b].position); + let direction = pb - pa; + if direction.hypot() <= f64::MIN_POSITIVE.max(1e-12) { + return true; + } + if (a + 1..b).any(|i| direction.cross(samples[i].position - pa) < -side_tolerance * direction.hypot().max(1.)) { + return false; + } + + // Check crossings against the surviving boundary outside the bypassed span + kept_indices.windows(2).all(|window| { + let (i, j) = (window[0], window[1]); + if i >= a && j <= b { + return true; + } + !segments_properly_cross(pa, pb, samples[i].position, samples[j].position) + }) + }; + + // Repeatedly delete the cheapest flattenable vertex until nothing is within budget. Deleting + // cheapest-first keeps the result canonical: nested shallow dents flatten before (and inside) + // surviving deeper ones, independent of boundary direction. + let mut kept = vec![true; count]; + loop { + let kept_indices: Vec = (0..count).filter(|&i| kept[i]).collect(); + + // Candidate deletions: reflex vertices (turning backward relative to the surviving boundary) + // whose bypassed original boundary stays within the concavity budget + let mut candidates: Vec<(f64, usize, usize, usize)> = kept_indices + .windows(3) + .filter_map(|window| { + let (previous, i, next) = (window[0], window[1], window[2]); + let before = (samples[i].position - samples[previous].position).normalize(); + let after = (samples[next].position - samples[i].position).normalize(); + if !(before.is_finite() && after.is_finite()) || signed_angle(before, after) >= -TURN_EPSILON { + return None; + } + + let turning = bypassed_turning(previous, next); + (turning <= max_concavity).then_some((turning, i, previous, next)) + }) + .collect(); + candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + + let deleted = candidates.into_iter().find(|&(_, _, previous, next)| chord_valid(&kept_indices, previous, next)); + let Some((_, i, ..)) = deleted else { break }; + kept[i] = false; + } + + // Assemble the surviving boundary: runs of consecutive kept samples become cuts of the original + // geometry, and gaps become chords polished to exact tangencies + assemble_pocket_pieces(cuts, segments, &samples, &kept, distance_epsilon) +} + +/// Converts the kept/deleted sample classification into boundary pieces, refining each chord's +/// endpoints to exact tangency with the adjoining curves. +fn assemble_pocket_pieces(cuts: &[PocketCut], segments: &[PathSeg], samples: &[PocketSample], kept: &[bool], distance_epsilon: f64) -> Vec { + let count = samples.len(); + let kept_indices: Vec = (0..count).filter(|&i| kept[i]).collect(); + + // Refined mouth parameters for each gap, keyed by the kept indices flanking it + let mut pieces = Vec::new(); + let mut run_start: (usize, f64) = (samples[0].cut, samples[0].u); + + let emit_run = |pieces: &mut Vec, from: (usize, f64), to: (usize, f64)| { + let (cut_a, u_a) = from; + let (cut_b, u_b) = to; + let mut push = |cut: &PocketCut, u0: f64, u1: f64| { + let (t0, t1) = (cut.to_source_t(u0), cut.to_source_t(u1)); + if (t1 - t0).abs() > PARAM_EPSILON { + pieces.push(PocketPiece::Cut { source: cut.source, t0, t1 }); + } + }; + + if cut_a == cut_b { + push(&cuts[cut_a], u_a, u_b); + } else { + push(&cuts[cut_a], u_a, 1.); + for cut in &cuts[cut_a + 1..cut_b] { + push(cut, 0., 1.); + } + push(&cuts[cut_b], 0., u_b); + } + }; + + for window in kept_indices.windows(2) { + let (a, b) = (window[0], window[1]); + if b == a + 1 { + continue; + } + + // Polish the chord mouths to exact tangency where they sit on curve interiors + let (mouth_a, mouth_b) = refine_chord_mouths(cuts, segments, samples, a, b); + + // Reject the polished chord if it dips below any bypassed boundary sample + let direction = mouth_b.position - mouth_a.position; + let tolerance = direction.hypot().max(1.) * 1e-9; + let polished_valid = (a + 1..b).all(|i| direction.cross(samples[i].position - mouth_a.position) >= -tolerance); + let (mouth_a, mouth_b) = if polished_valid { + (mouth_a, mouth_b) + } else { + let unrefined = |sample: &PocketSample| ChordMouth { + cut: sample.cut, + u: sample.u, + position: sample.position, + }; + (unrefined(&samples[a]), unrefined(&samples[b])) + }; + + emit_run(&mut pieces, run_start, (mouth_a.cut, mouth_a.u)); + if mouth_a.position.distance(mouth_b.position) > distance_epsilon { + pieces.push(PocketPiece::Chord(mouth_b.position)); + } + run_start = (mouth_b.cut, mouth_b.u); + } + + let last = *kept_indices.last().unwrap_or(&(count - 1)); + emit_run(&mut pieces, run_start, (samples[last].cut, samples[last].u)); + + pieces +} + +/// One endpoint of a dent-covering chord, as a location along the pocket walk plus its position. +struct ChordMouth { + cut: usize, + u: f64, + position: Point, +} + +/// Finds exact tangent parameters for a dent-covering chord between kept samples `a` and `b`. +/// Mouths at cut boundaries stay pinned. +fn refine_chord_mouths(cuts: &[PocketCut], segments: &[PathSeg], samples: &[PocketSample], a: usize, b: usize) -> (ChordMouth, ChordMouth) { + let window = 3. / POCKET_SAMPLES_PER_CUT as f64; + let free_cubic = |sample: &PocketSample| { + let cut = &cuts[sample.cut]; + let piece = cut.piece(segments); + let curved = !matches!(segments[cut.source], PathSeg::Line(_)); + (curved && sample.u > 0. && sample.u < 1.).then(|| piece.to_cubic()) + }; + + let (sample_a, sample_b) = (&samples[a], &samples[b]); + let (cubic_a, cubic_b) = (free_cubic(sample_a), free_cubic(sample_b)); + let (mut u_a, mut u_b) = (sample_a.u, sample_b.u); + + match (&cubic_a, &cubic_b) { + (None, None) => {} + (None, Some(curve)) => { + u_b = nearest_tangent_param(curve, sample_a.position, u_b, window).unwrap_or(u_b); + } + (Some(curve), None) => { + u_a = nearest_tangent_param(curve, sample_b.position, u_a, window).unwrap_or(u_a); + } + (Some(curve_a), Some(curve_b)) => { + // The same alternating exact tangency iteration used for hull bridges + let (guess_a, guess_b) = (u_a, u_b); + for _ in 0..MAX_BITANGENT_ITERATIONS { + let from_b = curve_b.eval(u_b); + if from_b.distance(curve_a.eval(u_a)) < f64::MIN_POSITIVE.max(1e-12) { + break; + } + let new_a = nearest_tangent_param(curve_a, from_b, guess_a, window).unwrap_or(u_a); + let new_b = nearest_tangent_param(curve_b, curve_a.eval(new_a), guess_b, window).unwrap_or(u_b); + + let converged = (new_a - u_a).abs() < TANGENCY_TOLERANCE && (new_b - u_b).abs() < TANGENCY_TOLERANCE; + (u_a, u_b) = (new_a, new_b); + if converged { + break; + } + } + } + } + + let mouth = |sample: &PocketSample, u: f64| ChordMouth { + cut: sample.cut, + u, + position: cuts[sample.cut].piece(segments).eval(u), + }; + (mouth(sample_a, u_a), mouth(sample_b, u_b)) +} + +/// The partial-hull variant of [`emit_hull_path`]: bridges whose pocket survives the concavity budget +/// are replaced by the pocket's flattened boundary instead of a straight line. +fn emit_partial_hull_path(contacts: &[Contact], arcs: &[ConvexArc], segments: &[PathSeg], runs: &[SubpathRun], max_concavity: f64, distance_epsilon: f64) -> BezPath { + // Which run each segment belongs to + let mut segment_run: Vec> = vec![None; segments.len()]; + for (run_index, run) in runs.iter().enumerate() { + for segment in run.segments.clone() { + if let Some(slot) = segment_run.get_mut(segment) { + *slot = Some(run_index); + } + } + } + + // The boundary interval each contact occupies on its run, for pocket gap matching + let mut occupied: Vec> = vec![Vec::new(); runs.len()]; + for (contact_index, contact) in contacts.iter().enumerate() { + let mut mark = |segment: usize, low: f64, high: f64| { + if let Some(run_index) = segment_run.get(segment).copied().flatten() { + let base = (segment - runs[run_index].segments.start) as f64; + occupied[run_index].push((contact_index, base + low.min(high), base + low.max(high))); + } + }; + match contact { + &Contact::Arc { arc, t_in, t_out } => { + let arc = &arcs[arc]; + mark(arc.source, arc.to_source_t(t_in), arc.to_source_t(t_out)); + } + Contact::Point { marks, .. } => { + for &(segment, t) in marks { + mark(segment, t, t); + } + } + } + } + + let contact_in_position = |contact: &Contact| match contact { + &Contact::Arc { arc, t_in, .. } => arcs[arc].cubic.eval(t_in), + Contact::Point { position, .. } => *position, + }; + let contact_out_position = |contact: &Contact| match contact { + &Contact::Arc { arc, t_out, .. } => arcs[arc].cubic.eval(t_out), + Contact::Point { position, .. } => *position, + }; + + let mut path = BezPath::new(); + let Some(first) = contacts.first() else { return path }; + path.move_to(contact_in_position(first)); + + for (i, contact) in contacts.iter().enumerate() { + if let &Contact::Arc { arc, t_in, t_out } = contact + && (t_out - t_in).abs() > PARAM_EPSILON + { + let arc = &arcs[arc]; + push_source_cut(&mut path, segments, arc.source, arc.to_source_t(t_in), arc.to_source_t(t_out)); + } + + // The bridge to the next contact: spliced with its pocket's surviving boundary where the + // pocket outlasts the concavity budget, otherwise a straight line as in the convex hull + let j = (i + 1) % contacts.len(); + let bridge_from = contact_out_position(contact); + let bridge_to = contact_in_position(&contacts[j]); + + let context = PocketContext { + contacts, + arcs, + segments, + runs, + segment_run: &segment_run, + occupied: &occupied, + }; + let pocket = pocket_for_bridge(&context, i, j, bridge_from, bridge_to); + if let Some(cuts) = pocket { + for piece in flatten_pocket(&cuts, segments, bridge_from, bridge_to, max_concavity, distance_epsilon) { + match piece { + PocketPiece::Cut { source, t0, t1 } => push_source_cut(&mut path, segments, source, t0, t1), + PocketPiece::Chord(to) => { + if to.distance(current_end(&path)) > distance_epsilon { + path.line_to(to); + } + } + } + } + // Land exactly on the next contact even if the pocket walk ends within tolerance of it + if bridge_to.distance(current_end(&path)) > distance_epsilon && j != 0 { + path.line_to(bridge_to); + } + } else if j != 0 && bridge_from.distance(bridge_to) > distance_epsilon { + path.line_to(bridge_to); + } + } + + path.close_path(); + path +} + +/// The current pen position of a path under construction. +fn current_end(path: &BezPath) -> Point { + match path.elements().last() { + Some(PathEl::MoveTo(point) | PathEl::LineTo(point) | PathEl::CurveTo(_, _, point) | PathEl::QuadTo(_, point)) => *point, + _ => Point::ZERO, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1012,6 +1633,276 @@ mod tests { } } + /// Line segments through the given points, closing back to the first. + fn closed_polyline(points: &[Point]) -> Vec { + (0..points.len()).map(|i| PathSeg::Line(Line::new(points[i], points[(i + 1) % points.len()]))).collect() + } + + fn whole_run(segments: &[PathSeg]) -> Vec { + vec![SubpathRun { + segments: 0..segments.len(), + closed: true, + }] + } + + /// Whether a point is inside (or within `tolerance` of the boundary of) the given closed path. + fn inside_or_near(path: &BezPath, point: Point, tolerance: f64) -> bool { + use kurbo::Shape; + if path.winding(point) != 0 { + return true; + } + path.segments().any(|segment| segment.nearest(point, 1e-9).distance_sq.sqrt() <= tolerance) + } + + /// A counterclockwise square with a rectangular notch cut into its top edge. + /// The notch's boundary turns backward by exactly 180 degrees (its two inner corners). + fn notched_square() -> Vec { + closed_polyline(&[ + Point::new(0., 0.), + Point::new(100., 0.), + Point::new(100., 100.), + Point::new(60., 100.), + Point::new(60., 60.), + Point::new(40., 60.), + Point::new(40., 100.), + Point::new(0., 100.), + ]) + } + + #[test] + fn notch_flattens_only_above_its_backward_turning() { + let segments = notched_square(); + let runs = whole_run(&segments); + let notch_area = 20. * 40.; + + // Below the notch's inner-corner turning (90 degrees each), nothing changes + let kept = partial_convex_hull_of_geometry(&segments, &runs, &[], 80_f64.to_radians()); + assert!((kept.area().abs() - (10_000. - notch_area)).abs() < 1., "notch must survive at 80 degrees, got area {}", kept.area()); + + // Above the notch's total backward turning of 180 degrees, it flattens completely + let flattened = partial_convex_hull_of_geometry(&segments, &runs, &[], 190_f64.to_radians()); + assert!((flattened.area().abs() - 10_000.).abs() < 1., "notch must flatten at 190 degrees, got area {}", flattened.area()); + + // In between, the notch's corners chamfer but the notch is not fully covered + let chamfered = partial_convex_hull_of_geometry(&segments, &runs, &[], 135_f64.to_radians()); + let chamfered_area = chamfered.area().abs(); + assert!( + chamfered_area > 10_000. - notch_area + 1. && chamfered_area < 10_000. - 1., + "expected partial flattening at 135 degrees, got area {chamfered_area}" + ); + } + + #[test] + fn nested_notch_inside_surviving_bay_is_flattened() { + // A square with a deep bay in its top edge, and a shallow notch (about 44 degrees of backward + // turning) poking into the material of the bay's west wall + let segments = closed_polyline(&[ + Point::new(0., 0.), + Point::new(100., 0.), + Point::new(100., 100.), + Point::new(70., 100.), + Point::new(70., 30.), + Point::new(30., 30.), + Point::new(30., 70.), + Point::new(28., 75.), + Point::new(30., 80.), + Point::new(30., 100.), + Point::new(0., 100.), + ]); + let runs = whole_run(&segments); + + // At 60 degrees: the notch (about 44 degrees) is flattened, but the bay (180 degrees) and its + // square corners (90 degrees each) all survive + let result = partial_convex_hull_of_geometry(&segments, &runs, &[], 60_f64.to_radians()); + + // The notch region is covered: no boundary point reaches into it + for piece in result.segments() { + for k in 0..=16 { + let point = piece.eval(k as f64 / 16.); + if point.y > 60. && point.y < 90. { + assert!(point.x > 30. - 1e-3 || point.x < 1., "notch must be flattened, found boundary point {point:?}"); + } + } + } + + // The bay itself survives: its inner corners remain on the boundary + for corner in [Point::new(70., 30.), Point::new(30., 30.)] { + let reached = result.segments().any(|piece| (0..=16).any(|k| piece.eval(k as f64 / 16.).distance(corner) < 0.5)); + assert!(reached, "bay corner {corner:?} must survive at 60 degrees"); + } + + // The bay is not covered: a point in its middle stays outside the result + assert_eq!(result.winding(Point::new(50., 70.)), 0, "bay interior must stay open at 60 degrees"); + } + + #[test] + fn material_bumps_are_never_cut() { + // A square with a bay whose floor carries a material tooth; the tooth is a protrusion, not a + // dent, so no concavity setting may ever slice it off + let segments = closed_polyline(&[ + Point::new(0., 0.), + Point::new(100., 0.), + Point::new(100., 100.), + Point::new(80., 100.), + Point::new(80., 30.), + Point::new(60., 30.), + Point::new(60., 55.), + Point::new(40., 55.), + Point::new(40., 30.), + Point::new(20., 30.), + Point::new(20., 100.), + Point::new(0., 100.), + ]); + let runs = whole_run(&segments); + + for degrees in [45_f64, 100., 170., 300.] { + let result = partial_convex_hull_of_geometry(&segments, &runs, &[], degrees.to_radians()); + let tooth_top = Point::new(50., 55.); + assert!(inside_or_near(&result, tooth_top, 1e-6), "the tooth must stay covered by material at {degrees} degrees"); + + // The tooth's top corners survive on the boundary as long as the bay itself survives + if degrees < 180. { + for corner in [Point::new(60., 55.), Point::new(40., 55.)] { + let reached = result.segments().any(|piece| (0..=16).any(|k| piece.eval(k as f64 / 16.).distance(corner) < 0.5)); + assert!(reached, "tooth corner {corner:?} must remain on the boundary at {degrees} degrees"); + } + } + } + } + + #[test] + fn zero_concavity_keeps_dents_bridges_islands_and_kills_holes() { + // Two notched squares side by side, plus a hole ring inside the first + let mut segments = notched_square(); + let island_two: Vec = notched_square() + .iter() + .map(|segment| match segment { + PathSeg::Line(line) => PathSeg::Line(Line::new(Point::new(line.p0.x + 200., line.p0.y), Point::new(line.p1.x + 200., line.p1.y))), + other => *other, + }) + .collect(); + let first_len = segments.len(); + segments.extend(island_two); + let hole_start = segments.len(); + segments.extend(circle_segments(Point::new(50., 40.), 15.)); + + let runs = vec![ + SubpathRun { segments: 0..first_len, closed: true }, + SubpathRun { + segments: first_len..hole_start, + closed: true, + }, + SubpathRun { + segments: hole_start..hole_start + 4, + closed: true, + }, + ]; + + let result = partial_convex_hull_of_geometry(&segments, &runs, &[], 0.); + + // Both outer notches survive: their interiors stay outside the result + assert_eq!(result.winding(Point::new(50., 90.)), 0, "first island's notch must stay open"); + assert_eq!(result.winding(Point::new(250., 90.)), 0, "second island's notch must stay open"); + + // The islands are bridged: the space between them is covered + assert_ne!(result.winding(Point::new(150., 50.)), 0, "the gap between islands must be bridged over"); + + // The hole is gone: its interior is covered by the result + assert_ne!(result.winding(Point::new(50., 40.)), 0, "the hole must not survive"); + + // All island boundary geometry is contained in the result + for segment in &segments[..hole_start] { + for k in 0..=20 { + let point = segment.eval(k as f64 / 20.); + assert!(inside_or_near(&result, point, 1e-6), "input boundary point {point:?} must be inside the result"); + } + } + } + + #[test] + fn unlimited_concavity_matches_the_convex_hull() { + let mut segments = notched_square(); + segments.extend(circle_segments(Point::new(250., 30.), 60.)); + let first_len = notched_square().len(); + let runs = vec![ + SubpathRun { segments: 0..first_len, closed: true }, + SubpathRun { + segments: first_len..segments.len(), + closed: true, + }, + ]; + + let convex = convex_hull_of_geometry(&segments, &[]); + for max_concavity in [f64::INFINITY, 1e6] { + let partial = partial_convex_hull_of_geometry(&segments, &runs, &[], max_concavity); + assert!( + (partial.area().abs() - convex.area().abs()).abs() < 1e-6 * convex.area().abs(), + "unlimited concavity must reproduce the convex hull" + ); + } + } + + #[test] + fn partial_hulls_are_sandwiched_and_monotone() { + // A deterministic PRNG so failures are reproducible + let mut state: u64 = 0x2545f4914f6cdd1d; + let mut random = move || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (state >> 11) as f64 / (1u64 << 53) as f64 + }; + + for iteration in 0..40 { + // A random star-like concave polygon + let vertex_count = 6 + iteration % 7; + let center = Point::new(random() * 200., random() * 200.); + let points: Vec = (0..vertex_count) + .map(|k| { + let angle = k as f64 / vertex_count as f64 * std::f64::consts::TAU; + let radius = 40. + random() * 120.; + center + Vec2::new(angle.cos(), angle.sin()) * radius + }) + .collect(); + let segments = closed_polyline(&points); + let runs = whole_run(&segments); + + let convex = convex_hull_of_geometry(&segments, &[]); + let mut previous_area = 0.; + for degrees in [0_f64, 40., 90., 170., 300., 1e9] { + let result = partial_convex_hull_of_geometry(&segments, &runs, &[], degrees.to_radians()); + + // Material is never removed: every input boundary point stays covered + for segment in &segments { + for k in 0..=16 { + let point = segment.eval(k as f64 / 16.); + assert!( + inside_or_near(&result, point, 1e-5), + "iteration {iteration}: input point {point:?} escaped the result at {degrees} degrees" + ); + } + } + + // The result never exceeds the convex hull + for piece in result.segments() { + for k in 0..=16 { + let point = piece.eval(k as f64 / 16.); + assert!( + inside_or_near(&convex, point, 1e-5), + "iteration {iteration}: result point {point:?} escaped the convex hull at {degrees} degrees" + ); + } + } + + // Growing the allowance only ever flattens more + let area = result.area().abs(); + assert!( + area >= previous_area - 1e-6 * area.max(1.), + "iteration {iteration}: area must grow with the allowance ({previous_area} -> {area} at {degrees} degrees)" + ); + previous_area = area; + } + } + } + #[test] fn mixed_open_subpaths_and_points_are_all_wrapped() { let mut segments = vec![ diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 372d275c88..1e8ea6f9fd 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -25,7 +25,7 @@ use vector_types::gradient::{build_transform_with_y_preservation, initial_gradie use vector_types::subpath::{BezierHandles, ManipulatorGroup}; use vector_types::vector::PointDomain; use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath}; -use vector_types::vector::algorithms::convex_hull::convex_hull_of_geometry; +use vector_types::vector::algorithms::convex_hull::{SubpathRun, convex_hull_of_geometry, partial_convex_hull_of_geometry}; use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; use vector_types::vector::algorithms::offset_subpath::offset_bezpath; use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open}; @@ -581,7 +581,7 @@ pub fn merge_by_distance( } } -/// Wraps all of the input geometry in its convex hull: the shape a taut rubber band would form when stretched around it. +/// Wraps all of the input geometry in its convex hull: the shape a taut rubber band would form when stretched around it. Dents in the geometry may optionally be kept based on how sharply their boundary turns backward. /// /// Convex portions of curved segments are kept exactly as they are, and the boundary departs from a curve only where it must, continuing along a straight bridging line that leaves and rejoins the curves at perfect tangents. The anchor points, floating points, and subpaths (open or closed) of all the input shapes are wrapped together into one combined hull. #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] @@ -590,12 +590,21 @@ async fn convex_hull( /// The `List` of vector paths to wrap in the convex hull. Nested `List`s are automatically flattened. #[implementations(List, List)] content: I, + /// The maximum backward turning, in degrees, of a dent that still gets flattened by the hull. + /// + /// At the maximum of 360°, the result is the fully convex hull. Lower values keep any dent whose boundary turns backward by more than this angle, so decreasing the value preserves progressively more concave detail, and at 0° every dent survives. Bridges between separate subpaths always apply, and interior geometry (such as holes) never survives. + #[range] + #[hard(0..360)] + #[default(360.)] + max_concavity: Angle, ) -> List { let content = content.into_graphic_list(); let flattened: List = content.clone().into_flattened_list(); - // Gather the world-space segments and floating anchor points of every input item + // Gather the world-space segments and floating anchor points of every input item, remembering + // which contiguous run of segments forms each subpath let mut segments = Vec::new(); + let mut runs = Vec::new(); let mut loose_points = Vec::new(); for index in 0..flattened.len() { let Some(element) = flattened.element(index) else { continue }; @@ -603,7 +612,15 @@ async fn convex_hull( let affine = Affine::new(transform.to_cols_array()); for bezpath in element.stroke_bezpath_iter() { + let start = segments.len(); segments.extend(bezpath.segments().map(|segment| affine * segment)); + if segments.len() > start { + let closed = matches!(bezpath.elements().last(), Some(kurbo::PathEl::ClosePath)); + runs.push(SubpathRun { + segments: start..segments.len(), + closed, + }); + } } // Anchor points not connected to any segment still participate in the hull @@ -615,7 +632,12 @@ async fn convex_hull( } } - let hull = convex_hull_of_geometry(&segments, &loose_points); + // The maximum angle means unlimited: the fully convex hull + let hull = if max_concavity >= 360. { + convex_hull_of_geometry(&segments, &loose_points) + } else { + partial_convex_hull_of_geometry(&segments, &runs, &loose_points, max_concavity.to_radians()) + }; // Carry over the attributes and stroke of the last input item, matching the Boolean Operation node let Some(last_index) = flattened.len().checked_sub(1) else { return List::new() }; @@ -3425,7 +3447,7 @@ mod test { floating.point_domain.push(PointId::generate(), DVec2::new(50., 200.)); content.push(Item::new_from_element(floating)); - let hull = super::convex_hull(Footprint::default(), content).await; + let hull = super::convex_hull(Footprint::default(), content, 360.).await; let element = hull.element(0).unwrap(); // The hull is the pentagon spanning both squares' outer corners and the floating point @@ -3440,6 +3462,27 @@ mod test { assert_eq!(transform, DAffine2::IDENTITY); } + #[tokio::test] + async fn convex_hull_max_concavity_keeps_dents() { + // A square with a rectangular notch in its top edge (180 degrees of backward turning) + let mut notched = BezPath::new(); + notched.move_to(Point::new(0., 0.)); + for point in [(100., 0.), (100., 100.), (60., 100.), (60., 60.), (40., 60.), (40., 100.), (0., 100.)] { + notched.line_to(Point::new(point.0, point.1)); + } + notched.close_path(); + let content = List::new_from_element(Vector::from_bezpath(notched)); + + // Below the notch's corner turning nothing flattens; at the maximum the hull is fully convex + let kept = super::convex_hull(Footprint::default(), content.clone(), 80.).await; + let kept_area: f64 = kept.element(0).unwrap().stroke_bezpath_iter().map(|bezpath| bezpath.area().abs()).sum(); + assert!((kept_area - 9200.).abs() < 1., "the notch must survive at 80 degrees, got area {kept_area}"); + + let convex = super::convex_hull(Footprint::default(), content, 360.).await; + let convex_area: f64 = convex.element(0).unwrap().stroke_bezpath_iter().map(|bezpath| bezpath.area().abs()).sum(); + assert!((convex_area - 10_000.).abs() < 1., "the hull must be fully convex at 360 degrees, got area {convex_area}"); + } + #[tokio::test] async fn sample_polyline() { let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]);