From b3ada24da73f25b023ca2296a5ee89d50c863871 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:09:45 +0300 Subject: [PATCH 1/2] 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. --- crates/three-d/src/solid.rs | 228 ++++++++++++++++++++++++------ crates/three-d/src/tests_solid.rs | 66 ++++++++- docs/three-d.md | 3 + 3 files changed, 253 insertions(+), 44 deletions(-) diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 37ee6d03..27f44e60 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -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>, 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>, 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 = vec![]; @@ -269,14 +287,14 @@ fn rings(path: &PathData) -> Result>, 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 { pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result, 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, 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> = 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, 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], 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>, +} + +impl Edges { + fn new(rings: &[Vec]) -> 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, (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], 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 = (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>> { 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, 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> { + // 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> = rings.iter().map(|ring| clip(ring, r)).filter(|c| !c.is_empty()).collect(); if pieces.iter().map(|p| signed_area(p)).sum::() <= 1e-9 * r.area() { return vec![]; diff --git a/crates/three-d/src/tests_solid.rs b/crates/three-d/src/tests_solid.rs index 48033594..6dc94565 100644 --- a/crates/three-d/src/tests_solid.rs +++ b/crates/three-d/src/tests_solid.rs @@ -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}"); + } +} diff --git a/docs/three-d.md b/docs/three-d.md index ae970612..91f4dbd3 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -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. From c3c036daf2ecd478cc08e94c1160835a35bce3ac Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 02:56:39 +0700 Subject: [PATCH 2/2] 3D and Materials: the edge index's search from outside its grid looks along the side A point outside the grid can be nearer an edge further along the grid's side than one straight ahead of it; the search stopped a ring early there. The bucket lookup is one function. --- crates/three-d/src/solid.rs | 26 +++++++++++++++----------- crates/three-d/src/tests_solid.rs | 13 +++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 27f44e60..f12fc2d7 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -701,14 +701,18 @@ impl Edges { Self { edges, origin: bounds.origin(), size, n: N, buckets } } + /// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis. + fn cell(&self, v: f64, o: f64) -> isize { + (((v - o) / self.size).floor() as isize).clamp(0, self.n as isize - 1) + } + /// 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)); + let (i0, i1) = (self.cell(p.x - tolerance, self.origin.x), self.cell(p.x + tolerance, self.origin.x)); + let (j0, j1) = (self.cell(p.y - tolerance, self.origin.y), self.cell(p.y + tolerance, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { self.buckets[(j * n + i) as usize] @@ -724,9 +728,8 @@ impl Edges { /// 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 j in self.cell(r.y0, self.origin.y)..=self.cell(r.y1, self.origin.y) { + for i in self.cell(r.x0, self.origin.x)..=self.cell(r.x1, self.origin.x) { for (a, b) in self.buckets[(j * n + i) as usize].iter().filter_map(|k| self.edges.get(*k as usize)) { 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); @@ -739,8 +742,8 @@ impl Edges { /// 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)); + let (i0, i1) = (self.cell(r.x0, self.origin.x), self.cell(r.x1, self.origin.x)); + let (j0, j1) = (self.cell(r.y0, self.origin.y), self.cell(r.y1, self.origin.y)); (j0..=j1).any(|j| { (i0..=i1).any(|i| { self.buckets[(j * n + i) as usize] @@ -760,7 +763,7 @@ impl Edges { } 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. + // How far `p` is outside the grid: the nearest point of the grid to it lies in `p`'s bucket. 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 { @@ -776,8 +779,9 @@ impl Edges { } } } - // Buckets r + 1 away are at least r buckets from any point of `p`'s own. - if best <= outside + r as f64 * self.size { + // Buckets r + 1 away are at least r buckets from any point of `p`'s own, and that + // far across the grid from a `p` outside it (at right angles to its way in, at worst). + if best <= outside.hypot(r as f64 * self.size) { break; } } diff --git a/crates/three-d/src/tests_solid.rs b/crates/three-d/src/tests_solid.rs index 6dc94565..424303ba 100644 --- a/crates/three-d/src/tests_solid.rs +++ b/crates/three-d/src/tests_solid.rs @@ -349,3 +349,16 @@ fn the_edge_index_finds_the_nearest_edge() { assert!((fast - slow).abs() < 1e-9, "{q:?}: {fast} against {slow}"); } } + +/// From a point outside the grid, an edge further along the grid's side can be nearer than one +/// straight ahead: the search doesn't stop before it has looked there. +#[test] +fn the_edge_index_looks_along_the_side_from_outside() { + let ring = |b: BezPath| rings(&shape(&b).0, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); + // Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid). + let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))]; + let edges = Edges::new(&rings); + let q = Point::new(-10.0, 0.5); + let (fast, slow) = (edges.distance(q), edge_distance(&rings, q)); + assert!((fast - slow).abs() < 1e-9, "{fast} against {slow}"); +}