mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 21:51:36 +00:00
3D: Extrude & Bevel and Inflate build detailed shapes they refused as too complex
Each Extrude & Bevel and Inflate solid now takes up to 65,536 outline points (was 2,048) and 163,840 faces (was 32,768), VectorCraft's own limits, as Revolve's. Before any point is sampled, the outline's lines and curves are counted with each curve's tolerance pieces, and the faces the walls, bevels and caps (Extrude) or the sides (Inflate) need. When they do not fit, every curve takes fewer pieces, down to 4; a shape that does not fit at 4 is refused with the existing surface error before any face is built. The subpath count is capped at the point limit too. The error texts are unchanged. A closed path of 2,000 curves now extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings. Docs: docs/three-d.md, docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -319,3 +319,6 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_inflate_shading;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_limits;
|
||||
|
||||
@@ -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<V3> { 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<Built, String> {
|
||||
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<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
||||
|
||||
@@ -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::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<Option<Puff>, 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);
|
||||
}
|
||||
@@ -312,7 +320,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<V3> {
|
||||
let t = k as f64 / slices as f64;
|
||||
let turn = Turn::new(bounds, o.twist, o.taper, t);
|
||||
@@ -320,6 +328,10 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
|
||||
};
|
||||
let mut pieces: Vec<Vec<usize>> = 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))?;
|
||||
|
||||
@@ -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<u32, String> {
|
||||
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<Vec<Vec<Point>>, 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<Vec<Vec<Point>>, 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<Vec<Vec<Point>>, 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<Vec<Vec<Point>>, String> {
|
||||
/// its subpath does, each curve in at most `most` pieces.
|
||||
pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result<Vec<Vec<Point>>, 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<Vec<Vec
|
||||
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());
|
||||
return Err(TOO_COMPLEX_SHAPE.into());
|
||||
}
|
||||
let mut ring: Vec<Point> = vec![];
|
||||
if let Some(first) = sp.anchors.first() {
|
||||
@@ -218,10 +281,10 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec
|
||||
}
|
||||
for i in 0..segments {
|
||||
let curve = sp.segment(i);
|
||||
let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance) };
|
||||
let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance).min(most.max(1)) };
|
||||
count += samples as usize;
|
||||
if count > 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<Vec<Point>>, rule: FillRule) -> Result<Vec<Vec<Point>>, 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<Point>], bounds: &[Rect], i: usize| -> Result<Vec<usize>, String> {
|
||||
let p = out.get(i).and_then(|r| r.first()).copied().unwrap_or_default();
|
||||
|
||||
@@ -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]
|
||||
@@ -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();
|
||||
@@ -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::<usize>(), 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;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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()));
|
||||
}
|
||||
@@ -2578,6 +2578,10 @@ 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.rotate` takes up to 65,536 points.
|
||||
|
||||
## Extrude & Bevel (Classic)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
+11
-5
@@ -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,15 @@ 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.
|
||||
- **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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user