Files
vectorcraft/crates/three-d/src/lib.rs
T
Ayberk A. 8a3458a974 Effect › 3D and Materials: Extrude & Bevel, Inflate and Rotate
The three 3D types besides Revolve, as live effects in the three-d crate,
drawn as shaded vector faces by the canvas, SVG/PDF export and Expand
Appearance alike, from the public 3D and Materials documentation:

- Extrude & Bevel: depth (0-2000 pt), twist, taper, solid or hollow caps,
  and bevels in six original cross-sections (classic, rounded, cove, step,
  ogee) with width, height, repeat, spacing, inside and both sides.
- Inflate: the shape as a membrane under pressure (a Poisson solve held at
  zero along the outline and holes), so it rises vertically from the edge
  and rounds without creases; volume, depth and both sides. Steep cells
  near the outline are refined without cracks.
- Rotate: every fill and stroke turned in 3D and shaded as one plane.

All three share Revolve's view and lighting parameters, the rotation and
light gizmos (turning about the art's centre) and a dialog with Object,
Rotation and Lighting tabs, with Position presets (Front ... Isometric
Bottom, our own values). An object takes one 3D effect. Inflate cuts the
grid cells that straddle a hole's edge with pathops, hence the new
pathops -> three-d layer order.

Docs: docs/three-d.md, mcp.md, and the 3D rows of the parity docs.
2026-10-13 00:35:07 +07:00

423 lines
19 KiB
Rust

//! Portable 3D effect geometry: Revolve, Extrude & Bevel, Inflate and Rotate. No windowing, GPU or
//! document dependencies.
//!
//! Open profiles generate uncapped surfaces. Faces are projected and sorted back to front,
//! then flat shaded. This vector preview uses a painter's algorithm, not a depth buffer:
//! intersecting profiles can have ambiguous visibility and are not solid modelling inputs.
#![forbid(unsafe_code)]
mod solid;
mod visibility;
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane};
pub use visibility::{VisibleFace, visible_faces};
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
/// Resource limits shared by desktop, CLI and browser evaluation.
const MAX_PROFILE_POINTS: usize = 1024;
const MAX_FACES: usize = 32768;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct V3(f64, f64, f64);
impl V3 {
fn sub(self, b: Self) -> Self {
Self(self.0 - b.0, self.1 - b.1, self.2 - b.2)
}
fn cross(self, b: Self) -> Self {
Self(self.1 * b.2 - self.2 * b.1, self.2 * b.0 - self.0 * b.2, self.0 * b.1 - self.1 * b.0)
}
fn dot(self, b: Self) -> f64 {
self.0 * b.0 + self.1 * b.1 + self.2 * b.2
}
fn unit(self) -> Self {
let l = self.dot(self).sqrt();
if l < 1e-12 { Self::default() } else { Self(self.0 / l, self.1 / l, self.2 / l) }
}
}
/// The vertical axis is offset outward from the selected edge of the profile's bounds.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Edge {
#[default]
Left,
Right,
}
/// All angles are degrees; offset is in document points, lighting values are 0..1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Revolve {
pub angle: f64,
pub offset: f64,
pub edge: Edge,
pub rotation_x: f64,
pub rotation_y: f64,
pub rotation_z: f64,
/// Perspective strength, 0 for orthographic projection, up to 1.
pub perspective: f64,
/// Radial subdivisions for a full turn, clamped to 8..128.
pub segments: usize,
pub light_azimuth: f64,
pub light_elevation: f64,
pub light_intensity: f64,
pub ambient: f64,
pub shade: bool,
}
impl Default for Revolve {
fn default() -> Self {
Self {
angle: 360.0,
offset: 0.0,
edge: Edge::Left,
rotation_x: 0.0,
rotation_y: 0.0,
rotation_z: 0.0,
perspective: 0.0,
segments: 64,
light_azimuth: -45.0,
light_elevation: 45.0,
light_intensity: 0.8,
ambient: 0.25,
shade: true,
}
}
}
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
if v.is_finite() { v.clamp(min, max) } else { fallback }
}
impl Revolve {
/// Unit bases of the rings that adjust the Euler X, Y and Z view parameters.
/// X turns before Y and Z, Y before Z, and Z turns in the screen plane.
/// Each pair has a cross product pointing along that parameter's rotation axis.
pub fn rotation_planes(self) -> [[[f64; 3]; 2]; 3] {
let o = self.sanitized();
let x_frame = Self { rotation_x: 0.0, ..o };
let y_frame = Self { rotation_x: 0.0, rotation_y: 0.0, ..o };
[
[x_frame.rotate(V3(0.0, 1.0, 0.0)), x_frame.rotate(V3(0.0, 0.0, 1.0))],
[y_frame.rotate(V3(1.0, 0.0, 0.0)), y_frame.rotate(V3(0.0, 0.0, -1.0))],
[V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)],
]
.map(|plane| plane.map(|v| [v.0, v.1, v.2]))
}
/// The fixed centre of view rotation, in document coordinates.
pub fn rotation_origin(self, bounds: Rect) -> Point {
let o = self.sanitized();
Point::new(if o.edge == Edge::Left { bounds.x0 - o.offset } else { bounds.x1 + o.offset }, bounds.center().y)
}
/// Rotated unit axes for a view-orientation guide (screen Y points down).
pub fn view_axes(self) -> [Point; 3] {
[V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0), V3(0.0, 0.0, 1.0)].map(|v| {
let p = self.sanitized().rotate(v);
Point::new(p.0, p.1)
})
}
/// The same projected vertical axis used by the surface evaluator.
pub fn projected_axis(self, bounds: Rect) -> [Point; 2] {
let o = self.sanitized();
let axis = match o.edge {
Edge::Left => bounds.x0 - o.offset,
Edge::Right => bounds.x1 + o.offset,
};
let center_y = bounds.center().y;
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
[bounds.y0, bounds.y1].map(|y| {
let v = o.rotate(V3(0.0, y - center_y, 0.0));
let factor = distance / (distance - v.2 * o.perspective);
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
})
}
/// Directional light in view space, shared by shading and interactive guides.
pub fn light_direction(self) -> [f64; 3] {
let o = self.sanitized();
let (sl, cl) = o.light_elevation.to_radians().sin_cos();
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
[cl * sa, -sl, cl * ca]
}
/// Light-direction orbits as (centre, cosine basis, sine basis), in view space.
/// The first changes azimuth at the current elevation. The second changes elevation
/// at the current azimuth. The meridian's valid parameter range is -90..90 degrees.
pub fn light_orbits(self) -> [[[f64; 3]; 3]; 2] {
let o = self.sanitized();
let (se, ce) = o.light_elevation.to_radians().sin_cos();
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
[[[0.0, -se, 0.0], [0.0, 0.0, ce], [ce, 0.0, 0.0]], [[0.0, 0.0, 0.0], [sa, 0.0, ca], [0.0, -1.0, 0.0]]]
}
/// Move a light on its current hemisphere, preserving the azimuth at the poles.
/// A light behind the object stays behind when its projected handle is dragged.
pub fn light_at_on_side(self, point: Point) -> [f64; 2] {
let o = self.sanitized();
let point = Point::new(finite(point.x, 0.0, -1e8, 1e8), finite(point.y, 0.0, -1e8, 1e8));
let length = point.x.hypot(point.y).max(1.0);
let (x, y) = (point.x / length, point.y / length);
let z = (1.0 - x * x - y * y).max(0.0).sqrt().copysign(o.light_direction()[2]);
let azimuth = if x.hypot(z) < 1e-9 { o.light_azimuth } else { x.atan2(z).to_degrees() };
[azimuth, (-y).clamp(-1.0, 1.0).asin().to_degrees()]
}
/// A dragged light position on the front hemisphere, in normalized view coordinates.
pub fn light_at(point: Point) -> [f64; 2] {
let length = point.x.hypot(point.y).max(1.0);
let (x, y) = (point.x / length, point.y / length);
let z = (1.0 - x * x - y * y).max(0.0).sqrt();
[x.atan2(z).to_degrees(), (-y).clamp(-1.0, 1.0).asin().to_degrees()]
}
fn sanitized(self) -> Self {
Self {
angle: finite(self.angle, 360.0, 0.0, 360.0),
offset: finite(self.offset, 0.0, 0.0, 1e5),
rotation_x: finite(self.rotation_x, 0.0, -360.0, 360.0),
rotation_y: finite(self.rotation_y, 0.0, -360.0, 360.0),
rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0),
perspective: finite(self.perspective, 0.0, 0.0, 1.0),
segments: self.segments.clamp(8, 128),
light_azimuth: finite(self.light_azimuth, -45.0, -360.0, 360.0),
light_elevation: finite(self.light_elevation, 45.0, -90.0, 90.0),
light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0),
ambient: finite(self.ambient, 0.25, 0.0, 1.0),
..self
}
}
fn rotate(self, v: V3) -> V3 {
let (sx, cx) = self.rotation_x.to_radians().sin_cos();
let (sy, cy) = self.rotation_y.to_radians().sin_cos();
let (sz, cz) = self.rotation_z.to_radians().sin_cos();
let v = V3(v.0, cx * v.1 - sx * v.2, sx * v.1 + cx * v.2);
let v = V3(cy * v.0 + sy * v.2, v.1, -sy * v.0 + cy * v.2);
V3(cz * v.0 - sz * v.1, sz * v.0 + cz * v.1, v.2)
}
}
/// One opaque vector polygon, already projected. Brightness multiplies the source colour.
#[derive(Clone, Debug, PartialEq)]
pub struct Face {
pub points: Vec<Point>,
pub depth: f64,
pub brightness: f64,
}
impl Face {
pub fn path(&self) -> PathData {
let mut bp = BezPath::new();
if let Some(first) = self.points.first() {
bp.move_to(*first);
for p in self.points.iter().skip(1) {
bp.line_to(*p);
}
bp.close_path();
}
PathData::from_bezpath(&bp)
}
}
/// Evaluate a profile in document space. All subpaths share one axis from `bounds`.
/// Invalid or excessively complex inputs return an error, never a partial surface.
pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Face>, String> {
let o = options.sanitized();
if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
return Err("Revolve requires finite profile coordinates within 100 million points".into());
}
let axis = match o.edge {
Edge::Left => bounds.x0 - o.offset,
Edge::Right => bounds.x1 + o.offset,
};
let center_y = (bounds.y0 + bounds.y1) * 0.5;
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1);
let sweep = o.angle.to_radians();
let [lx, ly, lz] = o.light_direction();
let light = V3(lx, ly, lz);
let mut faces = Vec::new();
let mut count = 0;
for sp in &path.subpaths {
let mut profile = Vec::new();
if let Some(first) = sp.anchors.first() {
profile.push(first.p);
count += 1;
if count > MAX_PROFILE_POINTS {
return Err("Revolve profile is too complex".into());
}
}
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
// Bound work before sampling any data. Curves get sixteen samples, straight edges one.
if segments > MAX_PROFILE_POINTS {
return Err("Revolve profile is too complex".into());
}
for i in 0..segments {
let curve = sp.segment(i);
let samples = if sp.segment_is_line(i) { 1 } else { 16 };
for k in 1..=samples {
profile.push(curve.eval(k as f64 / samples as f64));
count += 1;
if count > MAX_PROFILE_POINTS {
return Err("Revolve profile is too complex".into());
}
}
}
for pair in profile.windows(2) {
let [a, b] = pair else { continue };
if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
return Err("Revolve profile contains invalid coordinates".into());
}
for j in 0..steps {
let vertex = |p: Point, step: usize| {
let (s, c) = (sweep * step as f64 / steps as f64).sin_cos();
o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s))
};
let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)];
// The diagonal gives a nonzero normal even when one profile endpoint is on-axis.
let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit();
if normal.dot(normal) < 0.5 {
normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit();
}
if normal.dot(normal) < 0.5 {
continue;
}
// Profiles may run up or down. Shade the visible side of this two-sided surface.
if normal.2 < 0.0 {
normal = V3(-normal.0, -normal.1, -normal.2);
}
let brightness = if o.shade { (o.ambient + o.light_intensity * normal.dot(light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 };
let points: Vec<_> = vertices
.iter()
.map(|v| {
let factor = distance / (distance - v.2 * o.perspective);
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
})
.collect();
if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return Err("Revolve projection is not finite".into());
}
let area = points
.iter()
.zip(points.iter().cycle().skip(1))
.take(4)
.map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y))
.sum::<f64>();
if area.abs() < 1e-10 {
continue;
}
faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::<f64>() / 4.0, brightness });
if faces.len() > MAX_FACES {
return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into());
}
}
}
}
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
Ok(faces)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
for azimuth in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
for elevation in [-85.0f64, -30.0, 0.0, 45.0, 90.0] {
let o = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..Default::default() };
let direction = o.light_direction();
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([azimuth, elevation]) {
let (s, c) = angle.to_radians().sin_cos();
for i in 0..3 {
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
}
}
let [a, e] = o.light_at_on_side(Point::new(direction[0], direction[1]));
assert!((a - azimuth).abs() < 1e-9 && (e - elevation).abs() < 1e-9, "{azimuth}, {elevation} -> {a}, {e}");
}
}
let o = Revolve { light_azimuth: 135.0, ..Default::default() };
let [azimuth, elevation] = o.light_at_on_side(Point::new(0.3, -0.4));
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
assert!((x - 0.3).abs() < 1e-12 && (y + 0.4).abs() < 1e-12 && z < 0.0);
let [a, e] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
assert!(a.is_finite() && e.is_finite());
}
#[test]
fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() {
let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() };
let seed = V3(0.3, -0.5, 0.7);
let before = o.rotate(seed);
let planes = o.rotation_planes();
for (axis, [u, v]) in planes.iter().enumerate() {
let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2]));
assert!((normal.dot(normal) - 1.0).abs() < 1e-12);
let mut changed = o;
match axis {
0 => changed.rotation_x += 0.0001,
1 => changed.rotation_y += 0.0001,
_ => changed.rotation_z += 0.0001,
}
let actual = changed.rotate(seed).sub(before);
let expected = normal.cross(before);
let step = 0.0001f64.to_radians();
for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] {
assert!((a - b).abs() < 1e-5);
}
}
assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0));
}
#[test]
fn guides_match_projection_and_dragged_light_matches_shading_direction() {
let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() };
assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]);
assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]);
let [azimuth, elevation] = Revolve::light_at(Point::new(0.3, -0.4));
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
let [azimuth, elevation] = Revolve::light_at(Point::new(2.0, -2.0));
assert!(azimuth.is_finite() && elevation.is_finite() && elevation.abs() <= 90.0);
}
fn cylinder() -> (PathData, Rect) {
let mut bp = BezPath::new();
bp.move_to((40.0, 10.0));
bp.line_to((40.0, 90.0));
(PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0))
}
#[test]
fn cylinder_has_expected_silhouette_depth_order_and_shading() {
let (p, b) = cylinder();
let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect();
assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6);
assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6);
assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth));
assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4));
}
#[test]
fn edge_offset_sweep_and_rotation_change_the_surface() {
let (p, b) = cylinder();
let base = revolve(&p, b, Revolve::default()).unwrap();
for o in [
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
Revolve { angle: 120.0, ..Default::default() },
Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() },
] {
assert_ne!(base, revolve(&p, b, o).unwrap());
}
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
}
#[test]
fn malformed_and_degenerate_inputs_are_bounded() {
let (p, b) = cylinder();
let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap();
assert!(f.len() <= 128);
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
}
}