mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 21:56:24 +00:00
Effect › 3D and Materials › 3D (Classic) › Revolve (Classic)… sweeps the profile round a vertical axis, as Revolve does, and draws every face with the Wireframe, No Shading, Diffuse and Plastic surfaces of Extrude & Bevel (Classic). - `effect.apply` with `threeD.classicRevolve`: Angle (0 to 360), Cap, Offset (0 to 1000 pt) from the left or right edge of the profile's bounds, the Position rotation and Perspective, and the classic lights, Ambient Light, highlight, Blend Steps and Shading Color. - Groups and layers revolve about one shared axis, and the effect also goes on a fill or stroke, as Revolve's does. - `effect.lightingPreset` refuses it, as it refuses Extrude & Bevel (Classic). - Revolve faces carry their view-space normal, and `three_d::revolve_facets` hands them to the classic surface code. - Unknown parameters, numbers out of range and bad flags, edges, surfaces and colors are refused with an error.
359 lines
12 KiB
Rust
359 lines
12 KiB
Rust
//! 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, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_scale};
|
||
|
||
mod build;
|
||
mod inflate;
|
||
mod membrane;
|
||
mod outline;
|
||
mod rings;
|
||
|
||
#[cfg(test)]
|
||
use build::{Camera, cuts, edge_distance, newell, section};
|
||
pub(crate) use build::{Face3, extrude_faces, plane_faces};
|
||
pub use build::{extrude, plane};
|
||
#[cfg(test)]
|
||
use inflate::GRID_WORK;
|
||
#[cfg(test)]
|
||
pub(crate) use inflate::INFLATED;
|
||
pub(crate) use inflate::inflate_faces;
|
||
pub use inflate::{inflate, inflate_together};
|
||
#[cfg(test)]
|
||
use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, 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>,
|
||
/// Keep the faces of a closed solid that are turned away from the viewer (Draw Hidden Faces,
|
||
/// and the classic Wireframe surface), drawn behind the faces turned toward it.
|
||
pub hidden: bool,
|
||
}
|
||
|
||
impl Default for Extrude {
|
||
fn default() -> Self {
|
||
Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None, hidden: false }
|
||
}
|
||
}
|
||
|
||
/// 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],
|
||
/// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the
|
||
/// side the viewer sees.
|
||
pub normal: [f64; 3],
|
||
/// Smooth lighting on an inflated triangle, before clipping individual RGB channels.
|
||
pub rgb_gradient: Option<crate::RgbGradient>,
|
||
/// The facet's outline with the profile's curves kept as cubics, for [`Facet::path`]. The
|
||
/// contours stay the polygon that visibility and depth use.
|
||
pub outline: Option<PathData>,
|
||
/// The facet is a piece of the solid's sides (Extrude & Bevel's walls and bevels, Inflate's
|
||
/// sides) rather than of its ends (the caps, a plane, Inflate's front and back).
|
||
pub side: bool,
|
||
/// The facet is turned away from the viewer: a closed solid keeps it (seen edge-on, or with
|
||
/// Draw Hidden Faces) and draws it behind the facets turned toward the viewer.
|
||
pub away: bool,
|
||
/// 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 }
|
||
}
|
||
|
||
/// The facet's outline: its curved outline when it has one, else its contours.
|
||
pub fn path(&self) -> PathData {
|
||
if let Some(path) = &self.outline {
|
||
return path.clone();
|
||
}
|
||
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)
|
||
}
|
||
|
||
/// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting.
|
||
/// A face that joins the profile's ends round the axis is a side; a cap is an end.
|
||
pub(crate) fn of_face(face: crate::Face) -> Self {
|
||
Facet {
|
||
contours: vec![face.points],
|
||
depth: face.depth,
|
||
brightness: face.brightness,
|
||
illumination: face.illumination,
|
||
normal: face.normal,
|
||
rgb_gradient: None,
|
||
outline: face.outline,
|
||
side: !face.cap,
|
||
away: false,
|
||
#[cfg(test)]
|
||
view: vec![],
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
#[cfg(test)]
|
||
mod tests_limits;
|