vectorcraft/crates/three-d/src/solid.rs

1156 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Extrude & Bevel, Inflate and Rotate (a flat plane) as projected, shaded vector facets.
//!
//! The 2D art is the front of the solid: its outline is extruded backwards along the view's
//! z-axis, about the centre of its bounds, at the art's own position and size. Planar faces (caps
//! and the rotated plane) are drawn whole, holes included, so no triangulation is needed; curved
//! faces (bevels, side walls under twist or taper, inflated surfaces) are cut into small facets
//! and flat shaded, like Revolve.
//!
//! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away
//! from the viewer are left out, the rest are drawn back to front.
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
use crate::{MAX_FACES, Revolve, V3};
/// The view shared by every 3D effect: rotation, perspective and one directional light.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
pub rotation_x: f64,
pub rotation_y: f64,
pub rotation_z: f64,
/// Perspective strength, 0 for orthographic projection, up to 1.
pub perspective: f64,
pub light_azimuth: f64,
pub light_elevation: f64,
pub light_intensity: f64,
pub ambient: f64,
pub shade: bool,
}
impl Default for View {
fn default() -> Self {
let r = Revolve::default();
Self {
rotation_x: r.rotation_x,
rotation_y: r.rotation_y,
rotation_z: r.rotation_z,
perspective: r.perspective,
light_azimuth: r.light_azimuth,
light_elevation: r.light_elevation,
light_intensity: r.light_intensity,
ambient: r.ambient,
shade: r.shade,
}
}
}
impl View {
/// The same rotation, projection and lighting maths as Revolve's.
fn revolve(self) -> Revolve {
Revolve {
rotation_x: self.rotation_x,
rotation_y: self.rotation_y,
rotation_z: self.rotation_z,
perspective: self.perspective,
light_azimuth: self.light_azimuth,
light_elevation: self.light_elevation,
light_intensity: self.light_intensity,
ambient: self.ambient,
shade: self.shade,
..Revolve::default()
}
.sanitized()
}
}
/// The shape of a bevel's cross-section, from the cap's edge (0) to the side wall (1).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BevelShape {
/// A straight 45° chamfer when width and height are equal.
#[default]
Classic,
/// A convex quarter round.
Rounded,
/// A concave quarter round.
Cove,
/// Two flat steps.
Step,
/// A convex then concave S curve.
Ogee,
}
impl BevelShape {
pub const ALL: [BevelShape; 5] = [Self::Classic, Self::Rounded, Self::Cove, Self::Step, Self::Ogee];
pub fn id(self) -> &'static str {
match self {
Self::Classic => "classic",
Self::Rounded => "rounded",
Self::Cove => "cove",
Self::Step => "step",
Self::Ogee => "ogee",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.id() == s)
}
/// The cross-section at `s` (0..1): (share of the width, share of the height), both 0..1.
fn at(self, s: f64) -> (f64, f64) {
let s = s.clamp(0.0, 1.0);
let q = s * std::f64::consts::FRAC_PI_2;
match self {
Self::Classic => (s, s),
Self::Rounded => (1.0 - q.cos(), q.sin()),
Self::Cove => (q.sin(), 1.0 - q.cos()),
Self::Step if s >= 1.0 => (1.0, 1.0),
Self::Step => {
// Down, across, down, across.
let k = s * 4.0;
let (i, f) = (k.floor(), k.fract());
match i as u8 {
0 => (0.0, f * 0.5),
1 => (f * 0.5, 0.5),
2 => (0.5, 0.5 + f * 0.5),
_ => (0.5 + f * 0.5, 1.0),
}
}
Self::Ogee => {
let h = (1.0 - (s * std::f64::consts::PI).cos()) / 2.0;
(s, h)
}
}
}
/// Samples along the cross-section: the step shape needs its corners exactly.
fn samples(self) -> Vec<f64> {
match self {
Self::Classic => vec![0.0, 1.0],
Self::Step => vec![0.0, 0.25, 0.5, 0.75, 1.0],
_ => (0..=8).map(|i| f64::from(i) / 8.0).collect(),
}
}
}
/// A bevel along the front (and with `both_sides` the back) edge of an extrusion.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bevel {
pub shape: BevelShape,
/// How far the bevel reaches across the face, in points.
pub width: f64,
/// How deep it reaches along the extrusion, in points.
pub height: f64,
/// How many times the cross-section repeats across the width.
pub repeat: usize,
/// The share (0..1) of the width left flat between repeats.
pub space: f64,
/// Carved into the shape (true) or added around it (false).
pub inside: bool,
pub both_sides: bool,
}
impl Default for Bevel {
fn default() -> Self {
Self { shape: BevelShape::Classic, width: 4.0, height: 4.0, repeat: 1, space: 0.0, inside: false, both_sides: false }
}
}
/// Extrude & Bevel's options. Lengths are points, angles degrees.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Extrude {
pub depth: f64,
/// The turn of the back face against the front, about the shape's centre.
pub twist: f64,
/// The size of the back face, as a share (0.01..1) of the front's.
pub taper: f64,
/// Closed ends (a solid) or open ones (a hollow tube).
pub cap: bool,
pub bevel: Option<Bevel>,
}
impl Default for Extrude {
fn default() -> Self {
Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None }
}
}
/// Inflate's options.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Inflate {
/// The flat depth between the front and back, in points.
pub depth: f64,
/// How far the surface puffs out (0..100 %; 50 % rounds it like a half sphere).
pub volume: f64,
/// Puff the back out too; otherwise it stays flat.
pub both_sides: bool,
}
impl Default for Inflate {
fn default() -> Self {
Self { depth: 0.0, volume: 50.0, both_sides: false }
}
}
/// One projected, flat-shaded polygon. Holes are contours of their own (nonzero winding).
#[derive(Clone, Debug, PartialEq)]
pub struct Facet {
pub contours: Vec<Vec<Point>>,
pub depth: f64,
pub brightness: f64,
}
impl Facet {
/// About how thick the facet is across: twice its area over its perimeter (a w × l rectangle
/// long against its width gives about w).
pub fn thickness(&self) -> f64 {
let area: f64 = self.contours.iter().map(|c| signed_area(c)).sum::<f64>().abs();
let perimeter: f64 =
self.contours.iter().map(|c| c.iter().zip(c.iter().cycle().skip(1)).take(c.len()).map(|(a, b)| a.distance(*b)).sum::<f64>()).sum();
if perimeter > 0.0 { 2.0 * area / perimeter } else { 0.0 }
}
pub fn path(&self) -> PathData {
let mut bp = BezPath::new();
for c in &self.contours {
let Some(first) = c.first() else { continue };
bp.move_to(*first);
for p in c.iter().skip(1) {
bp.line_to(*p);
}
bp.close_path();
}
PathData::from_bezpath(&bp)
}
}
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
if v.is_finite() { v.clamp(min, max) } else { fallback }
}
impl Extrude {
fn sanitized(self) -> Self {
Self {
depth: finite(self.depth, 50.0, 0.0, 2000.0),
twist: finite(self.twist, 0.0, -360.0, 360.0),
taper: finite(self.taper, 1.0, 0.01, 1.0),
bevel: self.bevel.map(|b| Bevel {
width: finite(b.width, 4.0, 0.0, 1000.0),
height: finite(b.height, 4.0, 0.0, 1000.0),
repeat: b.repeat.clamp(1, 32),
space: finite(b.space, 0.0, 0.0, 0.9),
..b
}),
..self
}
}
}
impl Inflate {
fn sanitized(self) -> Self {
Self { depth: finite(self.depth, 0.0, 0.0, 2000.0), volume: finite(self.volume, 50.0, 0.0, 100.0), ..self }
}
}
/// 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. 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 > limit {
return Err("The shape is too complex for a 3D effect".into());
}
let mut ring: Vec<Point> = vec![];
if let Some(first) = sp.anchors.first() {
ring.push(first.p);
}
for i in 0..segments {
let curve = sp.segment(i);
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)));
}
}
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();
}
if ring.len() >= 3 && signed_area(&ring).abs() > 1e-9 {
out.push(ring);
}
}
// A ring inside an odd number of others is a hole.
let holes: Vec<bool> = out
.iter()
.enumerate()
.map(|(i, r)| {
let p = r.first().copied().unwrap_or_default();
out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).count() % 2 == 1
})
.collect();
for (r, hole) in out.iter_mut().zip(holes) {
if (signed_area(r) > 0.0) == hole {
r.reverse();
}
}
Ok(out)
}
fn signed_area(r: &[Point]) -> f64 {
r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>() / 2.0
}
fn contains(r: &[Point], p: Point) -> bool {
let mut inside = false;
for (a, b) in r.iter().zip(r.iter().cycle().skip(1)).take(r.len()) {
if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) {
inside = !inside;
}
}
inside
}
/// The unit normal on the left of `a`→`b`: towards the filled side.
fn left(a: Point, b: Point) -> (f64, f64) {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let l = dx.hypot(dy);
if l < 1e-12 { (0.0, 0.0) } else { (-dy / l, dx / l) }
}
/// `ring` moved `w` points towards its filled side (negative: away from it), each corner along
/// its bisector, its mitre limited to four times `w` so sharp corners don't shoot off. Each point
/// stays the partner of its source point, so rings at different insets join face to face.
fn inset(ring: &[Point], w: f64) -> Vec<Point> {
if w == 0.0 {
return ring.to_vec();
}
let n = ring.len();
(0..n)
.map(|i| {
let p = ring[i];
let prev = ring[(i + n - 1) % n];
let next = ring[(i + 1) % n];
let (ax, ay) = left(prev, p);
let (bx, by) = left(p, next);
let (mut mx, mut my) = (ax + bx, ay + by);
let ml = mx.hypot(my);
if ml < 1e-9 {
(mx, my) = (ax, ay);
} else {
(mx, my) = (mx / ml, my / ml);
}
let cos = (mx * ax + my * ay).max(0.25);
Point::new(p.x + mx * w / cos, p.y + my * w / cos)
})
.collect()
}
/// Turns 3D points into document points and shades faces for one view.
struct Camera {
view: Revolve,
origin: Point,
distance: f64,
light: V3,
}
impl Camera {
fn new(view: View, bounds: Rect, depth: f64) -> Self {
let view = view.revolve();
let [lx, ly, lz] = view.light_direction();
let size = (bounds.width() / 2.0).max(bounds.height() / 2.0).max(depth / 2.0).max(1.0);
Self { view, origin: bounds.center(), distance: 4.0 * size, light: V3(lx, ly, lz) }
}
fn rotate(&self, v: V3) -> V3 {
self.view.rotate(v)
}
/// A point of the art, `z` points in front of its plane, in view space.
fn place(&self, p: Point, z: f64) -> V3 {
self.rotate(V3(p.x - self.origin.x, p.y - self.origin.y, z))
}
fn project(&self, v: V3) -> Point {
let f = self.distance / (self.distance - v.2 * self.view.perspective);
Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f)
}
/// The cosine between a face's outward unit normal `n` and the direction from `at` (both in
/// view space) to the eye: positive when the face is turned towards the viewer.
fn facing(&self, at: V3, n: V3) -> f64 {
let to_eye = if self.view.perspective > 0.0 { V3(0.0, 0.0, self.distance / self.view.perspective).sub(at).unit() } else { V3(0.0, 0.0, 1.0) };
n.dot(to_eye)
}
fn brightness(&self, n: V3) -> f64 {
if self.view.shade { (self.view.ambient + self.view.light_intensity * n.dot(self.light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 }
}
}
/// Facets being collected for one solid.
struct Builder<'a> {
camera: &'a Camera,
/// A closed solid hides the faces turned away from the viewer; an open one shows their
/// insides.
closed: bool,
facets: Vec<Facet>,
}
impl Builder<'_> {
/// Add a face of view-space contours with outward normal `n` (view space).
fn face(&mut self, contours: Vec<Vec<V3>>, n: V3) -> Result<(), String> {
let count: usize = contours.iter().map(Vec::len).sum();
if count < 3 {
return Ok(());
}
let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2));
let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64);
let n = n.unit();
let facing = self.camera.facing(centre, n);
// A closed solid hides the faces turned away. One seen almost exactly edge-on is kept but
// drawn first, under everything: on a curved surface rounding alone turns such faces
// either way, and dropping every other one would leave gaps.
let (n, behind) = if facing > 0.0 {
(n, false)
} else if self.closed && facing > -0.02 {
(n, true)
} else if self.closed {
return Ok(());
} else {
(V3(-n.0, -n.1, -n.2), false)
};
let contours: Vec<Vec<Point>> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect();
if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return Err("The 3D projection is not finite".into());
}
if contours.iter().map(|c| signed_area(c).abs()).sum::<f64>() < 1e-10 {
return Ok(());
}
let depth = if behind { centre.2 - 4.0 * self.camera.distance } else { centre.2 };
self.facets.push(Facet { contours, depth, brightness: self.camera.brightness(n) });
if self.facets.len() > MAX_FACES {
return Err("The 3D surface is too complex; simplify the shape".into());
}
Ok(())
}
/// The band between two partner rings (same length), as one quad per edge. `outward` is the
/// direction (before rotation) the band faces away from the solid, for orienting normals.
fn band(&mut self, a: &[V3], b: &[V3], outward: impl Fn(usize) -> V3) -> Result<(), String> {
let n = a.len().min(b.len());
for i in 0..n {
let j = (i + 1) % n;
let quad = vec![a[i], a[j], b[j], b[i]];
let mut normal = quad[1].sub(quad[0]).cross(quad[2].sub(quad[0]));
if normal.dot(normal) < 1e-18 {
normal = quad[2].sub(quad[0]).cross(quad[3].sub(quad[0]));
}
if normal.dot(normal) < 1e-18 {
continue;
}
if normal.dot(self.camera.rotate(outward(i))) < 0.0 {
normal = V3(-normal.0, -normal.1, -normal.2);
}
self.face(vec![quad], normal)?;
}
Ok(())
}
fn finish(mut self) -> Vec<Facet> {
self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth));
self.facets
}
}
/// The area-weighted normal of (possibly non-planar) contours, by Newell's method: for contours
/// winding with their filled side on the left as seen from in front, it points to the front.
fn newell(contours: &[Vec<V3>]) -> V3 {
let mut n = V3::default();
for c in contours {
for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) {
let x = a.cross(*b);
n = V3(n.0 + x.0, n.1 + x.1, n.2 + x.2);
}
}
n
}
fn check_bounds(b: Rect) -> Result<(), String> {
if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
Ok(())
} else {
Err("3D effects require finite coordinates within 100 million points".into())
}
}
/// The art's outward direction at edge `i` of `ring` (before rotation), tilted towards the face
/// it borders by `z` (+1 front, -1 back, 0 a side wall).
fn outward(ring: &[Point], i: usize, z: f64) -> V3 {
let n = ring.len();
let (lx, ly) = left(ring[i], ring[(i + 1) % n]);
V3(-lx, -ly, z)
}
/// The cross-section of a side: each ring of the shape at depth fraction `t` (0 front, 1 back),
/// turned by the twist and scaled by the taper about the centre of `bounds`.
fn section(ring: &[Point], bounds: Rect, o: &Extrude, t: f64) -> Vec<Point> {
let c = bounds.center();
let (s, cos) = (o.twist * t).to_radians().sin_cos();
let k = 1.0 + (o.taper - 1.0) * t;
ring.iter()
.map(|p| {
let (x, y) = ((p.x - c.x) * k, (p.y - c.y) * k);
Point::new(c.x + x * cos - y * s, c.y + x * s + y * cos)
})
.collect()
}
/// One step of an extrusion's outline: how far it is inset (negative: outset) and its depth
/// fraction (0 front, 1 back), with the face it borders (+1 front bevel, -1 back bevel, 0 wall).
#[derive(Clone, Copy)]
struct Step {
inset: f64,
t: f64,
face: f64,
}
/// The outline's steps from the front cap's edge to the back cap's edge.
fn steps(o: &Extrude) -> Vec<Step> {
let d = o.depth.max(1e-6);
let mut out = vec![];
let bevel = o.bevel.filter(|b| b.width > 0.0 || b.height > 0.0);
// A bevel can't be deeper than the extrusion (half of it on each side with both sides).
let sides = if bevel.is_some_and(|b| b.both_sides) { 2.0 } else { 1.0 };
let height = bevel.map_or(0.0, |b| b.height.min(o.depth / sides));
// The bevel's cross-section across the width: (share across, share down).
let profile = |b: &Bevel| -> Vec<(f64, f64)> {
let mut pts = vec![(0.0, 0.0)];
let n = b.repeat.max(1) as f64;
let run = (1.0 - b.space) / n;
let gap = if b.repeat > 1 { b.space / (n - 1.0) } else { 0.0 };
for k in 0..b.repeat.max(1) {
let start = k as f64 * (run + gap);
for s in b.shape.samples().into_iter().skip(1) {
let (x, y) = b.shape.at(s);
pts.push((start + x * run, (k as f64 + y) / n));
}
if b.repeat > 1 && k + 1 < b.repeat {
pts.push((start + run + gap, (k as f64 + 1.0) / n));
}
}
if let Some(last) = pts.last_mut() {
last.0 = 1.0;
}
pts
};
// With the bevel inside, the cap is inset by its width and the wall is the outline; outside,
// the cap is the outline and the wall is outset by the width.
let wall = match bevel {
Some(b) if !b.inside => -b.width,
_ => 0.0,
};
if let Some(b) = bevel {
for (x, y) in profile(&b) {
let inset = if b.inside { b.width * (1.0 - x) } else { -b.width * x };
out.push(Step { inset, t: y * height / d, face: 1.0 });
}
} else {
out.push(Step { inset: 0.0, t: 0.0, face: 1.0 });
}
// Twist and taper bend the walls: cut them into slices.
let slices = if o.twist != 0.0 || o.taper != 1.0 { ((o.twist.abs() / 6.0).ceil() as usize).clamp(8, 64) } else { 1 };
let back_height = if bevel.is_some_and(|b| b.both_sides) { height } else { 0.0 };
let (t0, t1) = (height / d, 1.0 - back_height / d);
for k in 0..=slices {
let t = t0 + (t1 - t0) * k as f64 / slices as f64;
out.push(Step { inset: wall, t, face: 0.0 });
}
if let Some(b) = bevel.filter(|b| b.both_sides) {
for (x, y) in profile(&b).into_iter().rev() {
let inset = if b.inside { b.width * (1.0 - x) } else { -b.width * x };
out.push(Step { inset, t: 1.0 - y * height / d, face: -1.0 });
}
}
out.dedup_by(|a, b| (a.inset - b.inset).abs() < 1e-9 && (a.t - b.t).abs() < 1e-12);
out
}
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
/// and caps.
pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let o = options.sanitized();
// 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);
let z = |t: f64| o.depth / 2.0 - o.depth * t;
let ring_at =
|ring: &[Point], s: &Step| -> Vec<V3> { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() };
for ring in &rings {
let placed: Vec<Vec<V3>> = 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 };
// A band between two front bevel steps borders the front, one reaching a back bevel
// step the back; the rest are walls.
let face = if s1.face < 0.0 {
-1.0
} else if s0.face > 0.0 && s1.face > 0.0 {
1.0
} else {
0.0
};
b.band(a, c, |i| outward(ring, i, face))?;
}
}
if o.cap {
for (s, n) in [(steps.first(), 1.0), (steps.last(), -1.0)] {
let Some(s) = s else { continue };
b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)))?;
}
}
Ok(b.finish())
}
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
/// 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, 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();
b.face(contours, camera.rotate(V3(0.0, 0.0, 1.0)))?;
Ok(b.finish())
}
/// 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 {
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 }
}
/// 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 (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]
.iter()
.filter_map(|k| self.edges.get(*k as usize))
.any(|(a, b)| segment_distance(p, *a, *b) <= tolerance)
})
})
}
}
/// 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;
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);
}
}
}
}
}
/// 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 (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]
.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: 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 {
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, 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;
}
}
best
}
}
/// Is `p` filled by `rings` (nonzero winding, as they are oriented)?
fn filled(rings: &[Vec<Point>], p: Point) -> bool {
rings.iter().filter(|r| contains(r, p)).count() % 2 == 1
}
/// `ring` cut to the rectangle `cell` (Sutherland–Hodgman: the rectangle is convex, the ring
/// needn't be).
fn clip(ring: &[Point], cell: Rect) -> Vec<Point> {
let mut pts = ring.to_vec();
let sides: [fn(Point, Rect) -> f64; 4] = [|p, c| p.x - c.x0, |p, c| c.x1 - p.x, |p, c| p.y - c.y0, |p, c| c.y1 - p.y];
for side in sides {
if pts.is_empty() {
break;
}
let mut out = vec![];
for (a, b) in pts.iter().zip(pts.iter().cycle().skip(1)).take(pts.len()) {
let (da, db) = (side(*a, cell), side(*b, cell));
if da >= 0.0 {
out.push(*a);
}
if (da >= 0.0) != (db >= 0.0) {
out.push(a.lerp(*b, da / (da - db)));
}
}
pts = out;
}
pts.dedup_by(|a, b| a.distance(*b) < 1e-9);
if pts.len() >= 3 && signed_area(&pts).abs() > 1e-12 { pts } else { vec![] }
}
/// The shape's surface as a membrane under pressure, held at zero along its outline (and its
/// holes): the solution of ∇²φ = −1 on a grid, with each node's distance to the outline along the
/// grid lines taken into account where it is less than a grid step (Shortley–Weller), so the
/// outline needn't fall on the grid. √φ rises vertically from the outline and rounds smoothly
/// inside, without the creases a distance-to-the-edge height has along a shape's middle.
struct Membrane {
origin: Point,
step: f64,
nx: usize,
ny: usize,
/// φ at each node; outside the shape, extrapolated (negative) from the inside, so
/// interpolation crosses zero near the outline.
phi: Vec<f64>,
peak: f64,
}
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) && !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;
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).
let mut stencil: Vec<[(f64, Option<usize>); 4]> = vec![[(0.0, None); 4]; nx * ny];
for j in 0..ny {
for i in 0..nx {
let k = idx(i, j);
if !inside[k] {
continue;
}
let p = node(i, j);
let dirs = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)];
let h: Vec<f64> = dirs.iter().map(|(dx, dy)| crossing(p, *dx, *dy)).collect();
for (d, (dx, dy)) in dirs.iter().enumerate() {
let opp = h[d ^ 1];
let w = 2.0 / (h[d] * (h[d] + opp));
let (ni, nj) = (i as isize + *dx as isize, j as isize + *dy as isize);
let neighbour = (h[d] >= step && ni >= 0 && nj >= 0 && (ni as usize) < nx && (nj as usize) < ny)
.then(|| idx(ni as usize, nj as usize))
.filter(|n| inside[*n]);
stencil[k][d] = (w, neighbour);
}
}
}
// Successive over-relaxation.
let mut phi = vec![0.0; nx * ny];
let tolerance = 1e-9 * step * step * (nx * ny) as f64;
for _ in 0..2000 {
let mut change: f64 = 0.0;
for k in 0..nx * ny {
if !inside[k] {
continue;
}
let (mut sum, mut weight) = (1.0, 0.0);
for (w, n) in stencil[k] {
weight += w;
sum += w * n.map_or(0.0, |n| phi[n]);
}
let next = phi[k] + 1.8 * (sum / weight - phi[k]);
change = change.max((next - phi[k]).abs());
phi[k] = next;
}
if change < tolerance {
break;
}
}
let peak = phi.iter().copied().fold(0.0, f64::max);
// Outside nodes next to the shape: φ continued past the outline at the slope of their
// inside neighbours, so values interpolated between them reach zero at the outline.
let mut ext = phi.clone();
for j in 0..ny {
for i in 0..nx {
let k = idx(i, j);
if inside[k] {
continue;
}
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 = 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() {
ext[k] = -d_out * slopes.iter().sum::<f64>() / slopes.len() as f64;
}
}
}
Self { origin, step, nx, ny, phi: ext, peak }
}
/// φ at `p`, interpolated between the four nodes around it.
fn at(&self, p: Point) -> f64 {
let fx = ((p.x - self.origin.x) / self.step).clamp(0.0, (self.nx - 1) as f64);
let fy = ((p.y - self.origin.y) / self.step).clamp(0.0, (self.ny - 1) as f64);
let (i, j) = ((fx.floor() as usize).min(self.nx.saturating_sub(2)), (fy.floor() as usize).min(self.ny.saturating_sub(2)));
let (tx, ty) = (fx - i as f64, fy - j as f64);
let v = |i: usize, j: usize| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0);
if self.nx < 2 || self.ny < 2 {
return v(0, 0);
}
let top = v(i, j) * (1.0 - tx) + v(i + 1, j) * tx;
let bottom = v(i, j + 1) * (1.0 - tx) + v(i + 1, j + 1) * tx;
top * (1.0 - ty) + bottom * ty
}
}
/// The part of `outline` inside `cell`, as rings (holes running the other way).
fn material(outline: &PathData, cell: Rect) -> Option<Vec<Vec<Point>>> {
let r = PathData::single(vectorcraft_geom::SubPath::polyline(
&[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)],
true,
));
let rule = vectorcraft_geom::FillRule::NonZero;
let part =
vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
.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, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
if rings.is_empty() {
return Ok(vec![]);
}
let size = bounds.width().max(bounds.height());
if size <= 0.0 {
return Ok(vec![]);
}
const CELLS: usize = 48;
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 m.peak <= 0.0 || edges.near(p, 1e-9) {
return 0.0;
}
height * (m.at(p).max(0.0) / m.peak).sqrt()
};
let camera = Camera::new(view, bounds, o.depth + 2.0 * height);
let mut b = Builder { camera: &camera, closed: true, facets: vec![] };
let half = o.depth / 2.0;
// The grid's cells, those the surface rises steeply across (near the outline) cut into
// halves or quarters each way, so the steep rim is drawn in facets as fine as its curve.
// Every corner sits on one fine lattice (a quarter cell), computed the same way wherever it
// is used, and a cell's border takes its finer neighbour's lattice points too: where cells
// of different fineness meet, their edges follow the surface through the same points, with
// no cracks.
const FINE: usize = 4;
let unit = cell / FINE as f64;
let at = |qx: usize, qy: usize| Point::new(bounds.x0 + qx as f64 * unit, bounds.y0 + qy as f64 * unit);
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![];
}
// Where a hole's piece cancels part of an outline's, that only holds flat: lifted onto
// the surface the two no longer meet. Such a cell takes the shape's own part of it.
let mixed = pieces.iter().any(|p| signed_area(p) < 0.0) && pieces.iter().any(|p| signed_area(p) > 0.0);
if mixed { material(&outline, r).unwrap_or(pieces) } else { pieces }
};
// How many times each cell is halved each way (0, 1 or 2), from how steeply it rises.
let levels: Vec<u32> = (0..nx * ny)
.map(|k| {
let pieces = cut(base(k % nx, k / nx));
let (lo, hi) = pieces.iter().flatten().map(|q| lift(*q)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| (lo.min(h), hi.max(h)));
let rise = if pieces.is_empty() { 0.0 } else { (hi - lo) / cell };
if rise > 6.0 {
2
} else if rise > 2.0 {
1
} else {
0
}
})
.collect();
let level =
|i: isize, j: isize| -> u32 { if i < 0 || j < 0 || i as usize >= nx || j as usize >= ny { 0 } else { levels[j as usize * nx + i as usize] } };
for j in 0..ny {
for i in 0..nx {
let l = level(i as isize, j as isize);
let step = FINE >> l;
// The lattice spacing along each side of this cell: its own or its neighbour's.
let (ii, jj) = (i as isize, j as isize);
let side_step = |dl: u32| FINE >> l.max(dl);
let (left, right, top, bottom) =
(side_step(level(ii - 1, jj)), side_step(level(ii + 1, jj)), side_step(level(ii, jj - 1)), side_step(level(ii, jj + 1)));
let (bx0, by0, bx1, by1) = (i * FINE, j * FINE, (i + 1) * FINE, (j + 1) * FINE);
for sy in (by0..by1).step_by(step) {
for sx in (bx0..bx1).step_by(step) {
let r = Rect::from_points(at(sx, sy), at(sx + step, sy + step));
let pieces = cut(r);
if pieces.is_empty() {
continue;
}
// Lattice points along the cell's own border, between each edge's ends.
let stitch = |a: Point, b: Point| -> Vec<Point> {
let tol = 1e-7 * unit;
let along = |fixed_x: Option<usize>, fixed_y: Option<usize>, every: usize| -> Vec<Point> {
let mut out = vec![];
match (fixed_x, fixed_y) {
(Some(qx), None) => {
let (lo, hi) = (a.y.min(b.y), a.y.max(b.y));
for qy in (by0..=by1).step_by(every) {
let p = at(qx, qy);
if p.y > lo + tol && p.y < hi - tol {
out.push(p);
}
}
}
(None, Some(qy)) => {
let (lo, hi) = (a.x.min(b.x), a.x.max(b.x));
for qx in (bx0..=bx1).step_by(every) {
let p = at(qx, qy);
if p.x > lo + tol && p.x < hi - tol {
out.push(p);
}
}
}
_ => {}
}
if (a.x > b.x) || (a.y > b.y) {
out.reverse();
}
out
};
let on = |v: f64, w: f64| (v - w).abs() <= tol;
let (x0, x1, y0, y1) = (at(bx0, by0).x, at(bx1, by1).x, at(bx0, by0).y, at(bx1, by1).y);
if on(a.x, x0) && on(b.x, x0) {
along(Some(bx0), None, left)
} else if on(a.x, x1) && on(b.x, x1) {
along(Some(bx1), None, right)
} else if on(a.y, y0) && on(b.y, y0) {
along(None, Some(by0), top)
} else if on(a.y, y1) && on(b.y, y1) {
along(None, Some(by1), bottom)
} else {
vec![]
}
};
let pieces: Vec<Vec<Point>> = pieces
.iter()
.map(|p| {
let mut out = Vec::with_capacity(p.len());
for (a, b) in p.iter().zip(p.iter().cycle().skip(1)).take(p.len()) {
out.push(*a);
out.extend(stitch(*a, *b));
}
out
})
.collect();
let lifts: Vec<Vec<f64>> = pieces.iter().map(|p| p.iter().map(|q| lift(*q)).collect()).collect();
// Each piece lifted onto the surface. Its normal is the lifted piece's own average
// (Newell's), not the slope at one point: near the edge the surface turns
// vertical, and a point sample there misjudges which way a whole piece faces.
let lifted = |sign: f64| -> Vec<Vec<V3>> {
pieces.iter().zip(&lifts).map(|(p, h)| p.iter().zip(h).map(|(q, h)| camera.place(*q, sign * (half + h))).collect()).collect()
};
let front = lifted(1.0);
let n = newell(&front);
b.face(front, n)?;
if o.both_sides {
// The back winds the same way round as the front, so its outward normal is
// reversed.
let back = lifted(-1.0);
let n = newell(&back);
b.face(back, V3(-n.0, -n.1, -n.2))?;
}
}
}
}
}
if !o.both_sides {
let back: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, -half)).collect()).collect();
b.face(back, camera.rotate(V3(0.0, 0.0, -1.0)))?;
}
if o.depth > 0.0 {
for ring in &rings {
let front: Vec<V3> = ring.iter().map(|p| camera.place(*p, half)).collect();
let back: Vec<V3> = ring.iter().map(|p| camera.place(*p, -half)).collect();
b.band(&front, &back, |i| outward(ring, i, 0.0))?;
}
}
Ok(b.finish())
}
#[cfg(test)]
#[path = "tests_solid.rs"]
mod tests;