mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Extrude & Bevel and Inflate built one solid from the path, painted with the object's top fill or, without a fill, its top stroke. A filled and stroked object lost its stroke, and a stroked open path was extruded as the area it would enclose if it were closed. Each visible stroke now builds a solid from its outline in its own paint, beside the solid the top fill builds from the path, as Rotate already turns each stroke's outline. When the fill and the strokes are opaque with normal blending, the faces of all the solids are drawn in one back-to-front order; otherwise each solid is drawn on its own, in the order of the appearance. Under a light that is not white, a surface drawn as one composite or with an opacity mask is also drawn on its own. In that one order a flat cap turned toward the viewer is drawn after every other face, untwisted solids included, so the stroke's front cap covers the fill's walls inside it. The solids share one set of bounds, and with Perspective one eye sees every inflated solid. A stroke whose outline is past the 2,048-point limit is left out. Inflate's cells cut by a hole now pass only the nearby contours to the boolean. An inflated, outlined 72 pt "VectorCraft" takes about 8 ms instead of 34 ms, and 66 ms with a 3 pt stroke. Docs: docs/three-d.md (Fills and strokes), docs/target-app-parity.md.
791 lines
36 KiB
Rust
791 lines
36 KiB
Rust
//! Portable 3D effect geometry: Revolve, Extrude & Bevel, Inflate and Rotate. No windowing, GPU or
|
||
//! document dependencies.
|
||
//!
|
||
//! Open profiles optionally close their circular ends with caps. Faces are projected and sorted back to front,
|
||
//! then flat or smoothly shaded with vector gradients. This 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, inflate_together, plane};
|
||
pub use visibility::{VisibleFace, visible_faces};
|
||
mod shading;
|
||
pub use shading::{LightGradient, RgbGradient, ShadeGradient};
|
||
mod lights;
|
||
pub use lights::{Light, Lights, MAX_LIGHTS};
|
||
mod caps;
|
||
#[cfg(test)]
|
||
mod tests_lighting;
|
||
#[cfg(test)]
|
||
mod tests_multiple_lights;
|
||
|
||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv};
|
||
|
||
/// 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 (ambient 0..2).
|
||
#[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, 0° (none) to 160°: see [`eye_distance`].
|
||
pub perspective: f64,
|
||
/// Radial subdivisions for a full turn, clamped to 8..128.
|
||
pub segments: usize,
|
||
/// The light's Rotation, −180° to 180°: round the object on the page, 0° on its right and
|
||
/// counterclockwise.
|
||
pub light_rotation: f64,
|
||
/// The light's Height, 0° to 90°: from beside the object (0°) to straight in front of it.
|
||
pub light_height: f64,
|
||
/// The light is behind the object (Move light to the back of object) rather than in front.
|
||
pub light_behind: bool,
|
||
/// Opaque RGB light color, each channel 0..1. Ambient light stays neutral.
|
||
pub light_color: [f64; 3],
|
||
pub light_intensity: f64,
|
||
/// Softness, 0 (a sharp fall-off) to 1: how far round the object the light reaches.
|
||
pub light_softness: f64,
|
||
/// Explicit light list. None retains the earlier single-light parameters exactly.
|
||
pub lights: Option<Lights>,
|
||
/// Ambient Light, 0 to 2.
|
||
pub ambient: f64,
|
||
pub shade: bool,
|
||
/// Interpolate profile normals using vector gradients. Enabled by default.
|
||
pub smooth_shading: bool,
|
||
/// Close circular endpoint rings of open profiles. Partial sweeps get sectors;
|
||
/// the angular cut sides are left open. Enabled by default.
|
||
pub cap_ends: bool,
|
||
}
|
||
|
||
/// The default light's Rotation and Height: the light of the earlier default direction (45° to the
|
||
/// left of the eye and 45° up), to the left of and above the object, 30° in front of it.
|
||
pub const DEFAULT_LIGHT: (f64, f64) = (125.264_389_682_754_65, 30.0);
|
||
|
||
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_rotation: DEFAULT_LIGHT.0,
|
||
light_height: DEFAULT_LIGHT.1,
|
||
light_behind: false,
|
||
light_color: [1.0; 3],
|
||
light_intensity: 0.8,
|
||
light_softness: 0.0,
|
||
lights: None,
|
||
ambient: 0.25,
|
||
shade: true,
|
||
smooth_shading: true,
|
||
cap_ends: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The widest documented Perspective.
|
||
pub const MAX_PERSPECTIVE: f64 = 160.0;
|
||
|
||
/// How far the eye is from the centre of an object of half size `size`, for a Perspective of
|
||
/// `angle` degrees: from where a sphere of √3 × `size` about the centre (the box around the object
|
||
/// fits inside it) fills that angle of the view. 0° is no perspective (`None`, the eye infinitely
|
||
/// far); at 160° the eye is just outside the sphere, so never inside the object.
|
||
fn eye_distance(angle: f64, size: f64) -> Option<f64> {
|
||
let angle = finite(angle, 0.0, 0.0, MAX_PERSPECTIVE);
|
||
(angle > 0.0).then(|| 3f64.sqrt() * size / (angle / 2.0).to_radians().sin())
|
||
}
|
||
|
||
/// The scale of a view-space point `z` in front of the object's centre plane, for an eye at
|
||
/// `eye`: 1 without perspective. Parts reaching past the eye's sphere (a wide outward bevel at a
|
||
/// wide angle) are held just in front of the eye rather than turned inside out.
|
||
fn perspective_scale(eye: Option<f64>, z: f64) -> f64 {
|
||
eye.map_or(1.0, |e| e / (e - z).max(e * 1e-3))
|
||
}
|
||
|
||
/// The Perspective angle that shows exactly what the earlier 0–1 perspective strength did (files
|
||
/// from before the angle). The strength put the eye at 4 × the object's half size / strength, so
|
||
/// sin(angle / 2) = √3 × strength / 4: full strength is about 51°.
|
||
pub fn perspective_from_strength(strength: f64) -> f64 {
|
||
let s = finite(strength, 0.0, 0.0, 1.0);
|
||
2.0 * (3f64.sqrt() * s / 4.0).asin().to_degrees()
|
||
}
|
||
|
||
/// The Rotation, Height and side of the light an earlier effect set by its azimuth (degrees round
|
||
/// the vertical axis, 0° at the eye) and elevation (degrees up): the same direction, so it lights
|
||
/// the object exactly as before.
|
||
pub fn light_from_azimuth_elevation(azimuth: f64, elevation: f64) -> (f64, f64, bool) {
|
||
let (azimuth, elevation) = (finite(azimuth, -45.0, -360.0, 360.0), finite(elevation, 45.0, -90.0, 90.0));
|
||
let (sl, cl) = elevation.to_radians().sin_cos();
|
||
let (sa, ca) = azimuth.to_radians().sin_cos();
|
||
let [x, y, z] = [cl * sa, -sl, cl * ca];
|
||
// Straight in front or behind, any Rotation is the same light.
|
||
let rotation = if x.hypot(y) < 1e-12 { 0.0 } else { (-y).atan2(x).to_degrees() + 0.0 };
|
||
(rotation, z.abs().min(1.0).asin().to_degrees(), z < 0.0)
|
||
}
|
||
|
||
/// How bright a face with unit normal `n` is under the light `l` (both in view space): the ambient
|
||
/// light plus the light's intensity where it reaches. Without softness the light falls off with
|
||
/// the cosine to nothing at 90°; softness wraps it further round the object, at 100 % to nothing
|
||
/// only straight away from it. Never brighter than the art's own colour.
|
||
fn brightness(o: &Revolve, n: V3, l: V3) -> f64 {
|
||
if !o.shade {
|
||
return 1.0;
|
||
}
|
||
if o.lights.is_some() {
|
||
let rgb = illumination(o, n, l);
|
||
return (rgb[0] + rgb[1] + rgb[2]) / 3.0;
|
||
}
|
||
let s = o.light_softness;
|
||
let lit = ((n.dot(l) + s) / (1.0 + s)).max(0.0);
|
||
(o.ambient + o.light_intensity * lit).clamp(0.0, 1.0)
|
||
}
|
||
|
||
fn direct_light(o: &Revolve, n: V3, l: V3) -> f64 {
|
||
o.light_intensity * ((n.dot(l) + o.light_softness) / (1.0 + o.light_softness)).max(0.0)
|
||
}
|
||
|
||
fn illumination(o: &Revolve, n: V3, l: V3) -> [f64; 3] {
|
||
if !o.shade {
|
||
return [1.0; 3];
|
||
}
|
||
if o.lights.is_some() {
|
||
return unclipped_illumination(o, n, l).map(|v| v.clamp(0.0, 1.0));
|
||
}
|
||
let direct = direct_light(o, n, l);
|
||
o.light_color.map(|c| (o.ambient + direct * c).clamp(0.0, 1.0))
|
||
}
|
||
|
||
fn unclipped_illumination(o: &Revolve, n: V3, legacy_light: V3) -> [f64; 3] {
|
||
let mut rgb = [o.ambient; 3];
|
||
if let Some(lights) = o.lights {
|
||
for light in lights.iter() {
|
||
let direct = light.direct(n);
|
||
for (out, color) in rgb.iter_mut().zip(light.color) {
|
||
*out += direct * color;
|
||
}
|
||
}
|
||
} else {
|
||
let direct = direct_light(o, n, legacy_light);
|
||
for (out, color) in rgb.iter_mut().zip(o.light_color) {
|
||
*out += direct * color;
|
||
}
|
||
}
|
||
rgb
|
||
}
|
||
|
||
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 eye = eye_distance(o.perspective, 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 = perspective_scale(eye, v.2);
|
||
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 (sr, cr) = o.light_rotation.to_radians().sin_cos();
|
||
let (sh, ch) = o.light_height.to_radians().sin_cos();
|
||
[ch * cr, -ch * sr, if o.light_behind { -sh } else { sh }]
|
||
}
|
||
|
||
/// Light-direction orbits as (centre, cosine basis, sine basis), in view space.
|
||
/// The first changes the Rotation at the current Height: a circle on the page. The second
|
||
/// changes the Height at the current Rotation; its parameter runs 0..90 degrees.
|
||
pub fn light_orbits(self) -> [[[f64; 3]; 3]; 2] {
|
||
let o = self.sanitized();
|
||
let (sr, cr) = o.light_rotation.to_radians().sin_cos();
|
||
let (sh, ch) = o.light_height.to_radians().sin_cos();
|
||
let side = if o.light_behind { -1.0 } else { 1.0 };
|
||
[[[0.0, 0.0, side * sh], [ch, 0.0, 0.0], [0.0, -ch, 0.0]], [[0.0, 0.0, 0.0], [cr, -sr, 0.0], [0.0, 0.0, side]]]
|
||
}
|
||
|
||
/// The Rotation and Height of a light dragged to `point` on the page (in units of the light
|
||
/// guide's radius, y down), on the side it is already on: the further out, the lower. At the
|
||
/// centre the Rotation is kept.
|
||
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 r = point.x.hypot(point.y);
|
||
let rotation = if r < 1e-9 { o.light_rotation } else { (-point.y).atan2(point.x).to_degrees() };
|
||
[rotation, r.min(1.0).acos().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, MAX_PERSPECTIVE),
|
||
segments: self.segments.clamp(8, 128),
|
||
light_rotation: (finite(self.light_rotation, DEFAULT_LIGHT.0, -1e6, 1e6) + 180.0).rem_euclid(360.0) - 180.0,
|
||
light_height: finite(self.light_height, DEFAULT_LIGHT.1, 0.0, 90.0),
|
||
light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0),
|
||
light_softness: finite(self.light_softness, 0.0, 0.0, 1.0),
|
||
light_color: self.light_color.map(|c| finite(c, 1.0, 0.0, 1.0)),
|
||
ambient: finite(self.ambient, 0.25, 0.0, 2.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 projected vector face. Brightness multiplies the source colour; an optional
|
||
/// gradient replaces that constant brightness for a smoothly shaded triangle.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Face {
|
||
pub points: Vec<Point>,
|
||
pub depth: f64,
|
||
pub brightness: f64,
|
||
pub gradient: Option<ShadeGradient>,
|
||
/// RGB illumination and its unclipped smooth ramp, independent of the legacy white ramp.
|
||
pub illumination: [f64; 3],
|
||
pub light_gradient: Option<LightGradient>,
|
||
pub rgb_gradient: Option<RgbGradient>,
|
||
/// Curved vector boundary; points remain a bounded visibility approximation.
|
||
pub outline: Option<PathData>,
|
||
/// A single endpoint disk or sector, with its axis centre first in points.
|
||
pub cap: bool,
|
||
/// Bounded radial strips for a body face joining two curved endpoint rings.
|
||
pub visibility_pieces: Option<Vec<Vec<Point>>>,
|
||
}
|
||
|
||
impl Face {
|
||
pub fn path(&self) -> PathData {
|
||
if let Some(path) = &self.outline {
|
||
return path.clone();
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// Body faces and a cap's planar seed use identical two-sided shading and projection.
|
||
fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Point, eye: Option<f64>, light: V3) -> Result<Option<Face>, String> {
|
||
let Some(first) = vertices.first() else { return Ok(None) };
|
||
// A triangle fan also handles a quad whose first edge lies on the axis.
|
||
let mut normal = vertices
|
||
.get(1..)
|
||
.unwrap_or_default()
|
||
.windows(2)
|
||
.filter_map(|pair| {
|
||
let [a, b] = pair else { return None };
|
||
Some(a.sub(*first).cross(b.sub(*first)).unit())
|
||
})
|
||
.find(|n| n.dot(*n) >= 0.5)
|
||
.unwrap_or_default();
|
||
if normal.dot(normal) < 0.5 {
|
||
return Ok(None);
|
||
}
|
||
// 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 flat_brightness = brightness(&o, normal, light);
|
||
let points: Vec<_> = vertices
|
||
.iter()
|
||
.map(|v| {
|
||
let factor = perspective_scale(eye, v.2);
|
||
Point::new(origin.x + v.0 * factor, origin.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(points.len())
|
||
.map(|(a, b)| (a.x - origin.x) * (b.y - origin.y) - (b.x - origin.x) * (a.y - origin.y))
|
||
.sum::<f64>();
|
||
if area.abs() < 1e-10 {
|
||
return Ok(None);
|
||
}
|
||
let (brightness, gradient) = if let Some(normals) = normals {
|
||
let values: Vec<_> = normals
|
||
.iter()
|
||
.map(|n| {
|
||
let n = if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n };
|
||
brightness(&o, n, light)
|
||
})
|
||
.collect();
|
||
let brightness = if values.is_empty() { flat_brightness } else { values.iter().sum::<f64>() / values.len() as f64 };
|
||
(brightness, ShadeGradient::triangle(&points, &values))
|
||
} else {
|
||
(flat_brightness, None)
|
||
};
|
||
let (illumination, light_gradient) = if let Some(normals) = normals {
|
||
let normals: Vec<_> = normals.iter().map(|n| if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n }).collect();
|
||
let mut rgb = [0.0; 3];
|
||
for n in &normals {
|
||
for (out, value) in rgb.iter_mut().zip(illumination(&o, *n, light)) {
|
||
*out += value;
|
||
}
|
||
}
|
||
let rgb = if normals.is_empty() { illumination(&o, normal, light) } else { rgb.map(|v| v / normals.len() as f64) };
|
||
let ramp = if o.shade && o.lights.is_none() && o.light_color != [1.0; 3] {
|
||
let values: Vec<_> = normals.iter().map(|n| direct_light(&o, *n, light)).collect();
|
||
ShadeGradient::triangle(&points, &values).map(|diffuse| LightGradient { diffuse, ambient: o.ambient, color: o.light_color })
|
||
} else {
|
||
None
|
||
};
|
||
(rgb, ramp)
|
||
} else {
|
||
(illumination(&o, normal, light), None)
|
||
};
|
||
let rgb_gradient = normals.filter(|_| o.shade && o.lights.is_some()).and_then(|normals| {
|
||
let values: Vec<_> =
|
||
normals.iter().map(|n| unclipped_illumination(&o, if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n }, light)).collect();
|
||
RgbGradient::triangle(&points, &values)
|
||
});
|
||
Ok(Some(Face {
|
||
points,
|
||
depth: vertices.iter().map(|v| v.2).sum::<f64>() / vertices.len() as f64,
|
||
brightness,
|
||
gradient,
|
||
illumination,
|
||
light_gradient,
|
||
rgb_gradient,
|
||
outline: None,
|
||
cap: false,
|
||
visibility_pieces: None,
|
||
}))
|
||
}
|
||
|
||
/// 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 eye = eye_distance(o.perspective, 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 smooth = o.shade && o.smooth_shading;
|
||
let [lx, ly, lz] = o.light_direction();
|
||
let light = V3(lx, ly, lz);
|
||
let mut faces = Vec::new();
|
||
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 normal = |tangent: Vec2, step: usize| {
|
||
let (s, c) = (sweep * step as f64 / steps as f64).sin_cos();
|
||
o.rotate(V3(tangent.y * c, -tangent.x, tangent.y * s).unit())
|
||
};
|
||
let mut mesh_cost = 0;
|
||
let mut emit = |face: Option<Face>, cost: usize| -> Result<(), String> {
|
||
if let Some(face) = face {
|
||
faces.push(face);
|
||
mesh_cost += cost;
|
||
if mesh_cost > MAX_FACES {
|
||
return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into());
|
||
}
|
||
}
|
||
Ok(())
|
||
};
|
||
let mut count = 0;
|
||
let mut curve_budget = 0;
|
||
for sp in &path.subpaths {
|
||
let mut profile = Vec::new();
|
||
let mut tangents = 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 {
|
||
let t0 = (k - 1) as f64 / samples as f64;
|
||
let t1 = k as f64 / samples as f64;
|
||
let b = curve.eval(t1);
|
||
let tangent = |t: f64| {
|
||
if !smooth {
|
||
return Vec2::ZERO;
|
||
}
|
||
let a = curve.eval(t0);
|
||
if samples == 1 {
|
||
return b - a;
|
||
}
|
||
let d = curve.deriv();
|
||
let v = d.eval(t).to_vec2();
|
||
if v.hypot2() > 1e-20 {
|
||
return v;
|
||
}
|
||
// Repeated endpoint handles still have a one-sided curve tangent.
|
||
let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2();
|
||
if v.hypot2() > 1e-20 { v } else { b - a }
|
||
};
|
||
tangents.push([tangent(t0), tangent(t1)]);
|
||
profile.push(b);
|
||
count += 1;
|
||
if count > MAX_PROFILE_POINTS {
|
||
return Err("Revolve profile is too complex".into());
|
||
}
|
||
}
|
||
}
|
||
let ends = o.cap_ends && !sp.closed && profile.len() >= 2 && profile.first().zip(profile.last()).is_some_and(|(a, b)| a.distance(*b) > 1e-10);
|
||
let mut rings = [None, None];
|
||
if ends {
|
||
for (ring, end) in rings.iter_mut().zip([profile.first(), profile.last()]) {
|
||
if let Some(end) = end
|
||
&& (end.x - axis).abs() > 1e-10
|
||
{
|
||
*ring = Some(caps::Ring::new(*end, bounds, o, steps, &mut curve_budget)?);
|
||
}
|
||
}
|
||
}
|
||
for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() {
|
||
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 [v0, v1, v2, v3] = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)];
|
||
let first = (i == 0).then(|| rings.first().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten();
|
||
let last = (i + 2 == profile.len()).then(|| rings.last().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten();
|
||
let projected = |vertices: &[V3], normals: Option<&[V3]>, edges: &[(usize, &caps::RingArc, bool)]| {
|
||
let mut face = project_face(vertices, normals, o, Point::new(axis, center_y), eye, light)?;
|
||
if let Some(face) = &mut face {
|
||
caps::curve_edges(face, edges);
|
||
}
|
||
Ok::<_, String>(face)
|
||
};
|
||
if smooth {
|
||
let [n0, n1, n2, n3] = [normal(ta, j), normal(tb, j), normal(tb, j + 1), normal(ta, j + 1)];
|
||
emit(projected(&[v0, v1, v2], Some(&[n0, n1, n2]), &last.map(|arc| (1, arc, false)).into_iter().collect::<Vec<_>>())?, 1)?;
|
||
emit(projected(&[v0, v2, v3], Some(&[n0, n2, n3]), &first.map(|arc| (2, arc, true)).into_iter().collect::<Vec<_>>())?, 1)?;
|
||
} else {
|
||
let edges: Vec<_> = last.map(|arc| (1, arc, false)).into_iter().chain(first.map(|arc| (3, arc, true))).collect();
|
||
emit(projected(&[v0, v1, v2, v3], None, &edges)?, 1)?;
|
||
}
|
||
}
|
||
}
|
||
for (index, ring) in rings.iter().enumerate().filter_map(|(i, r)| r.as_ref().map(|r| (i, r))) {
|
||
let neighbours =
|
||
if index == 0 { profile.first().zip(profile.get(1)) } else { profile.last().zip(profile.get(profile.len().saturating_sub(2))) };
|
||
if let Some((end, neighbour)) = neighbours {
|
||
let dy = end.y - neighbour.y;
|
||
let outward = if dy.abs() > 1e-10 {
|
||
dy.signum()
|
||
} else if end.y < center_y {
|
||
-1.0
|
||
} else {
|
||
1.0
|
||
};
|
||
// Keep the original tessellation work budget, even though each cap
|
||
// now produces only one curved vector face.
|
||
emit(ring.face(bounds, o, light, outward)?, steps)?;
|
||
}
|
||
}
|
||
}
|
||
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
|
||
Ok(faces)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests_caps;
|
||
#[cfg(test)]
|
||
mod tests_smooth;
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
#[test]
|
||
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
|
||
for rotation in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
|
||
for height in [0.0f64, 15.0, 45.0, 80.0] {
|
||
for behind in [false, true] {
|
||
let o = Revolve { light_rotation: rotation, light_height: height, light_behind: behind, ..Default::default() };
|
||
let direction = o.light_direction();
|
||
assert!((direction[0].hypot(direction[1]).hypot(direction[2]) - 1.0).abs() < 1e-12);
|
||
assert_eq!(direction[2] < 0.0, behind && height > 0.0);
|
||
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([rotation, height]) {
|
||
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 [r, h] = o.light_at_on_side(Point::new(direction[0], direction[1]));
|
||
assert!((r - rotation).abs() < 1e-9 && (h - height).abs() < 1e-6, "{rotation}, {height} -> {r}, {h}");
|
||
}
|
||
}
|
||
}
|
||
// Dragged to the centre: straight in front, the Rotation kept; past the rim: at 0°.
|
||
let o = Revolve { light_rotation: 135.0, ..Default::default() };
|
||
assert_eq!(o.light_at_on_side(Point::new(0.0, 0.0)), [135.0, 90.0]);
|
||
let [r, h] = o.light_at_on_side(Point::new(0.0, -3.0));
|
||
assert!((r - 90.0).abs() < 1e-12 && h == 0.0);
|
||
let [r, h] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
|
||
assert!(r.is_finite() && h.is_finite());
|
||
}
|
||
|
||
/// An effect from before Rotation and Height (azimuth and elevation) gets the same light:
|
||
/// the same direction, so the same shading.
|
||
#[test]
|
||
fn an_earlier_azimuth_and_elevation_give_the_same_light() {
|
||
let old = |azimuth: f64, elevation: f64| {
|
||
let (sl, cl) = elevation.to_radians().sin_cos();
|
||
let (sa, ca) = azimuth.to_radians().sin_cos();
|
||
[cl * sa, -sl, cl * ca]
|
||
};
|
||
for azimuth in [-360.0f64, -170.0, -45.0, 0.0, 90.0, 135.0, 179.0] {
|
||
for elevation in [-90.0f64, -60.0, -1.0, 0.0, 30.0, 45.0, 89.0, 90.0] {
|
||
let (light_rotation, light_height, light_behind) = light_from_azimuth_elevation(azimuth, elevation);
|
||
assert!((-180.0..=180.0).contains(&light_rotation) && (0.0..=90.0).contains(&light_height));
|
||
let new = Revolve { light_rotation, light_height, light_behind, ..Default::default() }.light_direction();
|
||
let want = old(azimuth, elevation);
|
||
for i in 0..3 {
|
||
assert!((new[i] - want[i]).abs() < 1e-12, "{azimuth}, {elevation}: {new:?} against {want:?}");
|
||
}
|
||
}
|
||
}
|
||
// The default light is the earlier default's.
|
||
let (r, h, behind) = light_from_azimuth_elevation(-45.0, 45.0);
|
||
assert!((r - DEFAULT_LIGHT.0).abs() < 1e-12 && (h - DEFAULT_LIGHT.1).abs() < 1e-12 && !behind, "{r} {h}");
|
||
}
|
||
|
||
/// Softness wraps the light further round the object; without it, the earlier shading. Ambient
|
||
/// Light runs to 200 %, never past the art's own colour.
|
||
#[test]
|
||
fn softness_wraps_the_light_and_ambient_reaches_200_percent() {
|
||
let o = Revolve::default().sanitized();
|
||
let l = V3(0.0, 0.0, 1.0);
|
||
let n = |deg: f64| {
|
||
let (s, c) = deg.to_radians().sin_cos();
|
||
V3(s, 0.0, c)
|
||
};
|
||
for deg in [0.0, 30.0, 60.0, 89.0, 120.0] {
|
||
let lambert = (o.ambient + o.light_intensity * n(deg).dot(l).max(0.0)).clamp(0.0, 1.0);
|
||
assert!((brightness(&o, n(deg), l) - lambert).abs() < 1e-12);
|
||
}
|
||
let soft = Revolve { light_softness: 1.0, ..o };
|
||
assert!(brightness(&soft, n(120.0), l) > brightness(&o, n(120.0), l) + 0.1, "lit past 90°");
|
||
assert!((brightness(&soft, n(180.0), l) - o.ambient).abs() < 1e-12, "nothing straight away");
|
||
assert!((brightness(&soft, n(0.0), l) - brightness(&o, n(0.0), l)).abs() < 1e-12, "the same facing the light");
|
||
let bright = Revolve { ambient: 2.0, ..o };
|
||
assert_eq!(brightness(&bright, n(180.0), l), 1.0);
|
||
assert_eq!(
|
||
Revolve { ambient: 5.0, light_softness: -1.0, light_height: 120.0, ..o }.sanitized(),
|
||
Revolve { ambient: 2.0, light_softness: 0.0, light_height: 90.0, ..o }
|
||
);
|
||
}
|
||
|
||
#[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 [light_rotation, light_height] = o.light_at_on_side(Point::new(0.3, -0.4));
|
||
let [x, y, z] = Revolve { light_rotation, light_height, ..o }.light_direction();
|
||
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
|
||
let [rotation, height] = o.light_at_on_side(Point::new(2.0, -2.0));
|
||
assert!(rotation.is_finite() && height == 0.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: 40.0, ..Default::default() },
|
||
] {
|
||
assert_ne!(base, revolve(&p, b, o).unwrap());
|
||
}
|
||
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
|
||
}
|
||
/// The angle that stands for an earlier perspective strength puts the eye where that strength
|
||
/// did (4 × the half size / strength), so files from before the angle look as they did.
|
||
#[test]
|
||
fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() {
|
||
for strength in [0.05, 0.3, 0.6, 1.0] {
|
||
for size in [1.0, 37.5, 900.0] {
|
||
let eye = eye_distance(perspective_from_strength(strength), size).unwrap();
|
||
assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}");
|
||
}
|
||
}
|
||
assert_eq!(perspective_from_strength(0.0), 0.0);
|
||
assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0));
|
||
}
|
||
|
||
/// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside
|
||
/// the sphere the object's box fits in.
|
||
#[test]
|
||
fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() {
|
||
assert_eq!(eye_distance(0.0, 50.0), None);
|
||
assert_eq!(eye_distance(-20.0, 50.0), None);
|
||
assert_eq!(eye_distance(f64::NAN, 50.0), None);
|
||
assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0));
|
||
let widest = eye_distance(160.0, 50.0).unwrap();
|
||
assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}");
|
||
// Wider is closer.
|
||
let eyes: Vec<f64> = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect();
|
||
assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}");
|
||
let (p, b) = cylinder();
|
||
let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap();
|
||
assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||
assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap());
|
||
}
|
||
#[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() <= 258);
|
||
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());
|
||
}
|
||
}
|