diff --git a/crates/pathops/README.md b/crates/pathops/README.md index 2a3d36c18..05c24b3fb 100644 --- a/crates/pathops/README.md +++ b/crates/pathops/README.md @@ -87,6 +87,21 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec `simplify` fits least-squares cubics with fixed end tangents, then reparameterizes with Newton steps and splits at the point of worst error. Any anchor that turns more than the corner threshold (30° by default) stays a corner. +## Limits + +Every boolean, Pathfinder operation, region map and normalization goes through one sweep, and refuses input it can't +sweep with a [`PathOpsError`](src/lib.rs) rather than answering with part of the art missing: + +- `NonFinite`: a coordinate is NaN or infinite. +- `Degenerate`: the coordinates are so large that the sweep's tolerance would pass one unit (`MAX_SWEEP_EPS`), so + features thinner than it would collapse. +- `TooComplex`: more than [`MAX_SEGMENT_PAIRS`](src/budget.rs) pairs of segments have overlapping bounds. They + are counted exactly before the sweep, in O(n log n). This bounds what the sweep is given, not how many times the + segments cross: a compact path that crosses itself many times can stay under it and still take seconds. + +Results are refitted within the precision (`DEFAULT_PRECISION` unless a `_with` variant is given) and contours +thinner than it on average are dropped as slivers (see Booleans above). + ## Caveats - linesweeper is still in beta. When two identical curves overlap, the sweep can leave a tiny lens between them; the sliver filter removes it. diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index adba3bde3..c6a8fc7a9 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,4 +1,4 @@ -use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, resolved, rings, signed_area}; +use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings}; use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. @@ -403,8 +403,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio if o.depth <= 0.0 { return plane_faces(path, rule, bounds, view, mesh); } - let (path, rule) = resolved(path, rule); - let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; let camera = Camera::new(view, bounds, o.depth); let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) }; let steps = steps(&o); @@ -462,8 +461,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul /// [`plane`], or with `mesh` its face before projection instead of its facet. pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; - let (path, rule) = resolved(path, rule); - let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index a220884b1..40d70a79d 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,6 +1,6 @@ use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; -use super::rings::{MAX_SOLID_POINTS, resolved, rings, signed_area}; +use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. @@ -18,8 +18,7 @@ struct Puff { /// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area. fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result, String> { - let (path, rule) = resolved(path, rule); - let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; if rings.is_empty() { return Ok(None); } diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 123fc9ee2..e63d20cb7 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -25,17 +25,107 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { const DEBRIS: f64 = 1e-6; /// `path` with its overlaps resolved under `rule`: simple contours that do not cross, -/// holes running against their outlines. Type often draws one glyph as overlapping contours (a -/// "k"'s arm across its stem), and [`rings`] tells a contour's side from one point of it, so a -/// contour that crosses another must be cut where they meet first. Every contour that fills area -/// is kept, however narrow ([`DEBRIS`]). A path the boolean refuses is used as it is. -pub(super) fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule) { +/// holes running against their outlines. Every contour that fills area is kept, however narrow +/// ([`DEBRIS`]). A path the boolean refuses is used as it is. +fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule) { match vectorcraft_pathops::try_unite_all_with(&[(path, rule)], DEBRIS) { Ok(p) if !p.subpaths.is_empty() => (p, FillRule::NonZero), _ => (path.clone(), rule), } } +/// The rings of a solid's profile, as [`rings`], with crossing contours resolved first. Type often +/// draws one glyph as overlapping contours (a "k"'s arm across its stem), and [`rings`] tells a +/// contour's side from one point of it, so a contour that meets another, or itself, must be cut +/// where they meet. Only then does the profile go through the boolean ([`resolved`]): most art +/// has no such contours, and the boolean costs more than the rest of the solid. The `limit` is +/// checked on the profile as it is, before the boolean, and again on what it gives. +pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { + let sampled = sample(path, size, limit)?; + if !edges_meet(&sampled) { + return orient(sampled, rule); + } + let (path, rule) = resolved(path, rule); + rings(&path, rule, size, limit) +} + +/// Edges closer than this meet: the floor of the boolean's own snapping, so contours it would join +/// are resolved and contours it would leave apart are not. +const TOUCH: f64 = 1e-6; + +/// Most pairs of edges [`edges_meet`] compares. Past it the answer is yes, and the boolean, with +/// its own budget, decides. +const MAX_EDGE_PAIRS: usize = 1 << 22; + +/// Do any two edges of `rings` meet, within [`TOUCH`]: a contour crossing, touching or running +/// along another, or itself? Neighbouring edges of a ring share a corner and meet only where one +/// folds back along the other. Edges are swept in order of their left ends, so only pairs whose +/// bounds overlap across x are compared. +pub(super) fn edges_meet(rings: &[Vec]) -> bool { + struct Edge { + ring: usize, + index: usize, + a: Point, + b: Point, + x0: f64, + x1: f64, + } + let mut edges: Vec = rings + .iter() + .enumerate() + .flat_map(|(ring, r)| { + r.iter().zip(r.iter().cycle().skip(1)).enumerate().map(move |(index, (a, b))| Edge { + ring, + index, + a: *a, + b: *b, + x0: a.x.min(b.x), + x1: a.x.max(b.x), + }) + }) + .collect(); + edges.sort_by(|e, f| e.x0.total_cmp(&f.x0)); + let len = |ring: usize| rings.get(ring).map_or(0, Vec::len); + let mut pairs = 0usize; + for (i, e) in edges.iter().enumerate() { + for f in edges.iter().skip(i + 1) { + if f.x0 > e.x1 + TOUCH { + break; + } + pairs += 1; + if pairs > MAX_EDGE_PAIRS { + return true; + } + if e.a.y.min(e.b.y) > f.a.y.max(f.b.y) + TOUCH || f.a.y.min(f.b.y) > e.a.y.max(e.b.y) + TOUCH { + continue; + } + let n = len(e.ring); + let neighbours = e.ring == f.ring && ((e.index + 1) % n == f.index || (f.index + 1) % n == e.index); + if neighbours { + // They share a corner; they meet only if one runs back along the other. + let (shared, p, q) = if e.b == f.a { (e.b, e.a, f.b) } else { (e.a, e.b, f.a) }; + if super::build::segment_distance(q, shared, p) <= TOUCH || super::build::segment_distance(p, shared, q) <= TOUCH { + return true; + } + } else if segments_meet(e.a, e.b, f.a, f.b) { + return true; + } + } + } + false +} + +/// Do segments `a`–`b` and `c`–`d` cross, or come within [`TOUCH`] of each other? +fn segments_meet(a: Point, b: Point, c: Point, d: Point) -> bool { + let side = |p: Point, q: Point, r: Point| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x); + let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); + if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) { + return true; + } + use super::build::segment_distance as dist; + dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= TOUCH +} + /// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer /// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves /// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take @@ -45,6 +135,12 @@ pub(super) fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule) /// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would /// if nesting alone decided (#1724). pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { + orient(sample(path, size, limit)?, rule) +} + +/// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as +/// its subpath does. +fn sample(path: &PathData, size: f64, limit: usize) -> Result>, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -79,6 +175,12 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> out.push(ring); } } + Ok(out) +} + +/// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero +/// `rule` without the rings that are no edge of the shape, as [`rings`] describes. +fn orient(mut out: Vec>, rule: FillRule) -> Result>, String> { // Which rings hold which: each ring against the others, its edges walked only where its // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair // whose bounds miss the point costs a step too: many small rings are a square of pairs). diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index d2ae291c5..dfaff29ec 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -777,7 +777,9 @@ fn crossing_contours_make_one_outline() { let f = extrude(&k, rule, b, Extrude::default(), front()).unwrap(); assert_eq!(f.len(), 1, "{rule:?}: straight on, only the front cap shows"); assert!(close(area(&f), filled, 1e-6), "{rule:?}: Extrude's cap"); - assert!(inflate(&k, rule, b, Inflate::default(), front()).is_ok_and(|f| close(extent(&f).width(), 70.0, 1e-6)), "{rule:?}: Inflate"); + let puffed = inflate(&k, rule, b, Inflate::default(), front()).unwrap(); + assert_eq!(extent(&puffed), b, "{rule:?}: Inflate covers the whole k"); + assert!(close(area(&puffed), filled, filled * 1e-3), "{rule:?}: Inflate's front, {} against {filled}", area(&puffed)); } // The filled area is the union's: the stem, the arm past it and the leg past the arm. assert!(close(vectorcraft_pathops::area(&k, FillRule::NonZero), 20.0 * 100.0 + 40.0 * 20.0 + 20.0 * 50.0 - 10.0 * 10.0, 1e-6)); @@ -790,13 +792,22 @@ fn resolving_overlaps_keeps_narrow_contours() { let rect = |r: Rect| { vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true) }; + // A hairline beside a square, and two bars crossing elsewhere so the profile is resolved. let hairline = Rect::new(120.0, 0.0, 120.005, 50.0); - let p = PathData::new(vec![rect(Rect::new(0.0, 0.0, 100.0, 100.0)), rect(hairline)]); + let p = PathData::new(vec![ + rect(Rect::new(0.0, 0.0, 100.0, 100.0)), + rect(hairline), + rect(Rect::new(140.0, 40.0, 200.0, 60.0)), + rect(Rect::new(160.0, 20.0, 180.0, 80.0)), + ]); let b = p.bounds().unwrap(); - let filled = 100.0 * 100.0 + hairline.width() * hairline.height(); + let filled = |rule: FillRule| { + let bars = if rule == FillRule::NonZero { 60.0 * 20.0 + 20.0 * 40.0 } else { 60.0 * 20.0 + 20.0 * 40.0 - 20.0 * 20.0 }; + 100.0 * 100.0 + hairline.width() * hairline.height() + bars + }; for rule in [FillRule::NonZero, FillRule::EvenOdd] { - assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate"); - assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled, 1e-6), "{rule:?}: Extrude's cap"); + assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Rotate"); + assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Extrude's cap"); } // A circle drawn twice, exactly and a hair off, is one outline: no sliver between the copies. let circle = |cx: f64, cy: f64, r: f64| { @@ -814,6 +825,71 @@ fn resolving_overlaps_keeps_narrow_contours() { twice.extend(circle(50.0 + dx, 50.0, 40.0).iter()); let p = PathData::from_bezpath(&twice); let f = plane(&p, FillRule::NonZero, p.bounds().unwrap(), front()).unwrap(); + assert_eq!(f.len(), 1, "{dx}"); assert_eq!(f[0].contours.len(), 1, "{dx}"); + let once = PathData::from_bezpath(&circle(50.0, 50.0, 40.0)); + let one = area(&plane(&once, FillRule::NonZero, once.bounds().unwrap(), front()).unwrap()); + assert!(close(area(&f), one, one * 1e-6), "{dx}: the area of one circle, {} against {one}", area(&f)); } } + +/// Only a profile whose contours meet goes through the boolean: crossing, touching, running along +/// another or folding back on itself. Separate and nested contours are taken as they are. +#[test] +fn contours_meet_when_they_cross_touch_or_fold_back() { + let ring = |pts: &[(f64, f64)]| pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(); + let square = |x: f64, y: f64, s: f64| ring(&[(x, y), (x + s, y), (x + s, y + s), (x, y + s)]); + assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0)]), "apart"); + assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(3.0, 3.0, 4.0)]), "nested, as an o's hole"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)]), "crossing"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]), "sharing an edge"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 3.0, 4.0)]), "along part of an edge"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 10.0, 4.0)]), "at a corner"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0 + 1e-7, 0.0, 10.0)]), "within the boolean's snapping"); + assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])]), "a bowtie crossing itself"); + assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (10.0, 5.0), (0.0, 10.0)])]), "folding back on itself"); + // Squares sharing an edge inflate as one puff, not two with a seam between them. + let shared = PathData::new( + [Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(50.0, 0.0, 100.0, 50.0)] + .iter() + .map(|r| { + vectorcraft_geom::SubPath::polyline( + &[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], + true, + ) + }) + .collect(), + ); + let whole = PathData::from_bezpath(&{ + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((100.0, 0.0)); + b.line_to((100.0, 50.0)); + b.line_to((0.0, 50.0)); + b.close_path(); + b + }); + let b = whole.bounds().unwrap(); + let puff = |p: &PathData| inflate(p, FillRule::NonZero, b, Inflate::default(), front()).unwrap().len(); + assert_eq!(puff(&shared), puff(&whole)); +} + +/// A profile the boolean can't resolve is used as it is: one with no area builds nothing, as it did, +/// and one with an invalid coordinate is refused, as it was. +#[test] +fn a_profile_the_boolean_refuses_is_taken_as_it_is() { + let flat = + PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true)]); + let crossing_flat = PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 20.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)], true), + ]); + let b = Rect::new(0.0, 0.0, 20.0, 20.0); + for p in [&flat, &crossing_flat] { + assert_eq!(area(&plane(p, FillRule::NonZero, b, front()).unwrap()), 0.0); + assert_eq!(area(&extrude(p, FillRule::NonZero, b, Extrude::default(), front()).unwrap()), 0.0); + } + let mut bad = crossing_flat.clone(); + bad.subpaths[0].anchors[1].p = Point::new(f64::NAN, 10.0); + assert_eq!(plane(&bad, FillRule::NonZero, b, front()).unwrap_err(), "The shape contains invalid coordinates"); +} diff --git a/docs/three-d.md b/docs/three-d.md index 4fd658ba0..a85062dd2 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -17,6 +17,11 @@ Preview changes are temporary until OK; Cancel restores the document; OK is one 3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying another. +Each solid follows its shape as it is filled. Separate and nested contours are built as drawn. Contours that cross, +touch or run along each other or themselves, as type often draws a glyph, are first merged into one outline by +[`vectorcraft-pathops`](../crates/pathops/README.md), keeping contours however narrow. All three effects then share +its [limits](../crates/pathops/README.md#limits): a shape it refuses is built from its contours as drawn, where it can be. + ## 3D and Materials panel **Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the @@ -62,8 +67,7 @@ object is where the art was. | Bevel Inside | The bevel carved into the shape (the front face shrinks) instead of added around it (the walls stand outside the art). | | Bevel both sides | The back is bevelled too. | -Holes stay holes, with walls of their own. Contours that cross or overlap, as type often draws a glyph, are merged -first, so the solid follows the shape as it is filled. Bevels move each corner along its bisector (mitres are limited to four +Holes stay holes, with walls of their own. Bevels move each corner along its bisector (mitres are limited to four times the width), so a bevel wider than a narrow part of the shape folds over itself. ## Inflate @@ -229,7 +233,9 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. effect per object. - Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is - counted on its own), Rotate (which only turns the outline) 65,536. + counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, + when its contours meet, again on the merged outline, which can have more points: bars crossing in a grid trace + every hole between them. - **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate, unbuilt items. @@ -239,6 +245,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | | 2026-10-17 | Smooth colored Inflate lighting and surface batching; legacy white-light appearance and the 0.05 seam minimum preserved; shared light Color and multiple lights remain vector output. |