3D and Materials: curves follow a tolerance, and Rotate takes large outlines

Extrude & Bevel, Inflate and Rotate sampled every curve at sixteen points and
refused a shape past 1,024 points, about 64 curves: outlined words reached
that quickly, and Rotate refused them although it only turns the outline.

- Each curve takes as many straight pieces as keep it within 1/2000 of the
  shape's size (at least 0.05 pt), by Wang's bound: 1 to 32.
- Extrude & Bevel and Inflate take up to 2,048 points, Rotate 65,536.
- Inflate's distance and crossing tests use a bucketed edge index, and
  cells no edge crosses skip clipping: about 80 ms at 2,000 points (it was
  over 600 ms with the old search; 95 ms at the old 1,024-point limit).

Revolve's own sampling is unchanged.
This commit is contained in:
Ayberk A. authored and bflatastic committed 2026-10-13 03:14:50 +07:00
1 parent 905a965d1e
commit b3ada24da7
3 files changed
+253 -44

No files matched your search

+186 -42
View File
@@ -11,7 +11,7 @@
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
use crate::{MAX_FACES, MAX_PROFILE_POINTS, Revolve, V3};
use crate::{MAX_FACES, Revolve, V3};
/// The view shared by every 3D effect: rotation, perspective and one directional light.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -253,14 +253,32 @@ impl Inflate {
}
}
/// Outline points Extrude & Bevel and Inflate sample at most: every point costs walls, bevel
/// bands and distance tests.
const MAX_SOLID_POINTS: usize = 2048;
/// Outline points Rotate samples at most: it only turns the outline, so an outlined paragraph fits.
const MAX_PLANE_POINTS: usize = 65536;
/// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from
/// its chords), 1 to 32.
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 }
}
/// 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.
fn rings(path: &PathData) -> Result<Vec<Vec<Point>>, String> {
/// 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
/// few points and large ones more; more than `limit` points in all is refused.
fn rings(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
let tolerance = (size / 2000.0).max(0.05);
let mut count = 0;
let mut out = vec![];
for sp in &path.subpaths {
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
if segments > MAX_PROFILE_POINTS {
if segments > limit {
return Err("The shape is too complex for a 3D effect".into());
}
let mut ring: Vec<Point> = vec![];
@@ -269,14 +287,14 @@ fn rings(path: &PathData) -> Result<Vec<Vec<Point>>, String> {
}
for i in 0..segments {
let curve = sp.segment(i);
let samples: u32 = if sp.segment_is_line(i) { 1 } else { 16 };
let samples = if sp.segment_is_line(i) { 1 } else { pieces(curve, tolerance) };
count += samples as usize;
if count > limit {
return Err("The shape is too complex for a 3D effect".into());
}
for k in 1..=samples {
ring.push(curve.eval(f64::from(k) / f64::from(samples)));
}
count += samples as usize;
if count > MAX_PROFILE_POINTS {
return Err("The shape is too complex for a 3D effect".into());
}
}
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());
@@ -585,11 +603,11 @@ fn steps(o: &Extrude) -> Vec<Step> {
pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let o = options.sanitized();
let rings = rings(path)?;
// No depth: the flat shape, seen from either side.
if o.depth <= 0.0 {
return plane(path, bounds, view);
}
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
let steps = steps(&o);
@@ -625,7 +643,7 @@ pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> R
/// shape's own, so that every part of one object turns about the same point).
pub fn plane(path: &PathData, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let rings = rings(path)?;
let rings = rings(path, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, facets: vec![] };
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
@@ -633,18 +651,138 @@ pub fn plane(path: &PathData, bounds: Rect, view: View) -> Result<Vec<Facet>, St
Ok(b.finish())
}
/// The distance from `p` to the nearest edge of `rings`.
/// The distance from `p` to the segment `a`–`b`.
fn segment_distance(p: Point, a: Point, b: Point) -> f64 {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let l2 = dx * dx + dy * dy;
let t = if l2 < 1e-18 { 0.0 } else { (((p.x - a.x) * dx + (p.y - a.y) * dy) / l2).clamp(0.0, 1.0) };
p.distance(Point::new(a.x + dx * t, a.y + dy * t))
}
/// The distance from `p` to the nearest edge of `rings`, edge by edge.
#[cfg(test)]
fn edge_distance(rings: &[Vec<Point>], p: Point) -> f64 {
let mut best = f64::INFINITY;
for r in rings {
for (a, b) in r.iter().zip(r.iter().cycle().skip(1)).take(r.len()) {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let l2 = dx * dx + dy * dy;
let t = if l2 < 1e-18 { 0.0 } else { (((p.x - a.x) * dx + (p.y - a.y) * dy) / l2).clamp(0.0, 1.0) };
best = best.min(p.distance(Point::new(a.x + dx * t, a.y + dy * t)));
rings
.iter()
.flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()))
.map(|(a, b)| segment_distance(p, *a, *b))
.fold(f64::INFINITY, f64::min)
}
/// 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.
struct Edges {
edges: Vec<(Point, Point)>,
origin: Point,
size: f64,
n: usize,
buckets: Vec<Vec<u32>>,
}
impl Edges {
fn new(rings: &[Vec<Point>]) -> Self {
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();
let bounds = edges.iter().fold(None, |b: Option<Rect>, (a, c)| Some(b.map_or(Rect::from_points(*a, *c), |b| b.union_pt(*a).union_pt(*c))));
let bounds = bounds.unwrap_or(Rect::ZERO);
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));
for j in j0..=j1 {
for i in i0..=i1 {
buckets[j * N + i].push(k as u32);
}
}
}
Self { edges, origin: bounds.origin(), size, n: N, buckets }
}
/// Is `p` within `tolerance` of an edge? Only the buckets `p` could share an edge with are
/// looked at.
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 index = |v: f64, o: f64| (((v - o) / self.size).floor() as isize).clamp(0, n - 1);
let (i0, i1) = (index(p.x - tolerance, self.origin.x), index(p.x + tolerance, self.origin.x));
let (j0, j1) = (index(p.y - tolerance, self.origin.y), index(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)
})
})
}
}
best
/// 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;
let index = |v: f64, o: f64| (((v - o) / self.size).floor() as isize).clamp(0, n - 1);
for j in index(r.y0, self.origin.y)..=index(r.y1, self.origin.y) {
for i in index(r.x0, self.origin.x)..=index(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)) {
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);
}
}
}
}
}
/// Does any edge pass through `r` (its bounding box, at least, overlapping it)?
fn touch(&self, r: Rect) -> bool {
let n = self.n as isize;
let index = |v: f64, o: f64| (((v - o) / self.size).floor() as isize).clamp(0, n - 1);
let (i0, i1, j0, j1) = (index(r.x0, self.origin.x), index(r.x1, self.origin.x), index(r.y0, self.origin.y), index(r.y1, 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)| 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)
})
})
}
/// The distance from `p` to the nearest edge: the buckets are searched in growing squares
/// around `p`'s, until no unsearched bucket could hold a nearer edge.
fn distance(&self, p: Point) -> f64 {
let (fx, fy) = ((p.x - self.origin.x) / self.size, (p.y - self.origin.y) / self.size);
if !fx.is_finite() || !fy.is_finite() {
return f64::INFINITY;
}
let n = self.n as isize;
let (ci, cj) = ((fx.floor() as isize).clamp(0, n - 1), (fy.floor() as isize).clamp(0, n - 1));
// How far `p` is outside the grid, which every bucket's distance bound must add.
let outside = (fx.clamp(0.0, n as f64) - fx).hypot(fy.clamp(0.0, n as f64) - fy) * self.size;
let mut best = f64::INFINITY;
for r in 0..n {
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] {
if let Some((a, b)) = self.edges.get(*k as usize) {
best = best.min(segment_distance(p, *a, *b));
}
}
}
}
// Buckets r + 1 away are at least r buckets from any point of `p`'s own.
if best <= outside + r as f64 * self.size {
break;
}
}
best
}
}
/// Is `p` filled by `rings` (nonzero winding, as they are oriented)?
@@ -695,36 +833,36 @@ struct Membrane {
impl Membrane {
fn solve(rings: &[Vec<Point>], origin: Point, step: f64, nx: usize, ny: usize) -> Self {
let edges = Edges::new(rings);
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<bool> = (0..nx * ny)
.map(|k| {
let p = node(k % nx, k / nx);
filled(rings, p) && edge_distance(rings, p) > 1e-6 * step
filled(rings, p) && !edges.near(p, 1e-6 * step)
})
.collect();
// 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;
for r in rings {
for (a, b) in r.iter().zip(r.iter().cycle().skip(1)).take(r.len()) {
let t = if dy == 0.0 {
if (a.y > p.y) == (b.y > p.y) {
continue;
}
(a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) - p.x) * dx
} else {
if (a.x > p.x) == (b.x > p.x) {
continue;
}
(a.y + (p.x - a.x) / (b.x - a.x) * (b.y - a.y) - p.y) * dy
};
if t > 1e-9 * step && t < best {
best = t;
let reach = Rect::from_points(p, Point::new(p.x + dx * step, p.y + dy * step));
edges.each(reach, |a, b| {
let t = if dy == 0.0 {
if (a.y > p.y) == (b.y > p.y) {
return;
}
(a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) - p.x) * dx
} else {
if (a.x > p.x) == (b.x > p.x) {
return;
}
(a.y + (p.x - a.x) / (b.x - a.x) * (b.y - a.y) - p.y) * dy
};
if t > 1e-9 * step && t < best {
best = t;
}
}
});
best
};
// Each inside node's four neighbours: (weight, neighbour inside or None at the outline).
@@ -781,14 +919,14 @@ impl Membrane {
if inside[k] {
continue;
}
let d_out = edge_distance(rings, node(i, j));
let d_out = edges.distance(node(i, j));
let mut slopes = vec![];
for (di, dj) in [(1isize, 0isize), (-1, 0), (0, 1), (0, -1)] {
let (ni, nj) = (i as isize + di, j as isize + dj);
if ni < 0 || nj < 0 || ni as usize >= nx || nj as usize >= ny || !inside[idx(ni as usize, nj as usize)] {
continue;
}
let d_in = edge_distance(rings, node(ni as usize, nj as usize)).max(1e-9);
let d_in = edges.distance(node(ni as usize, nj as usize)).max(1e-9);
slopes.push(phi[idx(ni as usize, nj as usize)] / d_in);
}
if !slopes.is_empty() {
@@ -825,14 +963,14 @@ fn material(outline: &PathData, cell: Rect) -> Option<Vec<Vec<Point>>> {
let part =
vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
.ok()?;
rings(&part).ok()
rings(&part, cell.width(), MAX_PLANE_POINTS).ok()
}
/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is.
pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let o = options.sanitized();
let rings = rings(path)?;
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
if rings.is_empty() {
return Ok(vec![]);
}
@@ -844,11 +982,12 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
let cell = size / CELLS as f64;
let (nx, ny) = (((bounds.width() / cell).ceil() as usize).max(1), ((bounds.height() / cell).ceil() as usize).max(1));
let m = Membrane::solve(&rings, bounds.origin(), cell, nx + 1, ny + 1);
let edges = Edges::new(&rings);
let outline = PathData::new(rings.iter().map(|r| vectorcraft_geom::SubPath::polyline(r, true)).collect());
// A circle of radius r puffs into a half sphere of radius r at 50 % (its membrane peaks at r²/4).
let height = 2.0 * m.peak.sqrt() * o.volume / 50.0;
let lift = |p: Point| {
if edge_distance(&rings, p) < 1e-9 || m.peak <= 0.0 {
if m.peak <= 0.0 || edges.near(p, 1e-9) {
return 0.0;
}
height * (m.at(p).max(0.0) / m.peak).sqrt()
@@ -868,6 +1007,11 @@ pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> R
let base = |i: usize, j: usize| Rect::from_points(at(i * FINE, j * FINE), at((i + 1) * FINE, (j + 1) * FINE));
// The shape's part of a rectangle, as rings.
let cut = |r: Rect| -> Vec<Vec<Point>> {
// 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![] };
}
let pieces: Vec<Vec<Point>> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect();
if pieces.iter().map(|p| signed_area(p)).sum::<f64>() <= 1e-9 * r.area() {
return vec![];
+64 -2
View File
@@ -252,10 +252,10 @@ fn the_inflate_membrane_matches_known_solutions() {
let mut c = BezPath::new();
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 disc = Membrane::solve(&rings(&p).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
let disc = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).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 sq = Membrane::solve(&rings(&p).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
let sq = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).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.
@@ -287,3 +287,65 @@ fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() {
let flat = inflate(&p, r, Inflate::default(), front()).unwrap();
assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat));
}
/// A row of `count` small bumps, each a curve, closed along the bottom: like an outlined word,
/// many short curves.
fn bumps(count: usize) -> (PathData, Rect) {
let mut b = BezPath::new();
b.move_to((0.0, 20.0));
for i in 0..count {
let x = i as f64 * 4.0;
b.curve_to((x + 1.0, 14.0), (x + 3.0, 14.0), (x + 4.0, 20.0));
}
b.line_to((count as f64 * 4.0, 30.0));
b.line_to((0.0, 30.0));
b.close_path();
shape(&b)
}
/// 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.
#[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.
let (p, r) = bumps(300);
assert!(extrude(&p, r, Extrude::default(), front()).is_ok());
assert!(inflate(&p, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
assert!(plane(&p, r, front()).is_ok());
// 4,000 curves: too many for a solid, not for Rotate.
let (p, r) = bumps(4000);
assert!(extrude(&p, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
assert_eq!(plane(&p, r, front()).unwrap().len(), 1);
// 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();
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0), 0.01));
let (p, r) = shape(&c);
let ring = &rings(&p, 100.0, MAX_SOLID_POINTS).unwrap()[0];
for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)) {
let mid = a.midpoint(*b);
assert!(50.0 - mid.distance(Point::new(50.0, 50.0)) <= 0.05 + 1e-3, "{a:?} {b:?}");
}
assert!(r.width() > 0.0);
// A small bump takes a few points; a large curve more than sixteen where it needs them.
let (p, r) = bumps(300);
let small = rings(&p, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len();
assert!(small <= 300 * 4 + 3, "{small} points for 300 bumps");
assert!(ring.len() > 4 * 16, "a 100 pt circle within 0.05 pt: {}", ring.len());
}
/// The bucketed nearest-edge search agrees with checking every edge, inside, outside and far
/// beyond the shape.
#[test]
fn the_edge_index_finds_the_nearest_edge() {
let (p, r) = bumps(200);
let rings = rings(&p, r.width(), MAX_SOLID_POINTS).unwrap();
let edges = Edges::new(&rings);
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);
let (fast, slow) = (edges.distance(q), edge_distance(&rings, q));
assert!((fast - slow).abs() < 1e-9, "{q:?}: {fast} against {slow}");
}
}
+3
View File
@@ -88,5 +88,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used.
front. Concave solids seen at steep angles can overlap wrongly; open (hollow) solids show both sides.
- Shading is flat per facet: an inflated rim seen almost edge-on shows faint banding where the facets get finer.
- Paths and compound paths only: convert type, images and groups to outlines first. 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, Rotate (which only turns
the outline) 65,536.
- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate,
unbuilt items.