From 9bd8b69613ae63823a156f58beab9e323b0052ad Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Sat, 7 Nov 2026 22:16:50 -0700 Subject: [PATCH 1/5] 3D effects: resolve overlapping contours before building a solid Extrude & Bevel, Inflate and Rotate broke on shapes whose contours cross. Type often draws a glyph that way: Roboto Slab's "k" has its arm and leg as one contour that crosses both the stem and the leg's foot serif. rings() decided each contour's role (outline, hole, or no edge at all under nonzero) from the contour's first point only. A contour that starts inside another was dropped or flipped into a hole, cutting part of the glyph away. A contour that starts outside was kept whole, so its walls and bevels ran through the overlap and the overlapping parts drew as separate boxes. The profile now goes through vectorcraft_pathops::try_normalize first, so rings() only ever sees simple contours that do not cross. A path the boolean refuses is used as it was. --- crates/three-d/src/solid/build.rs | 8 +++++--- crates/three-d/src/solid/inflate.rs | 5 +++-- crates/three-d/src/solid/rings.rs | 12 ++++++++++++ crates/three-d/src/solid/tests.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 7fbeb3e8c..adba3bde3 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, rings, signed_area}; +use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, resolved, rings, signed_area}; 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,7 +403,8 @@ 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 rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let (path, rule) = resolved(path, rule); + let rings = 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); @@ -461,7 +462,8 @@ 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 rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let (path, rule) = resolved(path, rule); + let rings = 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 9f1f5c3f7..a220884b1 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, rings, signed_area}; +use super::rings::{MAX_SOLID_POINTS, resolved, rings, signed_area}; 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,7 +18,8 @@ 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 rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let (path, rule) = resolved(path, rule); + let rings = 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 44a8590ac..ac63fe2fa 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -19,6 +19,18 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } } +/// `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. A path the boolean refuses is +/// used as it is. +pub(super) fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule) { + match vectorcraft_pathops::try_normalize(path, rule) { + Ok(p) if !p.subpaths.is_empty() => (p, FillRule::NonZero), + _ => (path.clone(), rule), + } +} + /// 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 diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 0f78aa712..859bf703d 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -754,3 +754,31 @@ fn many_small_rings_far_apart_are_too_complex() { let e = rings(&p, FillRule::NonZero, r.width().max(r.height()), usize::MAX).unwrap_err(); assert!(e.contains("too complex"), "{e}"); } + +/// Type often draws a glyph as contours that cross (a "k"'s arm across its stem, its leg across +/// the arm). Each contour's side was told from its first point, so one starting inside another +/// was dropped or cut as a hole, and the parts left were walled where they overlapped. The +/// outline is the union the art fills, under either rule. +#[test] +fn crossing_contours_make_one_outline() { + 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) + }; + // The stem, an arm starting inside it, and a leg starting inside the arm. + let k = PathData::new(vec![ + rect(Rect::new(0.0, 0.0, 20.0, 100.0)), + rect(Rect::new(10.0, 40.0, 60.0, 60.0)), + rect(Rect::new(50.0, 50.0, 70.0, 100.0)), + ]); + let b = k.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let filled = vectorcraft_pathops::area(&k, rule); + assert!(close(area(&plane(&k, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate"); + 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"); + } + // 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)); +} From 5bd046ef64db6027a7eb40f604c158578430a9b2 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Sun, 8 Nov 2026 07:01:41 -0700 Subject: [PATCH 2/5] Docs: 3D solids merge crossing contours before building --- docs/three-d.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/three-d.md b/docs/three-d.md index bb0995315..4fd658ba0 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -62,7 +62,8 @@ 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. Bevels move each corner along its bisector (mitres are limited to four +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 times the width), so a bevel wider than a narrow part of the shape folds over itself. ## Inflate From e1108210f819c5c04a72d00c2c7891d223b1f6a6 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Sun, 8 Nov 2026 07:13:11 -0700 Subject: [PATCH 3/5] 3D effects: keep narrow contours when resolving overlaps try_normalize refits its result at Pathfinder's default precision and drops every contour thinner than 0.01 pt on average as a sliver. A hairline beside a larger shape is art, not debris, and resolved() only fell back to the input when nothing at all was left, so the hairline lost its cap, walls and surface. Resolve with try_unite_all_with at a precision far below any art, which still drops the debris a boolean leaves where curves coincide: a circle drawn twice, exactly or 1e-7 pt off, stays one outline. --- crates/three-d/src/solid/rings.rs | 11 +++++++--- crates/three-d/src/solid/tests.rs | 35 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index ac63fe2fa..123fc9ee2 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -19,13 +19,18 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } } +/// Contours thinner than this on average (twice their area over their perimeter) are dropped when +/// overlaps are resolved: the debris a boolean leaves where two curves coincide, far below any +/// art. Pathfinder's default precision would drop real hairlines too. +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. A path the boolean refuses is -/// used as it is. +/// 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) { - match vectorcraft_pathops::try_normalize(path, rule) { + match vectorcraft_pathops::try_unite_all_with(&[(path, rule)], DEBRIS) { Ok(p) if !p.subpaths.is_empty() => (p, FillRule::NonZero), _ => (path.clone(), rule), } diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 859bf703d..d2ae291c5 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -782,3 +782,38 @@ fn crossing_contours_make_one_outline() { // 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)); } + +/// Resolving overlaps keeps every contour that fills area, however narrow: a hairline beside a +/// large shape is art. What a boolean leaves where two curves coincide is still dropped. +#[test] +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) + }; + 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 b = p.bounds().unwrap(); + let filled = 100.0 * 100.0 + hairline.width() * hairline.height(); + 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"); + } + // 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| { + let (mut b, k) = (BezPath::new(), 0.5523 * r); + b.move_to((cx + r, cy)); + b.curve_to((cx + r, cy + k), (cx + k, cy + r), (cx, cy + r)); + b.curve_to((cx - k, cy + r), (cx - r, cy + k), (cx - r, cy)); + b.curve_to((cx - r, cy - k), (cx - k, cy - r), (cx, cy - r)); + b.curve_to((cx + k, cy - r), (cx + r, cy - k), (cx + r, cy)); + b.close_path(); + b + }; + for dx in [0.0, 1e-7] { + let mut twice = circle(50.0, 50.0, 40.0); + 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[0].contours.len(), 1, "{dx}"); + } +} From 62a01689f069e80f24d8452017948804bb8262bc Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Sun, 8 Nov 2026 07:55:40 -0700 Subject: [PATCH 4/5] 3D effects: resolve a profile only when its contours meet The boolean that merges crossing contours cost about as much as the rest of a plain extrusion, and it ran on every profile. Most art has no contours that meet, so the profile is now sampled first (its point limit checked there, before any boolean) and resolved only when two edges cross, touch, run along each other or fold back, in one contour or across two. Edges are swept by their left ends, within a pair budget past which the boolean decides. Touching counts: squares sharing an edge inflate as one puff, not two with a seam. On a release build, Extrude of 120 separate contours takes 0.31 ms instead of 4.18 ms; profiles that do cross cost what they did. Tests: the meeting cases, squares sharing an edge, the fallback for a profile the boolean refuses, Inflate's extent and area for the crossing k, and the coincident circles' area. The hairline test now holds a crossing pair so that its profile still goes through the boolean. Docs: the merge is described once for all three effects, linking to a new Limits section in the pathops README instead of repeating its limits, and the point limit notes that a merged outline can have more points. --- crates/pathops/README.md | 15 ++++ crates/three-d/src/solid/build.rs | 8 +- crates/three-d/src/solid/inflate.rs | 5 +- crates/three-d/src/solid/rings.rs | 112 ++++++++++++++++++++++++++-- crates/three-d/src/solid/tests.rs | 86 +++++++++++++++++++-- docs/three-d.md | 13 +++- 6 files changed, 218 insertions(+), 21 deletions(-) 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. | From bc87fd1b7e0483485c1cdb8a512dfecaa3a9a98b Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Sun, 8 Nov 2026 09:21:45 -0700 Subject: [PATCH 5/5] 3D effects: merged contours keep their parts, and edge cases are consistent The boolean's answer went through orient(), which judges each ring's side from its first point. The boolean often starts a contour where it touches another, so a triangle whose tip rests on a square's edge was dropped, and an even-odd hole touching its outline was filled. The boolean already runs holes against their outlines, so its rings are now only turned as a whole. Other fixes: - A bowtie drawn as one contour has no area of its own. It was dropped before the meet check and built nothing, although it fills both lobes. Sampling now keeps rings with no area, and orient() drops them after the check. Flat rings, every point on one line, fill nothing and are still dropped, so a stray flat contour doesn't send separate art through the boolean. - An open subpath was closed along its end handles when the profile went through the boolean, but straight otherwise, as the renderer fills it. It is now closed straight before the boolean too. - An empty union was taken as a refusal. Contours that cancel (a square drawn twice under even-odd) built a square. They now build nothing. - Contours meet within twice the boolean's own sweep tolerance, computed by its rule from every anchor and handle of the path it is given. pathops snaps anchors onto nearby heights before the sweep joins edges within that tolerance, so contours up to about 1.4 times it apart can come out as one; within twice it, every contour it might join goes through it, even far from the origin, and the few it leaves apart cost a boolean that builds the same shape. - A one-anchor subpath draws nothing, so it is skipped, as the renderer and the boolean skip it. - A profile the boolean refuses reuses the rings already sampled. Tests: - touching parts and holes; - an open subpath with handles, alone and beside crossing bars; - a flat contour beside separate squares; - contours joined far from the origin, including by handles alone, and contours a few millionths apart near it kept apart; - triangles snapped together and joined at 1.4 times the tolerance, the same triangles left apart and kept whole, and the factor pinned between 1.9 and 2.2 times, with the worst case just past twice the tolerance left apart by pathops; - a 24-bar grid: too complex for Extrude and Inflate, whole under Rotate; - the merged rings turned as a whole; - a one-anchor loop; - the bowtie and the cancelling squares under all three effects; - a 3,001-point star that the boolean refuses (TooComplex), built as drawn. Docs: the pathops README's Limits section now says only the try_ functions refuse, lists OpenPath and the caught panic, and describes unite_all's answers, and which calls take a precision. three-d.md and effects-parity.md (the 3D row, its history and status line) describe the merge and its limits. --- crates/pathops/README.md | 16 +- crates/three-d/src/solid/rings.rs | 144 +++++++++++---- crates/three-d/src/solid/tests.rs | 289 ++++++++++++++++++++++++++++-- docs/effects-parity.md | 5 +- docs/three-d.md | 11 +- 5 files changed, 408 insertions(+), 57 deletions(-) diff --git a/crates/pathops/README.md b/crates/pathops/README.md index 05c24b3fb..08380830b 100644 --- a/crates/pathops/README.md +++ b/crates/pathops/README.md @@ -89,18 +89,24 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec ## 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: +Every boolean, Pathfinder operation, region map and normalization goes through one sweep. The `try_` functions +(`try_boolean`, `try_normalize`, `try_unite_all`, …) refuse input it can't sweep with a [`PathOpsError`](src/lib.rs) +rather than answering with part of the art missing. The others have no error to return: `boolean`, `normalize` and +`area` answer an empty path or 0, and `unite_all` leaves a refused input out of the union, or answers an empty path +when the inputs together are refused. - `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. + features thinner than it would collapse. A panic inside the sweep on rare near-degenerate input is caught and + reported as `Degenerate` too (on native builds; on the web a panic aborts). +- `OpenPath`: the sweep could not form closed contours from what it was given (open subpaths are closed before it). - `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). +Results are refitted within the precision (`DEFAULT_PRECISION`, unless `try_boolean`, `unite_all_with`, +`try_unite_all_with` or `pathfinder_with` is given one) and contours thinner than it on average are dropped as slivers +(see Booleans above). ## Caveats diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index e63d20cb7..81e67cc03 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -22,46 +22,108 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { /// Contours thinner than this on average (twice their area over their perimeter) are dropped when /// overlaps are resolved: the debris a boolean leaves where two curves coincide, far below any /// art. Pathfinder's default precision would drop real hairlines too. -const DEBRIS: f64 = 1e-6; +pub(super) const DEBRIS: f64 = 1e-6; -/// `path` with its overlaps resolved under `rule`: simple contours that do not cross, -/// 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 +/// The rings of a solid's profile, as [`rings`], with contours that meet merged 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. +/// where they meet. Most art has no such contours, and the boolean costs more than the rest of the +/// solid, so it runs only when [`edges_meet`] finds some. Its answer already runs holes against +/// their outlines, so it is only turned as a whole ([`wound`]), never judged a ring at a time. +/// +/// The `limit` is checked on the profile as drawn, before the boolean, and again on the merged +/// outline, which can have more points (bars crossing in a grid trace every hole between them): +/// past it the shape is too complex, since its contours as drawn cross and can't be built as they +/// are. A profile the boolean refuses is built from its contours as drawn, as it was before +/// contours were merged. An empty answer is an answer: contours that cancel (a square drawn twice +/// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built. 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) { + let closed = closed_straight(path); + if !meets(&sampled, &closed) { return orient(sampled, rule); } - let (path, rule) = resolved(path, rule); - rings(&path, rule, size, limit) + match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { + Ok(merged) => sample(&merged, size, limit).map(wound), + Err(_) => orient(sampled, rule), + } } -/// 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; +/// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must +/// go through the boolean? Within twice the boolean's tolerance ([`touch`]): pathops first snaps +/// anchors onto nearby heights (by less than the tolerance), then the sweep joins edges within it +/// along the sweep line, so straight edges a little more than it apart can come out as one: at +/// most sin θ + cos θ ≤ √2 times it, θ the edges' angle to the sweep line (about 1.4 at 45°, +/// measured; the same when pathops turns the sweep a quarter turn, which it does for some art of +/// 2,048 segments or more). Straight contours it might join are merged; those it leaves apart cost +/// a boolean that builds the same shape. Curves are judged on their sampled outline (see +/// [`sample`]), which can lie a little inside them (up to the sampling tolerance, or more for a +/// long curve held to 32 pieces), so curves that touch or overlap by less than that can be judged +/// apart and built as drawn. +pub(super) fn meets(sampled: &[Vec], closed: &PathData) -> bool { + edges_meet(sampled, 2.0 * touch(closed)) +} + +/// `path` as the boolean should take it: each open subpath closed by a straight line, as [`sample`] +/// and the renderer's fill close it (the boolean would close it along the end anchors' handles). +/// Borrowed when nothing needs changing: every subpath closed, a single anchor (which the boolean +/// skips), or open with no handle on the closing side of either end anchor (which the boolean +/// already closes straight). +pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> { + // The closing segment (last anchor to first) as the boolean will draw it once closed; the + // length test comes first, so a subpath of fewer than two anchors never reaches the index. + let straight = |sp: &vectorcraft_geom::SubPath| sp.closed || sp.anchors.len() < 2 || sp.segment_is_line(sp.anchors.len() - 1); + if path.subpaths.iter().all(straight) { + return std::borrow::Cow::Borrowed(path); + } + let mut path = path.clone(); + for sp in path.subpaths.iter_mut().filter(|sp| !sp.closed) { + if let Some(last) = sp.anchors.last_mut() { + last.h_out = last.p; + } + if let Some(first) = sp.anchors.first_mut() { + first.h_in = first.p; + } + sp.closed = true; + } + std::borrow::Cow::Owned(path) +} + +/// The boolean's rings turned so the filled side is on each one's left. Its holes already run +/// against their outlines, so only the direction of the whole is chosen: the one whose rings +/// enclose positive area in all. +pub(super) fn wound(mut rings: Vec>) -> Vec> { + rings.retain(|r| signed_area(r).abs() > 1e-9); + if rings.iter().map(|r| signed_area(r)).sum::() < 0.0 { + rings.iter_mut().for_each(|r| r.reverse()); + } + rings +} + +/// The boolean's sweep tolerance for `path` as it is given to it (closed by [`closed_straight`]): +/// 64 times the rounding of the largest coordinate it reads, every anchor and handle of each +/// subpath of two anchors or more, and at least 1e-6 (pathops' `eps_for`). For a path [`sample`] +/// accepts (every point finite). +pub(super) fn touch(path: &PathData) -> f64 { + let m = path + .subpaths + .iter() + .filter(|sp| sp.anchors.len() >= 2) + .flat_map(|sp| &sp.anchors) + .flat_map(|a| [a.p, a.h_in, a.h_out]) + .fold(0.0f64, |m, q| m.max(q.x.abs()).max(q.y.abs())); + (m * f64::EPSILON * 64.0).max(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 +/// Do any two edges of `rings` meet, within `touch` (at that distance or closer): 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 { +pub(super) fn edges_meet(rings: &[Vec], touch: f64) -> bool { struct Edge { ring: usize, index: usize, @@ -89,14 +151,14 @@ pub(super) fn edges_meet(rings: &[Vec]) -> bool { 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 { + 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 { + 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); @@ -104,10 +166,10 @@ pub(super) fn edges_meet(rings: &[Vec]) -> bool { 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 { + 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) { + } else if segments_meet(e.a, e.b, f.a, f.b, touch) { return true; } } @@ -115,15 +177,15 @@ pub(super) fn edges_meet(rings: &[Vec]) -> bool { 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 { +/// 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, touch: f64) -> 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 + 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 @@ -140,12 +202,13 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> /// 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> { +pub(super) 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![]; - for sp in &path.subpaths { - let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + // A subpath of one anchor draws nothing, closed or not (the renderer and the boolean skip it). + for sp in path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) { + let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; if segments > limit { return Err("The shape is too complex for a 3D effect".into()); } @@ -171,7 +234,11 @@ fn sample(path: &PathData, size: f64, limit: usize) -> Result>, S while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { ring.pop(); } - if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 { + // Rings with no area are kept here: a contour that crosses itself into lobes that cancel + // (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat + // ring, every point on one line, fills nothing under either rule and is left out now, so + // that it doesn't send the profile through the boolean. + if ring.len() >= 3 && !flat(&ring) { out.push(ring); } } @@ -181,6 +248,7 @@ fn sample(path: &PathData, size: f64, limit: usize) -> Result>, S /// `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> { + out.retain(|r| signed_area(r).abs() > 1e-9); // 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). @@ -237,6 +305,14 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S /// the others, so many rings of many points each would otherwise take billions of steps. const MAX_CONTAINMENT_EDGES: usize = 50_000_000; +/// Does every point of `ring` lie on one line (within 1e-9, as [`sample`] merges points)? +fn flat(ring: &[Point]) -> bool { + let Some(&a) = ring.first() else { return true }; + let Some(&b) = ring.iter().max_by(|p, q| a.distance(**p).total_cmp(&a.distance(**q))) else { return true }; + let len = a.distance(b); + len <= 1e-9 || ring.iter().all(|p| ((b - a).cross(*p - a) / len).abs() <= 1e-9) +} + pub(super) fn signed_area(r: &[Point]) -> f64 { r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 } diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index dfaff29ec..c48b8f276 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -839,15 +839,15 @@ fn resolving_overlaps_keeps_narrow_contours() { 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"); + assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0)], 1e-6), "apart"); + assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(3.0, 3.0, 4.0)], 1e-6), "nested, as an o's hole"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)], 1e-6), "crossing"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], 1e-6), "sharing an edge"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 3.0, 4.0)], 1e-6), "along part of an edge"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 10.0, 4.0)], 1e-6), "at a corner"); + assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0 + 1e-7, 0.0, 10.0)], 1e-6), "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)])], 1e-6), "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)])], 1e-6), "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)] @@ -874,10 +874,10 @@ fn contours_meet_when_they_cross_touch_or_fold_back() { 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. +/// Flat contours fill nothing and build nothing, crossing or not; an invalid coordinate is refused, +/// as it was; and a profile the boolean refuses is built from its contours as drawn. #[test] -fn a_profile_the_boolean_refuses_is_taken_as_it_is() { +fn flat_invalid_and_refused_profiles() { 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![ @@ -892,4 +892,269 @@ fn a_profile_the_boolean_refuses_is_taken_as_it_is() { 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"); + // A star of 3,001 points, each joined to the one 1,500 on: every edge spans the middle, so + // past pathops' budget of segment pairs the boolean refuses it (`TooComplex`) without sweeping, + // and the profile is turned as drawn. + let n = 3001; + let star: Vec = (0..n) + .map(|i| { + let a = std::f64::consts::TAU * ((i * 1500) % n) as f64 / n as f64; + Point::new(100.0 + 100.0 * a.cos(), 100.0 + 100.0 * a.sin()) + }) + .collect(); + let star = PathData::new(vec![vectorcraft_geom::SubPath::polyline(&star, true)]); + assert_eq!( + vectorcraft_pathops::try_unite_all_with(&[(&star, FillRule::NonZero)], rings::DEBRIS), + Err(vectorcraft_pathops::PathOpsError::TooComplex) + ); + let b = star.bounds().unwrap(); + let drawn: f64 = + rings::rings(&star, FillRule::NonZero, b.width().max(b.height()), usize::MAX).unwrap().iter().map(|r| rings::signed_area(r)).sum(); + assert!(drawn > 0.0); + assert!(close(area(&plane(&star, FillRule::NonZero, b, front()).unwrap()), drawn, 1e-6), "built from its contours as drawn"); +} + +/// A contour that crosses itself into lobes that cancel has no area of its own, yet fills both +/// lobes; contours that cancel each other fill nothing. Both go through the boolean. +#[test] +fn self_crossing_and_cancelling_profiles_are_resolved() { + let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true); + let bowtie = PathData::new(vec![poly(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])]); + let b = bowtie.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + assert!(close(area(&plane(&bowtie, rule, b, front()).unwrap()), 50.0, 1e-6), "{rule:?}: Rotate"); + assert!(close(area(&extrude(&bowtie, rule, b, Extrude::default(), front()).unwrap()), 50.0, 1e-6), "{rule:?}: Extrude's cap"); + assert!(inflate(&bowtie, rule, b, Inflate::default(), front()).is_ok_and(|f| close(area(&f), 50.0, 1e-6)), "{rule:?}: Inflate"); + } + let square = poly(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]); + let twice = PathData::new(vec![square.clone(), square]); + let b = twice.bounds().unwrap(); + assert_eq!(area(&plane(&twice, FillRule::EvenOdd, b, front()).unwrap()), 0.0); + assert_eq!(area(&extrude(&twice, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap()), 0.0); + assert!(inflate(&twice, FillRule::EvenOdd, b, Inflate::default(), front()).is_ok_and(|f| area(&f) == 0.0)); +} + +/// The boolean's answer often starts a contour where it touches another. Its rings are turned as +/// a whole, never judged from one point each, so touching parts are kept and holes stay open. +#[test] +fn touching_contours_keep_their_parts_and_holes() { + let poly = |pts: &[(f64, f64)]| vectorcraft_geom::SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true); + // A triangle whose tip rests on a square's edge. + let tip = PathData::new(vec![poly(&[(0.0, 10.0), (10.0, 10.0), (10.0, 20.0), (0.0, 20.0)]), poly(&[(0.0, 0.0), (10.0, 0.0), (5.0, 10.0)])]); + let b = tip.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + assert!(close(area(&plane(&tip, rule, b, front()).unwrap()), 150.0, 1e-6), "{rule:?}: Rotate"); + assert!(close(area(&extrude(&tip, rule, b, Extrude::default(), front()).unwrap()), 150.0, 1e-6), "{rule:?}: Extrude's cap"); + } + // An even-odd triangular hole touching its square's edge. + let hole = + PathData::new(vec![poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), poly(&[(0.0, 50.0), (60.0, 0.0 + 20.0), (60.0, 80.0)])]); + let b = hole.bounds().unwrap(); + assert!(close(area(&plane(&hole, FillRule::EvenOdd, b, front()).unwrap()), 10000.0 - 1800.0, 1e-6)); +} + +/// An open subpath is closed by a straight line, whichever way the profile goes: the same as the +/// renderer's fill, whether or not other contours meet. +#[test] +fn an_open_subpath_closes_straight_whether_or_not_contours_meet() { + let open = vectorcraft_geom::SubPath { + anchors: vec![ + vectorcraft_geom::Anchor { + p: Point::new(0.0, 0.0), + h_in: Point::new(0.0, -50.0), + h_out: Point::new(0.0, 0.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + vectorcraft_geom::Anchor::corner(Point::new(50.0, 100.0)), + vectorcraft_geom::Anchor { + p: Point::new(100.0, 0.0), + h_in: Point::new(100.0, 0.0), + h_out: Point::new(100.0, -50.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + ], + closed: false, + }; + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { + vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) + }; + let alone = PathData::new(vec![open.clone()]); + let with_bars = PathData::new(vec![open, rect(200.0, 40.0, 260.0, 60.0), rect(220.0, 20.0, 240.0, 80.0)]); + let bars = 60.0 * 20.0 + 20.0 * 40.0; + assert!(close(area(&plane(&alone, FillRule::NonZero, alone.bounds().unwrap(), front()).unwrap()), 5000.0, 1e-6)); + assert!(close(area(&plane(&with_bars, FillRule::NonZero, with_bars.bounds().unwrap(), front()).unwrap()), 5000.0 + bars, 1e-6)); +} + +/// A flat contour beside separate ones fills nothing and doesn't send the profile through the +/// boolean; contours far from the origin meet within the boolean's wider snapping; and a merged +/// outline past the point limit is too complex. +#[test] +fn flat_far_and_too_large_merges() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { + vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) + }; + let squares: Vec<_> = (0..12).map(|i| rect(i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 10.0)).collect(); + let line = vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 300.0), Point::new(50.0, 300.0), Point::new(100.0, 300.0)], false); + let with_line = PathData::new(squares.iter().cloned().chain([line]).collect()); + assert!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX).unwrap(), &rings::closed_straight(&with_line))); + assert!(close(area(&plane(&with_line, FillRule::NonZero, with_line.bounds().unwrap(), front()).unwrap()), 1200.0, 1e-6)); + // Far from the origin the boolean snaps wider than 1e-6 (1.4e-6 at 9.9e7), and so does the + // test for meeting: squares 1.35e-6 apart there are one outline, one cap of both areas. + let far = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.35e-6, 0.0, 9.9e7 + 20.0, 10.0)]); + let cap = plane(&far, FillRule::NonZero, far.bounds().unwrap(), front()).unwrap(); + assert_eq!(cap.iter().map(|f| f.contours.len()).sum::(), 1, "joined into one outline"); + assert!(close(area(&cap), 200.0, 1e-3), "joined, not lost"); + // Handles count too: a lobe whose handles reach 1.3e8 widens the boolean's snapping to 1.85e-6. + let lobe = vectorcraft_geom::SubPath { + anchors: vec![ + vectorcraft_geom::Anchor { + p: Point::new(0.0, 1000.0), + h_in: Point::new(0.0, 1000.0), + h_out: Point::new(1.3e8, 1000.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + vectorcraft_geom::Anchor { + p: Point::new(0.0, 1100.0), + h_in: Point::new(1.3e8, 1100.0), + h_out: Point::new(0.0, 1100.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + ], + closed: true, + }; + let wide = PathData::new(vec![rect(9.9e7, 0.0, 9.9e7 + 10.0, 10.0), rect(9.9e7 + 10.0 + 1.6e-6, 0.0, 9.9e7 + 20.0, 10.0), lobe]); + let b = wide.bounds().unwrap(); + let joined = plane(&wide, FillRule::NonZero, b, front()).unwrap(); + assert_eq!(joined.iter().map(|f| f.contours.len()).sum::(), 2, "the squares joined, the lobe apart"); + // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced: + // past Extrude's and Inflate's 2,048 the shape is too complex (its contours as drawn cross, so + // they can't be built as they are), and Rotate's 65,536 takes it. + let mut bars = vec![]; + for i in 0..24 { + let x = i as f64 * 10.0; + bars.push(rect(x, 0.0, x + 4.0, 240.0)); + bars.push(rect(0.0, x, 240.0, x + 4.0)); + } + let grid = PathData::new(bars); + let b = grid.bounds().unwrap(); + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + assert_eq!(extrude(&grid, rule, b, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); + assert_eq!(inflate(&grid, rule, b, Inflate::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect", "{rule:?}"); + assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), vectorcraft_pathops::area(&grid, rule), 1e-6), "{rule:?}: Rotate"); + } +} + +/// A profile goes through the boolean when its contours come within twice the boolean's tolerance +/// of the path it is given: pathops snaps anchors onto nearby heights before sweeping, then the +/// sweep joins edges within its tolerance, so contours a little more than it apart can come out +/// as one. +#[test] +fn meets_within_twice_the_boolean_tolerance() { + let rect = |x0: f64, y0: f64, x1: f64, y1: f64| { + vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true) + }; + let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX).unwrap(), &rings::closed_straight(p)); + // Two triangles whose facing 45° edges are 1.4e-6 apart, the second's corners 0.99e-6 lower on + // the page (greater y) than the first's: snapped onto the first's heights, they are joined. + let d = 0.99e-6; + let snapped = PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + d, d), Point::new(10.0 + d, 10.0 + d), Point::new(d, 10.0 + d)], true), + ]); + assert!(meets(&snapped)); + let cap = plane(&snapped, FillRule::NonZero, snapped.bounds().unwrap(), front()).unwrap(); + assert_eq!(cap.iter().map(|f| f.contours.len()).sum::(), 1, "joined into one outline"); + assert!(close(area(&cap), 100.0, 1e-3), "joined, not lost"); + // At the same heights the boolean leaves the triangles apart, and the shape it builds is the + // one drawn: both triangles, two outlines. + let tri = |g: f64| { + PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, 0.0), Point::new(10.0 + g, 10.0), Point::new(g, 10.0)], true), + ]) + }; + let apart = tri(1.4e-6 * std::f64::consts::SQRT_2); + assert!(meets(&apart)); + assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&apart, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2)); + let cap = plane(&apart, FillRule::NonZero, apart.bounds().unwrap(), front()).unwrap(); + assert_eq!(cap.iter().map(|f| f.contours.len()).sum::(), 2); + assert!(close(area(&cap), 100.0, 1e-3), "both kept"); + // Twice the tolerance, not less: 45° edges 1.9e-6 apart still meet. + assert!(meets(&tri(1.9e-6 * std::f64::consts::SQRT_2))); + // And pathops joins nothing past it: the worst case, 2.05e-6 apart as `meets` measures them, + // with the second triangle's corners just under the tolerance lower so that the snap brings it + // closest, stays two outlines. + let lift = 0.999e-6; + let g = 2.05e-6 * std::f64::consts::SQRT_2 - lift; + let past = PathData::new(vec![ + vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true), + vectorcraft_geom::SubPath::polyline(&[Point::new(10.0 + g, lift), Point::new(10.0 + g, 10.0 + lift), Point::new(g, 10.0 + lift)], true), + ]); + assert!(!meets(&past)); + assert_eq!(vectorcraft_pathops::try_unite_all_with(&[(&past, FillRule::NonZero)], rings::DEBRIS).map(|p| p.subpaths.len()), Ok(2)); + // Near the origin, contours a few millionths apart stay apart and skip the boolean. + assert!(!meets(&PathData::new(vec![rect(0.0, 0.0, 10.0, 10.0), rect(10.0 + 2.2e-6, 0.0, 20.0, 10.0)]))); + // What the boolean is given decides the tolerance: an open subpath's end handles (closed + // straight) and a one-anchor subpath (skipped) don't widen it. + let ends = vectorcraft_geom::SubPath { + anchors: vec![ + vectorcraft_geom::Anchor { + p: Point::new(0.0, 0.0), + h_in: Point::new(1e9, 0.0), + h_out: Point::new(0.0, 0.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + vectorcraft_geom::Anchor { + p: Point::new(10.0, 0.0), + h_in: Point::new(10.0, 0.0), + h_out: Point::new(1e9, 0.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }, + ], + closed: false, + }; + let speck = vectorcraft_geom::SubPath { anchors: vec![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true }; + let quiet = PathData::new(vec![ends, speck]); + assert_eq!(rings::touch(&rings::closed_straight(&quiet)), 1e-6); + // A closing-side handle at either end alone is enough to close the subpath straight. + for kept in [0, 1] { + let mut one = quiet.subpaths[0].clone(); + one.anchors[0].h_in = if kept == 0 { Point::new(1e9, 0.0) } else { one.anchors[0].p }; + one.anchors[1].h_out = if kept == 1 { Point::new(1e9, 0.0) } else { one.anchors[1].p }; + assert_eq!(rings::touch(&rings::closed_straight(&PathData::new(vec![one]))), 1e-6, "handle kept at anchor {kept}"); + } + // Beside two squares 3e-6 apart near the origin (farther apart than twice 1e-6), they don't + // send the profile through the boolean; read unclosed, the end handles would. + let mut quiet_squares = quiet.clone(); + quiet_squares.subpaths.extend([rect(0.0, 100.0, 10.0, 110.0), rect(10.0 + 3e-6, 100.0, 20.0, 110.0)]); + assert!(!meets(&quiet_squares)); +} + +/// The boolean's rings are turned as a whole so the filled side is on their left: holes stay +/// against their outlines, and a profile wound the other way round is turned over. +#[test] +fn the_merged_rings_are_turned_as_a_whole() { + let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)]; + let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)]; + let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); + assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0); + let kept = rings::wound(vec![outline.clone(), hole.clone()]); + assert_eq!(kept, vec![outline, hole]); +} + +/// A subpath of one anchor draws nothing, so it builds nothing, beside other art or not. +#[test] +fn a_one_anchor_subpath_builds_nothing() { + let loop_ = vectorcraft_geom::SubPath { + anchors: vec![vectorcraft_geom::Anchor { + p: Point::new(0.0, 0.0), + h_in: Point::new(-20.0, 30.0), + h_out: Point::new(20.0, 30.0), + kind: vectorcraft_geom::AnchorKind::Corner, + }], + closed: true, + }; + let p = PathData::new(vec![loop_]); + let b = Rect::new(-20.0, 0.0, 20.0, 30.0); + assert_eq!(area(&plane(&p, FillRule::NonZero, b, front()).unwrap()), 0.0); } diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 7ca183813..37a504367 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -179,7 +179,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Item | Ours | |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | -| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | +| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | @@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable) is 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | | 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. | | 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index a85062dd2..92758f6b7 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -19,8 +19,11 @@ 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. +[`vectorcraft-pathops`](../crates/pathops/README.md), keeping every contour but the numerical debris a boolean leaves. +Whether contours meet is judged on the outline as it is followed (see the curve tolerance below), so curves that +overlap by less than that are built as drawn. All three effects share pathops' +[limits](../crates/pathops/README.md#limits): a shape it refuses is built from its contours as drawn, where it can be. +An open subpath is closed by a straight line either way. ## 3D and Materials panel @@ -234,8 +237,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. - 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. 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. + when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace + every hole between them); past it, the shape is too complex for the effect. - **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate, unbuilt items.