diff --git a/ROADMAP.md b/ROADMAP.md index 61448519..44ab4471 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,7 +114,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. - **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. -- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. @@ -173,7 +173,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) |---|---:|---:|---|---:| | Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 | -| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | +| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | | Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 12c4f491..a9e83390 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -16,7 +16,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; -use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _}; +use vectorcraft_geom::{FillRule, Point, Rect, Shape as _}; use vectorcraft_pathops as po; use vectorcraft_tools::builder::{ self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots, @@ -205,52 +205,6 @@ fn stroke_only(paint: Paint, width: f64) -> Appearance { Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() } } -/// A point strictly inside a filled path (for re-finding faces after a rebuild). -pub(crate) fn interior_point(path: &PathData) -> Option { - let bb = path.bounds()?; - let bp = path.to_bezpath(); - let mut edges: Vec<(Point, Point)> = vec![]; - let (mut first, mut last) = (Point::ZERO, Point::ZERO); - kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el { - kurbo::PathEl::MoveTo(p) => { - if last != first { - edges.push((last, first)); - } - first = p; - last = p; - } - kurbo::PathEl::LineTo(p) => { - edges.push((last, p)); - last = p; - } - kurbo::PathEl::ClosePath => { - if last != first { - edges.push((last, first)); - } - last = first; - } - _ => {} - }); - if last != first { - edges.push((last, first)); - } - let mut best: Option<(f64, Point)> = None; - for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] { - let y = bb.y0 + bb.height() * f; - let mut xs: Vec = - edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect(); - xs.sort_by(f64::total_cmp); - for w in xs.windows(2) { - let m = Point::new((w[0] + w[1]) / 2.0, y); - let width = w[1] - w[0]; - if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 { - best = Some((width, m)); - } - } - } - best.map(|b| b.1) -} - // ---------- Shape Builder ---------- fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result> { @@ -395,7 +349,11 @@ fn build_children(d: &mut Document, sources: Vec>) -> Result<(Vec>) -> Result<(Vec Result { let keep_faces: Vec<(Point, Paint)> = b::faces(&old) .iter() .filter(|f| !f.appearance.fill_paint().is_none()) - .filter_map(|f| Some((interior_point(f.path_data()?)?, f.appearance.fill_paint()))) + .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) .collect(); let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) .iter() diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index bb8dc6ff..2753d1a9 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -310,6 +310,56 @@ fn live_paint_bucket_tool_fills_face() { assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); } +fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId { + let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +/// Crossing lines, as in the bug report: the Live Paint Bucket fills the area they enclose. +#[test] +fn live_paint_bucket_fills_an_area_enclosed_by_lines() { + let mut s = session(); + let ids = [line(&mut s, (0.0, 0.0), (100.0, 100.0)), line(&mut s, (100.0, 0.0), (0.0, 100.0)), line(&mut s, (-10.0, 80.0), (110.0, 80.0))]; + s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 1.0)); + // Inside the triangle (20,80) (50,50) (80,80): highlighted, then filled. + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 70.0), v).unwrap(); + assert!(!s.overlays(v).is_empty(), "red face highlight"); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap(); + let g = s.doc().unwrap().selection.objects[0]; + let gn = node(&s, g); + assert!(vectorcraft_tools::builder::is_live_paint(&gn)); + assert_eq!(vectorcraft_tools::builder::faces(&gn).len(), 1); + let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 70.0)).unwrap(); + assert_eq!(f.appearance.fill_paint(), s.paint.fill); + assert!((vectorcraft_pathops::area(f.path_data().unwrap(), FillRule::NonZero).abs() - 900.0).abs() < 1e-6); + // The lines are edges, cut where they cross, and keep their stroke. + let edges = vectorcraft_tools::builder::edges(&gn); + assert_eq!(edges.len(), 9); + assert!(edges.iter().all(|e| !e.appearance.stroke_paint().is_none())); + // Outside the triangle there's nothing to fill. + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 20]})).is_err()); +} + +#[test] +fn a_line_across_a_live_paint_shape_splits_its_face() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0)); + let l = line(&mut s, (40.0, -20.0), (60.0, 70.0)); + s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap(); + let made = s.execute("livePaint.make", &json!({})).unwrap(); + assert_eq!(made["faces"], 2, "{made}"); + let g = NodeId(made["id"].as_u64().unwrap()); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [80, 25], "color": "#0000ff"})).unwrap(); + let gn = node(&s, g); + let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 25.0)).unwrap().appearance.fill_paint(); + assert_eq!(face(10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the left half keeps the rectangle's fill"); + assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap())); +} + // ---------- Image Trace ---------- /// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2. diff --git a/crates/pathops/README.md b/crates/pathops/README.md index 5454f1c3..67427039 100644 --- a/crates/pathops/README.md +++ b/crates/pathops/README.md @@ -1,6 +1,6 @@ # vectorcraft-pathops -Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`. +Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, Live Paint planar maps, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`. ## Booleans (curve-preserving) @@ -49,6 +49,15 @@ Each result takes its key as follows: | Crop | Keeps the visible parts of the lower objects inside the front-most object | | Outline | Returns open edge paths, split at every junction; coincident edges are de-duplicated, and each keeps the front-most key | +## Live Paint + +```rust +pub fn live_paint(shapes: &[Shape] /* back → front */) -> (Vec, Vec); // (faces, edges) +pub fn interior_point(path: &PathData) -> Option; +``` + +Unlike `regions`, which splits the filled areas of closed shapes, `live_paint` treats every path as an edge, open paths included. Three crossing lines enclose a triangle, a line across a rectangle splits it in two, and an area enclosed by paths becomes a face even when nothing fills it. Each face lists the inputs whose fill covers it; the list is empty for an area that is only enclosed by paths. Each edge is a piece of a path, split wherever another path meets it, and keeps the key of the front-most input it lies on. + ## Offset / stroke ```rust diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index d37e1f31..677424c7 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -157,7 +157,7 @@ fn el_points(el: &kurbo::PathEl) -> impl Iterator { /// handle within `eps` of its anchor's height is levelled with it (no y-extremum is left in a /// sub-`eps` sliver of a curve). Anchors move by less than `eps` and handles by less than `2·eps`, /// the order of the sweep's own tolerance. -fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) { +pub(crate) fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) { use kurbo::PathEl::*; let mut ys: Vec = paths.iter().flat_map(|p| p.elements().iter().filter_map(kurbo::PathEl::end_point)).map(|p| p.y).collect(); ys.sort_by(f64::total_cmp); @@ -339,7 +339,7 @@ pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData { /// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris /// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's /// Pathfinder precision setting does. -fn is_sliver(bp: &BezPath, precision: f64) -> bool { +pub(crate) fn is_sliver(bp: &BezPath, precision: f64) -> bool { let a = bp.area().abs(); let per = bp.perimeter(1e-6); per <= 0.0 || 2.0 * a / per < precision diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 7f9b367f..7d56bb95 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -11,6 +11,7 @@ mod edit; mod fit; mod offset; mod pathfinder; +mod planar; pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all}; pub use edit::{ @@ -18,6 +19,7 @@ pub use edit::{ }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; +pub use planar::{interior_point, live_paint}; /// Errors from fallible operations. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs new file mode 100644 index 00000000..bdc6274b --- /dev/null +++ b/crates/pathops/src/planar.rs @@ -0,0 +1,815 @@ +//! Live Paint planar maps: the faces and edges a set of paths divides the plane into. +//! +//! Unlike Pathfinder [`regions`](crate::regions), which splits the *filled areas* of closed shapes, +//! every path here is an edge, open ones included: three crossing lines enclose a triangle, a line +//! across a rectangle splits it in two, and an area enclosed by paths is a face whether or not +//! anything fills it. +//! +//! The sweep (`linesweeper`) only takes closed paths, so an open path goes in traced there and +//! back. Its two passes would cancel out as winding numbers, so the sweep counts boundary crossings +//! instead ([`Crossings`]), which never cancel. Faces are then walked on the planar graph of the +//! sweep's output segments; which inputs an edge came from and which fills cover a face are found +//! geometrically (the sweep's winding numbers can't be trusted once open paths and coincident +//! edges are in). +//! +//! The sweep runs in a sheared frame ([`SHEAR`]): it merges the two passes of a *horizontal* open +//! path as if they cancelled out, and in the sheared frame no edge drawn at a common angle (0°, +//! 45°, 90°…) is horizontal. The shear is by a power of two, so it maps typical coordinates +//! exactly and keeps exact coincidences (a corner on another shape's edge) exact. + +use std::collections::HashMap; + +use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _}; +use linesweeper::topology::{Topology, WindingNumber}; +use vectorcraft_geom::{FillRule, PathData}; + +use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg}; +use crate::pathfinder::{Region, Shape}; + +/// The sweep's frame: y + x / 32. Only edges sloping -1/32 in the document are horizontal in it. +const SHEAR: Affine = Affine::new([1.0, 1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); +const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]); + +/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s +/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one +/// to each, so its edges never cancel out of the map. +#[derive(Clone, Debug, Default)] +struct Crossings(Vec); + +impl Crossings { + fn get(&self, slot: usize) -> u32 { + self.0.get(slot).copied().unwrap_or(0) + } +} + +impl PartialEq for Crossings { + fn eq(&self, other: &Self) -> bool { + (0..self.0.len().max(other.0.len())).all(|i| self.get(i) == other.get(i)) + } +} +impl Eq for Crossings {} + +impl std::ops::Add for Crossings { + type Output = Crossings; + fn add(mut self, rhs: Self) -> Crossings { + self += rhs; + self + } +} + +impl std::ops::AddAssign for Crossings { + fn add_assign(&mut self, rhs: Self) { + if self.0.len() < rhs.0.len() { + self.0.resize(rhs.0.len(), 0); + } + for (a, b) in self.0.iter_mut().zip(rhs.0) { + *a = a.saturating_add(b); + } + } +} + +impl WindingNumber for Crossings { + type Tag = usize; + fn single(tag: usize, positive: bool) -> Self { + let slot = 2 * tag + usize::from(!positive); + let mut v = vec![0; slot + 1]; + if let Some(c) = v.get_mut(slot) { + *c = 1; + } + Crossings(v) + } + fn of_tag(&self, tag: usize) -> Self { + let mut v = vec![0; 2 * tag + 2]; + for slot in [2 * tag, 2 * tag + 1] { + if let Some(c) = v.get_mut(slot) { + *c = self.get(slot); + } + } + Crossings(v) + } +} + +/// A point in the middle of a run of segments. +fn mid(segs: &[Seg]) -> Option { + segs.get(segs.len() / 2).map(|s| s.c.eval(0.5)) +} + +/// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on. +struct Outlines { + segs: Vec<(kurbo::Rect, PathSeg, usize)>, + tol: f64, +} + +impl Outlines { + fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self { + let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect(); + // The sweep moves points by a few `eps` at most. + Self { segs, tol: 16.0 * eps } + } + + /// The front-most input whose outline passes through `p`. + fn source_at(&self, p: Point) -> Option { + use kurbo::ParamCurveNearest; + let tol = self.tol; + self.segs.iter().filter(|(r, s, _)| r.inflate(tol, tol).contains(p) && s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, _, i)| *i).max() + } +} + +/// The area an input's fill covers: its closed subpaths (open ones bound no face). +struct Fill { + path: BezPath, + bounds: kurbo::Rect, + rule: FillRule, +} + +impl Fill { + fn new(s: &Shape) -> Self { + let mut path = BezPath::new(); + for sp in s.path.subpaths.iter().filter(|sp| sp.closed && sp.anchors.len() >= 2) { + sp.to_bezpath_into(&mut path); + } + Self { bounds: path.bounding_box(), path, rule: s.rule } + } + + fn contains(&self, p: Point) -> bool { + !self.path.elements().is_empty() && self.bounds.contains(p) && crate::boolean::inside(self.rule, self.path.winding(p)) + } +} + +/// One output segment of the sweep: an edge of the planar graph between two vertices. +struct Edge { + segs: Vec, + ends: [usize; 2], + /// The input it came from (the front-most, where inputs share it). + source: usize, +} + +/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it. +fn twin(h: usize) -> usize { + h ^ 1 +} + +/// The segments of half-edge `h`, in its direction. +fn half_segs(edges: &[Edge], h: usize) -> Vec { + let Some(e) = edges.get(h / 2) else { return Vec::new() }; + if h.is_multiple_of(2) { + e.segs.clone() + } else { + e.segs.iter().rev().map(|s| Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line }).collect() + } +} + +/// The vertex half-edge `h` leaves from. +fn origin(edges: &[Edge], h: usize) -> Option { + edges.get(h / 2).map(|e| e.ends[h % 2]) +} + +/// Where half-edge `h` (leaving `v`) first gets `r` away from it, as an angle around `v`. +/// +/// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however +/// they start out (tangent curves, or the sweep's short straight stubs at a vertex). +fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 { + let segs = half_segs(edges, h); + let mut bp = BezPath::new(); + for (k, s) in segs.iter().enumerate() { + if k == 0 { + bp.move_to(s.c.p0); + } + bp.curve_to(s.c.p1, s.c.p2, s.c.p3); + } + let mut prev = segs.first().map_or(v, |s| s.c.p0); + let mut hit: Option = None; + kurbo::flatten(bp.iter(), (r * 1e-4).max(1e-12), |el| { + if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) { + if p.distance(v) >= r { + // The point on prev → p at distance r (prev is closer). + let (d, f) = (p - prev, prev - v); + let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r); + let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 }; + hit = Some(prev + d * t); + } + prev = p; + } + }); + // An edge too short to reach r (it can't, r is at most half of every chord) leaves along its + // tangent. + let far = + hit.unwrap_or_else(|| segs.first().map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3))); + (far - v).atan2() +} + +/// Path of a closed run of half-edges, tidied (split pieces of one input curve joined again). +fn cycle_bezpath(edges: &[Edge], cycle: &[usize]) -> BezPath { + let mut bp = BezPath::new(); + for (k, s) in cycle.iter().flat_map(|&h| half_segs(edges, h)).enumerate() { + if k == 0 { + bp.move_to(s.c.p0); + } + if s.line { + bp.line_to(s.c.p3); + } else { + bp.curve_to(s.c.p1, s.c.p2, s.c.p3); + } + } + bp.close_path(); + bp +} + +/// `cycle` without the spurs it runs out and back along (edges with the same face on both sides, +/// such as a line's loose end). +fn without_spurs(cycle: &[usize]) -> Vec { + let mut out: Vec = Vec::with_capacity(cycle.len()); + for &h in cycle { + if out.last() == Some(&twin(h)) { + out.pop(); + } else { + out.push(h); + } + } + // A spur where the walk started wraps around the ends. + while out.len() >= 2 && out.first().map(|&h| twin(h)) == out.last().copied() { + out.pop(); + out.remove(0); + } + out +} + +/// Append `open` traced there and back (a closed, zero-area path along it) to `out`. +fn there_and_back(open: &BezPath, out: &mut BezPath) { + let segs: Vec = open.segments().collect(); + let Some(first) = segs.first() else { return }; + out.move_to(first.start()); + for s in &segs { + out.push(s.as_path_el()); + } + for s in segs.iter().rev() { + out.push(s.reverse().as_path_el()); + } + out.close_path(); +} + +/// `path` back from the sweep's frame, its anchors within `tol` of an input anchor (`sorted` by x) +/// put exactly on it, handles moving with them. +fn unshear(mut path: PathData, sorted: &[Point], tol: f64) -> PathData { + path.transform(UNSHEAR); + for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { + let lo = sorted.partition_point(|q| q.x < a.p.x - tol); + if let Some(q) = sorted.get(lo..).unwrap_or_default().iter().take_while(|q| q.x <= a.p.x + tol).find(|q| (q.y - a.p.y).abs() <= tol) { + let d = *q - a.p; + a.p = *q; + a.h_in += d; + a.h_out += d; + } + } + path +} + +fn find(parent: &mut [usize], mut v: usize) -> usize { + while let Some(&p) = parent.get(v) { + if p == v { + break; + } + let gp = parent.get(p).copied().unwrap_or(p); + if let Some(slot) = parent.get_mut(v) { + *slot = gp; + } + v = p; + } + v +} + +/// The Live Paint planar map of `shapes` (back → front). +/// +/// Faces are the bounded areas the paths enclose, holes included, each with the inputs whose fill +/// covers it (ascending; empty for an area only enclosed by paths). Edges are the paths split +/// wherever they meet another path, keyed by the input they came from (the front-most where +/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate). +pub fn live_paint(shapes: &[Shape]) -> (Vec, Vec) { + build(shapes).unwrap_or_default() +} + +fn build(shapes: &[Shape]) -> Option<(Vec, Vec)> { + let mut inputs: Vec<(BezPath, usize)> = Vec::new(); + for (i, s) in shapes.iter().enumerate() { + let mut bp = BezPath::new(); + for sp in s.path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) { + if sp.closed { + sp.to_bezpath_into(&mut bp); + } else { + let mut open = BezPath::new(); + sp.to_bezpath_into(&mut open); + there_and_back(&open, &mut bp); + } + } + if !bp.elements().is_empty() { + bp.apply_affine(SHEAR); + inputs.push((bp, i)); + } + } + if inputs.is_empty() { + return None; + } + let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?; + snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::>(), eps); + // The sweep can panic on rare near-degenerate input; that must never take the app down. + let sweep = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps))); + let top = sweep.ok()?.ok()?; + let tidy = Tidy::keeping(DEFAULT_PRECISION, inputs.iter().map(|p| &p.0)); + + // The planar graph: vertices are the sweep's points, edges its segments. Points closer than + // the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge + // a rounding error away from where it cuts that edge). + let positions = top.compute_positions(); + let cell = 4.0 * eps; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut points: Vec = Vec::new(); + let mut vertex_id = |p: Point| { + let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64); + let near = (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|k| grid.get(&k)) + .flatten() + .copied() + .find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps)); + near.unwrap_or_else(|| { + points.push(p); + grid.entry((cx, cy)).or_default().push(points.len() - 1); + points.len() - 1 + }) + }; + let mut edges: Vec = Vec::new(); + let mut between: HashMap<(usize, usize), Vec> = HashMap::new(); + let outlines = Outlines::new(&inputs, eps); + for idx in top.segment_indices() { + let half = idx.first_half(); + let segs: Vec = positions[idx].path.segments().map(to_seg).collect(); + let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())]; + if segs.is_empty() || ends[0] == ends[1] { + continue; + } + let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue }; + // Coincident pieces the sweep left apart are one edge (the front-most input's). + let key = (ends[0].min(ends[1]), ends[0].max(ends[1])); + let here = mid(&segs); + let twin_of = between.get(&key).and_then(|v| { + v.iter().copied().find(|&e| { + let other = edges.get(e).and_then(|e| mid(&e.segs)); + matches!((here, other), (Some(a), Some(b)) if a.distance(b) < DEFAULT_PRECISION) + }) + }); + if let Some(e) = twin_of { + if let Some(e) = edges.get_mut(e) + && source > e.source + { + e.source = source; + } + continue; + } + between.entry(key).or_default().push(edges.len()); + edges.push(Edge { segs, ends, source }); + } + let nv = points.len(); + + // Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is + // the one just clockwise of the way back, so bounded faces come out with positive area and + // the outer boundary of each connected piece with negative area. + let mut around: Vec> = vec![Vec::new(); nv]; + for h in 0..2 * edges.len() { + if let Some(list) = origin(&edges, h).and_then(|v| around.get_mut(v)) { + list.push(h); + } + } + let mut slot_of = vec![0usize; 2 * edges.len()]; + for (v, list) in around.iter_mut().enumerate() { + let Some(&at) = points.get(v) else { continue }; + // Half the shortest chord of the edges here: every one of them gets that far from it. + let r = 0.5 * list.iter().filter_map(|&h| half_segs(&edges, h).last().map(|s| s.c.p3.distance(at))).fold(f64::INFINITY, f64::min); + let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect(); + keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); + *list = keyed.into_iter().map(|(_, h)| h).collect(); + for (k, &h) in list.iter().enumerate() { + if let Some(s) = slot_of.get_mut(h) { + *s = k; + } + } + } + let next = |h: usize| -> Option { + let back = twin(h); + let list = around.get(origin(&edges, back)?)?; + let k = *slot_of.get(back)?; + list.get((k + list.len() - 1) % list.len()).copied() + }; + + // Connected pieces of the graph. + let mut parent: Vec = (0..nv).collect(); + for e in &edges { + let (a, b) = (find(&mut parent, e.ends[0]), find(&mut parent, e.ends[1])); + if let Some(p) = parent.get_mut(a) { + *p = b; + } + } + let piece: Vec = (0..nv).map(|v| find(&mut parent, v)).collect(); + let piece_of = |cycle: &[usize]| cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| piece.get(v)).copied(); + + // Walk every cycle: positive ones are faces, negative ones outer boundaries (holes in the + // face around them, if any). + let mut seen = vec![false; 2 * edges.len()]; + let mut faces: Vec<(Vec, BezPath, f64)> = Vec::new(); + let mut holes: Vec<(Vec, BezPath)> = Vec::new(); + for start in 0..2 * edges.len() { + if seen.get(start).copied().unwrap_or(true) { + continue; + } + let mut cycle = vec![]; + let mut h = start; + while let Some(s) = seen.get_mut(h).filter(|s| !**s) { + *s = true; + cycle.push(h); + match next(h) { + Some(n) => h = n, + None => break, + } + } + let cycle = without_spurs(&cycle); + if cycle.is_empty() { + continue; + } + let bp = cycle_bezpath(&edges, &cycle); + let area = bp.area(); + if area > 0.0 && !is_sliver(&bp, tidy.precision) { + faces.push((cycle, bp, area)); + } else if area < -(tidy.precision * tidy.precision) { + holes.push((cycle, bp)); + } + } + + // Each hole goes in the smallest face of another piece around it. + let mut face_holes: Vec> = vec![Vec::new(); faces.len()]; + for (k, (cycle, _)) in holes.iter().enumerate() { + let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue }; + let own = piece_of(cycle); + let around_it = faces + .iter() + .enumerate() + .filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.bounding_box().contains(p) && bp.winding(p) != 0) + .min_by(|a, b| a.1.2.total_cmp(&b.1.2)) + .map(|(i, _)| i); + if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) { + list.push(k); + } + } + + let fills: Vec = shapes.iter().map(Fill::new).collect(); + // The inputs' anchors, which results land on again up to the sweep's tolerance. + let snap = 2.0 * eps; + let mut anchors: Vec = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect(); + anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + let subpath = |cycle: &[usize]| { + let segs: Vec = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect(); + segs_to_subpath(&tidy_segments(segs, true, &tidy), true) + }; + let regions = faces + .iter() + .zip(&face_holes) + .filter_map(|((cycle, _, _), hs)| { + let mut subpaths = vec![subpath(cycle)?]; + subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c))); + let path = unshear(PathData::new(subpaths), &anchors, snap); + let sources = + interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default(); + Some(Region { path, sources }) + }) + .collect(); + + // Edges: chains of segments through vertices where nothing else meets. + let degree: Vec = around.iter().map(Vec::len).collect(); + let passes = |v: usize| -> Option<(usize, usize)> { + let list = around.get(v)?; + let (&a, &b) = (list.first()?, list.get(1)?); + (degree.get(v) == Some(&2) && edges.get(a / 2)?.source == edges.get(b / 2)?.source).then_some((a, b)) + }; + let mut used = vec![false; edges.len()]; + let mut chains: Vec<(Vec, bool, usize)> = Vec::new(); + // A chain from half-edge `h0` up to the next vertex where it doesn't pass straight through; + // closed when it comes back round to where it started. + let walk = |h0: usize, used: &mut Vec| -> Option<(Vec, bool, usize)> { + let source = edges.get(h0 / 2)?.source; + let mut segs = Vec::new(); + let mut h = h0; + loop { + let u = used.get_mut(h / 2)?; + if *u { + return Some((segs, true, source)); + } + *u = true; + segs.extend(half_segs(&edges, h)); + let v = origin(&edges, twin(h))?; + let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) }; + h = if a == twin(h) { b } else { a }; + } + }; + for v in 0..nv { + if passes(v).is_some() { + continue; + } + for &h in around.get(v).map(Vec::as_slice).unwrap_or_default() { + if !used.get(h / 2).copied().unwrap_or(true) + && let Some(c) = walk(h, &mut used) + { + chains.push(c); + } + } + } + // What's left are closed loops with no junction on them. + for e in 0..edges.len() { + if !used.get(e).copied().unwrap_or(true) + && let Some(c) = walk(2 * e, &mut used) + { + chains.push(c); + } + } + let edge_shapes = chains + .into_iter() + .filter_map(|(segs, closed, source)| { + let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?; + Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key)) + }) + .collect(); + Some((regions, edge_shapes)) +} + +/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding +/// a Live Paint face after a rebuild). +pub fn interior_point(path: &PathData) -> Option { + let bb = path.bounds()?; + let bp = path.to_bezpath(); + let mut edges: Vec<(Point, Point)> = vec![]; + let (mut first, mut last) = (Point::ZERO, Point::ZERO); + kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el { + kurbo::PathEl::MoveTo(p) => { + if last != first { + edges.push((last, first)); + } + first = p; + last = p; + } + kurbo::PathEl::LineTo(p) => { + edges.push((last, p)); + last = p; + } + kurbo::PathEl::ClosePath => { + if last != first { + edges.push((last, first)); + } + last = first; + } + _ => {} + }); + if last != first { + edges.push((last, first)); + } + let mut best: Option<(f64, Point)> = None; + for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] { + // A little off the round fractions, which on art drawn to a grid fall on the height of + // some corner, where testing the point against other paths is unreliable. + let y = bb.y0 + bb.height() * (f + 0.0061803); + let mut xs: Vec = + edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect(); + xs.sort_by(f64::total_cmp); + for w in xs.windows(2) { + let m = Point::new((w[0] + w[1]) / 2.0, y); + let width = w[1] - w[0]; + if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 { + best = Some((width, m)); + } + } + } + best.map(|b| b.1) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use vectorcraft_geom::{Rect, SubPath, shapes}; + + fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape { + Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key) + } + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64, key: u64) -> Shape { + Shape::new(shapes::rectangle(Rect::new(x0, y0, x1, y1)), FillRule::NonZero, key) + } + + fn area(r: &Region) -> f64 { + r.path.to_bezpath().area() + } + + fn at(faces: &[Region], x: f64, y: f64) -> Vec<&Region> { + faces.iter().filter(|f| f.contains(Point::new(x, y))).collect() + } + + #[test] + fn crossing_lines_enclose_a_face() { + // Three lines crossing pairwise, their ends sticking out: one triangle. + let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 1), line((-10.0, 80.0), (110.0, 80.0), 2)]; + let (faces, edges) = live_paint(&s); + assert_eq!(faces.len(), 1, "{faces:?}"); + let f = &faces[0]; + assert!((area(f) - 900.0).abs() < 1e-6, "triangle (20,80) (50,50) (80,80): {}", area(f)); + assert!(f.sources.is_empty(), "nothing fills it yet"); + assert_eq!(f.path.subpaths.len(), 1); + assert_eq!(f.path.subpaths[0].anchors.len(), 3, "the loose ends are not part of it"); + // Each line is cut where the others cross it: 3 + 3 + 3 pieces. + assert_eq!(edges.len(), 9); + assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3); + } + + #[test] + fn a_grid_of_lines_makes_its_cells() { + let mut s = vec![]; + for k in 0..4 { + let v = f64::from(k) * 10.0; + s.push(line((v, -5.0), (v, 35.0), 0)); + s.push(line((-5.0, v), (35.0, v), 1)); + } + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 9); + assert!(faces.iter().all(|f| (area(f) - 100.0).abs() < 1e-6)); + assert_eq!(at(&faces, 15.0, 15.0).len(), 1); + } + + #[test] + fn a_line_across_a_shape_splits_it() { + let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)]; + let (faces, edges) = live_paint(&s); + assert_eq!(faces.len(), 2); + assert!(faces.iter().all(|f| f.sources == [0]), "both halves keep the rectangle's fill"); + assert!((faces.iter().map(area).sum::() - 5000.0).abs() < 1e-6); + assert_eq!(at(&faces, 10.0, 25.0).len(), 1); + assert_eq!(at(&faces, 90.0, 25.0).len(), 1); + // The rectangle's outline in two pieces, the line in three. + assert_eq!(edges.iter().filter(|e| e.key == 7).count(), 2); + assert_eq!(edges.iter().filter(|e| e.key == 8).count(), 3); + } + + #[test] + fn overlapping_shapes_keep_their_coverage() { + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 3); + assert_eq!(at(&faces, 25.0, 50.0)[0].sources, [0]); + assert_eq!(at(&faces, 75.0, 50.0)[0].sources, [0, 1]); + assert_eq!(at(&faces, 125.0, 50.0)[0].sources, [1]); + // Same faces as the Pathfinder regions. + let regions = crate::regions(&s); + assert_eq!(regions.len(), 3); + for f in &faces { + assert!(regions.iter().any(|r| r.sources == f.sources && (area(r).abs() - area(f)).abs() < 1e-6)); + } + } + + #[test] + fn a_closed_loop_of_lines_inside_a_shape_is_a_hole_and_a_face() { + let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)]; + let sq = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0), (40.0, 40.0)]; + for w in sq.windows(2) { + s.push(line(w[0], w[1], 1)); + } + // A loose line inside: no face of its own, no hole. + s.push(line((10.0, 10.0), (20.0, 20.0), 2)); + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 2, "{faces:?}"); + let outer = at(&faces, 5.0, 5.0); + assert_eq!(outer.len(), 1); + assert_eq!(outer[0].path.subpaths.len(), 2, "the inner square is a hole in it"); + assert!((area(outer[0]) - 9600.0).abs() < 1e-6); + let inner = at(&faces, 50.0, 50.0); + assert_eq!(inner.len(), 1, "the hole doesn't count as the outer face"); + assert_eq!(inner[0].sources, [0], "the rectangle fills it"); + assert!((area(inner[0]) - 400.0).abs() < 1e-6); + } + + #[test] + fn an_area_enclosed_by_shapes_is_an_unfilled_face() { + // A frame of four bars around a 50 × 50 gap. + let s = [rect(0.0, 0.0, 70.0, 10.0, 0), rect(0.0, 60.0, 70.0, 70.0, 0), rect(0.0, 0.0, 10.0, 70.0, 0), rect(60.0, 0.0, 70.0, 70.0, 0)]; + let (faces, _) = live_paint(&s); + let gap = at(&faces, 35.0, 35.0); + assert_eq!(gap.len(), 1); + assert!(gap[0].sources.is_empty()); + assert!((area(gap[0]) - 2500.0).abs() < 1e-6); + } + + #[test] + fn curves_and_a_circle_crossed_by_a_line() { + let circle = Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), FillRule::NonZero, 0); + let (faces, edges) = live_paint(&[circle, line((-10.0, 50.0), (110.0, 50.0), 1)]); + assert_eq!(faces.len(), 2); + for f in &faces { + assert!((area(f) - std::f64::consts::PI * 2500.0 / 2.0).abs() < 2.0, "{}", area(f)); + assert!(f.path.subpaths[0].anchors.len() <= 4, "no extra anchors from the sweep's splits"); + } + assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs"); + } + + #[test] + fn a_line_ending_on_an_edge_splits_the_shape() { + let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)]; + let (faces, _) = live_paint(&s); + assert_eq!(faces.len(), 2); + assert!((area(&faces[0]) + area(&faces[1]) - 400.0).abs() < 1e-6); + for f in &faces { + let p = crate::interior_point(&f.path).unwrap(); + assert_eq!(at(&faces, p.x, p.y).len(), 1, "{p:?}"); + assert_eq!(f.sources, [1]); + } + } + + #[test] + fn nothing_or_garbage_gives_an_empty_map() { + assert_eq!(live_paint(&[]), (vec![], vec![])); + assert!(live_paint(&[line((0.0, 0.0), (f64::NAN, 1.0), 0)]).0.is_empty()); + let (faces, edges) = live_paint(&[line((0.0, 0.0), (10.0, 10.0), 0)]); + assert!(faces.is_empty()); + assert_eq!(edges.len(), 1); + } + + fn arb_segment() -> impl Strategy { + let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0); + prop_oneof![ + (c(), c(), c(), c()).prop_map(|(a, b, x, y)| line((a, b), (x, y), 0)), + (c(), c(), 1..5i32, 1..5i32).prop_map(|(x, y, w, h)| rect(x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0, 0)), + (c(), c(), 1..5i32).prop_map(|(x, y, r)| { + let r = f64::from(r) * 10.0; + Shape::new(shapes::ellipse(Rect::new(x, y, x + r, y + r)), FillRule::EvenOdd, 0) + }), + ] + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + /// Faces never overlap, and a face's sources are exactly the inputs whose fill holds a + /// point inside it. + #[test] + fn faces_are_disjoint_and_know_what_covers_them(shapes in prop::collection::vec(arb_segment(), 1..7)) { + let (faces, _) = live_paint(&shapes); + for f in &faces { + prop_assert!(area(f) > 0.0); + let Some(p) = crate::interior_point(&f.path) else { continue }; + prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1, "{:?} in more than one face", p); + let covering: Vec = shapes + .iter() + .enumerate() + .filter(|(_, s)| s.path.is_closed() && vectorcraft_geom::hit::fill_contains(&s.path.to_bezpath(), s.rule, p)) + .map(|(i, _)| i) + .collect(); + prop_assert_eq!(&f.sources, &covering, "at {:?}", p); + } + } + + /// Closed shapes alone: the filled faces cover exactly the Pathfinder regions. + #[test] + fn filled_faces_are_the_pathfinder_regions(shapes in prop::collection::vec(arb_segment(), 1..6)) { + let closed: Vec = shapes.into_iter().filter(|s| s.path.is_closed()).collect(); + let (faces, _) = live_paint(&closed); + let filled: f64 = faces.iter().filter(|f| !f.sources.is_empty()).map(area).sum(); + let regions: f64 = crate::regions(&closed).iter().map(|r| area(r).abs()).sum(); + // Both refit split curves to [`DEFAULT_PRECISION`], so curved areas may differ by up + // to that much along every outline. + let outlines: f64 = closed.iter().map(|s| s.path.to_bezpath().perimeter(1e-6)).sum(); + prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions); + } + + /// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics. + #[test] + fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) { + let lines: Vec = pts.into_iter().map(|(a, b, x, y)| line((a, b), (x, y), 0)).collect(); + let (faces, edges) = live_paint(&lines); + prop_assert!(edges.len() >= lines.len().min(1)); + for f in &faces { + prop_assert!(f.sources.is_empty()); + if let Some(p) = crate::interior_point(&f.path) { + prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1); + } + } + } + } + + #[test] + fn many_lines_stay_quick() { + // 60 lines through a 100 pt square: about 900 crossings. + let lines: Vec = (0..60) + .map(|k| { + let t = f64::from(k) * 0.37; + line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0) + }) + .collect(); + let started = std::time::Instant::now(); + let (faces, _) = live_paint(&lines); + assert!(faces.len() > 500, "{}", faces.len()); + assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); + } +} diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 3a27c581..863dd101 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -243,9 +243,15 @@ pub fn hatch(path: &BezPath, gap: f64) -> BezPath { struct RegionCache { key: Option<(usize, Vec)>, regions: Vec<(po::Region, BezPath, Rect)>, + /// Live Paint faces (every path an edge, open ones included) rather than Shape Builder + /// regions (the filled areas of the shapes). + live: bool, } impl RegionCache { + fn live_paint() -> Self { + Self { live: true, ..Default::default() } + } fn get(&mut self, cx: &ToolContext) -> &[(po::Region, BezPath, Rect)] { let key = (cx.doc as *const Document as usize, cx.selection.objects.clone()); if self.key.as_ref() != Some(&key) { @@ -254,7 +260,8 @@ impl RegionCache { self.regions = if shapes.is_empty() { vec![] } else { - po::regions(&shapes) + let regions = if self.live { po::live_paint(&shapes).0 } else { po::regions(&shapes) }; + regions .into_iter() .filter_map(|r| { let bp = r.path.to_bezpath(); @@ -437,12 +444,17 @@ fn lp_overlays(h: &LpHover, cache: &RegionCache) -> Vec { /// 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. -#[derive(Default)] pub struct LivePaintBucketTool { cache: RegionCache, hover: LpHover, } +impl Default for LivePaintBucketTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None } + } +} + impl Tool for LivePaintBucketTool { fn id(&self) -> &'static str { "livePaintBucket" @@ -500,12 +512,17 @@ impl Tool for LivePaintBucketTool { // ---------- Live Paint Selection ---------- /// Live Paint Selection: click selects a face (Shift adds); Alt-click selects an edge. -#[derive(Default)] pub struct LivePaintSelectionTool { cache: RegionCache, hover: LpHover, } +impl Default for LivePaintSelectionTool { + fn default() -> Self { + Self { cache: RegionCache::live_paint(), hover: LpHover::None } + } +} + impl Tool for LivePaintSelectionTool { fn id(&self) -> &'static str { "livePaintSelection"