From f259c2372a6e7d78bed2fef5475e97457d018974 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 13:38:28 -0800 Subject: [PATCH] 3D: expanded solids keep the art's curves in caps and the walls' cap edges Extrude & Bevel (both kinds) and Rotate wrote every face as straight segments between sample points, so Expand Appearance and the exports gave a corner at every sample. Sampling now records the curve each polygon edge follows, and a facet carries an outline of cubics rebuilt from it: exact without perspective, within 0.001 pt with it, and within the sampling tolerance for bevel insets. Caps, Rotate's face and the wall and bevel edges that meet a cap keep curves; interior bevel edges, edges a twist cuts and edges past 1,048,576 curves stay straight. Inflate's faces stay polygons. Docs: docs/three-d.md, docs/target-app-parity.md. --- crates/three-d/src/solid.rs | 8 ++ crates/three-d/src/solid/build.rs | 155 ++++++++++++++++++++-- crates/three-d/src/solid/outline.rs | 194 ++++++++++++++++++++++++++++ crates/three-d/src/solid/rings.rs | 142 +++++++++++++++----- crates/three-d/src/solid/tests.rs | 157 +++++++++++++++++++++- docs/target-app-parity.md | 1 + docs/three-d.md | 9 ++ 7 files changed, 621 insertions(+), 45 deletions(-) create mode 100644 crates/three-d/src/solid/outline.rs diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 339b148a5..45d7d310b 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -16,6 +16,7 @@ use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_s mod build; mod inflate; mod membrane; +mod outline; mod rings; #[cfg(test)] @@ -253,6 +254,9 @@ pub struct Facet { pub normal: [f64; 3], /// Smooth lighting on an inflated triangle, before clipping individual RGB channels. pub rgb_gradient: Option, + /// The facet's outline with the profile's curves kept as cubics, for [`Facet::path`]. The + /// contours stay the polygon that visibility and depth use. + pub outline: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, @@ -268,7 +272,11 @@ impl Facet { if perimeter > 0.0 { 2.0 * area / perimeter } else { 0.0 } } + /// The facet's outline: its curved outline when it has one, else its contours. pub fn path(&self) -> PathData { + if let Some(path) = &self.outline { + return path.clone(); + } let mut bp = BezPath::new(); for c in &self.contours { let Some(first) = c.first() else { continue }; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index d03773ccd..66a22f80f 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -1,7 +1,14 @@ +use vectorcraft_geom::kurbo::ParamCurveDeriv; + +use super::outline::{self, Along, Budget, CURVE_TOLERANCE}; use super::rings::{ - CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, solid_rings, + CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, Ring, Span, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, + solid_rings_traced, +}; +use super::{ + Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect, Revolve, V3, View, eye_distance, + perspective_scale, }; -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 { @@ -134,6 +141,7 @@ impl Builder<'_> { illumination: crate::illumination(&self.camera.view, n, self.camera.light), normal: [n.0, n.1, n.2], rgb_gradient: None, + outline: None, #[cfg(test)] view, }); @@ -171,6 +179,19 @@ impl Builder<'_> { /// wall with `cuts` (see [`cuts`]) as `cuts[i]` quads along edge `i`. `outward` is the /// direction (before rotation) the band faces away from the solid, for orienting normals. pub(super) fn band(&mut self, a: &[V3], b: &[V3], cuts: &[usize], outward: impl Fn(usize) -> V3) -> Result<(), String> { + self.curved_band(a, b, cuts, outward, None) + } + + /// [`Builder::band`], with `curves` the pieces of curve the rings' edges follow and the + /// outlines at `a` and `b`: a quad whose edge is not cut keeps its edges along the profile curved. + pub(super) fn curved_band( + &mut self, + a: &[V3], + b: &[V3], + cuts: &[usize], + outward: impl Fn(usize) -> V3, + mut curves: Option, + ) -> Result<(), String> { let n = a.len().min(b.len()); // At 0 and 1, the corners themselves. let lerp = |p: V3, q: V3, s: f64| { @@ -198,12 +219,29 @@ impl Builder<'_> { if normal.dot(self.camera.rotate(outward(i))) < 0.0 { normal = V3(-normal.0, -normal.1, -normal.2); } + let before = self.facets.len(); self.face(vec![quad], normal, order)?; + if let Some(w) = curves.as_mut().filter(|_| pieces == 1) + && let Some(span) = w.spans.get(i).copied().flatten() + { + self.outline(before, |c| c.first().and_then(|quad| outline::wall(&span, quad, w.near, w.far, w.budget))); + } } } Ok(()) } + /// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the + /// outline `outline` makes from its projected contours. + pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec]) -> Option) { + if self.mesh.is_some() || self.facets.len() <= before { + return; + } + if let Some(f) = self.facets.last_mut() { + f.outline = outline(&f.contours); + } + } + /// How many faces the builder holds: facets, or faces kept for a mesh. pub(super) fn len(&self) -> usize { self.mesh.as_ref().map_or(self.facets.len(), Vec::len) @@ -216,6 +254,62 @@ impl Builder<'_> { } } +/// The curves a band's quads keep: the piece of curve each edge of its rings follows, and the +/// outlines at its two rings (`None` keeps that ring's edges straight). +pub(super) struct WallCurves<'a> { + pub(super) spans: &'a [Option], + pub(super) near: Option<&'a Along<'a>>, + pub(super) far: Option<&'a Along<'a>>, + pub(super) budget: &'a mut Budget, +} + +/// Where a point of the art goes in view space at one step: [`Camera::place`] after a [`Turn`], +/// at depth `z`, as one affine map worked out once for the step. +pub(super) struct Placing { + centre: Point, + at_centre: V3, + x: V3, + y: V3, +} + +impl Placing { + pub(super) fn new(camera: &Camera, centre: Point, turn: Turn, z: f64) -> Self { + let at = |p: Point| camera.place(turn.apply(p), z); + let at_centre = at(centre); + let x = at(Point::new(centre.x + 1.0, centre.y)).sub(at_centre); + let y = at(Point::new(centre.x, centre.y + 1.0)).sub(at_centre); + Self { centre, at_centre, x, y } + } + pub(super) fn place(&self, p: Point) -> V3 { + let (dx, dy) = (p.x - self.centre.x, p.y - self.centre.y); + V3( + self.at_centre.0 + self.x.0 * dx + self.y.0 * dy, + self.at_centre.1 + self.x.1 * dx + self.y.1 * dy, + self.at_centre.2 + self.x.2 * dx + self.y.2 * dy, + ) + } +} + +/// The point `w` points to the left of `span`'s curve at `t`, as the ring runs along it (toward +/// the filled side): where [`inset`] moves the ring's points, without its miters. +fn offset(span: &Span, t: f64, w: f64) -> Point { + let p = span.curve.eval(t); + if w == 0.0 { + return p; + } + let mut d = span.curve.deriv().eval(t).to_vec2(); + // A handle of zero length leaves no tangent at its end: the curve leaves toward its next point. + if d.hypot() < 1e-12 { + d = span.curve.p2 - span.curve.p0; + if t > 0.5 { + d = span.curve.p3 - span.curve.p1; + } + } + let d = if span.t1 < span.t0 { -d } else { d }; + let l = d.hypot(); + if l < 1e-12 { p } else { Point::new(p.x - d.y / l * w, p.y + d.x / l * w) } +} + /// How many pieces each edge of a twisted wall is drawn in, from two neighboring slices of the /// wall, `a` and `b` (partner rings): as many as make each piece about as long along the edge as /// the band between the slices is wide there, 1 to [`MAX_PIECES`]. Twisted walls pass in front of @@ -416,8 +510,9 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio 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); + let traced = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?; + let rings: Vec<&[Point]> = traced.iter().map(|r| r.points.as_slice()).collect(); + let edges = rings.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { return Err(TOO_COMPLEX_SURFACE.into()); } @@ -441,7 +536,33 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio }) .collect(); fit(&mut pieces, steps.len().saturating_sub(1), MAX_FACES); - for (ring, pieces) in rings.iter().zip(&pieces) { + // The outline at each step, through the profile's curves: exact where the step is not inset, + // and within the sampling tolerance (which the inset rings themselves keep to) where it is. + let sampled = (size / 2000.0).max(0.05); + let maps: Vec = steps.iter().map(|s| Placing::new(&camera, bounds.center(), Turn::new(bounds, o.twist, o.taper, s.t), z(s.t))).collect(); + let at: Vec<_> = steps + .iter() + .zip(&maps) + .map(|(s, m)| { + let (w, camera) = (s.inset, &camera); + move |span: &Span, t: f64| camera.project(m.place(offset(span, t, w))) + }) + .collect(); + // A step that is not inset moves the art point by point, affinely without perspective. + let point_maps: Vec<_> = maps.iter().map(|m| |p: Point| camera.project(m.place(p))).collect(); + let along: Vec = steps + .iter() + .zip(&at) + .zip(&point_maps) + .map(|((s, at), map)| Along { + at, + map: (s.inset == 0.0).then_some(map as &dyn Fn(Point) -> Point), + affine: camera.eye.is_none(), + tolerance: if s.inset == 0.0 { CURVE_TOLERANCE } else { sampled }, + }) + .collect(); + let mut budget = Budget::default(); + for ((ring, traced), pieces) in rings.iter().zip(&traced).zip(&pieces) { let placed: Vec> = steps.iter().map(|s| ring_at(ring, s)).collect(); for (k, pair) in placed.windows(2).enumerate() { let ([a, c], Some(s0), Some(s1)) = (pair, steps.get(k), steps.get(k + 1)) else { continue }; @@ -454,15 +575,25 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio } else { 0.0 }; - b.band(a, c, pieces, |i| outward(ring, i, face))?; + // The edges a band shares with a cap follow the cap's curves; the edges between two bands + // stay straight, within the sampling tolerance. + let (near, far) = (along.get(k).filter(|_| k == 0), along.get(k + 1).filter(|_| k + 2 == steps.len())); + let curves = (near.is_some() || far.is_some()).then(|| WallCurves { spans: &traced.spans, near, far, budget: &mut budget }); + b.curved_band(a, c, pieces, |i| outward(ring, i, face), curves)?; } } if o.cap { // A cap turned toward the viewer covers the walls it overlaps, even those nearer than its // center: twisted walls, and the walls of a fill inside the solid of its stroke. - for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] { + let caps = [(steps.first(), along.first(), 1.0), (steps.last(), along.last(), -1.0)]; + for (s, along, n) in caps { let Some(s) = s else { continue }; + let before = b.len(); b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?; + if let Some(along) = along { + let traced: Vec<&Ring> = traced.iter().collect(); + b.outline(before, |c| outline::face(&traced, c, along, &mut budget)); + } } } Ok(b.finish()) @@ -477,15 +608,21 @@ 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, CURVE_PIECES)?; + let rings = solid_rings_traced(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(); + let contours: Vec> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. let up = camera.rotate(V3(0.0, 0.0, 1.0)); let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up }; b.face(contours, n, Order::Last)?; + let m = Placing::new(&camera, bounds.center(), Turn::new(bounds, 0.0, 1.0, 0.0), 0.0); + let at = |span: &Span, t: f64| camera.project(m.place(span.curve.eval(t))); + let map = |p: Point| camera.project(m.place(p)); + let along = Along { at: &at, map: Some(&map), affine: camera.eye.is_none(), tolerance: CURVE_TOLERANCE }; + let rings: Vec<&Ring> = rings.iter().collect(); + b.outline(0, |c| outline::face(&rings, c, &along, &mut Budget::default())); Ok(b.finish()) } diff --git a/crates/three-d/src/solid/outline.rs b/crates/three-d/src/solid/outline.rs new file mode 100644 index 000000000..1d22e6450 --- /dev/null +++ b/crates/three-d/src/solid/outline.rs @@ -0,0 +1,194 @@ +//! Curved outlines for the facets of a solid: a cap, the flat plane of Rotate and the edges of a +//! wall along the profile follow the profile's curves as cubics, while each facet's polygon stays +//! the shape that visibility and depth are worked out on. + +use vectorcraft_geom::{BezPath, CubicBez, ParamCurve, PathData, Point}; + +use super::rings::{Ring, Span}; + +/// How far a fitted cubic may lie from the outline it follows, in points. +pub(super) const CURVE_TOLERANCE: f64 = 0.001; + +/// How many times one piece of curve is halved at most to fit it. A piece that still misses the +/// tolerance keeps its sampled straight edges. +const MAX_DEPTH: u32 = 8; + +/// Fitted cubics one solid's outlines hold at most. Past it, the remaining edges stay straight. +pub(super) const MAX_OUTLINE_CURVES: usize = 1 << 20; + +/// The fitted cubics of one solid: how many it holds so far, against [`MAX_OUTLINE_CURVES`]. +#[derive(Default)] +pub(super) struct Budget(usize); + +/// The outline of a solid at one step, as a point of the document for a point of a profile's +/// curve: `at(span, t)` is where the outline lies at `t` on `span`'s curve. `tolerance` is how far +/// a fitted cubic may lie from it. With `map`, the outline is the curve moved point by point by +/// that map, and a piece of curve is tried first as its control points moved by it: exact when +/// `affine`, otherwise kept when it lies within the tolerance. +pub(super) struct Along<'a> { + pub(super) at: &'a dyn Fn(&Span, f64) -> Point, + pub(super) map: Option<&'a dyn Fn(Point) -> Point>, + pub(super) affine: bool, + pub(super) tolerance: f64, +} + +/// `g` from `p` at `a` to `q` at `b` as cubics within `tolerance`, appended to `out`. False when it +/// does not fit within [`MAX_DEPTH`] halvings, or reaches a point that is not finite. +#[allow(clippy::too_many_arguments)] +fn fit(g: &dyn Fn(f64) -> Point, p: Point, q: Point, a: f64, b: f64, tolerance: f64, depth: u32, out: &mut Vec) -> bool { + // Second-order one-sided differences, taken inside the piece: a handle of zero length at an end + // of the source curve turns its normal, and so an inset outline's tangent, at that end. + let h = (b - a) * 1e-5; + if h == 0.0 || !h.is_finite() { + return false; + } + let (g0, g1, g2) = (g(a), g(a + h), g(a + 2.0 * h)); + let start = (g1 - g0) * 2.0 - (g2 - g0) * 0.5; + let (g0, g1, g2) = (g(b), g(b - h), g(b - 2.0 * h)); + let end = (g0 - g1) * 2.0 - (g0 - g2) * 0.5; + let (start, end) = (start / h, end / h); + let curve = CubicBez::new(p, p + start * ((b - a) / 3.0), q - end * ((b - a) / 3.0), q); + let points = [curve.p1, curve.p2]; + if points.iter().any(|c| !c.x.is_finite() || !c.y.is_finite()) { + return false; + } + let mut error = 0.0f64; + for u in [0.25, 0.5, 0.75] { + let want = g(a + (b - a) * u); + if !want.x.is_finite() || !want.y.is_finite() { + return false; + } + error = error.max(curve.eval(u).distance(want)); + } + if error <= tolerance { + out.push(curve); + return true; + } + if depth >= MAX_DEPTH { + return false; + } + let mid = (a + b) / 2.0; + let m = g(mid); + if !m.x.is_finite() || !m.y.is_finite() { + return false; + } + fit(g, p, m, a, mid, tolerance, depth + 1, out) && fit(g, m, q, mid, b, tolerance, depth + 1, out) +} + +/// The cubics from `p` to `q` along the piece of `span`'s curve from `t0` to `t1` at `step`, or +/// `None` when they do not fit, or would take the budget past [`MAX_OUTLINE_CURVES`]. +/// +/// `p` and `q` are the face's own corners, which a bevel's miter can move off the curve's offset +/// where the curve meets another edge at an angle. The difference is eased out from `p` by `fade.0` +/// and into `q` from `fade.1` (the ends of the first and last sampled edge), as the face's polygon +/// does over those edges, so the curves meet the face's corners. +#[allow(clippy::too_many_arguments)] +fn piece(step: &Along, span: &Span, t0: f64, t1: f64, fade: (f64, f64), p: Point, q: Point, budget: &mut Budget) -> Option> { + if let Some(map) = step.map { + let c = span.curve.subsegment(t0..t1); + let c = CubicBez::new(p, map(c.p1), map(c.p2), q); + let finite = [c.p1, c.p2].iter().all(|q| q.x.is_finite() && q.y.is_finite()); + let fits = || [0.25, 0.5, 0.75].into_iter().all(|u| c.eval(u).distance((step.at)(span, t0 + (t1 - t0) * u)) <= step.tolerance); + if finite && (step.affine || fits()) { + budget.0 = budget.0.saturating_add(1); + return (budget.0 <= MAX_OUTLINE_CURVES).then(|| vec![c]); + } + } + let (dp, dq) = (p - (step.at)(span, t0), q - (step.at)(span, t1)); + // 1 at the end, 0 from `to` on, with no slope at either. + let ease = |t: f64, from: f64, to: f64| { + let x = if to == from { 1.0 } else { ((t - from) / (to - from)).clamp(0.0, 1.0) }; + 1.0 - x * x * (3.0 - 2.0 * x) + }; + let g = |t: f64| (step.at)(span, t) + dp * ease(t, t0, fade.0) + dq * ease(t, t1, fade.1); + let mut out = vec![]; + if !fit(&g, p, q, t0, t1, step.tolerance, 0, &mut out) { + return None; + } + budget.0 = budget.0.saturating_add(out.len()); + (budget.0 <= MAX_OUTLINE_CURVES).then_some(out) +} + +/// Append contour `points` (closed), whose edges follow `ring`'s, to `path`: each run of edges along +/// one piece of curve as cubics, and the rest straight. False when no edge is curved. +fn contour(path: &mut BezPath, ring: &Ring, points: &[Point], step: &Along, budget: &mut Budget) -> bool { + let n = points.len(); + let Some(&first) = points.first() else { return false }; + if ring.spans.len() != n { + return false; + } + path.move_to(first); + let mut curved = false; + let mut i = 0; + while i < n { + let end = |k: usize| points.get((k + 1) % n).copied().unwrap_or(first); + let Some(span) = ring.spans.get(i).copied().flatten() else { + if i + 1 < n { + path.line_to(end(i)); + } + i += 1; + continue; + }; + // The run of edges that follow this curve one after the other. + let (mut j, mut t1, mut last) = (i, span.t1, span.t0); + while let Some(Some(next)) = ring.spans.get(j + 1) + && next.curve == span.curve + && next.t0 == t1 + { + (j, t1, last) = (j + 1, next.t1, next.t0); + } + let (p, q) = (points.get(i).copied().unwrap_or(first), end(j)); + match piece(step, &span, span.t0, t1, (span.t1, last), p, q, budget) { + Some(curves) => { + curved = true; + for c in curves { + path.curve_to(c.p1, c.p2, c.p3); + } + } + // The edge back to the first point is drawn by `close_path`. + None => (i..=j).filter(|k| k + 1 < n).for_each(|k| path.line_to(end(k))), + } + i = j + 1; + } + path.close_path(); + curved +} + +/// The outline of a face made of `rings` at one `step` (a cap, or Rotate's plane), projected as +/// `points` (the face's contours, one for each ring and as long). `None` when no edge is curved. +pub(super) fn face(rings: &[&Ring], points: &[Vec], step: &Along, budget: &mut Budget) -> Option { + if rings.len() != points.len() || rings.iter().all(|r| r.spans.iter().all(Option::is_none)) { + return None; + } + let mut path = BezPath::new(); + let mut curved = false; + for (ring, points) in rings.iter().zip(points) { + curved |= contour(&mut path, ring, points, step, budget); + } + curved.then(|| PathData::from_bezpath(&path)) +} + +/// The outline of a wall's quad `quad` (`a`, `a'`, `b'`, `b` projected: the edge at `near` from +/// `a` to `a'`, then the edge at `far` back from `b'` to `b`), whose edges along the profile follow +/// `span`. An edge without its outline stays straight. `None` when neither edge is curved. +pub(super) fn wall(span: &Span, quad: &[Point], near: Option<&Along>, far: Option<&Along>, budget: &mut Budget) -> Option { + let [a, a2, b2, b] = <[Point; 4]>::try_from(quad).ok()?; + let front = near.and_then(|near| piece(near, span, span.t0, span.t1, (span.t1, span.t0), a, a2, budget)); + let back = far.and_then(|far| piece(far, span, span.t1, span.t0, (span.t0, span.t1), b2, b, budget)); + if front.is_none() && back.is_none() { + return None; + } + let mut path = BezPath::new(); + path.move_to(a); + match front { + Some(curves) => curves.iter().for_each(|c| path.curve_to(c.p1, c.p2, c.p3)), + None => path.line_to(a2), + } + path.line_to(b2); + match back { + Some(curves) => curves.iter().for_each(|c| path.curve_to(c.p1, c.p2, c.p3)), + None => path.line_to(b), + } + path.close_path(); + Some(PathData::from_bezpath(&path)) +} diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 02fb6f406..7aed5e419 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -101,15 +101,7 @@ pub(super) const DEBRIS: f64 = 1e-6; /// under even-odd), or that are all thinner than [`DEBRIS`], fill nothing, so nothing is built. /// 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, most).map(wound), - Err(_) => orient(sampled, rule), - } + Ok(solid_rings_traced(path, rule, size, limit, most)?.into_iter().map(|r| r.points).collect()) } /// Do the `sampled` rings of `closed` (the profile as the boolean takes it) meet, so that it must @@ -155,10 +147,10 @@ pub(super) fn closed_straight(path: &PathData) -> std::borrow::Cow<'_, PathData> /// The boolean's rings turned so the filled side is on each one's left. Its holes already run /// against their outlines, so only the direction of the whole is chosen: the one whose rings /// enclose positive area in all. -pub(super) fn wound(mut rings: Vec>) -> Vec> { - rings.retain(|r| signed_area(r).abs() > 1e-9); - if rings.iter().map(|r| signed_area(r)).sum::() < 0.0 { - rings.iter_mut().for_each(|r| r.reverse()); +pub(super) fn wound(mut rings: Vec) -> Vec { + rings.retain(|r| signed_area(r.points()).abs() > 1e-9); + if rings.iter().map(|r| signed_area(r.points())).sum::() < 0.0 { + rings.iter_mut().for_each(Turnable::turn_around); } rings } @@ -251,6 +243,71 @@ fn segments_meet(a: Point, b: Point, c: Point, d: Point, touch: f64) -> bool { dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= touch } +/// Where one edge of a sampled ring lies on the curve it follows: the piece of `curve` from `t0` +/// to `t1` (`t1` below `t0` on a ring that runs against its curve). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Span { + pub(super) curve: vectorcraft_geom::CubicBez, + pub(super) t0: f64, + pub(super) t1: f64, +} + +/// A sampled ring with, for each edge (point `i` to point `i + 1`, the last back to the first), +/// the piece of curve it follows, or `None` for a straight edge. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Ring { + pub(super) points: Vec, + pub(super) spans: Vec>, +} + +/// A ring that [`orient`] and [`wound`] can turn around. +pub(super) trait Turnable { + fn points(&self) -> &[Point]; + fn turn_around(&mut self); +} + +impl Turnable for Vec { + fn points(&self) -> &[Point] { + self + } + fn turn_around(&mut self) { + self.reverse(); + } +} + +impl Turnable for Ring { + fn points(&self) -> &[Point] { + &self.points + } + fn turn_around(&mut self) { + // The ring keeps its first point, an anchor of the art, so that a run of edges along one + // curve doesn't start part way along it. Edge `j` of the turned ring is edge `n - 1 - j` + // of the ring, run the other way. + self.points.reverse(); + if !self.points.is_empty() { + self.points.rotate_right(1); + } + self.spans.reverse(); + for s in self.spans.iter_mut().flatten() { + std::mem::swap(&mut s.t0, &mut s.t1); + } + } +} + +/// [`solid_rings`] with the piece of curve each edge follows. +pub(super) fn solid_rings_traced(path: &PathData, rule: FillRule, size: f64, limit: usize, most: u32) -> Result, String> { + let sampled = sample_traced(path, size, limit, most)?; + let closed = closed_straight(path); + let points: Vec> = sampled.iter().map(|r| r.points.clone()).collect(); + if !meets(&points, &closed) { + return orient(sampled, rule); + } + match vectorcraft_pathops::try_unite_all_with(&[(&closed, rule)], DEBRIS) { + Ok(merged) => sample_traced(&merged, size, limit, most).map(wound), + Err(_) => orient(sampled, rule), + } +} + /// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer /// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves /// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take @@ -266,6 +323,11 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> /// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as /// its subpath does, each curve in at most `most` pieces. pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Result>, String> { + Ok(sample_traced(path, size, limit, most)?.into_iter().map(|r| r.points).collect()) +} + +/// [`sample`] with the piece of curve each edge follows. +fn sample_traced(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![]; @@ -275,34 +337,44 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Res if segments > limit { return Err(TOO_COMPLEX_SHAPE.into()); } - let mut ring: Vec = vec![]; + // Each point with the piece of curve of the edge that ends at it. + let mut ring: Vec<(Point, Option)> = vec![]; if let Some(first) = sp.anchors.first() { - ring.push(first.p); + ring.push((first.p, None)); } for i in 0..segments { let curve = sp.segment(i); - let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance).min(most.max(1)) }; + let line = sp.segment_is_line(i); + let samples = if line { 1 } else { pieces(curve, tolerance).min(most.max(1)) }; count += samples as usize; if count > limit { return Err(TOO_COMPLEX_SHAPE.into()); } for k in 1..=samples { - ring.push(curve.eval(f64::from(k) / f64::from(samples))); + let (t0, t1) = (f64::from(k - 1) / f64::from(samples), f64::from(k) / f64::from(samples)); + ring.push((curve.eval(t1), (!line).then_some(Span { curve, t0, t1 }))); } } - if ring.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { + if ring.iter().any(|(p, _)| !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1e8 || p.y.abs() > 1e8) { return Err("The shape contains invalid coordinates".into()); } - ring.dedup_by(|a, b| a.distance(*b) < 1e-9); - while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { - ring.pop(); + ring.dedup_by(|a, b| a.0.distance(b.0) < 1e-9); + // The last point back on the first closes the ring along the edge that ends at it. + while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| a.0.distance(b.0) < 1e-9) { + let closing = ring.pop().and_then(|(_, s)| s); + if let Some(first) = ring.first_mut() { + first.1 = closing; + } } + let points: Vec = ring.iter().map(|(p, _)| *p).collect(); // Rings with no area are kept here: a contour that crosses itself into lobes that cancel // (a bowtie) has none, and [`edges_meet`] must see it. [`orient`] leaves them out. A flat // ring, every point on one line, fills nothing under either rule and is left out now, so // that it doesn't send the profile through the boolean. - if ring.len() >= 3 && !flat(&ring) { - out.push(ring); + if points.len() >= 3 && !flat(&points) { + let n = points.len(); + let spans = (0..n).map(|i| ring.get((i + 1) % n).and_then(|(_, s)| *s)).collect(); + out.push(Ring { points, spans }); } } Ok(out) @@ -310,15 +382,15 @@ pub(super) fn sample(path: &PathData, size: f64, limit: usize, most: u32) -> Res /// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero /// `rule` without the rings that are no edge of the shape, as [`rings`] describes. -fn orient(mut out: Vec>, rule: FillRule) -> Result>, String> { - out.retain(|r| signed_area(r).abs() > 1e-9); +fn orient(mut out: Vec, rule: FillRule) -> Result, String> { + out.retain(|r| signed_area(r.points()).abs() > 1e-9); // Which rings hold which: each ring against the others, its edges walked only where its // bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair // whose bounds miss the point costs a step too: many small rings are a square of pairs). 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(); + let mut within = |out: &[R], bounds: &[Rect], i: usize| -> Result, String> { + let p = out.get(i).and_then(|r| r.points().first()).copied().unwrap_or_default(); let mut held_by = vec![]; for (j, (o, b)) in out.iter().zip(bounds).enumerate() { work = work.saturating_add(1); @@ -328,18 +400,18 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S } continue; } - work = work.saturating_add(o.len()); + work = work.saturating_add(o.points().len()); if work > MAX_CONTAINMENT_EDGES { return Err(too_complex()); } - if contains(o, p) { + if contains(o.points(), p) { held_by.push(j); } } Ok(held_by) }; - let bounds_of = |out: &[Vec]| -> Vec { - out.iter().map(|r| r.iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() + let bounds_of = |out: &[R]| -> Vec { + out.iter().map(|r| r.points().iter().map(|q| Rect::from_points(*q, *q)).reduce(|a, b| a.union(b)).unwrap_or_default()).collect() }; if rule == FillRule::NonZero { let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; @@ -347,8 +419,8 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S let mut edge = Vec::with_capacity(out.len()); for (i, r) in out.iter().enumerate() { // The winding number just outside the ring, and just inside it. - let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o)).sum(); - edge.push((outside != 0) != (outside + turn(r) != 0)); + let outside: i32 = within(&out, &bounds, i)?.into_iter().filter_map(|j| out.get(j)).map(|o| turn(o.points())).sum(); + edge.push((outside != 0) != (outside + turn(r.points()) != 0)); } let mut edge = edge.into_iter(); out.retain(|_| edge.next().unwrap_or(true)); @@ -357,8 +429,8 @@ fn orient(mut out: Vec>, rule: FillRule) -> Result>, S let bounds = bounds_of(&out); let holes: Vec = (0..out.len()).map(|i| within(&out, &bounds, i).map(|held| held.len() % 2 == 1)).collect::>()?; for (r, hole) in out.iter_mut().zip(holes) { - if (signed_area(r) > 0.0) == hole { - r.reverse(); + if (signed_area(r.points()) > 0.0) == hole { + r.turn_around(); } } Ok(out) diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index f3efe8455..5749e2e36 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -316,6 +316,161 @@ fn a_wider_perspective_brings_the_eye_nearer() { assert!(close(last, 100.0 * eye / (eye - 50.0), 1e-6), "{last}"); } +/// A circle of four cubics about `c`. +fn circle(c: Point, r: f64) -> BezPath { + let k = 0.552_284_749_830_793_6 * r; + let mut b = BezPath::new(); + b.move_to((c.x + r, c.y)); + b.curve_to((c.x + r, c.y + k), (c.x + k, c.y + r), (c.x, c.y + r)); + b.curve_to((c.x - k, c.y + r), (c.x - r, c.y + k), (c.x - r, c.y)); + b.curve_to((c.x - r, c.y - k), (c.x - k, c.y - r), (c.x, c.y - r)); + b.curve_to((c.x + k, c.y - r), (c.x + r, c.y - k), (c.x + r, c.y)); + b.close_path(); + b +} + +/// A "D" with its counter, as outlined type draws one: straight stems, round bowls, and corners +/// where they meet. +fn letter_d() -> BezPath { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((40.0, 0.0)); + b.curve_to((95.0, 0.0), (95.0, 100.0), (40.0, 100.0)); + b.line_to((0.0, 100.0)); + b.close_path(); + b.move_to((20.0, 20.0)); + b.line_to((20.0, 80.0)); + b.line_to((40.0, 80.0)); + b.curve_to((70.0, 80.0), (70.0, 20.0), (40.0, 20.0)); + b.close_path(); + b +} + +/// A path's curved segments, segments, smooth anchors and anchors. +fn anatomy(p: &PathData) -> [usize; 4] { + let mut n = [0; 4]; + for sp in &p.subpaths { + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + n[0] += (0..count).filter(|i| !sp.segment_is_line(*i)).count(); + n[1] += count; + n[2] += sp.anchors.iter().filter(|a| a.kind == vectorcraft_geom::AnchorKind::Smooth).count(); + n[3] += sp.anchors.len(); + } + n +} + +/// `b` flattened to within 1e-6 pt, each point moved by `map`. +fn fine(b: &BezPath, map: impl Fn(Point) -> Point) -> Vec> { + let mut out: Vec> = vec![]; + vectorcraft_geom::kurbo::flatten(b, 1e-6, |el| match el { + vectorcraft_geom::kurbo::PathEl::MoveTo(p) => out.push(vec![map(p)]), + vectorcraft_geom::kurbo::PathEl::LineTo(p) => out.last_mut().into_iter().for_each(|r| r.push(map(p))), + _ => {} + }); + out +} + +/// How far `p` lies from the closed polygons `target` at most, sampled along each segment. +fn farthest(p: &PathData, target: &[Vec]) -> f64 { + let mut far = 0.0f64; + for sp in &p.subpaths { + let count = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; + for i in 0..count { + let c = sp.segment(i); + for k in 0..=32 { + let q = c.eval(f64::from(k) / 32.0); + assert!(q.x.is_finite() && q.y.is_finite(), "{q:?}"); + far = far.max(edge_distance(target, q)); + } + } + } + far +} + +/// Seen straight on, the front cap of an extruded circle or letter is the art: each curve stays +/// one cubic, smooth anchors stay smooth, and no point lies farther than the curve tolerance from +/// the art. The facet's polygon, which visibility uses, is still the sampled outline. +#[test] +fn a_cap_keeps_the_curves_of_the_art() { + for art in [circle(Point::new(50.0, 50.0), 40.0), letter_d()] { + let (p, b) = shape(&art); + let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap(); + assert_eq!(f.len(), 1); + let cap = f[0].path(); + assert_eq!(anatomy(&cap), anatomy(&p), "{cap:?}"); + assert!(f[0].contours.iter().map(Vec::len).sum::() > anatomy(&p)[3]); + let far = farthest(&cap, &fine(&art, |q| q)); + assert!(far <= outline::CURVE_TOLERANCE, "{far}"); + } +} + +/// Turned and in perspective, the front cap's curves are fitted to the projected circle within +/// the curve tolerance, every anchor stays smooth, and the walls' edges along it are curved too. +#[test] +fn a_cap_in_perspective_fits_the_projected_curves() { + let art = circle(Point::new(50.0, 50.0), 40.0); + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, ..Extrude::default() }; + for view in [View { rotation_x: 20.0, rotation_y: 30.0, perspective: 60.0, ..front() }, View { perspective: 120.0, ..front() }] { + let f = extrude(&p, FillRule::EvenOdd, b, o, view).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap().path(); + let [curves, segments, smooth, anchors] = anatomy(&cap); + assert!(curves >= 4 && curves == segments && smooth == anchors, "{:?}", anatomy(&cap)); + let camera = Camera::new(view, b, o.depth); + let projected = fine(&art, |q| camera.project(camera.place(q, o.depth / 2.0))); + let far = farthest(&cap, &projected); + assert!(far <= outline::CURVE_TOLERANCE + 1e-6, "{far}"); + if view.rotation_y != 0.0 { + assert!(f.iter().filter(|f| f.depth != f64::MAX).any(|f| anatomy(&f.path())[0] == 2), "the walls' edges along the circle are curved"); + } + } +} + +/// A twisted, tapered back cap follows the turned, scaled art; a cap inset by a bevel stays +/// within the sampling tolerance of the solid; a tiny circle and a curve with a handle of zero +/// length keep their cubics. +#[test] +fn twisted_beveled_tiny_and_degenerate_caps_keep_their_curves() { + let art = circle(Point::new(50.0, 50.0), 40.0); + let (p, b) = shape(&art); + let o = Extrude { depth: 50.0, twist: 45.0, taper: 0.5, ..Extrude::default() }; + let behind = View { rotation_y: 180.0, ..front() }; + let f = extrude(&p, FillRule::EvenOdd, b, o, behind).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap().path(); + assert_eq!(anatomy(&cap)[0], 4); + let camera = Camera::new(behind, b, o.depth); + let back = fine(&art, |q| section(&[q], b, &o, 1.0).first().map_or(q, |q| camera.project(camera.place(*q, -o.depth / 2.0)))); + let far = farthest(&cap, &back); + assert!(far <= outline::CURVE_TOLERANCE + 1e-6, "{far}"); + + // Where a curve meets a straight edge at a corner, the bevel's miter moves the cap's corner off + // the curve's offset; the cap's curves still meet it. + let bevel = Extrude { bevel: Some(Bevel { inside: true, width: 4.0, height: 4.0, ..Bevel::default() }), ..Extrude::default() }; + for art in [art.clone(), letter_d()] { + let (p, b) = shape(&art); + let f = extrude(&p, FillRule::EvenOdd, b, bevel, front()).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX).unwrap(); + // An offset curve is no cubic: it may take several, but no edge of a curve stays straight. + let ([curves, segments, ..], [art_curves, art_segments, ..]) = (anatomy(&cap.path()), anatomy(&p)); + assert!(curves >= art_curves && segments - curves == art_segments - art_curves, "{:?}", cap.path()); + let far = farthest(&cap.path(), &cap.contours); + assert!(far <= 2.0 * 0.05, "{far}"); + } + + let mut flat = PathData::from_bezpath(&art); + if let Some(a) = flat.subpaths.first_mut().and_then(|sp| sp.anchors.first_mut()) { + a.h_out = a.p; + } + for p in [PathData::from_bezpath(&circle(Point::new(1.0, 1.0), 0.01)), flat] { + let b = p.bounds().unwrap(); + let f = extrude(&p, FillRule::NonZero, b, Extrude::default(), front()).unwrap(); + let cap = f[0].path(); + assert_eq!(anatomy(&cap)[0], anatomy(&p)[0], "{cap:?}"); + let far = farthest(&cap, &fine(&p.to_bezpath(), |q| q)); + assert!(far <= outline::CURVE_TOLERANCE, "{far}"); + } +} + #[test] fn the_inflate_membrane_matches_known_solutions() { // ∇²φ = −1, zero on the outline: a disc of radius r peaks at r²/4, a square of side l at @@ -1172,7 +1327,7 @@ fn meets_within_twice_the_boolean_tolerance() { fn the_merged_rings_are_turned_as_a_whole() { let outline = vec![Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)]; let hole = vec![Point::new(2.0, 2.0), Point::new(2.0, 8.0), Point::new(8.0, 8.0), Point::new(8.0, 2.0)]; - let turned = rings::wound(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); + let turned = rings::wound::>(vec![outline.iter().rev().copied().collect(), hole.iter().rev().copied().collect()]); assert!(rings::signed_area(&turned[0]) > 0.0 && rings::signed_area(&turned[1]) < 0.0); let kept = rings::wound(vec![outline.clone(), hole.clone()]); assert_eq!(kept, vec![outline, hole]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 22815e7a7..92aabc074 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1059,6 +1059,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). + - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - 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 e541a078a..38d96369c 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -245,6 +245,15 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. 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. +- The faces drawn, exported and made by **Object › Expand Appearance** keep the shape's curves: the caps of + Extrude & Bevel, the flat face of Rotate, and the edges of the walls and bevels where they meet a cap are cubic + curves, with smooth anchors where the art's are smooth, while visibility and depth are worked out on the + sampled points. Without perspective and bevel each curve maps exactly. With perspective, a curve is split into + cubics until each lies within 0.001 pt of the projected outline. A face inset or outset by a bevel follows its + sampled outline within the sampling tolerance above. The edges between two bevel steps or two wall slices, a + piece of curve that does not fit after 8 halvings, an edge of a twisted wall cut into pieces, and the curves + past 1,048,576 for one solid stay straight. The tolerances and limits are VectorCraft's own values. Inflate's + faces are polygons. - 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