mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
905a965d1e
commit
b3ada24da7
3 files changed
+253
-44
No files matched your search
+186
-42
@@ -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![];
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user