From 4169277508daa74246467d1336df0838d5c848b3 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 6 Jul 2026 19:24:11 -0700 Subject: [PATCH] Finish the convex hull node: in-repo algorithm, robustness, and tests Replaces the WIP node crate, which depended on a convex_hull crate that only existed outside the repository, with a self-contained implementation: - The curved hull algorithm lives in vector-types' algorithms module. It splits curves at inflections and cusps into curvature-monotone arcs, discovers the boundary structure from a sampled polygonal hull, refines every transition to an exact tangency with closed-form quartic tangent-through-point solves, then emits the boundary as cuts of the original segments joined by straight bridge lines. - The node moves into vector-nodes alongside the other 'Vector: Modifier' nodes, ported to the List/attributes model. It accepts Graphic[] or Vector[], wraps every subpath (open or closed) and free-floating anchor point across all items, and outputs a single world-space hull path. - The boolean-union pre-pass is dropped as unnecessary: the hull of all segments equals the hull of their union, and the polyline connector it relied on lost floating points and mishandled open paths. --- Cargo.toml | 1 - .../src/vector/algorithms/convex_hull.rs | 1028 +++++++++++++++++ .../vector-types/src/vector/algorithms/mod.rs | 1 + node-graph/nodes/convex_hull/Cargo.toml | 22 - node-graph/nodes/convex_hull/src/lib.rs | 457 -------- node-graph/nodes/gstd/Cargo.toml | 1 - node-graph/nodes/gstd/src/lib.rs | 1 - node-graph/nodes/vector/src/vector_nodes.rs | 92 +- 8 files changed, 1120 insertions(+), 483 deletions(-) create mode 100644 node-graph/libraries/vector-types/src/vector/algorithms/convex_hull.rs delete mode 100644 node-graph/nodes/convex_hull/Cargo.toml delete mode 100644 node-graph/nodes/convex_hull/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 3cc49a3614..59b46705cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,6 @@ vector-nodes = { path = "node-graph/nodes/vector" } repeat-nodes = { path = "node-graph/nodes/repeat" } math-nodes = { path = "node-graph/nodes/math" } path-bool-nodes = { path = "node-graph/nodes/path-bool" } -convex-hull-nodes = { path = "node-graph/nodes/convex_hull" } graph-craft = { path = "node-graph/graph-craft" } graph-storage = { path = "document/graph-storage", default-features = false } document-format = { path = "document/document-format" } 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 new file mode 100644 index 0000000000..38fcfb9638 --- /dev/null +++ b/node-graph/libraries/vector-types/src/vector/algorithms/convex_hull.rs @@ -0,0 +1,1028 @@ +//! Convex hull of Bezier path geometry. +//! +//! Unlike the classic convex hull of a point cloud or polygon, the hull of curved geometry keeps the convex +//! portions of the input curves and bridges between them with straight tangent lines, like a rubber band +//! stretched around the shapes. The result is built from three kinds of boundary pieces: +//! - Portions of input segments, cut exactly where the boundary departs from the curve +//! - Straight bridge lines connecting those portions, tangent to the curves they leave and enter +//! - Corner points (anchor points or free-floating points) that the rubber band bends around +//! +//! The algorithm proceeds in four stages: +//! 1. Normalize: split every curve at its inflections and cusps so each piece turns in only one direction, +//! and reduce straight or degenerate segments to their extreme points. +//! 2. Discover structure: densely sample all pieces, take the polygonal hull of the samples, and read off +//! which curve ranges and which corner points form the boundary and in what cyclic order. +//! 3. Refine: polish each transition between boundary pieces to an exact tangency using closed-form +//! tangent-through-point solves (a quartic), so bridge lines touch the curves at true tangent points. +//! 4. Emit: cut the boundary ranges out of the original input segments (preserving their exact geometry +//! and segment kind) and join them with the bridge lines into a single closed path. + +use kurbo::common::solve_quadratic; +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Point, Vec2}; + +/// Parameter-space epsilon below which a curve span is considered empty. +const PARAM_EPSILON: f64 = 1e-9; +/// Iteration cap for the alternating tangency refinement between two curves. +const MAX_BITANGENT_ITERATIONS: usize = 32; +/// Parameter-space convergence tolerance for the tangency refinement. +const TANGENCY_TOLERANCE: f64 = 1e-13; + +/// One curvature-monotone piece of an input segment, used as a candidate curve for the hull boundary. +struct ConvexArc { + /// Cubic representation of this piece, used for all internal math (exact degree elevation for quadratic sources). + cubic: CubicBez, + /// Index into the input segment list identifying the segment this arc is a piece of. + source: usize, + /// Parameter range of the source segment covered by this piece. + source_t0: f64, + source_t1: f64, + /// Number of sample intervals this arc contributes to hull structure discovery. + sample_count: usize, +} + +impl ConvexArc { + /// Map a local parameter on this arc to a parameter on its source segment. + fn to_source_t(&self, t: f64) -> f64 { + self.source_t0 + t * (self.source_t1 - self.source_t0) + } +} + +/// Where a hull candidate sample came from. +#[derive(Clone, Copy)] +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 unique candidate position, carrying every sample that landed exactly on it (e.g. the shared +/// anchor where two segments join contributes the end sample of one arc and the start sample of the next). +struct HullVertex { + position: Point, + tags: Vec, +} + +/// Classification of one edge of the sampled hull polygon. +#[derive(Clone, Copy)] +enum EdgeLabel { + /// Both endpoints are consecutive samples of the same arc, so the boundary follows that arc here. + OnArc { arc: usize, t_start: f64, t_end: f64 }, + /// The boundary jumps between different pieces of geometry here. + Bridge, +} + +/// A maximal contiguous piece of input geometry lying on the hull boundary. +enum Contact { + /// A parameter range of an arc. `t_in`/`t_out` are in boundary traversal order and may be descending + /// when the hull walks the arc against its parametrization. A zero-span contact is a tangential + /// 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 }, +} + +/// Computes the convex hull of a collection of path segments plus free-floating points, returned as a +/// single closed path. Curved portions of the input that lie on the hull are preserved exactly (as cuts +/// 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 { + // Stage 1: normalize the input into curvature-monotone arcs and standalone candidate points. + let (mut arcs, mut points) = 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 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])]), + _ => {} + } + } + + // 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); + + match hull.len() { + 0 => return BezPath::new(), + 1 => return 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]); + } + _ => {} + } + + // 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); + + // 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) +} + +/// 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) { + let mut arcs = Vec::new(); + let mut points: Vec = loose_points.iter().copied().filter(|point| point.is_finite()).collect(); + + for (source, segment) in segments.iter().enumerate() { + let cubic = segment.to_cubic(); + if !(cubic.p0.is_finite() && cubic.p1.is_finite() && cubic.p2.is_finite() && cubic.p3.is_finite()) { + continue; + } + + if let PathSeg::Line(line) = segment { + points.push(line.p0); + points.push(line.p1); + continue; + } + + // The local scale of this segment, for relative degeneracy tests + let spread = cubic + .p0 + .distance(cubic.p1) + .max(cubic.p0.distance(cubic.p2)) + .max(cubic.p0.distance(cubic.p3)) + .max(cubic.p1.distance(cubic.p3)); + + // A point-like segment contributes only its position + if spread < f64::MIN_POSITIVE.max(1e-12) { + points.push(cubic.p0); + continue; + } + + // A curve with collinear control points traces a straight line (possibly overshooting its + // endpoints), so it contributes the extreme points it reaches along that line + if let Some(direction) = collinear_direction(&cubic, spread) { + points.push(cubic.p0); + points.push(cubic.p3); + points.extend(straight_curve_interior_extremes(&cubic, direction)); + continue; + } + + // Split at inflections so each piece turns in only one direction. Cusps satisfy the same + // equation (the derivative vanishes, so its cross product with the second derivative is zero) + // and are therefore split points too. + let mut split_params: Vec = cubic.inflections().into_iter().filter(|t| (PARAM_EPSILON..1. - PARAM_EPSILON).contains(t)).collect(); + split_params.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + split_params.dedup(); + + let bounds: Vec = std::iter::once(0.).chain(split_params).chain(std::iter::once(1.)).collect(); + let first_new_arc = arcs.len(); + for window in bounds.windows(2) { + let (t0, t1) = (window[0], window[1]); + if t1 - t0 < PARAM_EPSILON { + continue; + } + arcs.push(ConvexArc { + cubic: cubic.subsegment(t0..t1), + source, + source_t0: t0, + source_t1: t1, + sample_count: 0, + }); + } + + // Stitch the exact endpoint positions across the split pieces so junction samples merge + // bit-exactly during hull vertex deduplication + for arc_index in first_new_arc..arcs.len() { + if arc_index > first_new_arc { + arcs[arc_index].cubic.p0 = arcs[arc_index - 1].cubic.p3; + } + } + if let Some(first) = arcs.get_mut(first_new_arc) { + first.cubic.p0 = cubic.p0; + } + if let Some(last) = arcs.last_mut() { + last.cubic.p3 = cubic.p3; + } + } + + (arcs, points) +} + +/// If all four control points of a cubic lie on one line (meaning the curve itself is straight), +/// returns the direction of that line. Uses the longest available chord as the reference direction so +/// out-and-back curves with coincident endpoints are still judged correctly. +fn collinear_direction(cubic: &CubicBez, spread: f64) -> Option { + let chords = [(cubic.p0, cubic.p3), (cubic.p0, cubic.p1), (cubic.p0, cubic.p2), (cubic.p1, cubic.p3)]; + let (base, tip) = chords + .into_iter() + .max_by(|a, b| a.0.distance_squared(a.1).partial_cmp(&b.0.distance_squared(b.1)).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or((cubic.p0, cubic.p3)); + let direction = tip - base; + + let tolerance = spread * spread * 1e-12; + let collinear = [cubic.p0, cubic.p1, cubic.p2, cubic.p3].into_iter().all(|point| direction.cross(point - base).abs() <= tolerance); + collinear.then(|| direction.normalize()) +} + +/// 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 + '_ { + let derivative = cubic.deriv(); + let (d0, d1, d2) = (derivative.p0.to_vec2(), derivative.p1.to_vec2(), derivative.p2.to_vec2()); + + // The derivative dotted with the line direction is a quadratic in Bernstein form; convert to power basis + let (a, b, c) = (d0.dot(direction), d1.dot(direction), d2.dot(direction)); + 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)) +} + +/// The overall size of the input, used to make tolerances scale-relative. +fn geometry_scale(arcs: &[ConvexArc], points: &[Point]) -> 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| { + min = Point::new(min.x.min(point.x), min.y.min(point.y)); + max = Point::new(max.x.max(point.x), max.y.max(point.y)); + }; + + for arc in arcs { + for point in [arc.cubic.p0, arc.cubic.p1, arc.cubic.p2, arc.cubic.p3] { + include(point); + } + } + for &point in points { + include(point); + } + + if min.x > max.x { 0. } else { (max - min).hypot() } +} + +/// Assigns each arc a sample density proportional to its size relative to the whole input, so large +/// features are resolved finely without spending thousands of samples on tiny ones. +fn assign_sample_counts(arcs: &mut [ConvexArc], scale: f64) { + for arc in arcs { + let control_polygon_length = (arc.cubic.p1 - arc.cubic.p0).hypot() + (arc.cubic.p2 - arc.cubic.p1).hypot() + (arc.cubic.p3 - arc.cubic.p2).hypot(); + let relative_size = if scale > 0. { control_polygon_length / scale } else { 0. }; + arc.sample_count = ((relative_size * 192.).ceil() as usize).clamp(16, 64); + } +} + +/// 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 { + use std::collections::HashMap; + + let mut vertices: Vec = Vec::new(); + let mut index_by_position: HashMap<(u64, u64), usize> = HashMap::new(); + let mut add = |position: Point, tag: SampleTag| { + let key = (position.x.to_bits(), position.y.to_bits()); + let index = *index_by_position.entry(key).or_insert_with(|| { + vertices.push(HullVertex { position, tags: Vec::new() }); + vertices.len() - 1 + }); + vertices[index].tags.push(tag); + }; + + for (arc_index, arc) in arcs.iter().enumerate() { + for k in 0..=arc.sample_count { + let t = k as f64 / arc.sample_count as f64; + // Endpoint samples use the exact control points so shared anchors merge bit-exactly + let position = match k { + 0 => arc.cubic.p0, + k if k == arc.sample_count => arc.cubic.p3, + _ => arc.cubic.eval(t), + }; + if position.is_finite() { + add(position, SampleTag::Arc { arc: arc_index, t }); + } + } + } + + for &point in points { + add(point, SampleTag::Point); + } + + vertices +} + +/// Andrew's monotone chain convex hull over the candidate vertices. Returns indices into `vertices` in +/// counterclockwise order (positive signed area), with collinear intermediate points dropped. +fn monotone_chain(vertices: &[HullVertex]) -> Vec { + let mut order: Vec = (0..vertices.len()).collect(); + order.sort_by(|&a, &b| { + let (pa, pb) = (vertices[a].position, vertices[b].position); + (pa.x, pa.y).partial_cmp(&(pb.x, pb.y)).unwrap_or(std::cmp::Ordering::Equal) + }); + + if order.len() <= 2 { + return order; + } + + let cross = |o: usize, a: usize, b: usize| { + let (po, pa, pb) = (vertices[o].position, vertices[a].position, vertices[b].position); + (pa - po).cross(pb - po) + }; + + 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. { + hull.pop(); + } + hull.push(index); + } + + // Upper hull + 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. { + hull.pop(); + } + hull.push(index); + } + + // The final vertex repeats the first + hull.pop(); + hull +} + +/// Classifies each cyclic edge of the hull polygon as either following an arc or bridging between +/// separate pieces of geometry. +fn label_hull_edges(vertices: &[HullVertex], hull: &[usize], arcs: &[ConvexArc]) -> Vec { + (0..hull.len()) + .map(|i| { + let (u, v) = (&vertices[hull[i]], &vertices[hull[(i + 1) % hull.len()]]); + + // The edge follows an arc if both endpoints are nearby samples of that same arc + let mut best: Option = None; + let mut best_gap = f64::INFINITY; + for tag_u in &u.tags { + let &SampleTag::Arc { arc, t: t_start } = tag_u else { continue }; + for tag_v in &v.tags { + let &SampleTag::Arc { arc: arc_v, t: t_end } = tag_v else { continue }; + if arc_v != arc { + continue; + } + let gap = (t_end - t_start).abs(); + let jump_limit = 2.5 / arcs[arc].sample_count as f64; + if gap <= jump_limit && gap < best_gap { + best_gap = gap; + best = Some(EdgeLabel::OnArc { arc, t_start, t_end }); + } + } + } + + best.unwrap_or(EdgeLabel::Bridge) + }) + .collect() +} + +/// Whether edge `b` directly continues edge `a` along the same arc. +fn continues(a: EdgeLabel, b: EdgeLabel) -> bool { + match (a, b) { + (EdgeLabel::OnArc { arc: arc_a, t_end, .. }, EdgeLabel::OnArc { arc: arc_b, t_start, .. }) => arc_a == arc_b && t_end == t_start, + _ => false, + } +} + +/// 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 { + let edge_count = labels.len(); + + // Rotate to start at an edge that does not continue its predecessor, so no arc chain wraps around + // the seam of the cyclic walk + let start = (0..edge_count).find(|&i| !continues(labels[(i + edge_count - 1) % edge_count], labels[i])).unwrap_or(0); + + // A corner vertex prefers acting as a refinable touch of an arc (an interior-parameter tag) over a + // fixed point; anchors and floating points have no interior tag and become fixed corner points + let vertex_contact = |vertex: &HullVertex| { + let interior_tag = vertex.tags.iter().find_map(|tag| match tag { + &SampleTag::Arc { arc, t } if t > 0. && t < 1. => Some((arc, t)), + _ => None, + }); + match interior_tag { + Some((arc, t)) => Contact::Arc { arc, t_in: t, t_out: t }, + None => Contact::Point { position: vertex.position }, + } + }; + + let mut contacts = Vec::new(); + let mut i = 0; + while i < edge_count { + let edge_index = (start + i) % edge_count; + match labels[edge_index] { + EdgeLabel::OnArc { arc, t_start, mut t_end } => { + // Extend the chain over every directly continuing edge + let mut length = 1; + while i + length < edge_count { + let next = labels[(start + i + length) % edge_count]; + if !continues(labels[(start + i + length - 1) % edge_count], next) { + break; + } + let EdgeLabel::OnArc { t_end: chained_end, .. } = next else { break }; + t_end = chained_end; + length += 1; + } + contacts.push(Contact::Arc { arc, t_in: t_start, t_out: t_end }); + i += length; + } + EdgeLabel::Bridge => { + // A vertex flanked by bridges on both sides is its own standalone contact + let previous = labels[(start + i + edge_count - 1) % edge_count]; + if !matches!(previous, EdgeLabel::OnArc { .. }) { + contacts.push(vertex_contact(&vertices[hull[(start + i) % edge_count]])); + } + i += 1; + } + } + } + + contacts +} + +/// The tangent parameter on `cubic` whose tangent line passes through `from`, chosen as the candidate +/// nearest `guess` within `window`. Returns `None` when no such tangency exists. +fn nearest_tangent_param(cubic: &CubicBez, from: Point, guess: f64, window: f64) -> Option { + cubic + .tangents_to_point(from) + .into_iter() + .filter(|t| (t - guess).abs() <= window) + .min_by(|a, b| (a - guess).abs().partial_cmp(&(b - guess).abs()).unwrap_or(std::cmp::Ordering::Equal)) +} + +/// One endpoint of a bridge line: either pinned to a fixed position (a corner anchor or standalone +/// point) or free to slide along an arc to its true tangent point. +enum BridgeEnd { + Fixed(Point), + Free { arc: usize, t: f64 }, +} + +/// Refines every transition between cyclically consecutive contacts so bridge lines touch their adjoining +/// curves at exact tangent points, writing the refined parameters back into the contacts. +fn refine_transitions(contacts: &mut [Contact], arcs: &[ConvexArc], distance_epsilon: f64) { + let contact_count = contacts.len(); + if contact_count == 0 { + return; + } + + // Record each arc contact's sampled traversal direction before any refinement mutates it + let sampled_ranges: Vec> = contacts + .iter() + .map(|contact| match contact { + &Contact::Arc { t_in, t_out, .. } => Some((t_in, t_out)), + Contact::Point { .. } => None, + }) + .collect(); + + for i in 0..contact_count { + let j = (i + 1) % contact_count; + + // Departure state of contact `i` and arrival state of contact `j`. An arc parameter at an + // exact endpoint is a corner the bridge is pinned to; an interior parameter is a tangency + // estimate to be refined. + 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), + }; + 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), + }; + + let (refined_out, refined_in) = match (out_end, in_end) { + (BridgeEnd::Fixed(_), BridgeEnd::Fixed(_)) => (None, None), + (BridgeEnd::Fixed(from), BridgeEnd::Free { arc, t }) => { + let window = 2.5 / arcs[arc].sample_count as f64; + (None, nearest_tangent_param(&arcs[arc].cubic, from, t, window)) + } + (BridgeEnd::Free { arc, t }, BridgeEnd::Fixed(from)) => { + let window = 2.5 / arcs[arc].sample_count as f64; + (nearest_tangent_param(&arcs[arc].cubic, from, t, window), None) + } + (BridgeEnd::Free { arc: arc_a, t: mut s }, BridgeEnd::Free { arc: arc_b, mut t }) => { + // Alternate exact tangent-through-point solves until the bridge is tangent to both + // curves. Each half-step is a closed-form quartic solve, so the iteration is stable; + // nearest-to-guess root selection keeps it anchored to the sampled estimate. + let window_a = 2.5 / arcs[arc_a].sample_count as f64; + let window_b = 2.5 / arcs[arc_b].sample_count as f64; + let (guess_s, guess_t) = (s, t); + + for _ in 0..MAX_BITANGENT_ITERATIONS { + let from_b = arcs[arc_b].cubic.eval(t); + if from_b.distance(arcs[arc_a].cubic.eval(s)) < distance_epsilon { + break; + } + let new_s = nearest_tangent_param(&arcs[arc_a].cubic, from_b, guess_s, window_a).unwrap_or(s); + let new_t = nearest_tangent_param(&arcs[arc_b].cubic, arcs[arc_a].cubic.eval(new_s), guess_t, window_b).unwrap_or(t); + + let converged = (new_s - s).abs() < TANGENCY_TOLERANCE && (new_t - t).abs() < TANGENCY_TOLERANCE; + (s, t) = (new_s, new_t); + if converged { + break; + } + } + + (Some(s), Some(t)) + } + }; + + if let (Some(t), Contact::Arc { t_out, .. }) = (refined_out, &mut contacts[i]) { + *t_out = t; + } + if let (Some(t), Contact::Arc { t_in, .. }) = (refined_in, &mut contacts[j]) { + *t_in = t; + } + } + + // If refining both ends of a short contact made its parameters cross over, the contact has no real + // extent on the boundary; collapse it to a single tangency point so emission stays consistent + for (contact, sampled) in contacts.iter_mut().zip(sampled_ranges) { + let (Contact::Arc { t_in, t_out, .. }, Some((sampled_in, sampled_out))) = (contact, sampled) else { + continue; + }; + if sampled_in != sampled_out && (sampled_out - sampled_in).signum() != (*t_out - *t_in).signum() { + let midpoint = (*t_in + *t_out) / 2.; + (*t_in, *t_out) = (midpoint, midpoint); + } + } +} + +/// 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, + }; + 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() { + // The portion of original geometry this contact contributes + if let &Contact::Arc { arc, t_in, t_out } = contact + && (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), + } + } + + // The bridge line to the next contact, unless the two already meet at a shared anchor. The + // final bridge back to the start is left to the implicit closing line. + if i + 1 < contacts.len() { + let next_in = contact_in_position(&contacts[i + 1]); + if contact_out_position(contact).distance(next_in) > distance_epsilon { + path.line_to(next_in); + } + } + } + + path.close_path(); + path +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::{Line, ParamCurveNearest, QuadBez, Shape}; + + /// Circle approximation constant for cubic Bezier quadrants. + const KAPPA: f64 = 0.552284749831; + + /// A circle as four cubic quadrants, counterclockwise in mathematical (Y-up) orientation. + fn circle_segments(center: Point, radius: f64) -> Vec { + let anchor = |dx: f64, dy: f64| Point::new(center.x + dx * radius, center.y + dy * radius); + let quadrant = |a: Point, b: Point| { + let handle_a = Point::new(a.x - (a.y - center.y) * KAPPA, a.y + (a.x - center.x) * KAPPA); + let handle_b = Point::new(b.x + (b.y - center.y) * KAPPA, b.y - (b.x - center.x) * KAPPA); + PathSeg::Cubic(CubicBez::new(a, handle_a, handle_b, b)) + }; + + let (right, top, left, bottom) = (anchor(1., 0.), anchor(0., 1.), anchor(-1., 0.), anchor(0., -1.)); + vec![quadrant(right, top), quadrant(top, left), quadrant(left, bottom), quadrant(bottom, right)] + } + + /// The hull polygon as densely sampled points, for geometric property checks. + fn sample_hull_polygon(hull: &BezPath) -> Vec { + let mut polygon = Vec::new(); + for segment in hull.segments() { + for k in 0..64 { + polygon.push(segment.eval(k as f64 / 64.)); + } + } + polygon + } + + /// Asserts the four defining properties of a valid hull: the path is closed, its boundary is convex, + /// it contains all the input geometry, and its curved portions lie exactly on the input curves. + fn assert_hull_valid(hull: &BezPath, segments: &[PathSeg], loose_points: &[Point]) { + assert!(matches!(hull.elements().last(), Some(PathEl::ClosePath)), "hull must be a closed path"); + + let polygon = sample_hull_polygon(hull); + assert!(polygon.len() >= 3, "hull must enclose an area"); + + let scale = polygon.iter().map(|p| p.to_vec2().hypot()).fold(0., f64::max).max(1.); + let turn_tolerance = scale * scale * 1e-9; + + // Convex and counterclockwise: every consecutive turn is a left turn (within tolerance) + let n = polygon.len(); + for i in 0..n { + let (a, b, c) = (polygon[i], polygon[(i + 1) % n], polygon[(i + 2) % n]); + let turn = (b - a).cross(c - b); + assert!(turn >= -turn_tolerance, "hull boundary must be convex, found right turn of {turn} at {b:?}"); + } + + // Containment: all input geometry lies inside the hull polygon. The polygon is a sampling of + // the true hull, so allow a tolerance for the flatness error between polygon samples. + let containment_tolerance = scale * 1e-4; + let inside = |point: Point| (0..n).all(|i| (polygon[(i + 1) % n] - polygon[i]).cross(point - polygon[i]) >= -containment_tolerance * scale); + for segment in segments { + for k in 0..=100 { + let point = segment.eval(k as f64 / 100.); + assert!(inside(point), "input point {point:?} on {segment:?} must be inside the hull"); + } + } + for &point in loose_points { + assert!(inside(point), "loose input point {point:?} must be inside the hull"); + } + + // Faithfulness: every curved piece of the hull lies on some input segment (bridge lines are the + // only geometry the hull is allowed to invent) + let on_input_tolerance = scale * 1e-6; + for piece in hull.segments() { + if matches!(piece, PathSeg::Line(_)) { + continue; + } + for k in 0..=16 { + let point = piece.eval(k as f64 / 16.); + let distance = segments.iter().map(|segment| segment.nearest(point, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + assert!(distance <= on_input_tolerance, "hull curve point {point:?} must lie on the input geometry (distance {distance})"); + } + } + } + + /// Asserts that every straight bridge line in the hull meets its adjacent curved pieces tangentially + /// (the line direction is parallel to the curve tangent at the junction). Only valid for inputs whose + /// bridges are all tangential (no corner anchors on the hull). + fn assert_bridges_tangent(hull: &BezPath) { + let pieces: Vec = hull.segments().collect(); + let n = pieces.len(); + + let tangent_at = |piece: &PathSeg, end: bool| match piece { + PathSeg::Line(line) => line.p1 - line.p0, + PathSeg::Quad(quad) => { + if end { + quad.p2 - quad.p1 + } else { + quad.p1 - quad.p0 + } + } + PathSeg::Cubic(cubic) => { + if end { + cubic.p3 - cubic.p2 + } else { + cubic.p1 - cubic.p0 + } + } + }; + + let mut checked_junctions = 0; + for i in 0..n { + let (piece, next) = (&pieces[i], &pieces[(i + 1) % n]); + let is_line = |p: &PathSeg| matches!(p, PathSeg::Line(_)); + if is_line(piece) == is_line(next) { + continue; + } + + let outgoing = tangent_at(piece, true).normalize(); + let incoming = tangent_at(next, false).normalize(); + let deviation = outgoing.cross(incoming).abs(); + assert!(deviation < 1e-6, "bridge line must be tangent to the adjacent curve, found angle deviation {deviation}"); + checked_junctions += 1; + } + assert!(checked_junctions > 0, "expected at least one line-to-curve junction to check"); + } + + /// Control points of every cubic in the path, for identity comparisons. + fn cubics_of(path_segments: impl Iterator) -> Vec<[Point; 4]> { + path_segments + .filter_map(|segment| match segment { + PathSeg::Cubic(cubic) => Some([cubic.p0, cubic.p1, cubic.p2, cubic.p3]), + _ => None, + }) + .collect() + } + + fn assert_same_cubic_set(a: &[[Point; 4]], b: &[[Point; 4]], tolerance: f64) { + assert_eq!(a.len(), b.len(), "cubic counts must match"); + for cubic in a { + let found = b.iter().any(|other| cubic.iter().zip(other).all(|(p, q)| p.distance(*q) <= tolerance)); + assert!(found, "cubic {cubic:?} has no match"); + } + } + + #[test] + fn empty_input_gives_empty_hull() { + let hull = convex_hull_of_geometry(&[], &[]); + assert!(hull.elements().is_empty()); + } + + #[test] + fn single_point_gives_single_anchor() { + let hull = convex_hull_of_geometry(&[], &[Point::new(5., 7.)]); + assert_eq!(hull.elements(), &[PathEl::MoveTo(Point::new(5., 7.))]); + } + + #[test] + fn collinear_points_give_degenerate_segment() { + let points: Vec = (0..7).map(|i| Point::new(i as f64 * 10., i as f64 * 5.)).collect(); + let hull = convex_hull_of_geometry(&[], &points); + assert_eq!(hull.elements().len(), 3, "expected move, line, close"); + assert!(hull.elements().contains(&PathEl::MoveTo(Point::new(0., 0.)))); + assert!(hull.elements().contains(&PathEl::LineTo(Point::new(60., 30.)))); + } + + #[test] + fn points_only_form_polygon_hull() { + let points = [ + Point::new(0., 0.), + Point::new(100., 0.), + Point::new(100., 100.), + Point::new(0., 100.), + Point::new(50., 50.), + Point::new(25., 75.), + ]; + let hull = convex_hull_of_geometry(&[], &points); + assert_hull_valid(&hull, &[], &points); + assert!((hull.area().abs() - 10_000.).abs() < 1e-9, "hull must be the outer square, got area {}", hull.area()); + assert_eq!(hull.segments().count(), 4, "square hull must have exactly 4 edges"); + } + + #[test] + fn convex_closed_shape_is_unchanged() { + let segments = circle_segments(Point::new(20., -30.), 100.); + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + assert_same_cubic_set(&cubics_of(hull.segments()), &cubics_of(segments.iter().copied()), 1e-9); + } + + #[test] + fn clockwise_convex_shape_is_reversed_not_cut() { + let segments: Vec = circle_segments(Point::new(0., 0.), 50.).iter().rev().map(|segment| segment.reverse()).collect(); + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + let expected: Vec = segments.iter().map(|segment| segment.reverse()).collect(); + assert_same_cubic_set(&cubics_of(hull.segments()), &cubics_of(expected.into_iter()), 1e-9); + } + + #[test] + fn concave_side_is_bridged_with_a_straight_line() { + // A half-moon: two convex quadrants on top, and a bottom edge that bulges inward (upward) + let circle = circle_segments(Point::new(0., 0.), 100.); + let segments = vec![ + circle[0], + circle[1], + PathSeg::Cubic(CubicBez::new(Point::new(-100., 0.), Point::new(-50., 60.), Point::new(50., 60.), Point::new(100., 0.))), + ]; + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + + // The two convex quadrants survive unchanged and the concave bottom is replaced by one straight line + assert_same_cubic_set(&cubics_of(hull.segments()), &cubics_of(circle[0..2].iter().copied()), 1e-9); + let line_count = hull.segments().filter(|segment| matches!(segment, PathSeg::Line(_))).count(); + assert_eq!(line_count, 1, "the concave side must collapse to exactly one bridge line"); + + let semicircle_area = std::f64::consts::PI * 100. * 100. / 2.; + assert!((hull.area().abs() - semicircle_area).abs() / semicircle_area < 1e-3); + } + + #[test] + fn disjoint_shapes_are_bridged_with_exact_tangents() { + // Different radii so the outer bitangents are slanted and touch mid-arc, exercising the + // free-free tangency refinement rather than corner-to-corner bridging + let mut segments = circle_segments(Point::new(0., 0.), 50.); + segments.extend(circle_segments(Point::new(300., 20.), 80.)); + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + assert_bridges_tangent(&hull); + + let bridge_count = hull.segments().filter(|segment| matches!(segment, PathSeg::Line(_))).count(); + assert_eq!(bridge_count, 2, "two disjoint shapes must be connected by exactly two bridge lines"); + } + + #[test] + fn floating_point_outside_shape_is_wrapped() { + let segments = circle_segments(Point::new(0., 0.), 100.); + let outlier = Point::new(400., 30.); + let interior = Point::new(10., 20.); + let hull = convex_hull_of_geometry(&segments, &[outlier, interior]); + + assert_hull_valid(&hull, &segments, &[outlier, interior]); + assert_bridges_tangent(&hull); + + // The two tangent lines meet at the outlier point + let lines: Vec = hull.segments().filter_map(|segment| if let PathSeg::Line(line) = segment { Some(line) } else { None }).collect(); + assert_eq!(lines.len(), 2); + assert!(lines.iter().any(|line| line.p0.distance(outlier) < 1e-9 || line.p1.distance(outlier) < 1e-9)); + } + + #[test] + fn open_convex_arc_is_closed_with_a_chord() { + let segments = vec![circle_segments(Point::new(0., 0.), 100.)[0]]; + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + assert_same_cubic_set(&cubics_of(hull.segments()), &cubics_of(segments.iter().copied()), 1e-9); + } + + #[test] + fn three_quarter_circle_keeps_arcs_and_adds_chord() { + let segments = circle_segments(Point::new(0., 0.), 100.)[0..3].to_vec(); + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + assert_same_cubic_set(&cubics_of(hull.segments()), &cubics_of(segments.iter().copied()), 1e-9); + + // Three quarters of the circle plus the triangle between the two open endpoints and the center + let expected_area = std::f64::consts::PI * 100. * 100. * 0.75 + 100. * 100. / 2.; + assert!((hull.area().abs() - expected_area).abs() / expected_area < 1e-3, "got area {}", hull.area()); + } + + #[test] + fn s_curve_is_split_at_its_inflection() { + let s_curve = PathSeg::Cubic(CubicBez::new(Point::new(0., 0.), Point::new(100., 0.), Point::new(0., 100.), Point::new(100., 100.))); + let hull = convex_hull_of_geometry(&[s_curve], &[]); + + assert_hull_valid(&hull, &[s_curve], &[]); + + // Each side of the S contributes a convex piece, so the hull must contain both curves and lines + assert!(hull.segments().any(|segment| matches!(segment, PathSeg::Cubic(_)))); + assert!(hull.segments().any(|segment| matches!(segment, PathSeg::Line(_)))); + } + + #[test] + fn quadratic_segments_are_preserved_as_quadratics() { + // A convex closed shape made of four outward-bulging quadratics + let quad = |a: Point, control: Point, b: Point| PathSeg::Quad(QuadBez::new(a, control, b)); + let segments = vec![ + quad(Point::new(100., 0.), Point::new(100., 100.), Point::new(0., 100.)), + quad(Point::new(0., 100.), Point::new(-100., 100.), Point::new(-100., 0.)), + quad(Point::new(-100., 0.), Point::new(-100., -100.), Point::new(0., -100.)), + quad(Point::new(0., -100.), Point::new(100., -100.), Point::new(100., 0.)), + ]; + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + assert_eq!( + hull.segments().filter(|segment| matches!(segment, PathSeg::Quad(_))).count(), + 4, + "quadratic segments must stay quadratic" + ); + } + + #[test] + fn straight_line_cubic_overshoot_reaches_its_extreme() { + // A cubic whose control points are collinear and overshoot past its end anchor: the curve + // travels out to some x beyond 100 and doubles back, so the hull must reach that far point + let overshoot = PathSeg::Cubic(CubicBez::new(Point::new(0., 0.), Point::new(300., 0.), Point::new(300., 0.), Point::new(100., 0.))); + let above = Point::new(0., 50.); + let hull = convex_hull_of_geometry(&[overshoot], &[above]); + + // The parametric maximum of x(t) along the line, estimated by dense sampling (the sampling + // undershoots the true maximum slightly, so the hull may exceed it by the sampling error) + let sampled_max_x = (0..=1000).map(|i| overshoot.eval(i as f64 / 1000.).x).fold(f64::NEG_INFINITY, f64::max); + assert!(sampled_max_x > 150., "test setup must actually overshoot"); + + let hull_max_x = sample_hull_polygon(&hull).iter().map(|point| point.x).fold(f64::NEG_INFINITY, f64::max); + assert!(hull_max_x >= sampled_max_x - 1e-9, "hull must reach at least the sampled extreme: {hull_max_x} vs {sampled_max_x}"); + assert!(hull_max_x <= sampled_max_x + 0.01, "hull must not overshoot the true extreme: {hull_max_x} vs {sampled_max_x}"); + assert_hull_valid(&hull, &[overshoot], &[above]); + } + + #[test] + fn interior_geometry_is_ignored() { + let mut segments = circle_segments(Point::new(0., 0.), 100.); + let outer = segments.clone(); + segments.extend(circle_segments(Point::new(10., 5.), 30.)); + segments.push(PathSeg::Line(Line::new(Point::new(-20., -20.), Point::new(20., 20.)))); + let hull = convex_hull_of_geometry(&segments, &[Point::new(0., 40.)]); + + assert_hull_valid(&hull, &segments, &[]); + assert_same_cubic_set(&cubics_of(hull.segments()), &cubics_of(outer.iter().copied()), 1e-9); + } + + #[test] + fn equal_shapes_bridge_exactly_between_anchors() { + // Equal radii make the outer bitangents horizontal, touching each circle exactly at the anchor + // points of its quadrant construction, exercising the corner-to-corner bridge path + let mut segments = circle_segments(Point::new(0., 0.), 50.); + segments.extend(circle_segments(Point::new(200., 0.), 50.)); + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + + let lines: Vec = hull.segments().filter_map(|segment| if let PathSeg::Line(line) = segment { Some(line) } else { None }).collect(); + assert_eq!(lines.len(), 2); + for line in lines { + assert!( + (line.p0.y.abs() - 50.).abs() < 1e-9 && (line.p1.y - line.p0.y).abs() < 1e-9, + "bridges must be the horizontal bitangents, got {line:?}" + ); + } + } + + #[test] + fn overlapping_shapes_are_wrapped_together() { + let mut segments = circle_segments(Point::new(0., 0.), 60.); + segments.extend(circle_segments(Point::new(70., 25.), 60.)); + let hull = convex_hull_of_geometry(&segments, &[]); + + assert_hull_valid(&hull, &segments, &[]); + assert_bridges_tangent(&hull); + } + + #[test] + fn randomized_inputs_always_produce_valid_hulls() { + // A deterministic PRNG so failures are reproducible + let mut state: u64 = 0x853c49e6748fea9b; + let mut random = move || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (state >> 11) as f64 / (1u64 << 53) as f64 + }; + + for iteration in 0..200 { + let mut point = || Point::new(random() * 1000., random() * 1000.); + + let mut segments = Vec::new(); + let mut loose_points = Vec::new(); + for _ in 0..(1 + iteration % 5) { + match iteration % 3 { + 0 => segments.push(PathSeg::Cubic(CubicBez::new(point(), point(), point(), point()))), + 1 => segments.push(PathSeg::Quad(QuadBez::new(point(), point(), point()))), + _ => segments.push(PathSeg::Line(Line::new(point(), point()))), + } + if iteration % 4 == 0 { + loose_points.push(point()); + } + } + + let hull = convex_hull_of_geometry(&segments, &loose_points); + assert_hull_valid(&hull, &segments, &loose_points); + } + } + + #[test] + fn mixed_open_subpaths_and_points_are_all_wrapped() { + let mut segments = vec![ + PathSeg::Line(Line::new(Point::new(-200., -50.), Point::new(-180., 40.))), + PathSeg::Line(Line::new(Point::new(-180., 40.), Point::new(-120., 60.))), + PathSeg::Cubic(CubicBez::new(Point::new(100., -80.), Point::new(160., -20.), Point::new(160., 40.), Point::new(100., 90.))), + ]; + segments.extend(circle_segments(Point::new(0., 200.), 40.)); + let loose = [Point::new(0., -150.), Point::new(10., 0.)]; + let hull = convex_hull_of_geometry(&segments, &loose); + + assert_hull_valid(&hull, &segments, &loose); + } +} diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs b/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs index 2ba54718e4..d87b73fcf2 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs @@ -1,5 +1,6 @@ pub mod bezpath_algorithms; mod contants; +pub mod convex_hull; pub mod intersection; pub mod merge_by_distance; pub mod offset_subpath; diff --git a/node-graph/nodes/convex_hull/Cargo.toml b/node-graph/nodes/convex_hull/Cargo.toml deleted file mode 100644 index 9658e54644..0000000000 --- a/node-graph/nodes/convex_hull/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "convex-hull-nodes" -version = "0.1.0" -edition = "2024" -description = "Convex hull computation node for vector data" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" - -[dependencies] -# Local dependencies -dyn-any = { workspace = true } -core-types = { workspace = true } -graphic-types = { workspace = true } -node-macro = { workspace = true } -glam = { workspace = true } -specta = { workspace = true } -log = { workspace = true } -path-bool = { workspace = true } -serde = { workspace = true } -vector-types = { workspace = true } -kurbo = { workspace = true } -convex_hull = { path = "../../../../convex_hull" } diff --git a/node-graph/nodes/convex_hull/src/lib.rs b/node-graph/nodes/convex_hull/src/lib.rs deleted file mode 100644 index bb0b59692c..0000000000 --- a/node-graph/nodes/convex_hull/src/lib.rs +++ /dev/null @@ -1,457 +0,0 @@ -use core_types::Ctx; -use core_types::table::{Table, TableRow, TableRowRef}; -use glam::{DAffine2, DVec2}; -use graphic_types::Vector; -use graphic_types::vector_types::subpath::{ManipulatorGroup, PathSegPoints, Subpath, pathseg_points}; -use graphic_types::vector_types::vector::PointId; -use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; -pub use path_bool as path_bool_lib; -use path_bool::{FillRule, PathBooleanOperation}; -use std::ops::Mul; - -use ::convex_hull::{HullSegment, MonotoneArc, convex_hull as compute_convex_hull, split_at_inflections}; -use kurbo::{CubicBez, Line as KurboLine, ParamCurve, PathSeg as KurboPathSeg, Point as KurboPoint}; - -// ─── Graham's Scan Convex Hull ─── - -/// Compute the convex hull of a set of 2D points using Graham's scan. -/// Returns points in counter-clockwise order. -fn graham_scan_hull(points: &[DVec2]) -> Vec { - if points.len() <= 2 { - return points.to_vec(); - } - - // Find the lowest-y point (leftmost if tied) - let mut pivot_idx = 0; - for (i, p) in points.iter().enumerate() { - if p.y < points[pivot_idx].y || (p.y == points[pivot_idx].y && p.x < points[pivot_idx].x) { - pivot_idx = i; - } - } - let pivot = points[pivot_idx]; - - // Sort remaining points by polar angle from pivot - let mut indexed: Vec<(usize, DVec2)> = points.iter().copied().enumerate().filter(|&(i, _)| i != pivot_idx).collect(); - indexed.sort_by(|&(_, a), &(_, b)| { - let da = a - pivot; - let db = b - pivot; - let angle_a = da.y.atan2(da.x); - let angle_b = db.y.atan2(db.x); - angle_a.partial_cmp(&angle_b).unwrap().then_with(|| { - // If same angle, closer point first - da.length_squared().partial_cmp(&db.length_squared()).unwrap() - }) - }); - - // Build hull using cross-product left-turn test - let mut hull = vec![pivot]; - for (_, p) in indexed { - while hull.len() >= 2 { - let a = hull[hull.len() - 2]; - let b = hull[hull.len() - 1]; - let cross = (b - a).perp_dot(p - b); - if cross <= 0.0 { - hull.pop(); - } else { - break; - } - } - hull.push(p); - } - - hull -} - -// ─── Kurbo PathSeg → CubicBez Conversion ─── - -/// Convert any `kurbo::PathSeg` to a `CubicBez`. -fn pathseg_to_cubicbez(seg: KurboPathSeg) -> CubicBez { - match seg { - KurboPathSeg::Cubic(cb) => cb, - KurboPathSeg::Quad(qb) => { - // Degree elevation: quadratic → cubic - let p0 = qb.p0; - let p3 = qb.p2; - let q1 = qb.p1; - let p1 = KurboPoint::new(p0.x + 2.0 / 3.0 * (q1.x - p0.x), p0.y + 2.0 / 3.0 * (q1.y - p0.y)); - let p2 = KurboPoint::new(p3.x + 2.0 / 3.0 * (q1.x - p3.x), p3.y + 2.0 / 3.0 * (q1.y - p3.y)); - CubicBez::new(p0, p1, p2, p3) - } - KurboPathSeg::Line(l) => { - // Place control points at 1/3 and 2/3 along the line - let p0 = l.p0; - let p3 = l.p1; - let p1 = KurboPoint::new(p0.x + (p3.x - p0.x) / 3.0, p0.y + (p3.y - p0.y) / 3.0); - let p2 = KurboPoint::new(p0.x + 2.0 * (p3.x - p0.x) / 3.0, p0.y + 2.0 * (p3.y - p0.y) / 3.0); - CubicBez::new(p0, p1, p2, p3) - } - } -} - -// ─── Subpath → Vec Conversion ─── - -/// Check if a CubicBez is degenerate (all control points at essentially the same location). -fn is_degenerate_cubic(cb: &CubicBez) -> bool { - const EPS_SQ: f64 = 1e-20; - let d03 = cb.p3 - cb.p0; - let d01 = cb.p1 - cb.p0; - let d02 = cb.p2 - cb.p0; - (d03.x * d03.x + d03.y * d03.y) < EPS_SQ && (d01.x * d01.x + d01.y * d01.y) < EPS_SQ && (d02.x * d02.x + d02.y * d02.y) < EPS_SQ -} - -/// Convert a `Subpath` into a `Vec` for the convex hull library. -/// Filters out degenerate zero-length segments. -fn subpath_to_cubicbez_vec(subpath: &Subpath) -> Vec { - subpath.iter().map(pathseg_to_cubicbez).filter(|cb| !is_degenerate_cubic(cb)).collect() -} - -// ─── Winding Direction ─── - -/// Compute the signed area of a closed cubic bezier path by sampling. -/// Positive = CCW in standard math coords, Negative = CW. -fn signed_area_of_cubic_path(segments: &[CubicBez]) -> f64 { - let mut area = 0.0; - let n = 16; - for seg in segments { - for i in 0..n { - let t0 = i as f64 / n as f64; - let t1 = (i + 1) as f64 / n as f64; - let p0 = seg.eval(t0); - let p1 = seg.eval(t1); - area += p0.x * p1.y - p1.x * p0.y; - } - } - area / 2.0 -} - -/// Reverse a cubic bezier path (reverse segment order + swap endpoints within each segment). -fn reverse_cubic_path(segments: &[CubicBez]) -> Vec { - segments.iter().rev().map(|cb| CubicBez::new(cb.p3, cb.p2, cb.p1, cb.p0)).collect() -} - -// ─── Select Outer Subpath ─── - -/// Select the outermost subpath from a Vector by choosing the one with the largest absolute area. -fn select_outer_subpath(vector: &Vector) -> Option> { - vector.stroke_bezier_paths().max_by(|a, b| { - let area_a = a.area_centroid_and_area(None, None).map(|(_, area)| area.abs()).unwrap_or(0.0); - let area_b = b.area_centroid_and_area(None, None).map(|(_, area)| area.abs()).unwrap_or(0.0); - area_a.partial_cmp(&area_b).unwrap_or(std::cmp::Ordering::Equal) - }) -} - -// ─── Hull Segments → Subpath ─── - -/// Convert hull segments back into a `Subpath`. -fn hull_segments_to_subpath(segments: &[HullSegment], arcs: &[MonotoneArc]) -> Option> { - let mut kurbo_segs: Vec = segments - .iter() - .map(|seg| match seg { - HullSegment::Arc { arc_index, t_start, t_end } => { - let sub = arcs[*arc_index].bezier.subsegment(*t_start..*t_end); - KurboPathSeg::Cubic(sub) - } - HullSegment::Line { start, end, .. } => KurboPathSeg::Line(KurboLine::new(*start, *end)), - }) - .collect(); - - if kurbo_segs.is_empty() { - return None; - } - - // Subpath::from_beziers requires at least 2 segments for a closed path. - // If we have only 1, split it at the midpoint. - if kurbo_segs.len() == 1 { - let seg = kurbo_segs[0]; - match seg { - KurboPathSeg::Cubic(cb) => { - let first_half = cb.subsegment(0.0..0.5); - let second_half = cb.subsegment(0.5..1.0); - kurbo_segs = vec![KurboPathSeg::Cubic(first_half), KurboPathSeg::Cubic(second_half)]; - } - KurboPathSeg::Line(l) => { - let mid = KurboPoint::new((l.p0.x + l.p1.x) / 2.0, (l.p0.y + l.p1.y) / 2.0); - kurbo_segs = vec![KurboPathSeg::Line(KurboLine::new(l.p0, mid)), KurboPathSeg::Line(KurboLine::new(mid, l.p1))]; - } - KurboPathSeg::Quad(qb) => { - let cb = pathseg_to_cubicbez(KurboPathSeg::Quad(qb)); - let first_half = cb.subsegment(0.0..0.5); - let second_half = cb.subsegment(0.5..1.0); - kurbo_segs = vec![KurboPathSeg::Cubic(first_half), KurboPathSeg::Cubic(second_half)]; - } - } - } - - Some(Subpath::from_beziers(&kurbo_segs, true)) -} - -// ─── Main Node ─── - -#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] -async fn convex_hull(_: impl Ctx, content: Table) -> Table { - // Handle empty input - if content.is_empty() { - return Table::default(); - } - - // Step 1: Collect one representative point per subpath (in world space) - let mut hull_points: Vec = Vec::new(); - for row in content.iter() { - let transform = *row.transform; - for subpath in row.element.stroke_bezier_paths() { - if let Some(first) = subpath.manipulator_groups().first() { - hull_points.push(transform.transform_point2(first.anchor)); - } - } - } - - // Step 2: Union all input shapes - let mut result_vector_table = union(content.iter()); - - // Step 3: Flatten union result to world space (apply transform, set to IDENTITY) - let style; - { - let Some(result_row) = result_vector_table.iter_mut().next() else { - return Table::default(); - }; - let transform = *result_row.transform; - *result_row.transform = DAffine2::IDENTITY; - Vector::transform(result_row.element, transform); - result_row.element.style.set_stroke_transform(DAffine2::IDENTITY); - - // Step 4: Save style - style = result_row.element.style.clone(); - } - - // Step 5: If the union has multiple disjoint subpaths AND we have ≥3 hull points, - // build a polyline convex hull and boolean-union it with the result to connect everything. - let subpath_count = result_vector_table.iter().next().map(|r| r.element.stroke_bezier_paths().count()).unwrap_or(0); - log::debug!("subpath_count: {}", subpath_count); - - if subpath_count > 1 && hull_points.len() >= 3 { - let poly_points = graham_scan_hull(&hull_points); - if poly_points.len() >= 3 { - // Build a polyline subpath from the hull points - let poly_subpath = Subpath::::from_anchors(poly_points.into_iter(), true); - let poly_vector = Vector::from_subpath(poly_subpath); - - // Boolean union the current result with the polyline - let current_vector = &result_vector_table.iter().next().unwrap().element; - let upper_path = to_path(current_vector, DAffine2::IDENTITY); - let lower_path = to_path(&poly_vector, DAffine2::IDENTITY); - - #[allow(unused_unsafe)] - let union_result_paths = unsafe { boolean_union(upper_path, lower_path) }; - let union_result = from_path(&union_result_paths); - - // Replace the result vector's geometry - let result_row = result_vector_table.iter_mut().next().unwrap(); - result_row.element.colinear_manipulators = union_result.colinear_manipulators; - result_row.element.point_domain = union_result.point_domain; - result_row.element.segment_domain = union_result.segment_domain; - result_row.element.region_domain = union_result.region_domain; - } - } - - // Step 6: Select the outer boundary subpath (largest by area) - let outer_subpath = { - let result_row = result_vector_table.iter().next().unwrap(); - select_outer_subpath(result_row.element) - }; - - let Some(outer_subpath) = outer_subpath else { - return result_vector_table; - }; - - // Step 7: Convert to Vec - let cubic_segments = subpath_to_cubicbez_vec(&outer_subpath); - if cubic_segments.is_empty() { - return result_vector_table; - } - - // The hull library expects CCW winding. Graphite paths are typically CW in screen coords - // (Y-down), so we reverse if the signed area is negative (CW in math coords). - let cubic_segments = if signed_area_of_cubic_path(&cubic_segments) < 0.0 { - reverse_cubic_path(&cubic_segments) - } else { - cubic_segments - }; - - log::debug!("path: {:?}", cubic_segments); - - // Step 8: Run the curved convex hull algorithm - let arcs = split_at_inflections(&cubic_segments); - log::debug!("arcs: {:?}", arcs); - let hull_segments = compute_convex_hull(&cubic_segments); - log::debug!("segments: {:?}", hull_segments); - - if hull_segments.is_empty() { - // Fallback: return the union result as-is - return result_vector_table; - } - - // Step 9: Reconstruct hull as Subpath - let Some(hull_subpath) = hull_segments_to_subpath(&hull_segments, &arcs) else { - return result_vector_table; - }; - log::debug!("hull_subpath: {:?}", hull_subpath); - - // Step 10: Create Vector from hull subpath, apply saved style - let mut hull_vector = Vector::from_subpath(hull_subpath); - hull_vector.style = style; - - // Step 11: Build result table - let mut result: Table = Table::new_from_element(hull_vector); - if let Some(row) = result.iter_mut().next() { - // Step 11: Clean up with merge_by_distance_spatial - row.element.merge_by_distance_spatial(*row.transform, 0.0001); - } - - result -} - -// ─── Boolean Operations (shared helpers) ─── - -fn union<'a>(vector: impl DoubleEndedIterator>) -> Table { - // Reverse the vector table rows so that the result style is the style of the first vector row - let mut vector_reversed = vector.rev(); - - let mut result_vector_table = Table::new_from_row(vector_reversed.next().map(|x| x.into_cloned()).unwrap_or_default()); - let mut first_row = result_vector_table.iter_mut().next().expect("Expected the one row we just pushed"); - - // Loop over all vector table rows and union it with the result - let default = TableRow::default(); - let mut second_vector = Some(vector_reversed.next().unwrap_or(default.as_ref())); - while let Some(lower_vector) = second_vector { - let transform_of_lower_into_space_of_upper = first_row.transform.inverse() * *lower_vector.transform; - - let result = &mut first_row.element; - - let upper_path_string = to_path(result, DAffine2::IDENTITY); - let lower_path_string = to_path(lower_vector.element, transform_of_lower_into_space_of_upper); - - #[allow(unused_unsafe)] - let boolean_operation_string = unsafe { boolean_union(upper_path_string, lower_path_string) }; - let boolean_operation_result = from_path(&boolean_operation_string); - - result.colinear_manipulators = boolean_operation_result.colinear_manipulators; - result.point_domain = boolean_operation_result.point_domain; - result.segment_domain = boolean_operation_result.segment_domain; - result.region_domain = boolean_operation_result.region_domain; - - second_vector = vector_reversed.next(); - } - - result_vector_table -} - -fn to_path(vector: &Vector, transform: DAffine2) -> Vec { - let mut path = Vec::new(); - for subpath in vector.stroke_bezier_paths() { - to_path_segments(&mut path, &subpath, transform); - } - path -} - -fn to_path_segments(path: &mut Vec, subpath: &Subpath, transform: DAffine2) { - use path_bool::PathSegment; - let mut global_start = None; - let mut global_end = DVec2::ZERO; - - for bezier in subpath.iter() { - const EPS: f64 = 1e-8; - let transform_point = |pos: DVec2| transform.transform_point2(pos).mul(EPS.recip()).round().mul(EPS); - - let PathSegPoints { p0, p1, p2, p3 } = pathseg_points(bezier); - - let p0 = transform_point(p0); - let p1 = p1.map(transform_point); - let p2 = p2.map(transform_point); - let p3 = transform_point(p3); - - if global_start.is_none() { - global_start = Some(p0); - } - global_end = p3; - - let segment = match (p1, p2) { - (None, None) => PathSegment::Line(p0, p3), - (None, Some(p2)) | (Some(p2), None) => PathSegment::Quadratic(p0, p2, p3), - (Some(p1), Some(p2)) => PathSegment::Cubic(p0, p1, p2, p3), - }; - - path.push(segment); - } - if let Some(start) = global_start { - path.push(PathSegment::Line(global_end, start)); - } -} - -fn from_path(path_data: &[Path]) -> Vector { - const EPSILON: f64 = 1e-5; - - fn is_close(a: DVec2, b: DVec2) -> bool { - (a - b).length_squared() < EPSILON * EPSILON - } - - let mut all_subpaths = Vec::new(); - - for path in path_data.iter().filter(|path| !path.is_empty()) { - let cubics: Vec<[DVec2; 4]> = path.iter().map(|segment| segment.to_cubic()).collect(); - let mut manipulators_list = Vec::new(); - let mut current_start = None; - - for (index, cubic) in cubics.iter().enumerate() { - let [start, handle1, handle2, end] = *cubic; - - if current_start.is_none() || !is_close(start, current_start.unwrap()) { - // Start a new subpath - if !manipulators_list.is_empty() { - all_subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true)); - } - // Use the correct in-handle (None) and out-handle for the start point - manipulators_list.push(ManipulatorGroup::new(start, None, Some(handle1))); - } else { - // Update the out-handle of the previous point - if let Some(last) = manipulators_list.last_mut() { - last.out_handle = Some(handle1); - } - } - - // Add the end point with the correct in-handle and out-handle (None) - manipulators_list.push(ManipulatorGroup::new(end, Some(handle2), None)); - - current_start = Some(end); - - // Check if this is the last segment - if index == cubics.len() - 1 { - all_subpaths.push(Subpath::new(manipulators_list, true)); - manipulators_list = Vec::new(); // Reset manipulators for the next path - } - } - } - - Vector::from_subpaths(all_subpaths, false) -} - -type Path = Vec; - -fn boolean_union(a: Path, b: Path) -> Vec { - path_bool(a, b, PathBooleanOperation::Union) -} - -fn path_bool(a: Path, b: Path, op: PathBooleanOperation) -> Vec { - match path_bool::path_boolean(&a, FillRule::NonZero, &b, FillRule::NonZero, op) { - Ok(results) => results, - Err(e) => { - let a_path = path_bool::path_to_path_data(&a, 0.001); - let b_path = path_bool::path_to_path_data(&b, 0.001); - log::error!("Boolean error {e:?} encountered while processing {a_path}\n {op:?}\n {b_path}"); - Vec::new() - } - } -} - -pub fn boolean_intersect(a: Path, b: Path) -> Vec { - path_bool(a, b, PathBooleanOperation::Intersection) -} diff --git a/node-graph/nodes/gstd/Cargo.toml b/node-graph/nodes/gstd/Cargo.toml index 2d7a1e77ed..2385c9a9b6 100644 --- a/node-graph/nodes/gstd/Cargo.toml +++ b/node-graph/nodes/gstd/Cargo.toml @@ -44,7 +44,6 @@ text-nodes = { workspace = true } transform-nodes = { workspace = true } vector-nodes = { workspace = true } path-bool-nodes = { workspace = true } -convex-hull-nodes = { workspace = true } math-nodes = { workspace = true } rendering = { workspace = true } graphene-application-io = { workspace = true } diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 9e56482c2f..7236bb8c63 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -7,7 +7,6 @@ pub mod render_pixel_preview; pub mod text; pub use blending_nodes; pub use brush_nodes as brush; -pub use convex_hull_nodes; pub use core_types::*; pub use graphene_application_io as application_io; pub use graphene_core; diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 3b08ac33d1..372d275c88 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -19,17 +19,19 @@ use graphic_types::{Graphic, IntoGraphicList}; use kurbo::simplify::{SimplifyOptions, simplify_bezpath}; use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape}; use rand::{Rng, SeedableRng}; +use std::collections::HashSet; use std::collections::hash_map::DefaultHasher; use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box}; 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::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}; use vector_types::vector::misc::{ CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, - bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, + bezpath_to_manipulator_groups, dvec2_to_point, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, }; use vector_types::vector::style::{GradientStops, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; @@ -579,6 +581,67 @@ 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. +/// +/// 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))] +async fn convex_hull( + _: impl Ctx, + /// The `List` of vector paths to wrap in the convex hull. Nested `List`s are automatically flattened. + #[implementations(List, List)] + content: I, +) -> 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 + let mut segments = Vec::new(); + let mut loose_points = Vec::new(); + for index in 0..flattened.len() { + let Some(element) = flattened.element(index) else { continue }; + let transform: DAffine2 = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let affine = Affine::new(transform.to_cols_array()); + + for bezpath in element.stroke_bezpath_iter() { + segments.extend(bezpath.segments().map(|segment| affine * segment)); + } + + // Anchor points not connected to any segment still participate in the hull + let connected_points: HashSet = element.segment_domain.start_point().iter().chain(element.segment_domain.end_point()).copied().collect(); + for (point_index, &position) in element.point_domain.positions().iter().enumerate() { + if !connected_points.contains(&point_index) { + loose_points.push(dvec2_to_point(transform.transform_point2(position))); + } + } + } + + let hull = convex_hull_of_geometry(&segments, &loose_points); + + // 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() }; + let mut attributes = flattened.clone_item_attributes(last_index); + let last_transform: DAffine2 = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, last_index); + + // The hull geometry is built in world space, so the result item carries no transform of its own + attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY); + bake_paint_transforms(&mut attributes, last_transform); + + let mut element = Vector { + stroke: flattened.element(last_index).map(|vector| vector.stroke.clone()).unwrap_or_default(), + ..Default::default() + }; + element.append_bezpath(hull); + element.set_stroke_transform(DAffine2::IDENTITY); + + let mut result = List::new(); + result.push(Item::from_parts(element, attributes)); + + // Snapshot the input layers so the renderer can recurse into them for editor click-target preservation + result.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, content); + + result +} + pub mod extrude_algorithms { use glam::DVec2; use kurbo::{ParamCurve, ParamCurveDeriv}; @@ -3350,6 +3413,33 @@ mod test { } } + #[tokio::test] + async fn convex_hull_wraps_multiple_items_and_floating_points() { + // Two squares far apart, each placed by its own transform, plus a free-floating anchor point far above + let square = Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY); + let mut content = List::new(); + content.push(create_vector_item(square.clone(), DAffine2::IDENTITY)); + content.push(create_vector_item(square, DAffine2::from_translation(DVec2::new(100., 0.)))); + + let mut floating = Vector::default(); + 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 element = hull.element(0).unwrap(); + + // The hull is the pentagon spanning both squares' outer corners and the floating point + let positions = element.point_domain.positions(); + assert_eq!(positions.len(), 5, "expected a pentagon, got anchors at {positions:?}"); + for expected in [DVec2::new(0., 0.), DVec2::new(110., 0.), DVec2::new(110., 10.), DVec2::new(50., 200.), DVec2::new(0., 10.)] { + assert!(positions.iter().any(|position| position.distance(expected) < 1e-6), "expected a hull anchor near {expected}"); + } + + // The hull geometry is emitted in world space with no residual transform + let transform: DAffine2 = hull.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + assert_eq!(transform, DAffine2::IDENTITY); + } + #[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.))]);