diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 29bbdb3bd..475868d6f 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -205,12 +205,12 @@ fn a_stroked_open_path_builds_a_ribbon() { /// still draws. #[test] fn a_stroke_past_the_limits_is_left_out() { - // 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline. - let points: Vec = (0..1500) + // 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline. + let points: Vec = (0..40_000) .map(|k| { - let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos(); - let r = if k % 2 == 0 { 100.0 } else { 96.0 }; - Point::new(200.0 + r * c, 200.0 + r * s) + let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos(); + let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 }; + Point::new(20_000.0 + r * c, 20_000.0 + r * s) }) .collect(); let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index fa22c10fb..5606cb884 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -25,6 +25,8 @@ const MAX_PROFILE_POINTS: usize = 16_384; const MAX_FACES: usize = 32768; /// Most faces Revolve builds for one object, its members together: about 460 MB of drawn art. const MAX_REVOLVE_FACES: usize = 163_840; +/// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's. +const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES; /// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. const CURVE_SAMPLES: usize = 16; const MIN_CURVE_SAMPLES: usize = 4; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 4d6e78188..339b148a5 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -11,7 +11,7 @@ use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec2}; -use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale}; +use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_scale}; mod build; mod inflate; @@ -23,11 +23,13 @@ use build::{Camera, cuts, edge_distance, newell, section}; pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; #[cfg(test)] +use inflate::GRID_WORK; +#[cfg(test)] pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] -use membrane::{Edges, Membrane, cell_triangles}; +use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; use rings::signed_area; #[cfg(test)] use rings::{MAX_SOLID_POINTS, rings}; @@ -319,3 +321,6 @@ mod tests; #[cfg(test)] mod tests_inflate_shading; + +#[cfg(test)] +mod tests_limits; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 9948c4796..d03773ccd 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,5 +1,7 @@ -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}; +use super::rings::{ + CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, solid_rings, +}; +use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale}; /// Turns 3D points into document points and shades faces for one view. pub(super) struct Camera { @@ -87,8 +89,8 @@ impl Builder<'_> { } if let Some(mesh) = &mut self.mesh { mesh.push((contours, n.unit())); - if mesh.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if mesh.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } return Ok(()); } @@ -135,8 +137,8 @@ impl Builder<'_> { #[cfg(test)] view, }); - if self.facets.len() > MAX_FACES { - return Err("The 3D surface is too complex; simplify the shape".into()); + if self.facets.len() > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); } Ok(()) } @@ -407,15 +409,25 @@ 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 = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let steps = steps(&o); + // One face for each edge of the outline in each band between two steps, and the two caps: + // counted before any point is sampled, and again on the rings, which can have more points + // once contours that meet are merged. + let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 }); + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, bands, caps)?; + let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; + let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } let camera = Camera::new(view, bounds, o.depth); let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; - let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; // Every band of a twisted outline, bevels included, is cut as its walls' first slice is (with - // bevels and no walls, as its shallowest band is), within the facet limit. + // bevels and no walls, as its shallowest band is), within [`MAX_FACES`] facets. let depth_of = |s: &[Step]| s.get(1).zip(s.first()).map_or(0.0, |(b, a)| (b.t - a.t).abs()); let wall = steps .windows(2) @@ -465,7 +477,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 rings = solid_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, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, hidden: 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 51f9af911..20242697f 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,7 +1,12 @@ 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, signed_area, solid_rings}; -use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; +use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; +use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; +use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; + +/// The slices the sides are cut into: more to follow a twist or taper. +fn slices(o: &Inflate) -> usize { + if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 } +} /// A shape to inflate: its rings and the membrane under them on the grid of the bounds. struct Puff { @@ -18,11 +23,14 @@ 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 = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + // The sides take one face for each edge of the outline in each slice; the grid's faces are + // counted as they are built. + let size = bounds.width().max(bounds.height()); + let most = curve_pieces(path, size, if o.depth > 0.0 { slices(o) } else { 0 }, 0)?; + let rings = solid_rings(path, rule, size, MAX_SOLID_POINTS, most)?; if rings.is_empty() { return Ok(None); } - let size = bounds.width().max(bounds.height()); if size <= 0.0 { return Ok(None); } @@ -35,8 +43,11 @@ fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result = const { std::cell::Cell::new(0) }; + /// The grid work of the last solid this thread inflated in full, for the tests. + pub(super) static GRID_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the @@ -143,14 +156,41 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo // builds the topology of every outline ring near its cell, so a self-crossing outline cost its // crossings again in every cell it spans. Past it, cells take their clipped pieces. let material_left = std::cell::Cell::new(MAX_MATERIAL_SEGMENTS); - // The shape's part of a rectangle, as rings. + // Every ring's bounds, and the crossings of the last few rows the cells' centres lie on (a + // row of cells has at most seven, at its three levels). + let ring_bounds: Vec> = + rings.iter().map(|r| Some(r.iter().skip(1).fold(Rect::from_points(*r.first()?, *r.first()?), |b, p| b.union_pt(*p)))).collect(); + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let rows: std::cell::RefCell)>> = std::cell::RefCell::new(vec![]); + let filled_at = |p: Point| -> bool { + let mut rows = rows.borrow_mut(); + if let Some((_, xs)) = rows.iter().find(|(y, _)| *y == p.y.to_bits()) { + return filled(xs, p.x); + } + let xs = if edges.charge(points) { crossings(&rings, p.y) } else { vec![] }; + let inside = filled(&xs, p.x); + if rows.len() >= 8 { + rows.remove(0); + } + rows.push((p.y.to_bits(), xs)); + inside + }; + // The shape's part of a rectangle, as rings: only the rings whose bounds meet it are clipped, + // each point counted as work. let cut = |r: Rect| -> Vec> { // A cell no edge crosses is wholly inside the shape or wholly outside it. if !edges.touch(r) { let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; - return if filled(&rings, r.center()) { vec![corners] } else { vec![] }; + return if filled_at(r.center()) { vec![corners] } else { vec![] }; } - let pieces: Vec> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect(); + let pieces: Vec> = rings + .iter() + .zip(&ring_bounds) + .filter(|(_, b)| b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)) + .filter(|(ring, _)| edges.charge(ring.len())) + .map(|(ring, _)| clip(ring, r)) + .filter(|c| !c.is_empty()) + .collect(); if pieces.iter().map(|p| signed_area(p)).sum::() <= 1e-9 * r.area() { return vec![]; } @@ -185,6 +225,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } }) .collect(); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } let level = |i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } }; for j in 0..ny { @@ -201,6 +244,9 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo for sx in (bx0..bx1).step_by(step) { let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step)); let pieces = cut(r); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } if pieces.is_empty() { continue; } @@ -312,7 +358,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo } if o.depth > 0.0 { // The sides, cut into slices that follow a twist or taper. - let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 }; + let slices = slices(&o); let at = |ring: &[Point], k: usize| -> Vec { let t = k as f64 / slices as f64; let turn = Turn::new(bounds, o.twist, o.taper, t); @@ -320,11 +366,20 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo }; let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); + let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + return Err(TOO_COMPLEX_SURFACE.into()); + } for (ring, pieces) in rings.iter().zip(&pieces) { for k in 0..slices { b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; } } } + #[cfg(test)] + GRID_WORK.with(|w| w.set(edges.work())); + if edges.spent() { + return Err(TOO_COMPLEX_SURFACE.into()); + } Ok(b.finish()) } diff --git a/crates/three-d/src/solid/membrane.rs b/crates/three-d/src/solid/membrane.rs index 02d258531..e8a855e29 100644 --- a/crates/three-d/src/solid/membrane.rs +++ b/crates/three-d/src/solid/membrane.rs @@ -1,9 +1,35 @@ +use std::cell::Cell; + use super::build::segment_distance; -use super::rings::{MAX_PLANE_POINTS, contains, left, rings, signed_area}; +use super::rings::{MAX_PLANE_POINTS, TOO_COMPLEX_SURFACE, left, rings, signed_area}; use super::{FillRule, PathData, Point, Rect, Vec2}; +/// Most entries the edge index of one inflated shape holds: one for each bucket an edge's bounds +/// cover (64 MiB). +pub(super) const MAX_EDGE_ENTRIES: usize = 1 << 24; + +/// Most work the grid of one inflated shape takes: the index's entries, the edges its queries +/// look at, the crossings of the rows it fills and the ring points its cells clip, together. +pub(super) const MAX_GRID_WORK: usize = 1 << 28; + +#[cfg(test)] +thread_local! { + /// The grid work the next edge indexes of this thread may take, for the tests. + pub(super) static GRID_BUDGET: Cell = const { Cell::new(MAX_GRID_WORK) }; +} + +/// The grid work a new edge index may take. +fn grid_budget() -> usize { + #[cfg(test)] + return GRID_BUDGET.with(Cell::get); + #[cfg(not(test))] + MAX_GRID_WORK +} + /// The edges of `rings` filed in a grid of square buckets, so the nearest edge to a point is -/// found among the buckets around it rather than among all the edges. +/// found among the buckets around it rather than among all the edges. It also counts the work +/// done with it (see [`MAX_GRID_WORK`]): once that is spent, its buckets read as empty, and the +/// caller refuses the shape. pub(super) struct Edges { edges: Vec<(Point, Point)>, normals: Vec<(Vec2, Vec2)>, @@ -11,10 +37,14 @@ pub(super) struct Edges { size: f64, n: usize, buckets: Vec>, + work: Cell, + budget: usize, } impl Edges { - pub(super) fn new(rings: &[Vec]) -> Self { + /// The index of `rings`' edges; an error when it would hold more than [`MAX_EDGE_ENTRIES`] + /// entries, counted before any is filed. + pub(super) fn new(rings: &[Vec]) -> Result { const N: usize = 64; let edges: Vec<(Point, Point)> = rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect(); @@ -37,16 +67,48 @@ impl Edges { let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9); let mut buckets = vec![vec![]; N * N]; let index = |v: f64, o: f64| (((v - o) / size).floor().max(0.0) as usize).min(N - 1); - for (k, (a, b)) in edges.iter().enumerate() { - let (i0, i1) = (index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)); - let (j0, j1) = (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0)); + let spans = |(a, b): &(Point, Point)| { + ((index(a.x.min(b.x), bounds.x0), index(a.x.max(b.x), bounds.x0)), (index(a.y.min(b.y), bounds.y0), index(a.y.max(b.y), bounds.y0))) + }; + let entries = edges.iter().map(spans).fold(0usize, |sum, ((i0, i1), (j0, j1))| sum.saturating_add((i1 - i0 + 1) * (j1 - j0 + 1))); + if entries > MAX_EDGE_ENTRIES { + return Err(TOO_COMPLEX_SURFACE.into()); + } + for (k, e) in edges.iter().enumerate() { + let ((i0, i1), (j0, j1)) = spans(e); for j in j0..=j1 { for i in i0..=i1 { buckets[j * N + i].push(k as u32); } } } - Self { edges, normals, origin: bounds.origin(), size, n: N, buckets } + Ok(Self { edges, normals, origin: bounds.origin(), size, n: N, buckets, work: Cell::new(entries), budget: grid_budget() }) + } + + /// Has the work done with the index gone past its budget? + pub(super) fn spent(&self) -> bool { + self.work.get() > self.budget + } + + /// The work done with the index so far. + #[cfg(test)] + pub(super) fn work(&self) -> usize { + self.work.get() + } + + /// Count `amount` more work; `false` once the budget is spent, when the work is not to be done. + pub(super) fn charge(&self, amount: usize) -> bool { + if self.spent() { + return false; + } + self.work.set(self.work.get().saturating_add(amount)); + !self.spent() + } + + /// The edges filed in bucket (`i`, `j`), counted as work; none once the budget is spent. + fn bucket(&self, i: isize, j: isize) -> &[u32] { + let bucket = self.buckets.get((j * self.n as isize + i) as usize).map_or(&[][..], Vec::as_slice); + if self.charge(bucket.len()) { bucket } else { &[] } } /// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis. @@ -58,15 +120,11 @@ impl Edges { /// looked at. pub(super) fn near(&self, p: Point, tolerance: f64) -> bool { self.touch(Rect::new(p.x - tolerance, p.y - tolerance, p.x + tolerance, p.y + tolerance)) && { - let n = self.n as isize; let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x)); let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] - .iter() - .filter_map(|k| self.edges.get(*k as usize)) - .any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) + self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)).any(|(a, b)| segment_distance(p, *a, *b) <= tolerance) }) }) } @@ -75,10 +133,9 @@ impl Edges { /// Call `f` with each edge whose bounding box overlaps `r` (an edge in several of the /// buckets `r` covers comes more than once). fn each(&self, r: Rect, mut f: impl FnMut(Point, Point)) { - let n = self.n as isize; for j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) { for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) { - for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) { + for (a, b) in self.bucket(i, j).iter().filter_map(|k| self.edges.get(*k as usize)) { if a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0 { f(*a, *b); } @@ -89,12 +146,11 @@ impl Edges { /// Does any edge pass through `r` (its bounding box, at least, overlapping it)? pub(super) fn touch(&self, r: Rect) -> bool { - let n = self.n as isize; let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x)); let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { - self.buckets[(j * n + i) as usize] + self.bucket(i, j) .iter() .filter_map(|k| self.edges.get(*k as usize)) .any(|(a, b)| a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0) @@ -122,12 +178,15 @@ impl Edges { let mut best = f64::INFINITY; let mut nearest = (p, Vec2::ZERO); for r in 0..n { + if self.spent() { + break; + } for j in (cj - r)..=(cj + r) { for i in (ci - r)..=(ci + r) { if (j - cj).abs() != r && (i - ci).abs() != r || i < 0 || j < 0 || i >= n || j >= n { continue; } - for k in &self.buckets[(j * n + i) as usize] { + for k in self.bucket(i, j) { if let Some((a, b)) = self.edges.get(*k as usize) { let d = *b - *a; let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) }; @@ -152,9 +211,24 @@ impl Edges { } } -/// Is `p` filled by `rings` (nonzero winding, as they are oriented)? -pub(super) fn filled(rings: &[Vec], p: Point) -> bool { - rings.iter().filter(|r| contains(r, p)).count() % 2 == 1 +/// Where the edges of `rings` cross the line through `y`, in order: the points of the line +/// filled by `rings` (nonzero winding, as they are oriented) are those with an odd number of +/// crossings to their right ([`filled`]). An edge crosses where one end is above the line and +/// the other is not, as [`super::rings::contains`] counts it. +pub(super) fn crossings(rings: &[Vec], y: f64) -> Vec { + let mut xs: Vec = rings + .iter() + .flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len())) + .filter(|(a, b)| (a.y > y) != (b.y > y)) + .map(|(a, b)| a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)) + .collect(); + xs.sort_by(f64::total_cmp); + xs +} + +/// Is `x` filled on the line whose [`crossings`] are `xs`? +pub(super) fn filled(xs: &[f64], x: f64) -> bool { + (xs.len() - xs.partition_point(|v| *v <= x)) % 2 == 1 } /// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring @@ -203,13 +277,17 @@ impl Membrane { pub(super) fn solve(rings: &[Vec], edges: &Edges, origin: Point, step: f64, nx: usize, ny: usize) -> Self { let node = |i: usize, j: usize| Point::new(origin.x + i as f64 * step, origin.y + j as f64 * step); let idx = |i: usize, j: usize| j * nx + i; - // A node on the outline itself is held at zero with the outside. - let inside: Vec = (0..nx * ny) - .map(|k| { - let p = node(k % nx, k / nx); - filled(rings, p) && !edges.near(p, 1e-6 * step) - }) - .collect(); + // A node on the outline itself is held at zero with the outside. Each row of nodes is + // tested against the crossings of its line. + let points = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); + let mut inside = Vec::with_capacity(nx * ny); + for j in 0..ny { + let xs = if edges.charge(points) { crossings(rings, node(0, j).y) } else { vec![] }; + inside.extend((0..nx).map(|i| { + let p = node(i, j); + filled(&xs, p.x) && !edges.near(p, 1e-6 * step) + })); + } // The nearest crossing of the outline from `p` along (dx, dy), within one step. let crossing = |p: Point, dx: f64, dy: f64| -> f64 { let mut best = step; diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 81e67cc03..02fb6f406 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -1,8 +1,7 @@ -use super::{FillRule, ParamCurve, PathData, Point, Rect}; +use super::{FillRule, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect}; -/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel -/// bands and distance tests. -pub(super) const MAX_SOLID_POINTS: usize = 2048; +/// Outline points Extrude & Bevel and Inflate sample at most, and contours they take at most. +pub(super) const MAX_SOLID_POINTS: usize = 65536; /// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits. pub(super) const MAX_PLANE_POINTS: usize = 65536; @@ -10,13 +9,76 @@ pub(super) const MAX_PLANE_POINTS: usize = 65536; /// Pieces a twisted wall's strip is cut into along one edge at most. pub(super) const MAX_PIECES: usize = 64; +/// Pieces a curve takes at most, where its tolerance asks for them. +pub(super) const CURVE_PIECES: u32 = 32; + +/// The fewest pieces a curve of a solid over its budget is held to: a curve whose tolerance asks +/// for fewer takes fewer. +pub(super) const MIN_CURVE_PIECES: u32 = 4; + +const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; +pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + /// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from -/// its chords), 1 to 32. +/// its chords), 1 to [`CURVE_PIECES`]. fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { let d = |a: Point, b: Point, c: Point| (a.to_vec2() - 2.0 * b.to_vec2() + c.to_vec2()).hypot(); let bend = d(c.p0, c.p1, c.p2).max(d(c.p1, c.p2, c.p3)); let n = (0.75 * bend / tolerance).sqrt().ceil(); - if n.is_finite() { (n as u32).clamp(1, 32) } else { 32 } + if n.is_finite() { (n as u32).clamp(1, CURVE_PIECES) } else { CURVE_PIECES } +} + +/// The most pieces each curve of a solid's outline takes, counted on `path` before any point is +/// sampled: [`CURVE_PIECES`] when the outline fits within [`MAX_SOLID_POINTS`] points and +/// [`MAX_SOLID_FACES`] faces, else the largest number down to [`MIN_CURVE_PIECES`] that fits, the +/// same for every curve. A curve whose tolerance (see [`sample`]) asks for fewer pieces takes +/// fewer. The faces are counted as `per_edge` for each edge of the outline and `fixed` more. +/// +/// An error when the outline takes more than [`MAX_SOLID_POINTS`] points, or contours, with +/// every curve held to [`MIN_CURVE_PIECES`], and when its faces do not fit with that many. +pub(super) fn curve_pieces(path: &PathData, size: f64, per_edge: usize, fixed: usize) -> Result { + let tolerance = (size / 2000.0).max(0.05); + // How many curves ask for each number of pieces. + let mut curves = [0usize; CURVE_PIECES as usize + 1]; + let (mut lines, mut segments, mut open) = (0usize, 0usize, 0usize); + for (contours, sp) in path.subpaths.iter().enumerate() { + if contours >= MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // A subpath of one anchor draws nothing, as in [`sample`]. + if sp.anchors.len() < 2 { + continue; + } + if sp.anchors.iter().any(|a| [a.p, a.h_in, a.h_out].iter().any(|q| !q.x.is_finite() || !q.y.is_finite())) { + return Err("The shape contains invalid coordinates".into()); + } + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len() - 1 }; + // Every segment takes a point at least. + segments = segments.saturating_add(count); + if segments > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + open += usize::from(!sp.closed); + for i in 0..count { + if sp.segment_is_line(i) { + lines += 1; + } else if let Some(n) = curves.get_mut(pieces(sp.segment(i), tolerance) as usize) { + *n += 1; + } + } + } + let points = |most: u32| -> usize { + curves.iter().enumerate().fold(lines, |sum, (asked, n)| sum.saturating_add(n.saturating_mul(asked.min(most as usize)))) + }; + if points(MIN_CURVE_PIECES) > MAX_SOLID_POINTS { + return Err(TOO_COMPLEX_SHAPE.into()); + } + // An open contour is closed by one more edge. + let faces = |most: u32| points(most).saturating_add(open).saturating_mul(per_edge).saturating_add(fixed); + (MIN_CURVE_PIECES..=CURVE_PIECES) + .rev() + .find(|most| points(*most) <= MAX_SOLID_POINTS && faces(*most) <= MAX_SOLID_FACES) + .ok_or_else(|| TOO_COMPLEX_SURFACE.into()) } /// Contours thinner than this on average (twice their area over their perimeter) are dropped when @@ -37,14 +99,15 @@ pub(super) const DEBRIS: f64 = 1e-6; /// 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)?; +/// Each curve takes at most `most` pieces, on the profile as drawn and on the merged outline. +pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result>, String> { + let sampled = sample(path, size, limit, most)?; let closed = closed_straight(path); if !meets(&sampled, &closed) { return orient(sampled, rule); } match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { - Ok(merged) => sample(&merged, size, limit).map(wound), + Ok(merged) => sample(&merged, size, limit, most).map(wound), Err(_) => orient(sampled, rule), } } @@ -197,12 +260,12 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { /// 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) + orient(sample(path, size, limit, CURVE_PIECES)?, rule) } /// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as -/// its subpath does. -pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result>, String> { +/// its subpath does, each curve in at most `most` pieces. +pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result>, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -210,7 +273,7 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result= 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()); + return Err(TOO_COMPLEX_SHAPE.into()); } let mut ring: Vec = vec![]; if let Some(first) = sp.anchors.first() { @@ -218,10 +281,10 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result limit { - return Err("The shape is too complex for a 3D effect".into()); + return Err(TOO_COMPLEX_SHAPE.into()); } for k in 1..=samples { ring.push(curve.eval(f64::from(k) / f64::from(samples))); @@ -252,7 +315,7 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S // 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). - let too_complex = || String::from("The shape is too complex for a 3D effect"); + let too_complex = || String::from(TOO_COMPLEX_SHAPE); let mut work = 0usize; let mut within = |out: &[Vec], bounds: &[Rect], i: usize| -> Result, String> { let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default(); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 46d178287..f3efe8455 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -277,11 +277,14 @@ fn malformed_and_degenerate_input_is_bounded() { // Too many anchors: refused, not truncated. let mut many = BezPath::new(); many.move_to((0.0, 0.0)); - for i in 0..3000 { + for i in 0..70_000 { many.line_to((f64::from(i), f64::from(i % 7))); } many.close_path(); - assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err()); + assert_eq!( + extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap_err(), + "The shape is too complex for a 3D effect" + ); } #[test] @@ -321,11 +324,11 @@ fn the_inflate_membrane_matches_known_solutions() { c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let disc = Membrane::solve(&r, &Edges::new(&r), b.origin(), b.width() / 48.0, 49, 49); + let disc = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), b.width() / 48.0, 49, 49); assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak); let (p, b) = shape(&square(0.0, 0.0, 100.0)); let r = rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(); - let sq = Membrane::solve(&r, &Edges::new(&r), b.origin(), 100.0 / 48.0, 49, 49); + let sq = Membrane::solve(&r, &Edges::new(&r).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak); // Smooth across the middle: no crease where a distance-to-the-edge height would have one // (the diagonals). The second difference along a diagonal stays small and even. @@ -374,8 +377,7 @@ fn bumps(count: usize) -> (PathData, Rect) { } /// Curves are followed within a tolerance rather than by sixteen points each: small curves take a -/// few points, so shapes of many curves (outlined type) fit; Rotate, which only turns the outline, -/// takes far more. +/// few points, so shapes of many curves (outlined type) fit, in a solid and in Rotate. #[test] fn small_curves_take_few_points_and_rotate_takes_large_outlines() { // 300 curves were 4,800 points at sixteen each, over the old limit of 1,024. @@ -383,10 +385,13 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok()); - // 4,000 curves: too many for a solid, not for Rotate. + // 4,000 curves fit a solid and Rotate alike; 70,000 fit neither. let (p, r) = bumps(4000); - assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex")); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1); + let (p, r) = bumps(70_000); + assert_eq!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err(), "The shape is too complex for a 3D effect"); + assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap_err(), "The shape is too complex for a 3D effect"); // Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt // here (1/2000 of its 100 pt size is less). let mut c = BezPath::new(); @@ -421,7 +426,7 @@ fn twisted_pieces_stay_within_the_facet_limit() { fn the_edge_index_finds_the_nearest_edge() { let (p, r) = bumps(200); let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap(); - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); for k in 0..400 { let t = f64::from(k); let q = Point::new(-300.0 + (t * 37.0) % 1400.0, -50.0 + (t * 13.0) % 130.0); @@ -437,7 +442,7 @@ fn the_edge_index_looks_along_the_side_from_outside() { let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); // Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid). let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))]; - let edges = Edges::new(&rings); + let edges = Edges::new(&rings).unwrap(); let q = Point::new(-10.0, 0.5); let (fast, slow) = (edges.distance(q), edge_distance(&rings, q)); assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}"); @@ -1019,7 +1024,7 @@ fn flat_far_and_too_large_merges() { 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!(!rings::meets(&rings::sample(&with_line, 300.0, usize::MAX, rings::CURVE_PIECES).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. @@ -1049,9 +1054,8 @@ fn flat_far_and_too_large_merges() { 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. + // 24 bars each way: 192 points as drawn, some 2,400 once every hole between them is traced, + // within the 65,536 points Extrude & Bevel, Inflate and Rotate take. let mut bars = vec![]; for i in 0..24 { let x = i as f64 * 10.0; @@ -1061,9 +1065,18 @@ fn flat_far_and_too_large_merges() { 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"); + // With a limit of 2,048 points the grid fits as drawn and its merged outline does not: its + // contours as drawn cross, so they can't be built as they are. + assert!(rings::sample(&grid, 240.0, 2048, rings::CURVE_PIECES).is_ok()); + assert_eq!( + rings::solid_rings(&grid, rule, 240.0, 2048, rings::CURVE_PIECES), + Err("The shape is too complex for a 3D effect".into()), + "{rule:?}" + ); + let expected = vectorcraft_pathops::area(&grid, rule); + assert!(close(area(&extrude(&grid, rule, b, Extrude::default(), front()).unwrap()), expected, 1e-6), "{rule:?}: Extrude & Bevel"); + assert!(!inflate(&grid, rule, b, Inflate::default(), front()).unwrap().is_empty(), "{rule:?}: Inflate"); + assert!(close(area(&plane(&grid, rule, b, front()).unwrap()), expected, 1e-6), "{rule:?}: Rotate"); } } @@ -1076,7 +1089,7 @@ 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)); + let meets = |p: &PathData| rings::meets(&rings::sample(p, 300.0, usize::MAX, rings::CURVE_PIECES).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; diff --git a/crates/three-d/src/solid/tests_limits.rs b/crates/three-d/src/solid/tests_limits.rs new file mode 100644 index 000000000..bd7009623 --- /dev/null +++ b/crates/three-d/src/solid/tests_limits.rs @@ -0,0 +1,197 @@ +use vectorcraft_geom::{Anchor, SubPath}; + +use super::*; + +const SHAPE: &str = "The shape is too complex for a 3D effect"; +const SURFACE: &str = "The 3D surface is too complex; simplify the shape"; + +/// A closed ring of `count` curved petals around a circle of `radius`: each curve leaves its anchor +/// outward, `lift` points out, and comes back to the next anchor, so a larger lift asks for more +/// pieces per curve. +fn petals(count: usize, radius: f64, lift: f64) -> PathData { + let chord = std::f64::consts::TAU * radius / count as f64; + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + let p = Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s); + let (out, along) = (Vec2::new(c, s) * lift, Vec2::new(-s, c) * (chord / 3.0)); + Anchor::with_handles(p, p + out - along, p + out + along) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// A closed polygon of `count` straight edges around a circle. +fn polygon(count: usize, radius: f64) -> PathData { + let anchors = (0..count) + .map(|i| { + let (s, c) = (std::f64::consts::TAU * i as f64 / count as f64).sin_cos(); + Anchor::corner(Point::new(radius * 2.0 + radius * c, radius * 2.0 + radius * s)) + }) + .collect(); + PathData::new(vec![SubPath::new(anchors, true)]) +} + +/// Turned so that the walls and bevels show. +fn turned() -> View { + View { rotation_x: -18.0, rotation_y: -26.0, rotation_z: 8.0, ..View::default() } +} + +fn size(path: &PathData) -> f64 { + let b = path.bounds().unwrap(); + b.width().max(b.height()) +} + +/// How many points the outline samples to, each curve in at most `most` pieces. +fn points(path: &PathData, most: u32) -> usize { + rings::sample(path, size(path), usize::MAX, most).unwrap().iter().map(Vec::len).sum() +} + +fn built(facets: &[Facet]) -> bool { + !facets.is_empty() + && facets.len() <= MAX_SOLID_FACES + && facets.iter().flat_map(|f| f.contours.iter().flatten()).all(|p| p.x.is_finite() && p.y.is_finite()) +} + +#[test] +fn two_thousand_curves_extrude_with_a_bevel_and_inflate() { + let path = petals(2000, 500.0, 4.0); + let b = path.bounds().unwrap(); + // Every curve takes several pieces: far more points than 2,048, all of them kept. + let sampled = points(&path, rings::CURVE_PIECES); + assert!(sampled >= 3 * 2000, "{sampled}"); + assert_eq!(rings::curve_pieces(&path, size(&path), 2, 2), Ok(rings::CURVE_PIECES)); + let bevel = Extrude { bevel: Some(Bevel::default()), ..Extrude::default() }; + let extruded = extrude(&path, FillRule::NonZero, b, bevel, turned()).unwrap(); + assert!(built(&extruded) && extruded.len() > sampled, "{}", extruded.len()); + let inflated = inflate(&path, FillRule::NonZero, b, Inflate::default(), turned()).unwrap(); + assert!(built(&inflated), "{}", inflated.len()); +} + +#[test] +fn an_outline_at_the_point_limit_is_taken_and_one_past_it_is_refused_before_sampling() { + let at = polygon(MAX_SOLID_POINTS, 20_000.0); + assert_eq!(rings::curve_pieces(&at, size(&at), 1, 2), Ok(rings::CURVE_PIECES)); + assert!(built(&extrude(&at, FillRule::NonZero, at.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let past = polygon(MAX_SOLID_POINTS + 1, 20_000.0); + assert_eq!(rings::curve_pieces(&past, size(&past), 1, 2), Err(SHAPE.to_string())); + assert_eq!(extrude(&past, FillRule::NonZero, past.bounds().unwrap(), Extrude::default(), turned()), Err(SHAPE.to_string())); + assert_eq!(inflate(&past, FillRule::NonZero, past.bounds().unwrap(), Inflate::default(), turned()), Err(SHAPE.to_string())); +} + +#[test] +fn faces_at_the_limit_are_taken_and_one_past_it_is_refused_before_sampling() { + // 40,960 straight edges in four bands are exactly the face limit. + let path = polygon(MAX_SOLID_FACES / 4, 20_000.0); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 0), Ok(rings::CURVE_PIECES)); + assert_eq!(rings::curve_pieces(&path, size(&path), 4, 1), Err(SURFACE.to_string())); + // A rounded bevel gives 20,000 edges nine bands each, past the limit. + let path = polygon(20_000, 20_000.0); + let rounded = Extrude { bevel: Some(Bevel { shape: BevelShape::Rounded, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&path, FillRule::NonZero, path.bounds().unwrap(), rounded, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn curves_over_the_budget_take_fewer_pieces_down_to_four() { + let path = petals(2000, 500.0, 20.0); + let s = size(&path); + assert_eq!(points(&path, rings::CURVE_PIECES), 6 * 2000, "six pieces a curve within the tolerance"); + // 16 faces an edge: 12,000 points are too many, 10,000 at five pieces a curve fit. + assert_eq!(rings::curve_pieces(&path, s, 16, 0), Ok(5)); + assert_eq!(points(&path, 5), 5 * 2000); + // At four pieces a curve, 21 faces an edge are still too many. + assert_eq!(rings::curve_pieces(&path, s, 21, 0), Err(SURFACE.to_string())); + // Twisted 96°, the walls are 16 slices: built with five pieces a curve. + let b = path.bounds().unwrap(); + let twisted = extrude(&path, FillRule::NonZero, b, Extrude { twist: 96.0, hidden: true, ..Extrude::default() }, turned()).unwrap(); + assert!(built(&twisted) && twisted.len() > MAX_FACES, "{}", twisted.len()); + // Twisted 360°, 60 slices are too many even at four pieces a curve. + assert_eq!(extrude(&path, FillRule::NonZero, b, Extrude { twist: 360.0, ..Extrude::default() }, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn five_and_ten_thousand_curves_build_at_the_defaults() { + let five = petals(5000, 1250.0, 2.0); + assert!(built(&inflate(&five, FillRule::NonZero, five.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(built(&extrude(&five, FillRule::NonZero, five.bounds().unwrap(), Extrude::default(), turned()).unwrap())); + let ten = petals(10_000, 2500.0, 4.0); + let b = ten.bounds().unwrap(); + assert!(points(&ten, rings::CURVE_PIECES) > 10_000); + assert!(built(&extrude(&ten, FillRule::NonZero, b, Extrude::default(), turned()).unwrap())); + let deep = Extrude { twist: 360.0, bevel: Some(Bevel { shape: BevelShape::Rounded, repeat: 4, ..Bevel::default() }), ..Extrude::default() }; + assert_eq!(extrude(&ten, FillRule::NonZero, b, deep, turned()), Err(SURFACE.to_string())); +} + +#[test] +fn many_empty_contours_and_invalid_handles_are_refused_before_sampling() { + // Contours of one anchor and of none draw nothing, yet each is counted. + let lone = |i: usize| SubPath::new(if i.is_multiple_of(2) { vec![] } else { vec![Anchor::corner(Point::new(1.0, i as f64))] }, false); + let square = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true); + let at = PathData::new((0..MAX_SOLID_POINTS - 1).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&at, 10.0, 1, 2), Ok(rings::CURVE_PIECES)); + let past = PathData::new((0..MAX_SOLID_POINTS).map(lone).chain([square.clone()]).collect()); + assert_eq!(rings::curve_pieces(&past, 10.0, 1, 2), Err(SHAPE.to_string())); + let b = Rect::new(0.0, 0.0, 10.0, 10.0); + assert_eq!(extrude(&past, FillRule::NonZero, b, Extrude::default(), turned()), Err(SHAPE.to_string())); + let mut bad = square; + bad.anchors[1].h_out = Point::new(f64::NAN, 0.0); + let bad = PathData::new(vec![bad]); + assert_eq!(rings::curve_pieces(&bad, 10.0, 1, 2), Err("The shape contains invalid coordinates".to_string())); + assert_eq!(inflate(&bad, FillRule::NonZero, b, Inflate::default(), turned()), Err("The shape contains invalid coordinates".to_string())); +} + +/// A comb of `teeth` teeth, 100 wide and 100 tall, on a strip along its foot: its outline +/// crosses every cell of the grid Inflate fills. +fn comb(teeth: usize) -> PathData { + let w = 100.0 / teeth as f64; + let mut points: Vec = (0..teeth) + .flat_map(|k| { + let x = k as f64 * w; + [Point::new(x, 0.0), Point::new(x, 100.0), Point::new(x + w / 2.0, 100.0), Point::new(x + w / 2.0, 0.0)] + }) + .collect(); + points.extend([Point::new(100.0, -1.0), Point::new(0.0, -1.0)]); + PathData::new(vec![SubPath::polyline(&points, true)]) +} + +/// Inflate `path` with the grid work its edge index may take set to `budget`. +fn inflated_within(path: &PathData, budget: usize) -> Result, String> { + GRID_BUDGET.with(|b| b.set(budget)); + let out = inflate(path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()); + GRID_BUDGET.with(|b| b.set(MAX_GRID_WORK)); + out +} + +#[test] +fn inflate_at_its_grid_work_budget_builds_and_one_past_it_is_refused() { + let path = comb(200); + assert!(built(&inflated_within(&path, MAX_GRID_WORK).unwrap())); + let work = GRID_WORK.with(std::cell::Cell::get); + assert!(work > 0); + assert!(built(&inflated_within(&path, work).unwrap())); + assert_eq!(GRID_WORK.with(std::cell::Cell::get), work, "the same work at its budget"); + assert_eq!(inflated_within(&path, work - 1), Err(SURFACE.to_string())); +} + +#[test] +fn the_edge_index_at_its_entry_limit_is_built_and_one_past_it_is_refused() { + // A diagonal across the whole grid of 64 by 64 buckets is filed in every bucket, each way. + let diagonal = vec![Point::new(0.0, 0.0), Point::new(64.0, 64.0)]; + assert_eq!(MAX_EDGE_ENTRIES, 2048 * 2 * 64 * 64); + let mut rings = vec![diagonal; 2048]; + assert!(Edges::new(&rings).is_ok_and(|e| e.work() == MAX_EDGE_ENTRIES)); + // A small triangle in one bucket: three entries more. + rings.push(vec![Point::new(0.0, 0.0), Point::new(0.5, 0.0), Point::new(0.0, 0.5)]); + assert_eq!(Edges::new(&rings).err(), Some(SURFACE.to_string())); +} + +#[test] +fn a_comb_that_crosses_every_cell_is_refused_once_its_grid_work_is_spent() { + // Two thousand curves take a small share of the budget. + let curves = petals(2000, 500.0, 4.0); + assert!(built(&inflate(&curves, FillRule::NonZero, curves.bounds().unwrap(), Inflate::default(), turned()).unwrap())); + assert!(GRID_WORK.with(std::cell::Cell::get) < MAX_GRID_WORK / 16); + // 16,002 points, each clipped in every cell its ring meets. + let path = comb(4000); + assert_eq!(inflate(&path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), turned()), Err(SURFACE.to_string())); +} diff --git a/docs/mcp.md b/docs/mcp.md index 67ce12721..d7e897473 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2578,6 +2578,12 @@ solid, false open behind), `volume` (0..100%, default 50), `bothSides`. All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06), `perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets, the scope and what differs from the public documentation. +`threeD.extrude` and `threeD.inflate` take up to 65,536 outline points and build up to 163,840 faces for each solid +(VectorCraft's own limits); with more faces than that, each curve takes fewer pieces, down to 4. A shape past the +points returns "The shape is too complex for a 3D effect", and one whose faces do not fit at 4 pieces a curve returns +"The 3D surface is too complex; simplify the shape". `threeD.inflate` also returns the surface error when its outline +has so many points across so many of its grid's cells that filling the grid would pass its work limit. `threeD.rotate` +takes up to 65,536 points. ## Extrude & Bevel (Classic) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fcc540c91..280f31896 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1058,6 +1058,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Revolve takes type, as its glyph outlines, and groups or layers, whose members sweep about one axis as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html) describes; a surface can have up to 16,384 profile points, and curves take fewer samples when a surface would pass 163,840 faces (#3617). diff --git a/docs/three-d.md b/docs/three-d.md index 0e9901061..e541a078a 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -105,7 +105,8 @@ The rest is our reading: - The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's grid stays flat. -- A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws. +- A stroke whose outline takes more than 65,536 points, or whose solid takes more than 163,840 faces, is left out, + and the fill's solid still draws. ## Groups @@ -235,10 +236,19 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. clipping groups and other live objects have to be converted first. Revolve on a group sweeps its members about one axis ([revolve.md](revolve.md)). One 3D 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. 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); past it, the shape is too complex for the effect. + and a large one up to 32. Extrude & Bevel and Inflate take up to 65,536 outline points and 163,840 faces for each + solid (a stroke's outline is counted on its own), and Rotate (which only turns the outline) takes 65,536 points. + These limits are VectorCraft's own values. Before any point is sampled, Extrude & Bevel counts the faces its walls, + bevels and caps need, and Inflate counts the faces of its sides; when they do not fit, every curve of the shape + takes fewer pieces, down to 4 (a curve that needs fewer takes fewer). A shape that does not fit at 4 pieces a + curve is refused because the 3D surface is too complex. Inflate counts the faces of its front and back as it + builds them, against the same 163,840. The point 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); past it, the shape is too complex for the effect. +- Inflate also limits the work of filling its grid, which grows with the outline's points times the grid cells its + outline crosses. An outline of tens of thousands of points that crosses nearly every cell (a comb of 4,000 teeth + across the shape) is refused because the 3D surface is too complex; a closed path of 65,536 points round a + circle builds. - **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items.