From 751a6ca77d1173771368a86b3e10e190857bbfe9 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 4 Mar 2026 15:18:08 +0100 Subject: [PATCH 1/2] Convex hull --- .../src/vector/algorithms/convex_hull.rs | 1028 +++++++++++++++++ .../vector-types/src/vector/algorithms/mod.rs | 1 + node-graph/nodes/vector/src/vector_nodes.rs | 91 +- 3 files changed, 1119 insertions(+), 1 deletion(-) create mode 100644 node-graph/libraries/vector-types/src/vector/algorithms/convex_hull.rs 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/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index d4cb1fc979..8db536df5d 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -24,12 +24,13 @@ use std::collections::{HashMap, HashSet}; 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::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::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; @@ -559,6 +560,67 @@ pub fn merge_by_distance( content } +/// 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}; @@ -3611,6 +3673,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.))]); From 9537a4ae03f4d45f3f4970878c2197d71a977cf0 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 15 Jul 2026 21:54:17 -0700 Subject: [PATCH 2/2] Plotter --- editor/src/consts.rs | 17 ++ editor/src/messages/dialog/dialog_message.rs | 6 + .../messages/dialog/dialog_message_handler.rs | 26 +++ editor/src/messages/dialog/mod.rs | 1 + .../dialog/send_to_plotter_dialog/mod.rs | 7 + .../send_to_plotter_dialog_message.rs | 10 ++ .../send_to_plotter_dialog_message_handler.rs | 153 ++++++++++++++++++ .../src/messages/dialog/simple_dialogs/mod.rs | 2 + .../send_to_plotter_success_dialog.rs | 34 ++++ .../src/messages/frontend/frontend_message.rs | 5 + .../messages/input_mapper/input_mappings.rs | 1 + .../menu_bar/menu_bar_message_handler.rs | 6 + .../document/document_message_handler.rs | 2 + .../messages/portfolio/portfolio_message.rs | 4 + .../portfolio/portfolio_message_handler.rs | 28 +++- editor/src/messages/prelude.rs | 1 + editor/src/node_graph_executor.rs | 33 +++- editor/src/node_graph_executor/runtime.rs | 4 + frontend/src/stores/portfolio.ts | 32 ++++ frontend/wrapper/src/editor_wrapper.rs | 7 + .../libraries/application-io/src/lib.rs | 4 + node-graph/libraries/rendering/src/lib.rs | 1 + .../rendering/src/plot_statistics.rs | 138 ++++++++++++++++ .../libraries/rendering/src/render_ext.rs | 7 +- .../libraries/rendering/src/renderer.rs | 84 +++++++--- .../nodes/gstd/src/render_background.rs | 37 +++-- .../src/render_background_checker_rect.wgsl | 7 +- .../render_background_checker_viewport.wgsl | 7 +- node-graph/nodes/gstd/src/render_node.rs | 2 + plotter-upload-spec.md | 139 ++++++++++++++++ 30 files changed, 766 insertions(+), 39 deletions(-) create mode 100644 editor/src/messages/dialog/send_to_plotter_dialog/mod.rs create mode 100644 editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message.rs create mode 100644 editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message_handler.rs create mode 100644 editor/src/messages/dialog/simple_dialogs/send_to_plotter_success_dialog.rs create mode 100644 node-graph/libraries/rendering/src/plot_statistics.rs create mode 100644 plotter-upload-spec.md diff --git a/editor/src/consts.rs b/editor/src/consts.rs index a014e8891f..f854b347ae 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -183,6 +183,23 @@ pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document"; pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 1; +// PLOTTER +/// Address of the pen plotter print server, hard-coded for the conference booth LAN where it won't change. +pub const PLOTTER_SERVER_ADDRESS: &str = "http://192.168.77.10:4747"; +/// Drawable area of letter paper in the plotter, in inches (portrait). The artwork's bounding box (the artboard is +/// dropped) is scaled to fit, rotated to landscape when wider than tall. +pub const PLOTTER_PAPER_SIZE_INCHES: (f64, f64) = (7.5, 10.); +/// Fixed per-job overhead in seconds before the pen starts moving. Derived directly from timed plots: two versions of +/// the same artwork plotted separately (3:24 + 12:17) minus both overlaid in a single job (15:23) leaves the setup +/// time counted one extra time, giving 18 seconds. +pub const PLOTTER_SETUP_SECONDS: f64 = 18.; +/// Pen-down drawing speed in inches per second. Fitted (with the pen lift time below) to timed plots of the same +/// artwork with solid strokes (349 in, 20 lifts, 3:24) and dash-baked strokes (176 in, 818 lifts, 12:17). +pub const PLOTTER_PEN_SPEED_INCHES_PER_SECOND: f64 = 2.047; +/// Seconds per pen lift/reposition/lower cycle, once per subpath. This also covers pen-up travel, which is not +/// modeled by distance because the print server reorders paths to minimize it. Fitted alongside the pen speed above. +pub const PLOTTER_PEN_LIFT_SECONDS: f64 = 0.774; + // INPUT pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500; diff --git a/editor/src/messages/dialog/dialog_message.rs b/editor/src/messages/dialog/dialog_message.rs index df52cce6b4..f3a359401d 100644 --- a/editor/src/messages/dialog/dialog_message.rs +++ b/editor/src/messages/dialog/dialog_message.rs @@ -10,6 +10,8 @@ pub enum DialogMessage { NewDocumentDialog(NewDocumentDialogMessage), #[child] PreferencesDialog(PreferencesDialogMessage), + #[child] + SendToPlotterDialog(SendToPlotterDialogMessage), // Messages Dismiss, @@ -22,6 +24,9 @@ pub enum DialogMessage { title: String, description: String, }, + DisplaySendToPlotterSuccess { + job_name: String, + }, RequestAboutGraphiteDialog, RequestAboutGraphiteDialogWithLocalizedCommitDate { localized_commit_date: String, @@ -37,6 +42,7 @@ pub enum DialogMessage { }, RequestNewDocumentDialog, RequestPreferencesDialog, + RequestSendToPlotterDialog, RequestConfirmRestartDialog { preferences_requiring_restart: Vec, }, diff --git a/editor/src/messages/dialog/dialog_message_handler.rs b/editor/src/messages/dialog/dialog_message_handler.rs index e788ff5364..77f982e2fd 100644 --- a/editor/src/messages/dialog/dialog_message_handler.rs +++ b/editor/src/messages/dialog/dialog_message_handler.rs @@ -4,6 +4,7 @@ use crate::messages::dialog::simple_dialogs::{ConfirmRestartDialog, LicensesThir use crate::messages::frontend::utility_types::ExportBounds; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::prelude::*; +use graphene_std::vector::style::RenderMode; #[derive(ExtractField)] pub struct DialogMessageContext<'a> { @@ -18,6 +19,7 @@ pub struct DialogMessageHandler { export_dialog: ExportDialogMessageHandler, new_document_dialog: NewDocumentDialogMessageHandler, preferences_dialog: PreferencesDialogMessageHandler, + send_to_plotter_dialog: SendToPlotterDialogMessageHandler, } #[message_handler_data] @@ -29,6 +31,7 @@ impl MessageHandler> for DialogMessageHa DialogMessage::ExportDialog(message) => self.export_dialog.process_message(message, responses, ExportDialogMessageContext { portfolio }), DialogMessage::NewDocumentDialog(message) => self.new_document_dialog.process_message(message, responses, ()), DialogMessage::PreferencesDialog(message) => self.preferences_dialog.process_message(message, responses, PreferencesDialogMessageContext { preferences }), + DialogMessage::SendToPlotterDialog(message) => self.send_to_plotter_dialog.process_message(message, responses, SendToPlotterDialogMessageContext { portfolio }), DialogMessage::Dismiss => { if let Some(message) = self.on_dismiss.take() { @@ -60,6 +63,11 @@ impl MessageHandler> for DialogMessageHa let dialog = simple_dialogs::ErrorDialog { title, description }; dialog.send_dialog_to_frontend(responses); } + DialogMessage::DisplaySendToPlotterSuccess { job_name } => { + self.on_dismiss = None; + let dialog = simple_dialogs::SendToPlotterSuccessDialog { job_name }; + dialog.send_dialog_to_frontend(responses); + } DialogMessage::RequestAboutGraphiteDialog => { self.on_dismiss = Some(DialogMessage::Close.into()); responses.add(FrontendMessage::TriggerAboutGraphiteLocalizedCommitDate { @@ -136,6 +144,23 @@ impl MessageHandler> for DialogMessageHa self.on_dismiss = Some(PreferencesDialogMessage::Confirm.into()); self.preferences_dialog.send_dialog_to_frontend(responses, preferences); } + DialogMessage::RequestSendToPlotterDialog => { + self.on_dismiss = Some(DialogMessage::Close.into()); + if let Some(document) = portfolio.active_document() { + // Switch the viewport to outline mode so the artwork previews as the pen plotter will draw it + responses.add(DocumentMessage::SetRenderMode { render_mode: RenderMode::Outline }); + + // Kick off a render of the plotter SVG purely to measure it for the time estimate shown in the dialog + responses.add(PortfolioMessage::SubmitPlotterExport { + job_name: document.name.clone(), + estimate_only: true, + }); + + self.send_to_plotter_dialog.job_name = document.name.clone(); + self.send_to_plotter_dialog.estimate = crate::messages::dialog::send_to_plotter_dialog::PlotTimeEstimate::Pending; + self.send_to_plotter_dialog.send_dialog_to_frontend(responses); + } + } DialogMessage::RequestConfirmRestartDialog { preferences_requiring_restart } => { self.on_dismiss = Some(DialogMessage::Close.into()); let dialog = ConfirmRestartDialog { preferences_requiring_restart }; @@ -149,5 +174,6 @@ impl MessageHandler> for DialogMessageHa RequestExportDialog, RequestNewDocumentDialog, RequestPreferencesDialog, + RequestSendToPlotterDialog, ); } diff --git a/editor/src/messages/dialog/mod.rs b/editor/src/messages/dialog/mod.rs index 67a186f993..aead067195 100644 --- a/editor/src/messages/dialog/mod.rs +++ b/editor/src/messages/dialog/mod.rs @@ -11,6 +11,7 @@ mod dialog_message_handler; pub mod export_dialog; pub mod new_document_dialog; pub mod preferences_dialog; +pub mod send_to_plotter_dialog; pub mod simple_dialogs; #[doc(inline)] diff --git a/editor/src/messages/dialog/send_to_plotter_dialog/mod.rs b/editor/src/messages/dialog/send_to_plotter_dialog/mod.rs new file mode 100644 index 0000000000..71c63687c8 --- /dev/null +++ b/editor/src/messages/dialog/send_to_plotter_dialog/mod.rs @@ -0,0 +1,7 @@ +mod send_to_plotter_dialog_message; +mod send_to_plotter_dialog_message_handler; + +#[doc(inline)] +pub use send_to_plotter_dialog_message::{SendToPlotterDialogMessage, SendToPlotterDialogMessageDiscriminant}; +#[doc(inline)] +pub use send_to_plotter_dialog_message_handler::{PlotTimeEstimate, SendToPlotterDialogMessageContext, SendToPlotterDialogMessageHandler, estimated_plot_seconds}; diff --git a/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message.rs b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message.rs new file mode 100644 index 0000000000..53c17df8c1 --- /dev/null +++ b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message.rs @@ -0,0 +1,10 @@ +use crate::messages::prelude::*; + +#[impl_message(Message, DialogMessage, SendToPlotterDialog)] +#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] +pub enum SendToPlotterDialogMessage { + JobName { name: String }, + UpdateTimeEstimate { seconds: Option }, + + Submit, +} diff --git a/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message_handler.rs b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message_handler.rs new file mode 100644 index 0000000000..71e662b8f9 --- /dev/null +++ b/editor/src/messages/dialog/send_to_plotter_dialog/send_to_plotter_dialog_message_handler.rs @@ -0,0 +1,153 @@ +use crate::consts::{PLOTTER_PAPER_SIZE_INCHES, PLOTTER_PEN_LIFT_SECONDS, PLOTTER_PEN_SPEED_INCHES_PER_SECOND, PLOTTER_SETUP_SECONDS}; +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; +use graphene_std::renderer::plot_statistics::PlotStatistics; + +#[derive(ExtractField)] +pub struct SendToPlotterDialogMessageContext<'a> { + pub portfolio: &'a PortfolioMessageHandler, +} + +/// How long the plotter is expected to take on the current document, shown in the dialog. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum PlotTimeEstimate { + /// The estimate render is still in flight. + #[default] + Pending, + /// The estimate could not be computed. + Unavailable, + /// Estimated plot duration in seconds. + Seconds(f64), +} + +/// A dialog to send the current document to the pen plotter print server as an SVG. +#[derive(Debug, Clone, Default, ExtractField)] +pub struct SendToPlotterDialogMessageHandler { + pub job_name: String, + pub estimate: PlotTimeEstimate, +} + +#[message_handler_data] +impl MessageHandler> for SendToPlotterDialogMessageHandler { + fn process_message(&mut self, message: SendToPlotterDialogMessage, responses: &mut VecDeque, context: SendToPlotterDialogMessageContext) { + let SendToPlotterDialogMessageContext { portfolio } = context; + + match message { + SendToPlotterDialogMessage::JobName { name } => self.job_name = name, + + SendToPlotterDialogMessage::UpdateTimeEstimate { seconds } => { + self.estimate = match seconds { + Some(seconds) => PlotTimeEstimate::Seconds(seconds), + None => PlotTimeEstimate::Unavailable, + }; + + // Refresh only the dialog content, since re-displaying the whole dialog would reopen it if the user has already closed it + self.send_layout(responses, LayoutTarget::DialogColumn1); + return; + } + + SendToPlotterDialogMessage::Submit => { + // Fall back to the document name so the attendant can still tell jobs apart if the field was cleared + let job_name = if self.job_name.trim().is_empty() { + portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default() + } else { + self.job_name.clone() + }; + + responses.add_front(PortfolioMessage::SubmitPlotterExport { job_name, estimate_only: false }); + } + } + + self.send_dialog_to_frontend(responses); + } + + advertise_actions!(SendToPlotterDialogUpdate; + ); +} + +impl DialogLayoutHolder for SendToPlotterDialogMessageHandler { + const ICON: &'static str = "File"; + const TITLE: &'static str = "Send to Plotter"; + + fn layout_buttons(&self) -> Layout { + let widgets = vec![ + TextButton::new("Send") + .emphasized(true) + .on_update(|_| { + DialogMessage::CloseAndThen { + followups: vec![SendToPlotterDialogMessage::Submit.into()], + } + .into() + }) + .widget_instance(), + TextButton::new("Cancel").on_update(|_| FrontendMessage::DialogClose.into()).widget_instance(), + ]; + + Layout(vec![LayoutGroup::row(widgets)]) + } +} + +impl LayoutHolder for SendToPlotterDialogMessageHandler { + fn layout(&self) -> Layout { + let job_name = vec![ + TextLabel::new("Job Name").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextInput::new(&self.job_name) + .on_update(|text_input: &TextInput| SendToPlotterDialogMessage::JobName { name: text_input.value.clone() }.into()) + .min_width(200) + .widget_instance(), + ]; + + let estimate_text = match self.estimate { + PlotTimeEstimate::Pending => "Estimating…".to_string(), + PlotTimeEstimate::Unavailable => "Unknown".to_string(), + PlotTimeEstimate::Seconds(seconds) => format_plot_duration(seconds), + }; + let estimated_time = vec![ + TextLabel::new("Estimated Time").table_align(true).min_width(100).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextLabel::new(estimate_text).widget_instance(), + ]; + + Layout(vec![LayoutGroup::row(job_name), LayoutGroup::row(estimated_time)]) + } +} + +/// Estimates how long the plotter will take to draw the given SVG, in seconds. +/// +/// The artwork's bounding box is scaled to fit the paper (rotated to landscape when wider than tall), then timed as a +/// fixed setup cost, pen-down drawing at a constant speed, and a fixed cost per pen lift (which also covers pen-up +/// travel, since the print server reorders paths to minimize it). The pen draws every path as its outline, so total +/// path length approximates the pen-down distance regardless of fills. Intricate fine detail like text tends to run +/// over this estimate because the machine cannot reach full speed on tiny curves. +pub fn estimated_plot_seconds(statistics: &PlotStatistics) -> f64 { + let (paper_width, paper_height) = if statistics.width > statistics.height { + (PLOTTER_PAPER_SIZE_INCHES.1, PLOTTER_PAPER_SIZE_INCHES.0) + } else { + PLOTTER_PAPER_SIZE_INCHES + }; + let scale = if statistics.width > 0. && statistics.height > 0. { + (paper_width / statistics.width).min(paper_height / statistics.height) + } else { + 0. + }; + + let pen_down_inches = statistics.pen_down_distance * scale; + + PLOTTER_SETUP_SECONDS + pen_down_inches / PLOTTER_PEN_SPEED_INCHES_PER_SECOND + statistics.pen_lift_count as f64 * PLOTTER_PEN_LIFT_SECONDS +} + +/// Formats an estimated duration like "About 3 min 20 sec", rounded to the nearest 5 seconds. +fn format_plot_duration(seconds: f64) -> String { + let total = (((seconds / 5.).round() * 5.).max(5.)) as u64; + let minutes = total / 60; + let seconds = total % 60; + + if minutes == 0 { + format!("About {seconds} sec") + } else if seconds == 0 { + format!("About {minutes} min") + } else { + format!("About {minutes} min {seconds} sec") + } +} diff --git a/editor/src/messages/dialog/simple_dialogs/mod.rs b/editor/src/messages/dialog/simple_dialogs/mod.rs index aa2e8103c3..feee3607eb 100644 --- a/editor/src/messages/dialog/simple_dialogs/mod.rs +++ b/editor/src/messages/dialog/simple_dialogs/mod.rs @@ -8,6 +8,7 @@ mod failed_to_load_documents_dialog; mod failed_to_open_document_dialog; mod licenses_dialog; mod licenses_third_party_dialog; +mod send_to_plotter_success_dialog; pub use about_graphite_dialog::AboutGraphiteDialog; pub use close_all_documents_dialog::CloseAllDocumentsDialog; @@ -20,3 +21,4 @@ pub use failed_to_load_documents_dialog::FailedToLoadDocumentsDialog; pub use failed_to_open_document_dialog::FailedToOpenDocumentDialog; pub use licenses_dialog::LicensesDialog; pub use licenses_third_party_dialog::LicensesThirdPartyDialog; +pub use send_to_plotter_success_dialog::SendToPlotterSuccessDialog; diff --git a/editor/src/messages/dialog/simple_dialogs/send_to_plotter_success_dialog.rs b/editor/src/messages/dialog/simple_dialogs/send_to_plotter_success_dialog.rs new file mode 100644 index 0000000000..b6dbc2203b --- /dev/null +++ b/editor/src/messages/dialog/simple_dialogs/send_to_plotter_success_dialog.rs @@ -0,0 +1,34 @@ +use crate::messages::layout::utility_types::widget_prelude::*; +use crate::messages::prelude::*; + +/// A dialog to confirm that a job was successfully queued on the pen plotter print server. +pub struct SendToPlotterSuccessDialog { + pub job_name: String, +} + +impl DialogLayoutHolder for SendToPlotterSuccessDialog { + const ICON: &'static str = "CheckboxChecked"; + const TITLE: &'static str = "Send to Plotter"; + + fn layout_buttons(&self) -> Layout { + let widgets = vec![TextButton::new("OK").emphasized(true).on_update(|_| FrontendMessage::DialogClose.into()).widget_instance()]; + + Layout(vec![LayoutGroup::row(widgets)]) + } +} + +impl LayoutHolder for SendToPlotterSuccessDialog { + fn layout(&self) -> Layout { + Layout(vec![ + LayoutGroup::row(vec![TextLabel::new("Sent to the plotter queue").bold(true).widget_instance()]), + LayoutGroup::row(vec![ + TextLabel::new(format!( + "The job \"{}\" was added to the queue.\nA booth attendant will start the plot from the dashboard.", + self.job_name + )) + .multiline(true) + .widget_instance(), + ]), + ]) + } +} diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 5d9e73e765..9ca61a8b07 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -106,6 +106,11 @@ pub enum FrontendMessage { mime: String, size: (f64, f64), }, + TriggerSendToPlotter { + name: String, + svg: String, + address: String, + }, TriggerFetchAndOpenDocument { name: String, filename: String, diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 8023d0f926..8a1ad797aa 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -453,6 +453,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { // DialogMessage entry!(KeyDown(KeyE); modifiers=[Accel], action_dispatch=DialogMessage::RequestExportDialog), entry!(KeyDown(KeyN); modifiers=[Accel], action_dispatch=DialogMessage::RequestNewDocumentDialog), + entry!(KeyDown(KeyP); modifiers=[Accel], action_dispatch=DialogMessage::RequestSendToPlotterDialog), entry!(KeyDown(Comma); modifiers=[Accel], action_dispatch=DialogMessage::RequestPreferencesDialog), // // DebugMessage diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 43946b2b7d..86baef018f 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -172,6 +172,12 @@ impl LayoutHolder for MenuBarMessageHandler { .tooltip_shortcut(action_shortcut!(DialogMessageDiscriminant::RequestExportDialog)) .on_commit(|_| DialogMessage::RequestExportDialog.into()) .disabled(no_active_document), + MenuListEntry::new("Send to Plotter…") + .label("Send to Plotter…") + .icon("FileExport") + .tooltip_shortcut(action_shortcut!(DialogMessageDiscriminant::RequestSendToPlotterDialog)) + .on_commit(|_| DialogMessage::RequestSendToPlotterDialog.into()) + .disabled(no_active_document), ], #[cfg(not(target_os = "macos"))] vec![preferences], diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index a7c57f4659..6ef52d76aa 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1323,6 +1323,8 @@ impl MessageHandler> for DocumentMes DocumentMessage::SetRenderMode { render_mode } => { self.render_mode = render_mode; responses.add_front(NodeGraphMessage::RunDocumentGraph); + // Keep the document bar's render mode radio buttons in sync when the mode is set programmatically + responses.add(PortfolioMessage::UpdateDocumentWidgets); } DocumentMessage::AddTransaction => { // Reverse order since they are added to the front diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 8edca5950d..2a4407141a 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -197,6 +197,10 @@ pub enum PortfolioMessage { artboard_name: Option, artboard_count: usize, }, + SubmitPlotterExport { + job_name: String, + estimate_only: bool, + }, SubmitActiveGraphRender, SubmitGraphRender { document_id: DocumentId, diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 5dbd6fb55c..7f7f410a01 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -5,7 +5,7 @@ use crate::application::{Editor, generate_uuid}; use crate::consts::{DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, GDD_FILE_EXTENSION}; use crate::messages::animation::TimingInformation; use crate::messages::dialog::simple_dialogs; -use crate::messages::frontend::utility_types::{DocumentInfo, PersistedState}; +use crate::messages::frontend::utility_types::{DocumentInfo, ExportBounds, FileType, PersistedState}; use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::input_mapper::utility_types::macros::{action_shortcut, action_shortcut_manual}; use crate::messages::layout::utility_types::widget_prelude::*; @@ -1461,6 +1461,32 @@ impl MessageHandler> for Portfolio }); } } + PortfolioMessage::SubmitPlotterExport { job_name, estimate_only } => { + let document_id = self.active_document_id.expect("Tried to render non-existent document"); + let document = self.documents.get_mut(&document_id).expect("Tried to render non-existent document"); + let export_config = ExportConfig { + name: job_name, + file_type: FileType::Svg, + scale_factor: 1., + bounds: ExportBounds::AllArtwork, + for_plotter: true, + plotter_estimate_only: estimate_only, + ..Default::default() + }; + let result = self.executor.submit_document_export(document, document_id, export_config); + + if let Err(description) = result { + // A failed estimate render (e.g. an empty document) just marks the estimate as unavailable + if estimate_only { + responses.add(DialogMessage::SendToPlotterDialog(SendToPlotterDialogMessage::UpdateTimeEstimate { seconds: None })); + } else { + responses.add(DialogMessage::DisplayDialogError { + title: "Unable to send to the plotter".to_string(), + description, + }); + } + } + } PortfolioMessage::SubmitActiveGraphRender => { if let Some(document_id) = self.active_document_id { responses.add(PortfolioMessage::SubmitGraphRender { document_id, ignore_hash: false }); diff --git a/editor/src/messages/prelude.rs b/editor/src/messages/prelude.rs index 3b99ad1afb..c66df2aa9f 100644 --- a/editor/src/messages/prelude.rs +++ b/editor/src/messages/prelude.rs @@ -14,6 +14,7 @@ pub use crate::messages::defer::{DeferMessage, DeferMessageDiscriminant, DeferMe pub use crate::messages::dialog::export_dialog::{ExportDialogMessage, ExportDialogMessageContext, ExportDialogMessageDiscriminant, ExportDialogMessageHandler}; pub use crate::messages::dialog::new_document_dialog::{NewDocumentDialogMessage, NewDocumentDialogMessageDiscriminant, NewDocumentDialogMessageHandler}; pub use crate::messages::dialog::preferences_dialog::{PreferencesDialogMessage, PreferencesDialogMessageContext, PreferencesDialogMessageDiscriminant, PreferencesDialogMessageHandler}; +pub use crate::messages::dialog::send_to_plotter_dialog::{SendToPlotterDialogMessage, SendToPlotterDialogMessageContext, SendToPlotterDialogMessageDiscriminant, SendToPlotterDialogMessageHandler}; pub use crate::messages::dialog::{DialogMessage, DialogMessageContext, DialogMessageDiscriminant, DialogMessageHandler}; pub use crate::messages::frontend::{FrontendMessage, FrontendMessageDiscriminant}; pub use crate::messages::future::{FutureMessage, FutureMessageContext, FutureMessageDiscriminant, FutureMessageHandler, MessageFuture, MessageSpawner, Wake}; diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index dec56ecd8a..15f102a9bf 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -196,6 +196,8 @@ impl NodeGraphExecutor { render_mode: document.render_mode, for_export: false, for_eyedropper: false, + hide_artboard_background: false, + bake_stroke_dashes: false, }; // Execute the node graph @@ -267,6 +269,8 @@ impl NodeGraphExecutor { render_mode, for_export: false, for_eyedropper: true, + hide_artboard_background: false, + bake_stroke_dashes: false, }; // Execute the node graph @@ -315,15 +319,25 @@ impl NodeGraphExecutor { ..Default::default() }; + // The plotter flow switches the viewport to Outline mode as a preview, so pin its export to the normal render + // mode for a deterministic serialization regardless of what the viewport is showing + let render_mode = if export_config.for_plotter { + graphene_std::vector::style::RenderMode::Normal + } else { + document.render_mode + }; + let render_config = RenderConfig { viewport, scale: export_config.scale_factor, time: Default::default(), pointer: DVec2::ZERO, export_format, - render_mode: document.render_mode, + render_mode, for_export: true, for_eyedropper: false, + hide_artboard_background: export_config.for_plotter, + bake_stroke_dashes: export_config.for_plotter, }; export_config.size = resolution; @@ -568,6 +582,8 @@ impl NodeGraphExecutor { render_mode: document.render_mode, for_export: false, for_eyedropper: false, + hide_artboard_background: false, + bake_stroke_dashes: false, }; let execution_id = self.queue_execution(render_config); self.futures.push_back(( @@ -733,6 +749,8 @@ impl NodeGraphExecutor { size, artboard_name, artboard_count, + for_plotter, + plotter_estimate_only, .. } = export_config; @@ -753,7 +771,18 @@ impl NodeGraphExecutor { data: RenderOutputType::Svg { svg, .. }, .. }) => { - if file_type == FileType::Svg { + if plotter_estimate_only { + // Measure the SVG the plotter would receive and report the time estimate back to the dialog + let seconds = + graphene_std::renderer::plot_statistics::svg_plot_statistics(&svg).map(|statistics| crate::messages::dialog::send_to_plotter_dialog::estimated_plot_seconds(&statistics)); + responses.add(DialogMessage::SendToPlotterDialog(SendToPlotterDialogMessage::UpdateTimeEstimate { seconds })); + } else if for_plotter { + responses.add(FrontendMessage::TriggerSendToPlotter { + name: base_name, + svg, + address: crate::consts::PLOTTER_SERVER_ADDRESS.to_string(), + }); + } else if file_type == FileType::Svg { responses.add(FrontendMessage::TriggerSaveFile { name, folder, diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index d4be4156de..e7b736f86a 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -91,6 +91,10 @@ pub struct ExportConfig { pub size: UVec2, pub artboard_name: Option, pub artboard_count: usize, + /// Send the exported SVG to the pen plotter print server instead of saving it as a file. + pub for_plotter: bool, + /// Only measure the plotter SVG for a time estimate instead of sending it to the print server. + pub plotter_estimate_only: bool, } #[derive(Clone)] diff --git a/frontend/src/stores/portfolio.ts b/frontend/src/stores/portfolio.ts index 3de2524d18..9e004d4359 100644 --- a/frontend/src/stores/portfolio.ts +++ b/frontend/src/stores/portfolio.ts @@ -29,6 +29,9 @@ const initialState: PortfolioStoreState = { let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; +// Prevents double-submitting a plotter job while one upload is still in flight +let plotterUploadInFlight = false; + // Store state persisted across HMR to maintain reactive subscriptions in the component tree const store: Writable = import.meta.hot?.data?.store || writable(initialState); if (import.meta.hot) import.meta.hot.data.store = store; @@ -129,6 +132,34 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor: } }); + subscriptions.subscribeFrontendMessage("TriggerSendToPlotter", async (data) => { + const { name, svg, address } = data; + + if (plotterUploadInFlight) return; + plotterUploadInFlight = true; + + try { + // POST the SVG to the print server's job queue endpoint, with the job name in the query string + const endpoint = `${address}/api/jobs?name=${encodeURIComponent(name)}`; + let response; + try { + response = await fetch(endpoint, { method: "POST", headers: { "content-type": "image/svg+xml" }, body: svg }); + } catch { + editor.errorDialog("Send to Plotter failed", `Could not reach the print server. Is it running at ${address} and connected to the same network?`); + return; + } + + if (response.status === 201) { + editor.sendToPlotterSuccessDialog(name); + } else { + const serverError = (await response.json().catch(() => undefined))?.error; + editor.errorDialog("Send to Plotter failed", `The print server rejected the job${serverError ? `: ${serverError}` : ` (HTTP ${response.status})`}.`); + } + } finally { + plotterUploadInFlight = false; + } + }); + subscriptions.subscribeFrontendMessage("UpdateWorkspacePanelLayout", (data) => { update((state) => { state.panelLayout = data.panelLayout; @@ -196,6 +227,7 @@ export function destroyPortfolioStore() { subscriptions.unsubscribeFrontendMessage("TriggerSaveDocument"); subscriptions.unsubscribeFrontendMessage("TriggerSaveFile"); subscriptions.unsubscribeFrontendMessage("TriggerExportImage"); + subscriptions.unsubscribeFrontendMessage("TriggerSendToPlotter"); subscriptions.unsubscribeFrontendMessage("UpdateWorkspacePanelLayout"); subscriptions.unsubscribeLayoutUpdate("WelcomeScreenButtons"); subscriptions.unsubscribeLayoutUpdate("PropertiesPanel"); diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 81843a6f6d..5e3803b31d 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -318,6 +318,13 @@ impl EditorWrapper { self.dispatch(message); } + /// Displays a dialog confirming that a job was successfully queued on the pen plotter print server + #[wasm_bindgen(js_name = sendToPlotterSuccessDialog)] + pub fn send_to_plotter_success_dialog(&self, job_name: String) { + let message = DialogMessage::DisplaySendToPlotterSuccess { job_name }; + self.dispatch(message); + } + /// Answer whether or not the editor has crashed #[wasm_bindgen(js_name = hasCrashed)] pub fn has_crashed(&self) -> bool { diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index b3df3314d3..144b5b3ef0 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -83,6 +83,10 @@ pub struct RenderConfig { pub export_format: ExportFormat, pub for_export: bool, pub for_eyedropper: bool, + /// Skip rendering artboard background rectangles, used for pen plotter output. + pub hide_artboard_background: bool, + /// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output. + pub bake_stroke_dashes: bool, } impl RenderConfig { diff --git a/node-graph/libraries/rendering/src/lib.rs b/node-graph/libraries/rendering/src/lib.rs index 418f4e9c0c..4b16bde280 100644 --- a/node-graph/libraries/rendering/src/lib.rs +++ b/node-graph/libraries/rendering/src/lib.rs @@ -1,4 +1,5 @@ pub mod convert_usvg_path; +pub mod plot_statistics; pub mod render_ext; mod renderer; pub mod to_peniko; diff --git a/node-graph/libraries/rendering/src/plot_statistics.rs b/node-graph/libraries/rendering/src/plot_statistics.rs new file mode 100644 index 0000000000..bb6e536f3c --- /dev/null +++ b/node-graph/libraries/rendering/src/plot_statistics.rs @@ -0,0 +1,138 @@ +use kurbo::{BezPath, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape}; + +/// Pen movement totals for plotting an SVG document with a pen plotter, which draws every path as its outline. +/// Distances are in SVG user units; scale by the paper fit before converting to wall-clock time. +/// +/// Pen-up travel between subpaths is deliberately not measured: the print server reorders paths (and rotates the +/// start points of closed ones) to minimize travel, so document-order travel distance is meaningless. Its cost is +/// captured as part of the constant time per pen lift instead. +pub struct PlotStatistics { + /// Total distance drawn with the pen down, following every path's geometry. + pub pen_down_distance: f64, + /// Number of pen lift/reposition/lower cycles (one per subpath). + pub pen_lift_count: usize, + /// Width of the artwork's bounding box, which the print server scales to fit the paper (the document size is ignored). + pub width: f64, + /// Height of the artwork's bounding box. + pub height: f64, +} + +#[derive(Default)] +struct MeasureState { + pen_down_distance: f64, + pen_lift_count: usize, + bounds: Option, +} + +/// Measures the pen plotter movement statistics of an SVG document by walking every path in document order. +/// Returns `None` if the SVG cannot be parsed or contains no path geometry. +pub fn svg_plot_statistics(svg: &str) -> Option { + let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).ok()?; + + let mut state = MeasureState::default(); + accumulate_group(tree.root(), &mut state); + + let bounds = state.bounds?; + Some(PlotStatistics { + pen_down_distance: state.pen_down_distance, + pen_lift_count: state.pen_lift_count, + width: bounds.width(), + height: bounds.height(), + }) +} + +fn accumulate_group(group: &usvg::Group, state: &mut MeasureState) { + for node in group.children() { + match node { + usvg::Node::Group(group) => accumulate_group(group, state), + usvg::Node::Path(path) => accumulate_bezpath(&usvg_path_to_bezpath(path), state), + _ => {} + } + } +} + +fn accumulate_bezpath(bezpath: &BezPath, state: &mut MeasureState) { + const ARC_LENGTH_ACCURACY: f64 = 0.1; + + if bezpath.elements().is_empty() { + return; + } + + for segment in bezpath.segments() { + state.pen_down_distance += match segment { + PathSeg::Line(line) => line.p0.distance(line.p1), + segment => segment.arclen(ARC_LENGTH_ACCURACY), + }; + } + + state.pen_lift_count += bezpath.elements().iter().filter(|element| matches!(element, PathEl::MoveTo(_))).count(); + + let bounds = bezpath.bounding_box(); + if !bounds.is_nan() { + state.bounds = Some(state.bounds.map_or(bounds, |existing| existing.union(bounds))); + } +} + +/// Converts a usvg path into a kurbo path with its absolute transform applied. +fn usvg_path_to_bezpath(path: &usvg::Path) -> BezPath { + let transform = path.abs_transform(); + let to_point = |point: &usvg::tiny_skia_path::Point| { + let (x, y) = (point.x as f64, point.y as f64); + Point::new( + transform.sx as f64 * x + transform.kx as f64 * y + transform.tx as f64, + transform.ky as f64 * x + transform.sy as f64 * y + transform.ty as f64, + ) + }; + + let mut bezpath = BezPath::new(); + let mut points = path.data().points().iter(); + + for verb in path.data().verbs() { + match verb { + usvg::tiny_skia_path::PathVerb::Move => { + let Some(point) = points.next().map(to_point) else { continue }; + bezpath.move_to(point); + } + usvg::tiny_skia_path::PathVerb::Line => { + let Some(end) = points.next().map(to_point) else { continue }; + bezpath.line_to(end); + } + usvg::tiny_skia_path::PathVerb::Quad => { + let Some(handle) = points.next().map(to_point) else { continue }; + let Some(end) = points.next().map(to_point) else { continue }; + bezpath.quad_to(handle, end); + } + usvg::tiny_skia_path::PathVerb::Cubic => { + let Some(first_handle) = points.next().map(to_point) else { continue }; + let Some(second_handle) = points.next().map(to_point) else { continue }; + let Some(end) = points.next().map(to_point) else { continue }; + bezpath.curve_to(first_handle, second_handle, end); + } + usvg::tiny_skia_path::PathVerb::Close => bezpath.close_path(), + } + } + + bezpath +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn measures_lines_squares_and_bounds() { + // A 30x30 square (120 units drawn, 1 lift) followed by a vertical line (100 units drawn, 1 lift), + // with an artwork bounding box spanning (10,10) to (40,150) + let svg = r##" + + + "##; + + let statistics = svg_plot_statistics(svg).unwrap(); + + assert_eq!(statistics.pen_lift_count, 2); + assert!((statistics.pen_down_distance - 220.).abs() < 1e-6, "pen down was {}", statistics.pen_down_distance); + assert!((statistics.width - 30.).abs() < 1e-6, "width was {}", statistics.width); + assert!((statistics.height - 140.).abs() < 1e-6, "height was {}", statistics.height); + } +} diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 1113e5816c..2e3526ff31 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -179,10 +179,11 @@ impl RenderExt for Stroke { let default_weight = if self.align != StrokeAlign::Center && render_params.aligned_strokes { 1. / 2. } else { 1. }; - // Set to None if the value is the SVG default + // Set to None if the value is the SVG default. When dashes are baked into the path geometry, the dash attributes must be + // omitted so the pattern isn't applied a second time to the already-cut dash segments. let weight = (self.weight != default_weight).then_some(self.weight); - let dash_array = (!self.dash_lengths.is_empty()).then_some(self.dash_lengths()); - let dash_offset = (self.dash_offset != 0.).then_some(self.dash_offset); + let dash_array = (!self.dash_lengths.is_empty() && !render_params.bake_stroke_dashes).then_some(self.dash_lengths()); + let dash_offset = (self.dash_offset != 0. && !render_params.bake_stroke_dashes).then_some(self.dash_offset); let stroke_cap = (self.cap != StrokeCap::Butt).then_some(self.cap); let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join); let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a3b86ea6ec..46262710f8 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -230,6 +230,10 @@ pub struct RenderParams { pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. pub viewport_zoom: f64, + /// Skip rendering artboard background rectangles, used for pen plotter output where a background would be traced as a giant filled contour. + pub hide_artboard_background: bool, + /// Cut dashed strokes into their visible dash segments so the path geometry itself carries the dash pattern, used for pen plotter output where `stroke-dasharray` would be ignored. + pub bake_stroke_dashes: bool, } impl RenderParams { @@ -718,16 +722,18 @@ impl Render for List { let height = dimensions.y.abs(); // Background - render.leaf_tag("rect", |attributes| { - attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex())); - if background.a() < 1. { - attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); - } - attributes.push("x", x.to_string()); - attributes.push("y", y.to_string()); - attributes.push("width", width.to_string()); - attributes.push("height", height.to_string()); - }); + if !render_params.hide_artboard_background { + render.leaf_tag("rect", |attributes| { + attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex())); + if background.a() < 1. { + attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string()); + } + attributes.push("x", x.to_string()); + attributes.push("y", y.to_string()); + attributes.push("width", width.to_string()); + attributes.push("height", height.to_string()); + }); + } // Artwork render.parent_tag( @@ -775,10 +781,12 @@ impl Render for List { let artboard_transform = kurbo::Affine::new(transform.to_cols_array()); - let color = SRGBA8::from(background).to_peniko_color(); - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); - scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); - scene.pop_layer(); + if !render_params.hide_artboard_background { + let color = SRGBA8::from(background).to_peniko_color(); + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect); + scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect); + scene.pop_layer(); + } if clip { scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), &rect); @@ -1114,7 +1122,33 @@ impl Render for List { let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; let use_face_fill = vector.use_face_fill(); - if needs_separate_alignment_fill && !wants_stroke_below { + // When baking dashes, cut each stroked path into its visible dash segments so the geometry itself carries the dash + // pattern, letting consumers that ignore `stroke-dasharray` (like a pen plotter) still draw the dashes. + let baked_dash_path = (render_params.bake_stroke_dashes && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent())) + .then_some(vector.stroke.as_ref()) + .flatten() + .filter(|stroke| stroke.has_renderable_stroke() && !stroke.dash_lengths.is_empty()) + .and_then(|stroke| { + let dash_pattern: Vec = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect(); + if dash_pattern.iter().sum::() <= 0. { + return None; + } + + let mut dashed_path = String::new(); + for mut bezpath in vector.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + let dashed_bezpath: BezPath = kurbo::dash(bezpath.iter(), stroke.dash_offset, &dash_pattern).collect(); + dashed_path.push_str(dashed_bezpath.to_svg().as_str()); + } + Some(dashed_path) + }); + + // When dashes are baked, the fill is dropped entirely rather than emitted as a separate path: the pen plotter + // draws every path as its outline, and a fill's contour is the same uncut geometry the dashes were cut from, + // so it would be drawn as a solid line right over the dashes. + let emit_separate_fill = needs_separate_alignment_fill && baked_dash_path.is_none(); + + if emit_separate_fill && !wants_stroke_below { emit_svg_fill_path( render, path.clone(), @@ -1142,7 +1176,8 @@ impl Render for List { (id, mask_type, vector_item) }); - if use_face_fill { + // Face fills are dropped when dashes are baked for the same reason as above: their boundaries retrace the dashed edges + if use_face_fill && baked_dash_path.is_none() { for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); let face_d = face_path.to_svg(); @@ -1161,7 +1196,7 @@ impl Render for List { } render.leaf_tag("path", |attributes| { - attributes.push("d", path.clone()); + attributes.push("d", baked_dash_path.clone().unwrap_or_else(|| path.clone())); let matrix = format_transform_matrix(element_transform); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); @@ -1229,7 +1264,7 @@ impl Render for List { String::new() }; - let fill_attribute = if needs_separate_alignment_fill || use_face_fill { + let fill_attribute = if needs_separate_alignment_fill || use_face_fill || baked_dash_path.is_some() { r#" fill="none""#.to_string() } else { fill_graphic_list @@ -1261,7 +1296,7 @@ impl Render for List { }); // When splitting passes and stroke is below, draw the fill after the stroke. - if needs_separate_alignment_fill && wants_stroke_below { + if emit_separate_fill && wants_stroke_below { emit_svg_fill_path( render, path.clone(), @@ -1473,7 +1508,16 @@ impl Render for List { // Render the path match render_params.render_mode { RenderMode::Outline => { - let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); + let (mut outline_stroke, outline_color_peniko) = get_outline_styles(render_params); + + // Show the stroke's dash pattern in the outline preview so dashed strokes read the same as they will be plotted + if let Some(stroke) = stroke.filter(|stroke| stroke.has_renderable_stroke()) { + let dash_pattern: kurbo::Dashes = stroke.dash_lengths.iter().map(|length| length.max(0.)).collect(); + if dash_pattern.iter().sum::() > 0. { + outline_stroke.dash_pattern = dash_pattern; + outline_stroke.dash_offset = stroke.dash_offset; + } + } scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); } diff --git a/node-graph/nodes/gstd/src/render_background.rs b/node-graph/nodes/gstd/src/render_background.rs index dd2c0f3646..24fb79ded6 100644 --- a/node-graph/nodes/gstd/src/render_background.rs +++ b/node-graph/nodes/gstd/src/render_background.rs @@ -7,6 +7,7 @@ use core_types::{Ctx, ExtractFootprint}; use glam::{Affine2, UVec2, Vec2}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphic_types::raster_types::Texture; +use graphic_types::vector_types::vector::style::RenderMode; use rendering::{RenderParams, SvgRender, SvgRenderOutput}; use std::fmt::Write; use wgpu::util::DeviceExt; @@ -33,6 +34,9 @@ async fn render_background<'a: 'n>( let mut render_params = render_params.clone(); render_params.footprint = *footprint; + // Outline mode previews the artwork as it would be drawn on paper, so transparency shows as solid white instead of the checkerboard + let solid_white_background = render_params.render_mode == RenderMode::Outline; + let data = match foreground_data { RenderOutputType::Texture(foreground_texture) => { let doc_to_screen = render_params.footprint.transform.as_affine2(); @@ -43,6 +47,7 @@ async fn render_background<'a: 'n>( backgrounds: &metadata.backgrounds, document_to_screen: doc_to_screen, zoom: render_params.viewport_zoom.to_f32(), + solid_white: solid_white_background, }) .await; @@ -58,6 +63,17 @@ async fn render_background<'a: 'n>( if render_params.viewport_zoom > 0. { let draw_checkerboard = |render: &mut SvgRender, rect: vello::kurbo::Rect, pattern_origin: glam::DVec2, checker_id_prefix: &str| { + if solid_white_background { + render.leaf_tag("rect", |attributes| { + attributes.push("x", rect.x0.to_string()); + attributes.push("y", rect.y0.to_string()); + attributes.push("width", rect.width().to_string()); + attributes.push("height", rect.height().to_string()); + attributes.push("fill", "#ffffff".to_string()); + }); + return; + } + let checker_id = format!("{checker_id_prefix}-{}", generate_uuid()); let cell_size = 8. / render_params.viewport_zoom; let pattern_size = cell_size * 2.; @@ -148,6 +164,8 @@ pub struct CompositeBackgroundArgs<'a> { backgrounds: &'a [rendering::Background], document_to_screen: Affine2, zoom: f32, + /// Draw solid white instead of the transparency checkerboard, used in Outline mode. + solid_white: bool, } impl AsyncWgpuPipeline for CompositeBackground { @@ -339,6 +357,7 @@ impl AsyncWgpuPipeline for CompositeBackground { backgrounds, document_to_screen, zoom, + solid_white, } = args; let foreground_size = foreground.size(); @@ -362,7 +381,7 @@ impl AsyncWgpuPipeline for CompositeBackground { let checker_draws = if backgrounds.is_empty() { vec![( 3, - self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc)), + self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc, solid_white)), )] } else { backgrounds @@ -378,7 +397,7 @@ impl AsyncWgpuPipeline for CompositeBackground { return None; } - let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc); + let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc, solid_white); Some((6, self.create_checker_bind_group(device, uniforms))) }) .collect() @@ -474,19 +493,19 @@ struct CompositeUniforms { viewport_size: [f32; 2], pattern_origin: [f32; 2], checker_size: f32, - _pad: f32, + solid_white: f32, } impl CompositeUniforms { - fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32) -> Self { - Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc) + fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32, solid_white: bool) -> Self { + Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc, solid_white) } - fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32) -> Self { - Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc) + fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32, solid_white: bool) -> Self { + Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc, solid_white) } - fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32) -> Self { + fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32, solid_white: bool) -> Self { Self { transform_x: transform.matrix2.x_axis.to_array(), transform_y: transform.matrix2.y_axis.to_array(), @@ -496,7 +515,7 @@ impl CompositeUniforms { viewport_size: viewport_size.to_array(), pattern_origin: pattern_origin.to_array(), checker_size, - _pad: 0., + solid_white: if solid_white { 1. } else { 0. }, } } } diff --git a/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl b/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl index 7dce58c939..4a543a5760 100644 --- a/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl +++ b/node-graph/nodes/gstd/src/render_background_checker_rect.wgsl @@ -7,7 +7,7 @@ struct CompositeUniforms { viewport_size: vec2, pattern_origin: vec2, checker_size: f32, - _pad: f32, + solid_white: f32, }; @group(0) @binding(0) @@ -44,7 +44,10 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { fn fs_main(in: VertexOutput) -> @location(0) vec4 { let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); let parity = i32(tile.x + tile.y) & 1; - let luminance = select(1.0, 0.8, parity == 1); + var luminance = select(1.0, 0.8, parity == 1); + if uniforms.solid_white != 0.0 { + luminance = 1.0; + } let fw = fwidthFine(in.document_position); let coverage_max = 1.0 - smoothstep(uniforms.rect_max - fw, uniforms.rect_max, in.document_position); diff --git a/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl b/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl index c583efcc38..d0705ab2c9 100644 --- a/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl +++ b/node-graph/nodes/gstd/src/render_background_checker_viewport.wgsl @@ -7,7 +7,7 @@ struct CompositeUniforms { viewport_size: vec2, pattern_origin: vec2, checker_size: f32, - _pad: f32, + solid_white: f32, }; @group(0) @binding(0) @@ -40,6 +40,9 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { fn fs_main(in: VertexOutput) -> @location(0) vec4 { let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size); let parity = i32(tile.x + tile.y) & 1; - let luminance = vec3(select(1.0, 0.8, parity == 1)); + var luminance = vec3(select(1.0, 0.8, parity == 1)); + if uniforms.solid_white != 0.0 { + luminance = vec3(1.0); + } return vec4(luminance, 1.0); } diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index fc9eb9551b..6e54835a99 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -174,6 +174,8 @@ async fn create_context<'a: 'n>( render_output_type, scale: render_config.scale, viewport_zoom: logical_viewport.scale_magnitudes().x, + hide_artboard_background: render_config.hide_artboard_background, + bake_stroke_dashes: render_config.bake_stroke_dashes, ..Default::default() }; diff --git a/plotter-upload-spec.md b/plotter-upload-spec.md new file mode 100644 index 0000000000..d933d3929e --- /dev/null +++ b/plotter-upload-spec.md @@ -0,0 +1,139 @@ +# Spec: "Send to Plotter" — upload the current document as SVG to a LAN print server + +## What this is + +We are running a conference booth where visitors draw vector artwork in Graphite at demo +stations, and their drawing gets physically plotted with a pen on letter paper by a Cricut +machine as a takeaway. A small HTTP print server (a separate, already-finished project) drives +the Cricut; it accepts SVG jobs over the LAN and manages a queue with a live dashboard. + +The missing piece, and the task for you, is on the Graphite side: an in-app action that exports +the current document as SVG and POSTs it to that print server. Today we have to save an SVG file +and manually upload it via the dashboard; visitors should instead be able to click one thing +inside Graphite. + +## Booth context (why the design looks like this) + +- Four Windows 11 demo stations run Graphite. The booth LAN is an isolated switch with **no + internet**: static IPs on `192.168.77.0/24` (stations are `.10`, `.20`, `.30`, `.40`), blank + gateway. Because there is no internet, each station runs Graphite from a **local dev server** + (`npm start`, i.e. an `http://localhost` origin), not from the production website. +- The print server runs on the station connected to the Cricut, listening on port `4747`. At + the booth it is expected at `http://192.168.77.10:4747`, but the host may change, so the + endpoint URL must be user-configurable and persisted. For development, the natural default is + `http://localhost:4747`. +- Sending a job is safe and non-destructive: the queue is human-gated twice (a booth attendant + resumes the paused queue from the dashboard, and each plot additionally waits for a physical + Go button press on the machine). Graphite only needs to fire the upload and report success or + failure; queue management, previews, and status all already exist on the server's dashboard. + +## The endpoint + +`POST http://:4747/api/jobs` + +Two accepted body forms; **use the raw form** (simplest): + +1. **Raw SVG body** — any `content-type` that is not `application/json` (use + `image/svg+xml`). The job name is taken from the `?name=` query parameter. +2. JSON body (`content-type: application/json`): `{ "svg": "", "name": "…", "options": { … } }`. + +Example of the raw form: + +``` +POST http://192.168.77.10:4747/api/jobs?name=Alices%20drawing +content-type: image/svg+xml + + +``` + +Server behavior: + +- **Success**: `201` with JSON `{ "id": "", "name": "", "status": "queued" }`. +- **Validation failure**: `400` with JSON `{ "error": "request body must contain an " }` + (the body must match `/]/i`). Other errors return `500` with `{ "error": "…" }`. +- **Body limit**: 25 MB. +- **CORS**: fully open. Every response carries `access-control-allow-origin: *`, and `OPTIONS` + preflights are answered with `access-control-allow-methods: GET,POST,DELETE,OPTIONS` and + `access-control-allow-headers: content-type` (plus `access-control-allow-private-network: + true` for Chrome's private-network-access preflight). A plain browser `fetch` works. +- If a `name` is omitted the server invents one; still, always send the document name so the + attendant can tell jobs apart on the dashboard. +- Do **not** send job options from Graphite (paper size, rotation, etc. exist as query + parameters, but the booth-wide defaults are configured on the server; the client staying + dumb is a feature). + +## What the plotter does with the SVG (sets expectations for the export) + +- The artwork is auto-scaled to fit 7.5×10" (letter paper minus margin) preserving aspect + ratio, and auto-rotated to portrait when clearly landscape. Absolute units and document size + in the SVG are irrelevant; only the aspect ratio and the shapes matter. +- Everything is drawn with a **pen**: every path renders as its outline. Fills are not filled + in; a filled shape plots as its contour. +- The server already strips Graphite's artboard background: an exported artboard produces a + background `` immediately before a `` group, and the + server removes exactly that rect. So exporting a document with an artboard is fine as-is. + Known limitation: a solid background drawn as anything else (a giant ``, a path) + is NOT stripped and would be plotted, but that is a server concern, not yours. +- Use the same SVG serialization as the existing file export (File > Export); the server is + known to handle that output. Do not invent a new export path. + +## What to build in Graphite + +1. A menu action (e.g. **File > Send to Plotter…**, near Export) that opens a small dialog: + - **Server address** text field, persisted across sessions (default `http://localhost:4747`). + Accept a bare `host:port` or full origin; normalize to `/api/jobs` internally. + - **Job name** text field, defaulting to the document name. + - A **Send** button. +2. On send: export the current document to an SVG string exactly as File > Export SVG would + (whole document / all artboards, default settings), then + `fetch(endpoint, { method: 'POST', headers: { 'content-type': 'image/svg+xml' }, body: svg })` + with the name in the query string. +3. Feedback: + - Success (`201`): a brief confirmation (e.g. a toast/dialog: "Sent to plotter queue as + ''"). + - Failure: show the reason. Distinguish "could not reach the server" (network error — + wrong address, server not running) from a server-reported error (`400`/`500` JSON + `error` field). Booth attendants are non-experts under time pressure; the message should + say what to check ("Is the print server running at
?"). + - A pending state on the button while in flight; sends should not be double-fireable. +4. Non-goals: no queue status display, no job management, no auth, no retry logic, no + settings beyond the address field. The server dashboard covers all of that. + +## Testing without the plotter + +The print server is a zero-dependency Node ≥ 20 project; run `node bin/cricut-print-server.mjs +serve` from its repo and it listens on `:4747` even with no Cricut hardware or Design Space +running — submitted jobs simply sit in the paused queue, visible with previews at +`http://127.0.0.1:4747/`. That is the ideal end-to-end check: send from Graphite, see the job +card appear with the right name and a correct preview. + +If you don't have that repo, a sufficient mock is: + +```js +require('node:http').createServer((req, res) => { + let b = ''; + req.on('data', (c) => (b += c)); + req.on('end', () => { + const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', 'access-control-allow-private-network': 'true' }; + if (req.method === 'OPTIONS') { res.writeHead(204, cors); return res.end(); } + const ok = /]/i.test(b); + res.writeHead(ok ? 201 : 400, { ...cors, 'content-type': 'application/json' }); + res.end(JSON.stringify(ok ? { id: 'test1', name: new URL(req.url, 'http://x').searchParams.get('name'), status: 'queued' } : { error: 'request body must contain an ' })); + console.log(req.method, req.url, b.length, 'bytes'); + }); +}).listen(4747); +``` + +## Gotchas + +- **Mixed content**: a Graphite instance served over HTTPS (the production site) cannot + `fetch` an `http://` LAN address — the browser blocks it silently-ish. This is fine for the + booth (stations run `http://localhost` dev builds, and localhost is a secure context allowed + to reach private hosts), but don't be confused if a test from the production site fails; + consider mentioning it in the failure message if `location.protocol === 'https:'` and the + target is `http:`. +- **Chrome private network access**: Chrome sends a special preflight when a page reaches into + a private network. The server answers it (`access-control-allow-private-network: true`), so + this should just work; noted here in case a future Chrome version tightens behavior. +- The SVG can be large (procedural documents); it is sent as one POST body, well under the + 25 MB limit in practice. No chunking or compression needed.