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

312 lines
10 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 are cut into small facets. Colored Inflate interpolates shared membrane normals;
//! bevels and side walls retain their planar lighting.
//!
//! 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, FillRule, ParamCurve, PathData, Point, Rect, Vec2};
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
mod build;
mod inflate;
mod membrane;
mod rings;
#[cfg(test)]
use build::{Camera, cuts, edge_distance, newell, section};
pub use build::{extrude, plane};
pub use inflate::{inflate, inflate_together};
#[cfg(test)]
use membrane::{Edges, Membrane, cell_triangles};
use rings::signed_area;
#[cfg(test)]
use rings::{MAX_SOLID_POINTS, rings};
/// The view shared by every 3D effect: rotation, perspective and directional lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
pub rotation_x: f64,
pub rotation_y: f64,
pub rotation_z: f64,
/// Perspective, 0° (none) to 160°, as Revolve's.
pub perspective: f64,
pub light_rotation: f64,
pub light_height: f64,
pub light_behind: bool,
pub light_color: [f64; 3],
pub light_intensity: f64,
pub light_softness: f64,
pub lights: Option<crate::Lights>,
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_rotation: r.light_rotation,
light_height: r.light_height,
light_behind: r.light_behind,
light_color: r.light_color,
light_intensity: r.light_intensity,
light_softness: r.light_softness,
lights: r.lights,
ambient: r.ambient,
shade: r.shade,
}
}
}
impl View {
/// Whether the active light setup needs RGB shading rather than neutral brightness.
pub fn has_colored_lights(self) -> bool {
let view = self.revolve();
view.lights.map_or(view.light_color != [1.0; 3], |lights| lights.iter().any(|light| light.color != [1.0; 3]))
}
/// 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_rotation: self.light_rotation,
light_height: self.light_height,
light_behind: self.light_behind,
light_color: self.light_color,
light_intensity: self.light_intensity,
light_softness: self.light_softness,
lights: self.lights,
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: 0° to 360°.
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,
/// The turn of the back against the front, about the shape's centre: 0° to 360°.
pub twist: f64,
/// The size of the back, as a share (0.01..1) of the front's.
pub taper: f64,
/// A closed back (a solid) or none, so the inflated front is a shell open behind.
pub cap: bool,
}
impl Default for Inflate {
fn default() -> Self {
Self { depth: 0.0, volume: 50.0, both_sides: false, twist: 0.0, taper: 1.0, cap: true }
}
}
/// One projected 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,
pub illumination: [f64; 3],
/// Smooth lighting on an inflated triangle, before clipping individual RGB channels.
pub rgb_gradient: Option<crate::RgbGradient>,
/// The contours in view space, before projection, for tests that measure depths.
#[cfg(test)]
view: Vec<Vec<V3>>,
}
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, 0.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),
twist: finite(self.twist, 0.0, 0.0, 360.0),
taper: finite(self.taper, 1.0, 0.01, 1.0),
..self
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_inflate_shading;