From e987e6dc584e814027549d1ebd80f0b3b4d5a3f7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:39:00 +0000 Subject: [PATCH] refactor(tools): move builder.rs items to builder/{cache,live_paint,lookup,map}.rs --- crates/tools/src/builder.rs | 1182 +----------------------- crates/tools/src/builder/cache.rs | 195 ++++ crates/tools/src/builder/live_paint.rs | 265 ++++++ crates/tools/src/builder/lookup.rs | 259 ++++++ crates/tools/src/builder/map.rs | 462 +++++++++ 5 files changed, 1198 insertions(+), 1165 deletions(-) create mode 100644 crates/tools/src/builder/cache.rs create mode 100644 crates/tools/src/builder/live_paint.rs create mode 100644 crates/tools/src/builder/lookup.rs create mode 100644 crates/tools/src/builder/map.rs diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index d5d665e75..2ed8ff6d6 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -23,914 +23,26 @@ use vectorcraft_pathops as po; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; -/// Names starting with this mark a Live Paint group, together with its sources group ([`SOURCES_NAME`]). -pub const LIVE_PAINT_PREFIX: &str = "Live Paint"; -/// Default name of a new Live Paint group. -pub const LIVE_PAINT_NAME: &str = "Live Paint Group"; -/// The hidden child group holding the source paths. -pub const SOURCES_NAME: &str = "Live Paint Sources"; -pub const FACE_NAME: &str = "Face"; -pub const EDGE_NAME: &str = "Edge"; - -/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red. -pub const HATCH: [u8; 3] = [0x80, 0x80, 0x80]; -pub const HIGHLIGHT_RED: [u8; 3] = [0xff, 0x2a, 0x2a]; - -/// Is `n` a Live Paint group: an unclipped group whose name starts with [`LIVE_PAINT_PREFIX`] and -/// that holds a group named [`SOURCES_NAME`]? A plain group with such a name is not one. -pub fn is_live_paint(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { clip: false, .. }) - && n.name.as_deref().is_some_and(|s| s.starts_with(LIVE_PAINT_PREFIX)) - && sources(n).is_some_and(|s| matches!(s.kind, NodeKind::Group { .. })) -} - -/// Whether Live Paint can take `n` as it is: a Live Paint group, a path or compound path, or a -/// group of those. Type, images, symbols, blends, meshes and the rest have to be converted to -/// paths first (); -/// Make and Merge refuse them rather than hide them among the group's sources. -pub fn live_paint_takes(n: &Node) -> bool { - is_live_paint(n) - || match &n.kind { - NodeKind::Path { .. } | NodeKind::Compound { .. } => true, - NodeKind::Group { children, .. } => children.iter().all(|c| live_paint_takes(c)), - _ => false, - } -} - -fn named(n: &Node, name: &str) -> bool { - n.name.as_deref() == Some(name) -} - -/// Face children of a Live Paint group (paint order). -pub fn faces(group: &Node) -> Vec<&Arc> { - group.children().map(|c| c.iter().filter(|n| named(n, FACE_NAME)).collect()).unwrap_or_default() -} - -/// Edge children of a Live Paint group. -pub fn edges(group: &Node) -> Vec<&Arc> { - group.children().map(|c| c.iter().filter(|n| named(n, EDGE_NAME)).collect()).unwrap_or_default() -} - -/// The hidden sources group of a Live Paint group. -pub fn sources(group: &Node) -> Option<&Arc> { - group.children()?.iter().find(|n| named(n, SOURCES_NAME)) -} - -/// Does the path (filled) contain `p`? -pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { - if path.bounds().is_none_or(|b| !b.inflate(1e-6, 1e-6).contains(p)) { - return false; - } - let w = path.to_bezpath().winding(p); - match rule { - FillRule::EvenOdd => w % 2 != 0, - _ => w != 0, - } -} - -/// The face of `group` under `p` (front-most). -pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { - front_face(faces(group).into_iter(), p) -} - -/// The face of `group` under `p` (front-most) among its faces that are not hidden. -pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc> { - front_face(faces(group).into_iter().filter(|f| f.visible), p) -} - -/// The face of `faces` (paint order) under `p` (front-most). -fn front_face<'a>(faces: impl DoubleEndedIterator>, p: Point) -> Option<&'a Arc> { - faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) -} - -/// The edge of `group` nearest to `p` within `tol`. -pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { - nearest_edge(edges(group), p, tol) -} - -/// The edge of `group` nearest to `p` within `tol` among its edges that are not hidden. -pub fn visible_edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { - nearest_edge(edges(group).into_iter().filter(|e| e.visible), p, tol) -} - -/// The edge of `edges` nearest to `p` within `tol`. -fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, tol: f64) -> Option<&'a Arc> { - let mut best: Option<(f64, &Arc)> = None; - for e in edges { - if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) - && d <= tol - && best.is_none_or(|b| d < b.0) - { - best = Some((d, e)); - } - } - best.map(|b| b.1) -} - -/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). -pub fn live_paint_at(doc: &Document, p: Point) -> Option { - let mut hit = None; - doc.walk(|n| { - if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { - hit = Some(n.id); - } - }); - hit -} - -/// Paths, compound paths (as one shape) of a subtree in paint order; guides, clipping paths and -/// hidden objects are skipped. Live Paint groups contribute their faces and edges. -pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { - collect_leaves(n, false, out); -} - -/// [`leaf_shapes`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn collect_leaves<'a>(n: &'a Node, filled_faces: bool, out: &mut Vec<&'a Node>) { - if !n.visible { - return; - } - if n.extras().shaper.is_some() { - for c in n.children().into_iter().flatten().skip(1) { - collect_leaves(c, filled_faces, out); - } - return; - } - match &n.kind { - NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} - NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), - NodeKind::Group { .. } if filled_faces && is_live_paint(n) => { - out.extend(faces(n).into_iter().filter(|f| f.visible && !f.appearance.fill_paint().is_none()).map(|f| &**f)); - out.extend(edges(n).into_iter().filter(|e| e.visible && !e.appearance.stroke_paint().is_none()).map(|e| &**e)); - } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { - for c in children { - collect_leaves(c, filled_faces, out); - } - } - _ => {} - } -} - -/// Filled outline of a path or compound path node. -pub fn node_outline(n: &Node) -> Option<(PathData, FillRule)> { - match &n.kind { - NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), - NodeKind::Compound { children, rule } => { - let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); - Some((PathData::new(subs), *rule)) - } - _ => None, - } -} - -/// How far (pt) a piece of the planar map may lie from `outline`, the outline it follows, to be -/// matched to it or cut out of it: a ten-thousandth of the outline's larger side, at least 0.05. -/// The map's pieces follow the outlines to within a few hundredths of a point. -pub fn outline_snap(outline: &PathData) -> f64 { - outline.bounds().map_or(0.05, |b| (b.width().max(b.height()) / 2000.0).max(0.25) * 0.2) -} - -/// The outlines of `leaves` as they are drawn ([`node_outline`]), in their order, for -/// [`BuilderMap::stroke_at`]. A leaf without an outline, and one inside a Live Paint group or a -/// Shaper Group (which make their own paths), gets an empty outline, so no stroke of it splits. -pub fn drawn_outlines(doc: &Document, leaves: &[&Node]) -> Vec { - let in_live_group = |id: NodeId| { - let mut up = doc.parent_of(id); - while let Some(p) = up { - if doc.node(p).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()) { - return true; - } - up = doc.parent_of(p); - } - false - }; - leaves.iter().map(|l| if in_live_group(l.id) { None } else { node_outline(l) }.map_or_else(PathData::default, |o| o.0)).collect() -} - -/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index, -/// with the leaves they came from. -pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, Vec<&'a Node>) { - shapes_of(doc, roots, false) -} - -/// [`shapes_for`]; with `filled_faces`, a Live Paint group contributes only its filled faces and -/// its stroked edges. -fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Vec, Vec<&'a Node>) { - let mut lv = vec![]; - for id in roots { - if let Some(n) = doc.node(*id) { - collect_leaves(n, filled_faces, &mut lv); - } - } - let mut shapes = vec![]; - let mut used = vec![]; - for l in lv { - if let Some((p, r)) = node_outline(l) - && !p.is_empty() - && p.subpaths.iter().all(|s| s.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite())) - { - shapes.push(po::Shape::new(p, r, used.len() as u64)); - used.push(l); - } - } - (shapes, used) -} - -/// Shape Builder inputs: [`shapes_for`], with the open subpaths of filled paths closed where that -/// encloses an area (the fill bounds a region, as if an invisible edge closed it) when -/// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions -/// they cross, and so do filled open paths without `open_filled_as_closed`. A Live Paint group -/// contributes its filled faces as shapes and its stroked edges as open lines; its unfilled faces -/// and its edges without a stroke are left out. -pub fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { - let (mut shapes, leaves) = shapes_of(doc, roots, true); - for (s, l) in shapes.iter_mut().zip(&leaves) { - if named(l, EDGE_NAME) && doc.parent_of(l.id).and_then(|p| doc.node(p)).is_some_and(is_live_paint) { - // A closed edge opens at its first anchor and keeps its closing segment. - let start = BTreeSet::from([0]); - s.path.subpaths = s.path.subpaths.iter().flat_map(|sp| sp.cut_at(&start)).collect(); - continue; - } - if !open_filled_as_closed || l.appearance.fill_paint().is_none() { - continue; - } - for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) { - sp.closed = po::encloses_area(sp); - } - } - (shapes, leaves) -} - -/// A planar-map face with its outline and bounds, ready for hit tests. -pub type Face = (po::Region, BezPath, Rect); - -fn faces_of(regions: Vec) -> Vec { - regions - .into_iter() - .filter_map(|r| { - let bp = r.path.to_bezpath(); - let b = r.path.bounds()?; - Some((r, bp, b)) - }) - .collect() -} - -thread_local! { - /// The Shape Builder maps built on this thread ([`maps_built`]). - static MAPS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -/// How many Shape Builder maps ([`BuilderMap::of_shapes`]) this thread has built, for tests that -/// count them. -pub fn maps_built() -> usize { - MAPS_BUILT.with(std::cell::Cell::get) -} - -/// The Shape Builder's planar map of a selection. -#[derive(Default)] -pub struct BuilderMap { - /// The inputs, back → front (see [`po::shape_builder`]). - pub shapes: Vec, - pub regions: Vec, - /// Pieces of the open paths that cut regions (see [`BuilderMap::new`]), keyed by input index, - /// with their bounds. - pub lines: Vec<(po::Shape, Rect)>, - /// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with - /// their bounds: the edges erasing deletes. Only in a map made `with_edges`. - pub edges: Vec<(po::Shape, Rect)>, -} - -/// What a Shape Builder gesture touches at a point. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Hit { - Region(usize), - Line(usize), - Edge(usize), -} - -/// The piece of `pieces` nearest `p` within `tol`, with its distance. -fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, usize)> { - let mut best: Option<(f64, usize)> = None; - for (i, (l, b)) in pieces.iter().enumerate() { - if b.inflate(tol, tol).contains(p) - && let Some((.., d)) = l.path.nearest(p) - && d <= tol - && best.is_none_or(|b| d < b.0) - { - best = Some((d, i)); - } - } - best -} - -impl BuilderMap { - /// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline - /// edges only `with_edges`. With `open_filled_as_closed` (Consider Open Filled Paths as - /// Closed), a filled open path bounds a region as if an invisible edge closed it; without it, - /// the path cuts like a line. - pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool) -> (Self, Vec<&'a Node>) { - Self::with_gap(doc, roots, with_edges, open_filled_as_closed, 0.0) - } - - /// The same arrangement with Gap Detection, using the shared Live Paint gap finder. - pub fn with_gap<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool, gap: f64) -> (Self, Vec<&'a Node>) { - let (shapes, leaves) = shape_builder_shapes(doc, roots, open_filled_as_closed); - (Self::of_shapes(shapes, with_edges, gap), leaves) - } - - /// The map of `shapes` (the inputs, back → front, as [`shape_builder_shapes`] gives them), with - /// the outline edges only `with_edges`, closing gaps up to `gap`. - pub fn of_shapes(shapes: Vec, with_edges: bool, gap: f64) -> Self { - if shapes.is_empty() { - return Self::default(); - } - MAPS_BUILT.with(|n| n.set(n.get() + 1)); - let arr = po::shape_builder_with_gap(&shapes, with_edges, gap); - let bounded = |pieces: Vec| { - pieces - .into_iter() - .filter_map(|l| { - let b = l.path.bounds()?; - Some((l, b)) - }) - .collect() - }; - Self { shapes, regions: faces_of(arr.regions), lines: bounded(arr.lines), edges: bounded(arr.edges) } - } - - /// The region containing `p`. - pub fn region_at(&self, p: Point) -> Option { - self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0) - } - - /// The regions a rectangular marquee from `from` (where the drag started) to `to` touches: each - /// region whose outline crosses the rectangle or has an anchor inside it, and the region the - /// whole rectangle lies in. Of those, the region under `from` comes first, then the one under - /// `to`, then the others in map order. A marquee of no size touches the region under `from`. - pub fn regions_in_marquee(&self, from: Point, to: Point) -> Vec { - let r = Rect::from_points(from, to); - if r.width() <= 0.0 && r.height() <= 0.0 { - return self.region_at(from).into_iter().collect(); - } - let c = r.center(); - let touches = |(reg, bp, b): &Face| (b.contains(c) && bp.winding(c) != 0) || vectorcraft_geom::hit::intersects_rect(®.path, r); - let mut out: Vec = self.regions.iter().enumerate().filter(|(_, f)| touches(f)).map(|(i, _)| i).collect(); - for p in [to, from] { - if let Some(k) = self.region_at(p).and_then(|i| out.iter().position(|&j| j == i)) { - let i = out.remove(k); - out.insert(0, i); - } - } - out - } - - /// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that - /// close, else the region under it. - pub fn hit(&self, p: Point, piece_tol: Option) -> Option { - self.piece_hit(p, piece_tol).or_else(|| self.region_at(p).map(Hit::Region)) - } - - /// The line piece or edge within `piece_tol` of `p` that [`Self::hit`] gives (None without - /// `piece_tol`). - fn piece_hit(&self, p: Point, piece_tol: Option) -> Option { - piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) { - (Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)), - (Some(l), _) => Some(Hit::Line(l.1)), - (None, e) => e.map(|e| Hit::Edge(e.1)), - }) - } - - /// The stroke a click at `p` splits with In Merge Mode, Clicking Stroke Splits the Path: the - /// line piece or edge nearest `p` within `tol` (as [`BuilderMap::hit`] finds it), from the - /// anchor or crossing point before `p` to the one after it on the path it lies on. `outlines` - /// are the inputs' outlines as they are drawn (a filled open path without the edge that closes - /// it in the map). Returns the input's index and the run, going the way the path goes; None - /// when no piece is that close, when the run is not on the outline as drawn, when it would be a - /// whole closed subpath, when it is too short to cut out (see [`po::cut_out`]), or when it is - /// all of the path (a lone line) or leaves no more of it than [`outline_snap`], which leaves - /// nothing to split off. - pub fn stroke_at(&self, outlines: &[PathData], p: Point, tol: f64) -> Option<(usize, SubPath)> { - use kurbo::ParamCurve as _; - let piece = match self.hit(p, Some(tol))? { - Hit::Line(i) => self.lines.get(i), - Hit::Edge(i) => self.edges.get(i), - Hit::Region(_) => None, - }?; - let i = piece.0.key as usize; - let drawn = outlines.get(i)?; - let stops: Vec = drawn.anchors().map(|(.., a)| a.p).collect(); - let snap = outline_snap(drawn); - let mut run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?; - let n = run.segment_count(); - if n == 0 { - return None; - } - let mid = run.segment(n / 2).eval(0.5); - drawn.nearest(mid).filter(|n| n.4 <= snap)?; - // A run that cannot be cut out, or that is all of the path (such as a lone line) or all - // but a speck of it, leaves nothing to split off. - if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| rest.length() > snap) { - return None; - } - // Reverse the run when the path's parameter goes down along it. - let seg = run.segment(n / 2); - if let (Some(a), Some(b)) = (drawn.nearest(seg.eval(0.25)), drawn.nearest(seg.eval(0.75))) - && a.0 == b.0 - && (b.1 as f64 + b.2) < (a.1 as f64 + a.2) - { - run.reverse(); - } - Some((i, run)) - } - - /// What a Shape Builder gesture at `p` touches: [`Self::hit`], except that while erasing, a - /// point in a region that is no more than `2 * piece_tol` across there (see - /// [`Self::narrow_at`]) touches the region and not the line piece or edge beside it. - pub fn builder_hit(&self, p: Point, piece_tol: Option) -> Option { - match (piece_tol, self.piece_hit(p, piece_tol)) { - (Some(tol), Some(piece)) => Some(match self.region_at(p) { - Some(r) if self.narrow_at(r, p, tol) => Hit::Region(r), - _ => piece, - }), - (_, piece) => piece.or_else(|| self.region_at(p).map(Hit::Region)), - } - } - - /// Is region `r` no more than `2 * tol` across at `p`, measured from a point of its outline - /// within `tol` of `p`, through `p`, to its outline on the other side? Every point on that line - /// is then within `tol` of the outline. The points tried are the point of the outline nearest - /// `p` and each point of a segment where the segment is square to the line to `p`, so a - /// point near the end of a thin bar measures across the bar from its sides as well as along it - /// from the end, and splitting a segment in two does not change the answer. False when `p` is - /// on the outline or more than `tol` from it. - fn narrow_at(&self, r: usize, p: Point, tol: f64) -> bool { - use kurbo::{ParamCurve as _, ParamCurveNearest as _}; - let Some((_, bp, _)) = self.regions.get(r) else { return false }; - // Each segment with the parameter, point and distance of its point nearest `p`. - let near: Vec<(kurbo::PathSeg, f64, Point, f64)> = bp - .segments() - .map(|seg| { - let n = seg.nearest(p, 1e-6); - (seg, n.t, seg.eval(n.t), n.distance_sq.sqrt()) - }) - .collect(); - if !near.iter().all(|&(.., d)| d.is_finite() && d > 1e-9) { - return false; - } - let closest = near.iter().map(|&(.., d)| d).fold(f64::INFINITY, f64::min); - // A line from `p` no longer than `2 * tol` only meets segments that close to `p`. - let reach: Vec = near.iter().filter(|&&(.., d)| d <= 2.0 * tol).map(|&(seg, ..)| seg).collect(); - // A segment's end is its nearest point when the segment turns away from `p` or stops - // short of the point square to it; there, only the nearest point of all the outline and - // an end where the segment is square to the line to `p` count. - let square = |seg: kurbo::PathSeg, t: f64, n: Point| { - use kurbo::ParamCurveDeriv as _; - let dir = match seg { - kurbo::PathSeg::Line(l) => l.p1 - l.p0, - kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), - kurbo::PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), - }; - let v = p - n; - dir.hypot() > 1e-12 && dir.dot(v).abs() <= 1e-6 * dir.hypot() * v.hypot() - }; - let from = - |&&(seg, t, n, d): &&(kurbo::PathSeg, f64, Point, f64)| d <= tol && (d == closest || (t > 1e-9 && t < 1.0 - 1e-9) || square(seg, t, n)); - near.iter().filter(from).any(|&(_, _, n, d)| { - let line = kurbo::Line::new(p, p + (p - n) * ((2.0 * tol - d) / d)); - reach.iter().any(|seg| seg.intersect_line(line).iter().any(|x| x.line_t > 1e-9 && x.line_t <= 1.0)) - }) - } - - /// What a gesture along the polyline `pts` touches, in order: [`Self::hit`] at each vertex and - /// at the points [`Self::probes`] picks between them, so it touches every region it crosses, - /// however narrow. - pub fn hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { - self.along(pts, piece_tol, Self::hit) - } - - /// What a Shape Builder gesture along the polyline `pts` touches: [`Self::hits_along`] with - /// [`Self::builder_hit`], so an erasing gesture also touches each region it goes into where the - /// region is no more than `2 * piece_tol` across. - pub fn builder_hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { - self.along(pts, piece_tol, Self::builder_hit) - } - - fn along(&self, pts: &[Point], piece_tol: Option, hit: fn(&Self, Point, Option) -> Option) -> Touched { - let mut touched = Touched::default(); - let mut last: Option = None; - for &p in pts { - if let Some(a) = last { - for q in self.probes(a, p, piece_tol) { - touched.add(hit(self, q, piece_tol)); - } - } - touched.add(hit(self, p, piece_tol)); - last = Some(p); - } - touched - } - - /// The points strictly between `a` and `b`, in order, that [`Self::hits_along`] tests: the - /// middle of each stretch between two places where the segment crosses a region's outline, and - /// with `piece_tol` (erasing) those crossings, the places where it crosses a line piece or an - /// edge, and points `piece_tol / 2` apart (at most 4,000 per segment), so the line pieces and - /// edges the segment crosses or passes close to are touched too. - fn probes(&self, a: Point, b: Point, piece_tol: Option) -> Vec { - let len = a.distance(b); - if !(len.is_finite() && len > 0.0) { - return vec![]; - } - let line = kurbo::Line::new(a, b); - let lb = Rect::from_points(a, b); - let mut cross: Vec = vec![]; - for (_, bp, rb) in &self.regions { - if rb.x0 > lb.x1 || rb.x1 < lb.x0 || rb.y0 > lb.y1 || rb.y1 < lb.y0 { - continue; - } - for seg in bp.segments() { - cross.extend(seg.intersect_line(line).iter().map(|x| x.line_t)); - } - } - cross.retain(|t| t.is_finite() && *t > 0.0 && *t < 1.0); - cross.sort_by(f64::total_cmp); - // Crossings closer than this along the segment are one place. - let gap = 1e-9 / len; - let mut ts: Vec = vec![]; - let mut from = 0.0; - for &t in cross.iter().chain([1.0].iter()) { - if t - from > gap { - ts.push((from + t) / 2.0); - from = t; - } - } - if let Some(tol) = piece_tol { - ts.extend(cross.iter().copied()); - for (piece, pb) in self.lines.iter().chain(&self.edges) { - if pb.x0 > lb.x1 || pb.x1 < lb.x0 || pb.y0 > lb.y1 || pb.y1 < lb.y0 { - continue; - } - for seg in piece.path.to_bezpath().segments() { - ts.extend(seg.intersect_line(line).iter().map(|x| x.line_t).filter(|t| t.is_finite() && *t > 0.0 && *t < 1.0)); - } - } - let n = ((len / (tol / 2.0).max(1e-3)).ceil() as usize).min(4000); - ts.extend((1..n).map(|k| k as f64 / n as f64)); - ts.sort_by(f64::total_cmp); - } - ts.into_iter().map(|t| a.lerp(b, t)).collect() - } - - /// The regions just either side of the middle of `piece` (None: outside every region). - pub fn sides(&self, piece: &po::Shape) -> [Option; 2] { - let Some(sp) = piece.path.subpaths.first() else { return [None; 2] }; - let n = sp.segment_count(); - if n == 0 { - return [None; 2]; - } - let c = sp.segment(n / 2); - use kurbo::{ParamCurve as _, ParamCurveDeriv as _}; - let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2()); - let len = d.hypot(); - if !(len.is_finite() && len > 0.0) { - return [None; 2]; - } - // A little to each side: inside the faces next to it, which share it exactly. - let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len); - [m + off, m - off].map(|q| self.region_at(q)) - } - - /// Does line piece `i` run between two different regions of `regions` (an edge that merging or - /// deleting them removes)? A piece with the same region on both sides, such as the end of a - /// line inside a shape, bounds nothing and stays. - pub fn line_between(&self, i: usize, regions: &[usize]) -> bool { - self.lines.get(i).is_some_and(|(l, _)| matches!(self.sides(l), [Some(a), Some(b)] if a != b && regions.contains(&a) && regions.contains(&b))) - } - - /// The edges `touched` deletes: those that bound none of its regions (deleting a region takes - /// its edges with it, so an erase drag that crosses an edge into a region deletes the region). - pub fn erased_edges(&self, touched: &Touched) -> Vec { - let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r))); - touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect() - } -} - -/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Touched { - pub regions: Vec, - pub lines: Vec, - pub edges: Vec, -} - -impl Touched { - pub fn add(&mut self, hit: Option) { - let (list, i) = match hit { - Some(Hit::Region(i)) => (&mut self.regions, i), - Some(Hit::Line(i)) => (&mut self.lines, i), - Some(Hit::Edge(i)) => (&mut self.edges, i), - None => return, - }; - if !list.contains(&i) { - list.push(i); - } - } - - /// Adds what `other` touched, in its order. - pub fn extend(&mut self, other: &Touched) { - let regions = other.regions.iter().map(|&i| Hit::Region(i)); - for hit in regions.chain(other.lines.iter().map(|&i| Hit::Line(i))).chain(other.edges.iter().map(|&i| Hit::Edge(i))) { - self.add(Some(hit)); - } - } - - pub fn is_empty(&self) -> bool { - self.regions.is_empty() && self.lines.is_empty() && self.edges.is_empty() - } -} - -/// Selected roots sorted back → front in document order. -pub fn sorted_roots(doc: &Document, ids: &[NodeId]) -> Vec { - let mut v: Vec<(Vec, NodeId)> = ids.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); - v.sort(); - v.dedup_by(|a, b| a.1 == b.1); - v.into_iter().map(|(_, id)| id).collect() -} - -/// Sample points along a polyline every `step` units (always including the vertices). -pub fn sample_polyline(pts: &[Point], step: f64) -> Vec { - let mut out = vec![]; - let step = step.max(1e-3); - for (i, &p) in pts.iter().enumerate() { - if i > 0 { - let a = pts[i - 1]; - let n = ((a.distance(p) / step).ceil() as usize).min(4000); - for k in 1..n { - out.push(a.lerp(p, k as f64 / n as f64)); - } - } - out.push(p); - } - out -} - -/// Diagonal hatch lines filling `path` (the Shape Builder's grey mesh highlight), `gap` apart. -pub fn hatch(path: &BezPath, gap: f64) -> BezPath { - let mut out = BezPath::new(); - let b = path.bounding_box(); - if b.width() <= 0.0 || b.height() <= 0.0 { - return out; - } - // Rotate by -45° so the hatch lines become horizontal scanlines. - let s = std::f64::consts::FRAC_1_SQRT_2; - let rot = |p: Point| Point::new((p.x + p.y) * s, (p.y - p.x) * s); - let unrot = |p: Point| Point::new((p.x - p.y) * s, (p.x + p.y) * s); - let mut edges: Vec<(Point, Point)> = vec![]; - let mut first = Point::ZERO; - let mut last = Point::ZERO; - kurbo::flatten(path.iter(), gap.max(1e-3) * 0.1, |el| match el { - kurbo::PathEl::MoveTo(p) => { - if last != first { - edges.push((rot(last), rot(first))); - } - first = p; - last = p; - } - kurbo::PathEl::LineTo(p) => { - edges.push((rot(last), rot(p))); - last = p; - } - kurbo::PathEl::ClosePath => { - if last != first { - edges.push((rot(last), rot(first))); - } - last = first; - } - _ => {} - }); - if last != first { - edges.push((rot(last), rot(first))); - } - let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); - for (a, c) in &edges { - y0 = y0.min(a.y.min(c.y)); - y1 = y1.max(a.y.max(c.y)); - } - let gap = gap.max((y1 - y0) / 400.0).max(1e-6); - let mut y = (y0 / gap).floor() * gap + gap * 0.5; - let mut xs: Vec = vec![]; - while y < y1 { - xs.clear(); - for (a, c) in &edges { - if (a.y <= y) != (c.y <= y) { - xs.push(a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)); - } - } - xs.sort_by(f64::total_cmp); - for pair in xs.as_chunks::<2>().0 { - out.move_to(unrot(Point::new(pair[0], y))); - out.line_to(unrot(Point::new(pair[1], y))); - } - y += gap; - } - out -} - -/// What a [`RegionCache`] map was built for: the document's address and revision, the selected ids -/// and whether filled open paths count as closed. -type CacheKey = (usize, (u64, u64), Vec, bool); - -/// Hovering builds the map of inputs with more segments than this on another thread -/// ([`RegionCache::ready`]); smaller maps are built on the calling thread. -const BACKGROUND_SEGMENTS: usize = 128; - -/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and -/// the channel it arrives on. -struct Pending { - key: CacheKey, - outlines: Vec, - done: Receiver, -} - -/// The planar map of the current selection, cached by document identity, revision, selection and -/// whether filled open paths count as closed. -#[derive(Default)] -struct RegionCache { - key: Option, - /// The map, which the Shape Builder's merge command takes too ([`Tool::shape_builder_map`]). - map: Arc, - /// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions. - live: bool, - /// Gap Detection distance; changing tool options invalidates the cache. - gap: f64, - /// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]). - outlines: Vec, - /// The map being built on another thread, if any. - pending: Option, - /// The key of the last map whose build on another thread failed. - failed: Option, - /// How many times the map has been built. - #[cfg(test)] - builds: usize, - /// How many of those builds ran on another thread. - #[cfg(test)] - background: usize, -} - -/// The map of `shapes`: Live Paint faces with `live`, else the Shape Builder map with its edges, -/// closing gaps up to `gap`. -fn build_map(shapes: Vec, live: bool, gap: f64) -> BuilderMap { - if live { - let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) }; - BuilderMap { regions, ..Default::default() } - } else { - BuilderMap::of_shapes(shapes, true, gap) - } -} - -/// Builds the map of `shapes` ([`build_map`]) on a new thread; None when no thread starts. -#[cfg(not(target_arch = "wasm32"))] -fn spawn_build(shapes: Vec, live: bool, gap: f64) -> Option> { - let (tx, done) = std::sync::mpsc::channel(); - let spawned = std::thread::Builder::new().name("vectorcraft-region-map".into()).spawn(move || { - // The tool may have dropped the receiver meanwhile (another tool, another selection). - let _ = tx.send(build_map(shapes, live, gap)); - }); - spawned.is_ok().then_some(done) -} - -/// The web build has no threads. -#[cfg(target_arch = "wasm32")] -fn spawn_build(_shapes: Vec, _live: bool, _gap: f64) -> Option> { - None -} - -impl RegionCache { - fn live_paint() -> Self { - Self { live: true, ..Default::default() } - } - - fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { - (cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed) - } - - /// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder - /// only). - fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { - let roots = sorted_roots(cx.doc, &cx.selection.objects); - if self.live { - (shapes_for(cx.doc, &roots).0, vec![]) - } else { - let (shapes, leaves) = shape_builder_shapes(cx.doc, &roots, open_filled_as_closed); - let outlines = drawn_outlines(cx.doc, &leaves); - (shapes, outlines) - } - } - - fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec) { - self.map = Arc::new(map); - self.outlines = outlines; - self.key = Some(key); - #[cfg(test)] - { - self.builds += 1; - } - } - - /// Forgets the map and any map being built (the tool options changed). - fn clear(&mut self) { - self.key = None; - self.pending = None; - self.failed = None; - } - - /// Is a map being built on another thread? - fn waiting(&self) -> bool { - self.pending.is_some() - } - - /// The map, built again only when the key changes, with filled open paths closed when - /// `open_filled_as_closed` (Shape Builder only). A Shape Builder map has the outline edges too, - /// so hovering and erasing use the same map. Waits for a map being built on another thread for - /// the same key. - fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap { - let key = Self::key_for(cx, open_filled_as_closed); - if self.key.as_ref() != Some(&key) { - let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines))); - let (map, outlines) = match built { - Some(b) => { - #[cfg(test)] - { - self.background += 1; - } - b - } - None => { - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); - (build_map(shapes, self.live, self.gap), outlines) - } - }; - self.install(key, map, outlines); - } - &self.map - } - - /// Takes the map being built on another thread once it has arrived, or with `wait` once it - /// arrives, as the map for the key it was built for. - fn settle(&mut self, wait: bool) { - let Some(p) = self.pending.take() else { return }; - let got = if wait { p.done.recv().map_err(|_| TryRecvError::Disconnected) } else { p.done.try_recv() }; - match got { - Ok(map) => { - #[cfg(test)] - { - self.background += 1; - } - self.install(p.key, map, p.outlines); - } - // The build failed (a bug in the planar map): hovering does not build it again, and a - // gesture builds it on the calling thread, which reports the failure. - Err(TryRecvError::Disconnected) => self.failed = Some(p.key), - Err(TryRecvError::Empty) => self.pending = Some(p), - } - } - - /// The map as [`Self::get`] gives it, without waiting: when the key changes and the inputs have - /// more than [`BACKGROUND_SEGMENTS`] segments, the map is built on another thread, and this is - /// None until it arrives ([`Self::waiting`] holds meanwhile). A map being built for an earlier - /// key is let finish before the next one starts. - fn ready(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<&BuilderMap> { - self.settle(false); - let key = Self::key_for(cx, open_filled_as_closed); - if self.key.as_ref() == Some(&key) { - return Some(&self.map); - } - if self.pending.is_some() || self.failed.as_ref() == Some(&key) { - return None; - } - let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); - let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); - if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) { - if let Some(done) = spawn_build(shapes, self.live, self.gap) { - self.pending = Some(Pending { key, outlines, done }); - return None; - } - // No thread started: build here. - return Some(self.get(cx, open_filled_as_closed)); - } - let map = build_map(shapes, self.live, self.gap); - self.install(key, map, outlines); - Some(&self.map) - } - - fn at(&mut self, cx: &ToolContext, p: Point) -> Option { - self.get(cx, true).region_at(p) - } -} - // ---------- Shape Builder ---------- pub use po::SHAPE_BUILDER_MAX_GAP; + +mod cache; +mod live_paint; +mod lookup; +mod map; + +use cache::RegionCache; +#[cfg(test)] +use live_paint::LpHover; +use live_paint::lp_color; +pub use live_paint::{LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE, LivePaintBucketTool, LivePaintSelectionTool}; +pub use lookup::{ + EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, drawn_outlines, edge_near, edges, face_at, faces, is_live_paint, + leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for, sources, + visible_edge_near, visible_face_at, +}; +pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots}; /// Small, Medium and Large gap lengths in points. pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0]; @@ -1409,266 +521,6 @@ impl Tool for ShapeBuilderTool { } } -// ---------- Live Paint Bucket ---------- - -/// What the Live Paint tools highlight under the cursor. -#[derive(Clone, Debug, Default, PartialEq)] -enum LpHover { - #[default] - None, - /// A face (or edge) of an existing Live Paint group. - Part(BezPath), - /// A region of the (not yet live) selection. - Region(usize), -} - -/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face -/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under -/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside -/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the -/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. -fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { - if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { - return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); - } - if !edge { - return None; - } - let mut best: Option<(f64, NodeId, &Arc)> = None; - doc.walk(|n| { - if is_live_paint(n) - && let Some(e) = visible_edge_near(n, p, tol) - && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) - && best.is_none_or(|b| d <= b.0) - && doc.is_visible(n.id) - { - best = Some((d, n.id, e)); - } - }); - best.map(|(_, g, e)| (g, Some(e))) -} - -/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`]), else -/// with `cache` the region of the selection under `p`. A large selection's regions are found on -/// another thread ([`RegionCache::ready`]), and until they are, no region is highlighted. -fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool) -> LpHover { - if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { - return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); - } - let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); - if !sel_is_live && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) { - return LpHover::Region(i); - } - LpHover::None -} - -/// The highlight of `h`, `width` screen pixels wide in `color`. -fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) -> Vec { - match h { - LpHover::None => vec![], - LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }], - LpHover::Region(i) => { - cache.map.regions.get(*i).map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }]).unwrap_or_default() - } - } -} - -/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click -/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a -/// Live Paint group first. -pub struct LivePaintBucketTool { - cache: RegionCache, - hover: LpHover, - /// Where the pointer last moved, and whether Shift was held. - at: Option<(Point, bool)>, -} - -impl Default for LivePaintBucketTool { - fn default() -> Self { - Self { cache: RegionCache::live_paint(), hover: LpHover::None, at: None } - } -} - -impl Tool for LivePaintBucketTool { - fn id(&self) -> &'static str { - "livePaintBucket" - } - - fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; - match ev.kind { - PointerKind::Move => { - self.at = Some((p, ev.mods.shift)); - self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift); - vec![] - } - PointerKind::Down => { - self.hover = LpHover::None; - let point = json!([p.x, p.y]); - if ev.mods.shift { - return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { - Some((g, Some(_))) => { - vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] - } - _ => vec![], - }; - } - if let Some(g) = live_paint_at(cx.doc, p) { - return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; - } - if self.cache.at(cx, p).is_some() { - let roots = sorted_roots(cx.doc, &cx.selection.objects); - return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))]; - } - vec![] - } - _ => vec![], - } - } - - fn overlays(&self, _cx: &ToolContext) -> Vec { - lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0) - } - - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::LivePaintBucket - } - - fn waiting(&self) -> bool { - self.cache.waiting() - } - - fn poll(&mut self, cx: &ToolContext, wait: bool) { - if !self.cache.waiting() { - return; - } - // As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part - // of a Live Paint group the hover takes no map. - loop { - self.cache.settle(wait); - if let Some((p, shift)) = self.at { - self.hover = lp_hover(cx, Some(&mut self.cache), p, shift); - } - if !wait || !self.cache.waiting() { - break; - } - } - } -} - -// ---------- Live Paint Selection ---------- - -/// The Live Paint Selection tool's highlight width (screen pixels) by default. -pub const LP_SELECT_WIDTH: f64 = 4.0; -/// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes. -pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); - -/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool -/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight -/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select -/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills -/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click -/// selects no face or edge. -pub struct LivePaintSelectionTool { - cache: RegionCache, - hover: LpHover, - fills: bool, - strokes: bool, - color: [u8; 3], - width: f64, -} - -impl Default for LivePaintSelectionTool { - fn default() -> Self { - Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH } - } -} - -impl LivePaintSelectionTool { - /// What a click picks, Alt held or not: Some(true) an edge, Some(false) a face, None nothing. - fn picks_edge(&self, alt: bool) -> Option { - match (self.fills, self.strokes) { - (true, true) => Some(alt), - (true, false) => Some(false), - (false, true) => Some(true), - (false, false) => None, - } - } -} - -/// A highlight color option: `#rrggbb` or a layer color's name (any case). -fn lp_color(v: &Value) -> Option<[u8; 3]> { - let s = v.as_str()?.trim(); - if let Some((_, c)) = vectorcraft_doc::LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) { - return Some(*c); - } - let [r, g, b, _] = vectorcraft_color::Color::from_hex(s)?.to_rgba8(1.0); - Some([r, g, b]) -} - -impl Tool for LivePaintSelectionTool { - fn id(&self) -> &'static str { - "livePaintSelection" - } - - fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; - let edge = self.picks_edge(ev.mods.alt); - match ev.kind { - PointerKind::Move => { - // Only the parts of Live Paint groups highlight, so no map of the selection is built. - self.hover = match edge.map(|e| lp_hover(cx, None, p, e)) { - Some(h @ LpHover::Part(_)) => h, - _ => LpHover::None, - }; - vec![] - } - PointerKind::Down => { - let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); - match (hit, ev.mods.shift) { - (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], - (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], - (None, true) => vec![], - (None, false) => vec![Action::Exec("select.none".into(), json!({}))], - } - } - _ => vec![], - } - } - - fn overlays(&self, _cx: &ToolContext) -> Vec { - lp_overlays(&self.hover, &self.cache, self.color, self.width as f32) - } - - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow - } - - fn options(&self) -> Value { - let [r, g, b] = self.color; - json!({ - "selectFills": self.fills, - "selectStrokes": self.strokes, - "color": format!("#{r:02x}{g:02x}{b:02x}"), - "width": self.width, - }) - } - - fn set_option(&mut self, key: &str, v: &Value) { - match key { - "selectFills" => self.fills = v.as_bool().unwrap_or(self.fills), - "selectStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes), - "color" => self.color = lp_color(v).unwrap_or(self.color), - "width" => { - let (min, max) = LP_SELECT_WIDTH_RANGE; - self.width = v.as_f64().filter(|w| w.is_finite()).map_or(self.width, |w| w.clamp(min, max)); - } - _ => return, - } - self.hover = LpHover::None; - } -} - /// Create a builder-family tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { Some(match id { diff --git a/crates/tools/src/builder/cache.rs b/crates/tools/src/builder/cache.rs new file mode 100644 index 000000000..27487e4c3 --- /dev/null +++ b/crates/tools/src/builder/cache.rs @@ -0,0 +1,195 @@ +use super::lookup::{drawn_outlines, faces_of, shape_builder_shapes, shapes_for}; +use super::map::{BuilderMap, sorted_roots}; +use super::*; + +/// What a [`RegionCache`] map was built for: the document's address and revision, the selected ids +/// and whether filled open paths count as closed. +pub(super) type CacheKey = (usize, (u64, u64), Vec, bool); + +/// Hovering builds the map of inputs with more segments than this on another thread +/// ([`RegionCache::ready`]); smaller maps are built on the calling thread. +const BACKGROUND_SEGMENTS: usize = 128; + +/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and +/// the channel it arrives on. +struct Pending { + key: CacheKey, + outlines: Vec, + done: Receiver, +} + +/// The planar map of the current selection, cached by document identity, revision, selection and +/// whether filled open paths count as closed. +#[derive(Default)] +pub(super) struct RegionCache { + pub(super) key: Option, + /// The map, which the Shape Builder's merge command takes too ([`Tool::shape_builder_map`]). + pub(super) map: Arc, + /// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions. + live: bool, + /// Gap Detection distance; changing tool options invalidates the cache. + pub(super) gap: f64, + /// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]). + pub(super) outlines: Vec, + /// The map being built on another thread, if any. + pending: Option, + /// The key of the last map whose build on another thread failed. + failed: Option, + /// How many times the map has been built. + #[cfg(test)] + pub(super) builds: usize, + /// How many of those builds ran on another thread. + #[cfg(test)] + pub(super) background: usize, +} + +/// The map of `shapes`: Live Paint faces with `live`, else the Shape Builder map with its edges, +/// closing gaps up to `gap`. +fn build_map(shapes: Vec, live: bool, gap: f64) -> BuilderMap { + if live { + let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) }; + BuilderMap { regions, ..Default::default() } + } else { + BuilderMap::of_shapes(shapes, true, gap) + } +} + +/// Builds the map of `shapes` ([`build_map`]) on a new thread; None when no thread starts. +#[cfg(not(target_arch = "wasm32"))] +fn spawn_build(shapes: Vec, live: bool, gap: f64) -> Option> { + let (tx, done) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("vectorcraft-region-map".into()).spawn(move || { + // The tool may have dropped the receiver meanwhile (another tool, another selection). + let _ = tx.send(build_map(shapes, live, gap)); + }); + spawned.is_ok().then_some(done) +} + +/// The web build has no threads. +#[cfg(target_arch = "wasm32")] +fn spawn_build(_shapes: Vec, _live: bool, _gap: f64) -> Option> { + None +} + +impl RegionCache { + pub(super) fn live_paint() -> Self { + Self { live: true, ..Default::default() } + } + + fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey { + (cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed) + } + + /// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder + /// only). + fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec, Vec) { + let roots = sorted_roots(cx.doc, &cx.selection.objects); + if self.live { + (shapes_for(cx.doc, &roots).0, vec![]) + } else { + let (shapes, leaves) = shape_builder_shapes(cx.doc, &roots, open_filled_as_closed); + let outlines = drawn_outlines(cx.doc, &leaves); + (shapes, outlines) + } + } + + fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec) { + self.map = Arc::new(map); + self.outlines = outlines; + self.key = Some(key); + #[cfg(test)] + { + self.builds += 1; + } + } + + /// Forgets the map and any map being built (the tool options changed). + pub(super) fn clear(&mut self) { + self.key = None; + self.pending = None; + self.failed = None; + } + + /// Is a map being built on another thread? + pub(super) fn waiting(&self) -> bool { + self.pending.is_some() + } + + /// The map, built again only when the key changes, with filled open paths closed when + /// `open_filled_as_closed` (Shape Builder only). A Shape Builder map has the outline edges too, + /// so hovering and erasing use the same map. Waits for a map being built on another thread for + /// the same key. + pub(super) fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap { + let key = Self::key_for(cx, open_filled_as_closed); + if self.key.as_ref() != Some(&key) { + let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines))); + let (map, outlines) = match built { + Some(b) => { + #[cfg(test)] + { + self.background += 1; + } + b + } + None => { + let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + (build_map(shapes, self.live, self.gap), outlines) + } + }; + self.install(key, map, outlines); + } + &self.map + } + + /// Takes the map being built on another thread once it has arrived, or with `wait` once it + /// arrives, as the map for the key it was built for. + pub(super) fn settle(&mut self, wait: bool) { + let Some(p) = self.pending.take() else { return }; + let got = if wait { p.done.recv().map_err(|_| TryRecvError::Disconnected) } else { p.done.try_recv() }; + match got { + Ok(map) => { + #[cfg(test)] + { + self.background += 1; + } + self.install(p.key, map, p.outlines); + } + // The build failed (a bug in the planar map): hovering does not build it again, and a + // gesture builds it on the calling thread, which reports the failure. + Err(TryRecvError::Disconnected) => self.failed = Some(p.key), + Err(TryRecvError::Empty) => self.pending = Some(p), + } + } + + /// The map as [`Self::get`] gives it, without waiting: when the key changes and the inputs have + /// more than [`BACKGROUND_SEGMENTS`] segments, the map is built on another thread, and this is + /// None until it arrives ([`Self::waiting`] holds meanwhile). A map being built for an earlier + /// key is let finish before the next one starts. + pub(super) fn ready(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<&BuilderMap> { + self.settle(false); + let key = Self::key_for(cx, open_filled_as_closed); + if self.key.as_ref() == Some(&key) { + return Some(&self.map); + } + if self.pending.is_some() || self.failed.as_ref() == Some(&key) { + return None; + } + let (shapes, outlines) = self.inputs(cx, open_filled_as_closed); + let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::()).sum(); + if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) { + if let Some(done) = spawn_build(shapes, self.live, self.gap) { + self.pending = Some(Pending { key, outlines, done }); + return None; + } + // No thread started: build here. + return Some(self.get(cx, open_filled_as_closed)); + } + let map = build_map(shapes, self.live, self.gap); + self.install(key, map, outlines); + Some(&self.map) + } + + pub(super) fn at(&mut self, cx: &ToolContext, p: Point) -> Option { + self.get(cx, true).region_at(p) + } +} diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs new file mode 100644 index 000000000..667a6e2e3 --- /dev/null +++ b/crates/tools/src/builder/live_paint.rs @@ -0,0 +1,265 @@ +use super::cache::RegionCache; +use super::lookup::{is_live_paint, live_paint_at, visible_edge_near, visible_face_at}; +use super::map::{HIGHLIGHT_RED, sorted_roots}; +use super::*; + +// ---------- Live Paint Bucket ---------- + +/// What the Live Paint tools highlight under the cursor. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) enum LpHover { + #[default] + None, + /// A face (or edge) of an existing Live Paint group. + Part(BezPath), + /// A region of the (not yet live) selection. + Region(usize), +} + +/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face +/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under +/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside +/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the +/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. +fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { + if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { + return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); + } + if !edge { + return None; + } + let mut best: Option<(f64, NodeId, &Arc)> = None; + doc.walk(|n| { + if is_live_paint(n) + && let Some(e) = visible_edge_near(n, p, tol) + && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && best.is_none_or(|b| d <= b.0) + && doc.is_visible(n.id) + { + best = Some((d, n.id, e)); + } + }); + best.map(|(_, g, e)| (g, Some(e))) +} + +/// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`]), else +/// with `cache` the region of the selection under `p`. A large selection's regions are found on +/// another thread ([`RegionCache::ready`]), and until they are, no region is highlighted. +fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool) -> LpHover { + if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { + return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); + } + let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); + if !sel_is_live && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) { + return LpHover::Region(i); + } + LpHover::None +} + +/// The highlight of `h`, `width` screen pixels wide in `color`. +fn lp_overlays(h: &LpHover, cache: &RegionCache, color: [u8; 3], width: f32) -> Vec { + match h { + LpHover::None => vec![], + LpHover::Part(bp) => vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }], + LpHover::Region(i) => { + cache.map.regions.get(*i).map(|(_, bp, _)| vec![Overlay::Path { path: bp.clone(), color, width, dashed: false }]).unwrap_or_default() + } + } +} + +/// Live Paint Bucket: click fills the face under the cursor with the current fill; Shift-click +/// paints the nearest edge with the current stroke. Clicking selected ordinary paths makes them a +/// Live Paint group first. +pub struct LivePaintBucketTool { + pub(super) cache: RegionCache, + pub(super) hover: LpHover, + /// Where the pointer last moved, and whether Shift was held. + at: Option<(Point, bool)>, +} + +impl Default for LivePaintBucketTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None, at: None } + } +} + +impl Tool for LivePaintBucketTool { + fn id(&self) -> &'static str { + "livePaintBucket" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match ev.kind { + PointerKind::Move => { + self.at = Some((p, ev.mods.shift)); + self.hover = lp_hover(cx, Some(&mut self.cache), p, ev.mods.shift); + vec![] + } + PointerKind::Down => { + self.hover = LpHover::None; + let point = json!([p.x, p.y]); + if ev.mods.shift { + return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { + Some((g, Some(_))) => { + vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] + } + _ => vec![], + }; + } + if let Some(g) = live_paint_at(cx.doc, p) { + return vec![Action::Exec("livePaint.fill".into(), json!({ "group": g.0, "point": point }))]; + } + if self.cache.at(cx, p).is_some() { + let roots = sorted_roots(cx.doc, &cx.selection.objects); + return vec![Action::Exec("livePaint.fill".into(), json!({ "ids": json_ids(&roots), "point": point }))]; + } + vec![] + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + lp_overlays(&self.hover, &self.cache, HIGHLIGHT_RED, 4.0) + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::LivePaintBucket + } + + fn waiting(&self) -> bool { + self.cache.waiting() + } + + fn poll(&mut self, cx: &ToolContext, wait: bool) { + if !self.cache.waiting() { + return; + } + // As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part + // of a Live Paint group the hover takes no map. + loop { + self.cache.settle(wait); + if let Some((p, shift)) = self.at { + self.hover = lp_hover(cx, Some(&mut self.cache), p, shift); + } + if !wait || !self.cache.waiting() { + break; + } + } + } +} + +// ---------- Live Paint Selection ---------- + +/// The Live Paint Selection tool's highlight width (screen pixels) by default. +pub const LP_SELECT_WIDTH: f64 = 4.0; + +/// The least and greatest highlight width (screen pixels) the Live Paint Selection tool takes. +pub const LP_SELECT_WIDTH_RANGE: (f64, f64) = (1.0, 20.0); + +/// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. Its Tool +/// Options (double-click the tool) are `selectFills` and `selectStrokes` (both on), the highlight +/// `color` (`#rrggbb`, red by default) and its `width` ([`LP_SELECT_WIDTH`] pixels). With Select +/// Strokes off, Alt-click selects the face under the pointer as a click does; with Select Fills +/// off, a click selects the edge nearest the pointer as Alt-click does; with both off, a click +/// selects no face or edge. +pub struct LivePaintSelectionTool { + pub(super) cache: RegionCache, + pub(super) hover: LpHover, + fills: bool, + strokes: bool, + color: [u8; 3], + width: f64, +} + +impl Default for LivePaintSelectionTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None, fills: true, strokes: true, color: HIGHLIGHT_RED, width: LP_SELECT_WIDTH } + } +} + +impl LivePaintSelectionTool { + /// What a click picks, Alt held or not: Some(true) an edge, Some(false) a face, None nothing. + fn picks_edge(&self, alt: bool) -> Option { + match (self.fills, self.strokes) { + (true, true) => Some(alt), + (true, false) => Some(false), + (false, true) => Some(true), + (false, false) => None, + } + } +} + +/// A highlight color option: `#rrggbb` or a layer color's name (any case). +pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> { + let s = v.as_str()?.trim(); + if let Some((_, c)) = vectorcraft_doc::LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) { + return Some(*c); + } + let [r, g, b, _] = vectorcraft_color::Color::from_hex(s)?.to_rgba8(1.0); + Some([r, g, b]) +} + +impl Tool for LivePaintSelectionTool { + fn id(&self) -> &'static str { + "livePaintSelection" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + let edge = self.picks_edge(ev.mods.alt); + match ev.kind { + PointerKind::Move => { + // Only the parts of Live Paint groups highlight, so no map of the selection is built. + self.hover = match edge.map(|e| lp_hover(cx, None, p, e)) { + Some(h @ LpHover::Part(_)) => h, + _ => LpHover::None, + }; + vec![] + } + PointerKind::Down => { + let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); + match (hit, ev.mods.shift) { + (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], + (None, true) => vec![], + (None, false) => vec![Action::Exec("select.none".into(), json!({}))], + } + } + _ => vec![], + } + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + lp_overlays(&self.hover, &self.cache, self.color, self.width as f32) + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Arrow + } + + fn options(&self) -> Value { + let [r, g, b] = self.color; + json!({ + "selectFills": self.fills, + "selectStrokes": self.strokes, + "color": format!("#{r:02x}{g:02x}{b:02x}"), + "width": self.width, + }) + } + + fn set_option(&mut self, key: &str, v: &Value) { + match key { + "selectFills" => self.fills = v.as_bool().unwrap_or(self.fills), + "selectStrokes" => self.strokes = v.as_bool().unwrap_or(self.strokes), + "color" => self.color = lp_color(v).unwrap_or(self.color), + "width" => { + let (min, max) = LP_SELECT_WIDTH_RANGE; + self.width = v.as_f64().filter(|w| w.is_finite()).map_or(self.width, |w| w.clamp(min, max)); + } + _ => return, + } + self.hover = LpHover::None; + } +} diff --git a/crates/tools/src/builder/lookup.rs b/crates/tools/src/builder/lookup.rs new file mode 100644 index 000000000..30475be34 --- /dev/null +++ b/crates/tools/src/builder/lookup.rs @@ -0,0 +1,259 @@ +use super::*; + +/// Names starting with this mark a Live Paint group, together with its sources group ([`SOURCES_NAME`]). +pub const LIVE_PAINT_PREFIX: &str = "Live Paint"; + +/// Default name of a new Live Paint group. +pub const LIVE_PAINT_NAME: &str = "Live Paint Group"; + +/// The hidden child group holding the source paths. +pub const SOURCES_NAME: &str = "Live Paint Sources"; + +pub const FACE_NAME: &str = "Face"; + +pub const EDGE_NAME: &str = "Edge"; + +/// Is `n` a Live Paint group: an unclipped group whose name starts with [`LIVE_PAINT_PREFIX`] and +/// that holds a group named [`SOURCES_NAME`]? A plain group with such a name is not one. +pub fn is_live_paint(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && n.name.as_deref().is_some_and(|s| s.starts_with(LIVE_PAINT_PREFIX)) + && sources(n).is_some_and(|s| matches!(s.kind, NodeKind::Group { .. })) +} + +/// Whether Live Paint can take `n` as it is: a Live Paint group, a path or compound path, or a +/// group of those. Type, images, symbols, blends, meshes and the rest have to be converted to +/// paths first (); +/// Make and Merge refuse them rather than hide them among the group's sources. +pub fn live_paint_takes(n: &Node) -> bool { + is_live_paint(n) + || match &n.kind { + NodeKind::Path { .. } | NodeKind::Compound { .. } => true, + NodeKind::Group { children, .. } => children.iter().all(|c| live_paint_takes(c)), + _ => false, + } +} + +fn named(n: &Node, name: &str) -> bool { + n.name.as_deref() == Some(name) +} + +/// Face children of a Live Paint group (paint order). +pub fn faces(group: &Node) -> Vec<&Arc> { + group.children().map(|c| c.iter().filter(|n| named(n, FACE_NAME)).collect()).unwrap_or_default() +} + +/// Edge children of a Live Paint group. +pub fn edges(group: &Node) -> Vec<&Arc> { + group.children().map(|c| c.iter().filter(|n| named(n, EDGE_NAME)).collect()).unwrap_or_default() +} + +/// The hidden sources group of a Live Paint group. +pub fn sources(group: &Node) -> Option<&Arc> { + group.children()?.iter().find(|n| named(n, SOURCES_NAME)) +} + +/// Does the path (filled) contain `p`? +pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { + if path.bounds().is_none_or(|b| !b.inflate(1e-6, 1e-6).contains(p)) { + return false; + } + let w = path.to_bezpath().winding(p); + match rule { + FillRule::EvenOdd => w % 2 != 0, + _ => w != 0, + } +} + +/// The face of `group` under `p` (front-most). +pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { + front_face(faces(group).into_iter(), p) +} + +/// The face of `group` under `p` (front-most) among its faces that are not hidden. +pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc> { + front_face(faces(group).into_iter().filter(|f| f.visible), p) +} + +/// The face of `faces` (paint order) under `p` (front-most). +fn front_face<'a>(faces: impl DoubleEndedIterator>, p: Point) -> Option<&'a Arc> { + faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) +} + +/// The edge of `group` nearest to `p` within `tol`. +pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group), p, tol) +} + +/// The edge of `group` nearest to `p` within `tol` among its edges that are not hidden. +pub fn visible_edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group).into_iter().filter(|e| e.visible), p, tol) +} + +/// The edge of `edges` nearest to `p` within `tol`. +fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, tol: f64) -> Option<&'a Arc> { + let mut best: Option<(f64, &Arc)> = None; + for e in edges { + if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && d <= tol + && best.is_none_or(|b| d < b.0) + { + best = Some((d, e)); + } + } + best.map(|b| b.1) +} + +/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). +pub fn live_paint_at(doc: &Document, p: Point) -> Option { + let mut hit = None; + doc.walk(|n| { + if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { + hit = Some(n.id); + } + }); + hit +} + +/// Paths, compound paths (as one shape) of a subtree in paint order; guides, clipping paths and +/// hidden objects are skipped. Live Paint groups contribute their faces and edges. +pub fn leaf_shapes<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { + collect_leaves(n, false, out); +} + +/// [`leaf_shapes`]; with `filled_faces`, a Live Paint group contributes only its filled faces and +/// its stroked edges. +fn collect_leaves<'a>(n: &'a Node, filled_faces: bool, out: &mut Vec<&'a Node>) { + if !n.visible { + return; + } + if n.extras().shaper.is_some() { + for c in n.children().into_iter().flatten().skip(1) { + collect_leaves(c, filled_faces, out); + } + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => out.push(n), + NodeKind::Group { .. } if filled_faces && is_live_paint(n) => { + out.extend(faces(n).into_iter().filter(|f| f.visible && !f.appearance.fill_paint().is_none()).map(|f| &**f)); + out.extend(edges(n).into_iter().filter(|e| e.visible && !e.appearance.stroke_paint().is_none()).map(|e| &**e)); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children { + collect_leaves(c, filled_faces, out); + } + } + _ => {} + } +} + +/// Filled outline of a path or compound path node. +pub fn node_outline(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); + Some((PathData::new(subs), *rule)) + } + _ => None, + } +} + +/// How far (pt) a piece of the planar map may lie from `outline`, the outline it follows, to be +/// matched to it or cut out of it: a ten-thousandth of the outline's larger side, at least 0.05. +/// The map's pieces follow the outlines to within a few hundredths of a point. +pub fn outline_snap(outline: &PathData) -> f64 { + outline.bounds().map_or(0.05, |b| (b.width().max(b.height()) / 2000.0).max(0.25) * 0.2) +} + +/// The outlines of `leaves` as they are drawn ([`node_outline`]), in their order, for +/// [`BuilderMap::stroke_at`]. A leaf without an outline, and one inside a Live Paint group or a +/// Shaper Group (which make their own paths), gets an empty outline, so no stroke of it splits. +pub fn drawn_outlines(doc: &Document, leaves: &[&Node]) -> Vec { + let in_live_group = |id: NodeId| { + let mut up = doc.parent_of(id); + while let Some(p) = up { + if doc.node(p).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()) { + return true; + } + up = doc.parent_of(p); + } + false + }; + leaves.iter().map(|l| if in_live_group(l.id) { None } else { node_outline(l) }.map_or_else(PathData::default, |o| o.0)).collect() +} + +/// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index, +/// with the leaves they came from. +pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, Vec<&'a Node>) { + shapes_of(doc, roots, false) +} + +/// [`shapes_for`]; with `filled_faces`, a Live Paint group contributes only its filled faces and +/// its stroked edges. +fn shapes_of<'a>(doc: &'a Document, roots: &[NodeId], filled_faces: bool) -> (Vec, Vec<&'a Node>) { + let mut lv = vec![]; + for id in roots { + if let Some(n) = doc.node(*id) { + collect_leaves(n, filled_faces, &mut lv); + } + } + let mut shapes = vec![]; + let mut used = vec![]; + for l in lv { + if let Some((p, r)) = node_outline(l) + && !p.is_empty() + && p.subpaths.iter().all(|s| s.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite())) + { + shapes.push(po::Shape::new(p, r, used.len() as u64)); + used.push(l); + } + } + (shapes, used) +} + +/// Shape Builder inputs: [`shapes_for`], with the open subpaths of filled paths closed where that +/// encloses an area (the fill bounds a region, as if an invisible edge closed it) when +/// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions +/// they cross, and so do filled open paths without `open_filled_as_closed`. A Live Paint group +/// contributes its filled faces as shapes and its stroked edges as open lines; its unfilled faces +/// and its edges without a stroke are left out. +pub fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { + let (mut shapes, leaves) = shapes_of(doc, roots, true); + for (s, l) in shapes.iter_mut().zip(&leaves) { + if named(l, EDGE_NAME) && doc.parent_of(l.id).and_then(|p| doc.node(p)).is_some_and(is_live_paint) { + // A closed edge opens at its first anchor and keeps its closing segment. + let start = BTreeSet::from([0]); + s.path.subpaths = s.path.subpaths.iter().flat_map(|sp| sp.cut_at(&start)).collect(); + continue; + } + if !open_filled_as_closed || l.appearance.fill_paint().is_none() { + continue; + } + for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) { + sp.closed = po::encloses_area(sp); + } + } + (shapes, leaves) +} + +/// A planar-map face with its outline and bounds, ready for hit tests. +pub type Face = (po::Region, BezPath, Rect); + +pub(super) fn faces_of(regions: Vec) -> Vec { + regions + .into_iter() + .filter_map(|r| { + let bp = r.path.to_bezpath(); + let b = r.path.bounds()?; + Some((r, bp, b)) + }) + .collect() +} + +thread_local! { + /// The Shape Builder maps built on this thread ([`maps_built`]). + pub(super) static MAPS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/tools/src/builder/map.rs b/crates/tools/src/builder/map.rs new file mode 100644 index 000000000..cf9327dd4 --- /dev/null +++ b/crates/tools/src/builder/map.rs @@ -0,0 +1,462 @@ +use super::lookup::{Face, MAPS_BUILT, faces_of, outline_snap, shape_builder_shapes}; +use super::*; + +/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red. +pub const HATCH: [u8; 3] = [0x80, 0x80, 0x80]; + +pub const HIGHLIGHT_RED: [u8; 3] = [0xff, 0x2a, 0x2a]; + +/// How many Shape Builder maps ([`BuilderMap::of_shapes`]) this thread has built, for tests that +/// count them. +pub fn maps_built() -> usize { + MAPS_BUILT.with(std::cell::Cell::get) +} + +/// The Shape Builder's planar map of a selection. +#[derive(Default)] +pub struct BuilderMap { + /// The inputs, back → front (see [`po::shape_builder`]). + pub shapes: Vec, + pub regions: Vec, + /// Pieces of the open paths that cut regions (see [`BuilderMap::new`]), keyed by input index, + /// with their bounds. + pub lines: Vec<(po::Shape, Rect)>, + /// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with + /// their bounds: the edges erasing deletes. Only in a map made `with_edges`. + pub edges: Vec<(po::Shape, Rect)>, +} + +/// What a Shape Builder gesture touches at a point. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Hit { + Region(usize), + Line(usize), + Edge(usize), +} + +/// The piece of `pieces` nearest `p` within `tol`, with its distance. +fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, usize)> { + let mut best: Option<(f64, usize)> = None; + for (i, (l, b)) in pieces.iter().enumerate() { + if b.inflate(tol, tol).contains(p) + && let Some((.., d)) = l.path.nearest(p) + && d <= tol + && best.is_none_or(|b| d < b.0) + { + best = Some((d, i)); + } + } + best +} + +impl BuilderMap { + /// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline + /// edges only `with_edges`. With `open_filled_as_closed` (Consider Open Filled Paths as + /// Closed), a filled open path bounds a region as if an invisible edge closed it; without it, + /// the path cuts like a line. + pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool) -> (Self, Vec<&'a Node>) { + Self::with_gap(doc, roots, with_edges, open_filled_as_closed, 0.0) + } + + /// The same arrangement with Gap Detection, using the shared Live Paint gap finder. + pub fn with_gap<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool, gap: f64) -> (Self, Vec<&'a Node>) { + let (shapes, leaves) = shape_builder_shapes(doc, roots, open_filled_as_closed); + (Self::of_shapes(shapes, with_edges, gap), leaves) + } + + /// The map of `shapes` (the inputs, back → front, as [`shape_builder_shapes`] gives them), with + /// the outline edges only `with_edges`, closing gaps up to `gap`. + pub fn of_shapes(shapes: Vec, with_edges: bool, gap: f64) -> Self { + if shapes.is_empty() { + return Self::default(); + } + MAPS_BUILT.with(|n| n.set(n.get() + 1)); + let arr = po::shape_builder_with_gap(&shapes, with_edges, gap); + let bounded = |pieces: Vec| { + pieces + .into_iter() + .filter_map(|l| { + let b = l.path.bounds()?; + Some((l, b)) + }) + .collect() + }; + Self { shapes, regions: faces_of(arr.regions), lines: bounded(arr.lines), edges: bounded(arr.edges) } + } + + /// The region containing `p`. + pub fn region_at(&self, p: Point) -> Option { + self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0) + } + + /// The regions a rectangular marquee from `from` (where the drag started) to `to` touches: each + /// region whose outline crosses the rectangle or has an anchor inside it, and the region the + /// whole rectangle lies in. Of those, the region under `from` comes first, then the one under + /// `to`, then the others in map order. A marquee of no size touches the region under `from`. + pub fn regions_in_marquee(&self, from: Point, to: Point) -> Vec { + let r = Rect::from_points(from, to); + if r.width() <= 0.0 && r.height() <= 0.0 { + return self.region_at(from).into_iter().collect(); + } + let c = r.center(); + let touches = |(reg, bp, b): &Face| (b.contains(c) && bp.winding(c) != 0) || vectorcraft_geom::hit::intersects_rect(®.path, r); + let mut out: Vec = self.regions.iter().enumerate().filter(|(_, f)| touches(f)).map(|(i, _)| i).collect(); + for p in [to, from] { + if let Some(k) = self.region_at(p).and_then(|i| out.iter().position(|&j| j == i)) { + let i = out.remove(k); + out.insert(0, i); + } + } + out + } + + /// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that + /// close, else the region under it. + pub fn hit(&self, p: Point, piece_tol: Option) -> Option { + self.piece_hit(p, piece_tol).or_else(|| self.region_at(p).map(Hit::Region)) + } + + /// The line piece or edge within `piece_tol` of `p` that [`Self::hit`] gives (None without + /// `piece_tol`). + fn piece_hit(&self, p: Point, piece_tol: Option) -> Option { + piece_tol.and_then(|t| match (piece_near(&self.lines, p, t), piece_near(&self.edges, p, t)) { + (Some(l), Some(e)) if e.0 < l.0 => Some(Hit::Edge(e.1)), + (Some(l), _) => Some(Hit::Line(l.1)), + (None, e) => e.map(|e| Hit::Edge(e.1)), + }) + } + + /// The stroke a click at `p` splits with In Merge Mode, Clicking Stroke Splits the Path: the + /// line piece or edge nearest `p` within `tol` (as [`BuilderMap::hit`] finds it), from the + /// anchor or crossing point before `p` to the one after it on the path it lies on. `outlines` + /// are the inputs' outlines as they are drawn (a filled open path without the edge that closes + /// it in the map). Returns the input's index and the run, going the way the path goes; None + /// when no piece is that close, when the run is not on the outline as drawn, when it would be a + /// whole closed subpath, when it is too short to cut out (see [`po::cut_out`]), or when it is + /// all of the path (a lone line) or leaves no more of it than [`outline_snap`], which leaves + /// nothing to split off. + pub fn stroke_at(&self, outlines: &[PathData], p: Point, tol: f64) -> Option<(usize, SubPath)> { + use kurbo::ParamCurve as _; + let piece = match self.hit(p, Some(tol))? { + Hit::Line(i) => self.lines.get(i), + Hit::Edge(i) => self.edges.get(i), + Hit::Region(_) => None, + }?; + let i = piece.0.key as usize; + let drawn = outlines.get(i)?; + let stops: Vec = drawn.anchors().map(|(.., a)| a.p).collect(); + let snap = outline_snap(drawn); + let mut run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?; + let n = run.segment_count(); + if n == 0 { + return None; + } + let mid = run.segment(n / 2).eval(0.5); + drawn.nearest(mid).filter(|n| n.4 <= snap)?; + // A run that cannot be cut out, or that is all of the path (such as a lone line) or all + // but a speck of it, leaves nothing to split off. + if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| rest.length() > snap) { + return None; + } + // Reverse the run when the path's parameter goes down along it. + let seg = run.segment(n / 2); + if let (Some(a), Some(b)) = (drawn.nearest(seg.eval(0.25)), drawn.nearest(seg.eval(0.75))) + && a.0 == b.0 + && (b.1 as f64 + b.2) < (a.1 as f64 + a.2) + { + run.reverse(); + } + Some((i, run)) + } + + /// What a Shape Builder gesture at `p` touches: [`Self::hit`], except that while erasing, a + /// point in a region that is no more than `2 * piece_tol` across there (see + /// [`Self::narrow_at`]) touches the region and not the line piece or edge beside it. + pub fn builder_hit(&self, p: Point, piece_tol: Option) -> Option { + match (piece_tol, self.piece_hit(p, piece_tol)) { + (Some(tol), Some(piece)) => Some(match self.region_at(p) { + Some(r) if self.narrow_at(r, p, tol) => Hit::Region(r), + _ => piece, + }), + (_, piece) => piece.or_else(|| self.region_at(p).map(Hit::Region)), + } + } + + /// Is region `r` no more than `2 * tol` across at `p`, measured from a point of its outline + /// within `tol` of `p`, through `p`, to its outline on the other side? Every point on that line + /// is then within `tol` of the outline. The points tried are the point of the outline nearest + /// `p` and each point of a segment where the segment is square to the line to `p`, so a + /// point near the end of a thin bar measures across the bar from its sides as well as along it + /// from the end, and splitting a segment in two does not change the answer. False when `p` is + /// on the outline or more than `tol` from it. + fn narrow_at(&self, r: usize, p: Point, tol: f64) -> bool { + use kurbo::{ParamCurve as _, ParamCurveNearest as _}; + let Some((_, bp, _)) = self.regions.get(r) else { return false }; + // Each segment with the parameter, point and distance of its point nearest `p`. + let near: Vec<(kurbo::PathSeg, f64, Point, f64)> = bp + .segments() + .map(|seg| { + let n = seg.nearest(p, 1e-6); + (seg, n.t, seg.eval(n.t), n.distance_sq.sqrt()) + }) + .collect(); + if !near.iter().all(|&(.., d)| d.is_finite() && d > 1e-9) { + return false; + } + let closest = near.iter().map(|&(.., d)| d).fold(f64::INFINITY, f64::min); + // A line from `p` no longer than `2 * tol` only meets segments that close to `p`. + let reach: Vec = near.iter().filter(|&&(.., d)| d <= 2.0 * tol).map(|&(seg, ..)| seg).collect(); + // A segment's end is its nearest point when the segment turns away from `p` or stops + // short of the point square to it; there, only the nearest point of all the outline and + // an end where the segment is square to the line to `p` count. + let square = |seg: kurbo::PathSeg, t: f64, n: Point| { + use kurbo::ParamCurveDeriv as _; + let dir = match seg { + kurbo::PathSeg::Line(l) => l.p1 - l.p0, + kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + kurbo::PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + }; + let v = p - n; + dir.hypot() > 1e-12 && dir.dot(v).abs() <= 1e-6 * dir.hypot() * v.hypot() + }; + let from = + |&&(seg, t, n, d): &&(kurbo::PathSeg, f64, Point, f64)| d <= tol && (d == closest || (t > 1e-9 && t < 1.0 - 1e-9) || square(seg, t, n)); + near.iter().filter(from).any(|&(_, _, n, d)| { + let line = kurbo::Line::new(p, p + (p - n) * ((2.0 * tol - d) / d)); + reach.iter().any(|seg| seg.intersect_line(line).iter().any(|x| x.line_t > 1e-9 && x.line_t <= 1.0)) + }) + } + + /// What a gesture along the polyline `pts` touches, in order: [`Self::hit`] at each vertex and + /// at the points [`Self::probes`] picks between them, so it touches every region it crosses, + /// however narrow. + pub fn hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { + self.along(pts, piece_tol, Self::hit) + } + + /// What a Shape Builder gesture along the polyline `pts` touches: [`Self::hits_along`] with + /// [`Self::builder_hit`], so an erasing gesture also touches each region it goes into where the + /// region is no more than `2 * piece_tol` across. + pub fn builder_hits_along(&self, pts: &[Point], piece_tol: Option) -> Touched { + self.along(pts, piece_tol, Self::builder_hit) + } + + fn along(&self, pts: &[Point], piece_tol: Option, hit: fn(&Self, Point, Option) -> Option) -> Touched { + let mut touched = Touched::default(); + let mut last: Option = None; + for &p in pts { + if let Some(a) = last { + for q in self.probes(a, p, piece_tol) { + touched.add(hit(self, q, piece_tol)); + } + } + touched.add(hit(self, p, piece_tol)); + last = Some(p); + } + touched + } + + /// The points strictly between `a` and `b`, in order, that [`Self::hits_along`] tests: the + /// middle of each stretch between two places where the segment crosses a region's outline, and + /// with `piece_tol` (erasing) those crossings, the places where it crosses a line piece or an + /// edge, and points `piece_tol / 2` apart (at most 4,000 per segment), so the line pieces and + /// edges the segment crosses or passes close to are touched too. + fn probes(&self, a: Point, b: Point, piece_tol: Option) -> Vec { + let len = a.distance(b); + if !(len.is_finite() && len > 0.0) { + return vec![]; + } + let line = kurbo::Line::new(a, b); + let lb = Rect::from_points(a, b); + let mut cross: Vec = vec![]; + for (_, bp, rb) in &self.regions { + if rb.x0 > lb.x1 || rb.x1 < lb.x0 || rb.y0 > lb.y1 || rb.y1 < lb.y0 { + continue; + } + for seg in bp.segments() { + cross.extend(seg.intersect_line(line).iter().map(|x| x.line_t)); + } + } + cross.retain(|t| t.is_finite() && *t > 0.0 && *t < 1.0); + cross.sort_by(f64::total_cmp); + // Crossings closer than this along the segment are one place. + let gap = 1e-9 / len; + let mut ts: Vec = vec![]; + let mut from = 0.0; + for &t in cross.iter().chain([1.0].iter()) { + if t - from > gap { + ts.push((from + t) / 2.0); + from = t; + } + } + if let Some(tol) = piece_tol { + ts.extend(cross.iter().copied()); + for (piece, pb) in self.lines.iter().chain(&self.edges) { + if pb.x0 > lb.x1 || pb.x1 < lb.x0 || pb.y0 > lb.y1 || pb.y1 < lb.y0 { + continue; + } + for seg in piece.path.to_bezpath().segments() { + ts.extend(seg.intersect_line(line).iter().map(|x| x.line_t).filter(|t| t.is_finite() && *t > 0.0 && *t < 1.0)); + } + } + let n = ((len / (tol / 2.0).max(1e-3)).ceil() as usize).min(4000); + ts.extend((1..n).map(|k| k as f64 / n as f64)); + ts.sort_by(f64::total_cmp); + } + ts.into_iter().map(|t| a.lerp(b, t)).collect() + } + + /// The regions just either side of the middle of `piece` (None: outside every region). + pub fn sides(&self, piece: &po::Shape) -> [Option; 2] { + let Some(sp) = piece.path.subpaths.first() else { return [None; 2] }; + let n = sp.segment_count(); + if n == 0 { + return [None; 2]; + } + let c = sp.segment(n / 2); + use kurbo::{ParamCurve as _, ParamCurveDeriv as _}; + let (m, d) = (c.eval(0.5), c.deriv().eval(0.5).to_vec2()); + let len = d.hypot(); + if !(len.is_finite() && len > 0.0) { + return [None; 2]; + } + // A little to each side: inside the faces next to it, which share it exactly. + let off = d.turn_90() * (0.05f64.min(0.05 * c.p0.distance(c.p3)).max(1e-6) / len); + [m + off, m - off].map(|q| self.region_at(q)) + } + + /// Does line piece `i` run between two different regions of `regions` (an edge that merging or + /// deleting them removes)? A piece with the same region on both sides, such as the end of a + /// line inside a shape, bounds nothing and stays. + pub fn line_between(&self, i: usize, regions: &[usize]) -> bool { + self.lines.get(i).is_some_and(|(l, _)| matches!(self.sides(l), [Some(a), Some(b)] if a != b && regions.contains(&a) && regions.contains(&b))) + } + + /// The edges `touched` deletes: those that bound none of its regions (deleting a region takes + /// its edges with it, so an erase drag that crosses an edge into a region deletes the region). + pub fn erased_edges(&self, touched: &Touched) -> Vec { + let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r))); + touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect() + } +} + +/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Touched { + pub regions: Vec, + pub lines: Vec, + pub edges: Vec, +} + +impl Touched { + pub fn add(&mut self, hit: Option) { + let (list, i) = match hit { + Some(Hit::Region(i)) => (&mut self.regions, i), + Some(Hit::Line(i)) => (&mut self.lines, i), + Some(Hit::Edge(i)) => (&mut self.edges, i), + None => return, + }; + if !list.contains(&i) { + list.push(i); + } + } + + /// Adds what `other` touched, in its order. + pub fn extend(&mut self, other: &Touched) { + let regions = other.regions.iter().map(|&i| Hit::Region(i)); + for hit in regions.chain(other.lines.iter().map(|&i| Hit::Line(i))).chain(other.edges.iter().map(|&i| Hit::Edge(i))) { + self.add(Some(hit)); + } + } + + pub fn is_empty(&self) -> bool { + self.regions.is_empty() && self.lines.is_empty() && self.edges.is_empty() + } +} + +/// Selected roots sorted back → front in document order. +pub fn sorted_roots(doc: &Document, ids: &[NodeId]) -> Vec { + let mut v: Vec<(Vec, NodeId)> = ids.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); + v.sort(); + v.dedup_by(|a, b| a.1 == b.1); + v.into_iter().map(|(_, id)| id).collect() +} + +/// Sample points along a polyline every `step` units (always including the vertices). +pub fn sample_polyline(pts: &[Point], step: f64) -> Vec { + let mut out = vec![]; + let step = step.max(1e-3); + for (i, &p) in pts.iter().enumerate() { + if i > 0 { + let a = pts[i - 1]; + let n = ((a.distance(p) / step).ceil() as usize).min(4000); + for k in 1..n { + out.push(a.lerp(p, k as f64 / n as f64)); + } + } + out.push(p); + } + out +} + +/// Diagonal hatch lines filling `path` (the Shape Builder's grey mesh highlight), `gap` apart. +pub fn hatch(path: &BezPath, gap: f64) -> BezPath { + let mut out = BezPath::new(); + let b = path.bounding_box(); + if b.width() <= 0.0 || b.height() <= 0.0 { + return out; + } + // Rotate by -45° so the hatch lines become horizontal scanlines. + let s = std::f64::consts::FRAC_1_SQRT_2; + let rot = |p: Point| Point::new((p.x + p.y) * s, (p.y - p.x) * s); + let unrot = |p: Point| Point::new((p.x - p.y) * s, (p.x + p.y) * s); + let mut edges: Vec<(Point, Point)> = vec![]; + let mut first = Point::ZERO; + let mut last = Point::ZERO; + kurbo::flatten(path.iter(), gap.max(1e-3) * 0.1, |el| match el { + kurbo::PathEl::MoveTo(p) => { + if last != first { + edges.push((rot(last), rot(first))); + } + first = p; + last = p; + } + kurbo::PathEl::LineTo(p) => { + edges.push((rot(last), rot(p))); + last = p; + } + kurbo::PathEl::ClosePath => { + if last != first { + edges.push((rot(last), rot(first))); + } + last = first; + } + _ => {} + }); + if last != first { + edges.push((rot(last), rot(first))); + } + let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY); + for (a, c) in &edges { + y0 = y0.min(a.y.min(c.y)); + y1 = y1.max(a.y.max(c.y)); + } + let gap = gap.max((y1 - y0) / 400.0).max(1e-6); + let mut y = (y0 / gap).floor() * gap + gap * 0.5; + let mut xs: Vec = vec![]; + while y < y1 { + xs.clear(); + for (a, c) in &edges { + if (a.y <= y) != (c.y <= y) { + xs.push(a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)); + } + } + xs.sort_by(f64::total_cmp); + for pair in xs.as_chunks::<2>().0 { + out.move_to(unrot(Point::new(pair[0], y))); + out.line_to(unrot(Point::new(pair[1], y))); + } + y += gap; + } + out +}