mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
Add opaque RGB light color and up to ten independently adjustable lights to
Revolve, Extrude & Bevel, Inflate and Rotate. Keep light selection, Add/Delete,
selected-light gizmos and preview transactions on the existing command path.
Preserve legacy white-light Inflate polygons, paint composition and seam joins,
including the 0.05 seam minimum. Use shared smooth normals for colored Inflate,
and batch solid-fill RGB channel paths behind one surface clip. Keep source
paths editable and SVG/PDF output vector artwork.
Rebase onto main f7de3d6a, retaining both sides of progress docs and all 22
catalogs. Shade freeform points once through the shared paint mapper. Document
release measurements and a reproducible CLI fixture, including the remaining
source-gradient cost and the one-light timing tradeoff.
Validation: full cargo xtask ci passed all 10 steps; 7,978 tests passed, zero
failed and 11 ignored. Four legacy white-light PNGs match unchanged main byte
for byte. Three paired release bench/perf repetitions completed without busy
warnings; all six standard perf runs passed every budget. Two-light solid
Inflate improved from 136.2 to 96.8 ms/frame at 1200x1200, default eight threads.
70 lines
2.3 KiB
Rust
70 lines
2.3 KiB
Rust
//! Bounded directional lights, shared by all four effects and independent of the UI.
|
|
use crate::{DEFAULT_LIGHT, V3, finite};
|
|
|
|
pub const MAX_LIGHTS: usize = 10;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Light {
|
|
pub rotation: f64,
|
|
pub height: f64,
|
|
pub behind: bool,
|
|
pub color: [f64; 3],
|
|
pub intensity: f64,
|
|
pub softness: f64,
|
|
}
|
|
|
|
impl Default for Light {
|
|
fn default() -> Self {
|
|
Self { rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, behind: false, color: [1.0; 3], intensity: 0.8, softness: 0.0 }
|
|
}
|
|
}
|
|
|
|
impl Light {
|
|
pub(crate) fn sanitized(self) -> Self {
|
|
Self {
|
|
rotation: (finite(self.rotation, DEFAULT_LIGHT.0, -1e6, 1e6) + 180.0).rem_euclid(360.0) - 180.0,
|
|
height: finite(self.height, DEFAULT_LIGHT.1, 0.0, 90.0),
|
|
color: self.color.map(|c| finite(c, 1.0, 0.0, 1.0)),
|
|
intensity: finite(self.intensity, 0.8, 0.0, 1.0),
|
|
softness: finite(self.softness, 0.0, 0.0, 1.0),
|
|
..self
|
|
}
|
|
}
|
|
|
|
pub fn direction(self) -> [f64; 3] {
|
|
let light = self.sanitized();
|
|
let (sr, cr) = light.rotation.to_radians().sin_cos();
|
|
let (sh, ch) = light.height.to_radians().sin_cos();
|
|
[ch * cr, -ch * sr, if light.behind { -sh } else { sh }]
|
|
}
|
|
|
|
pub(crate) fn direct(self, normal: V3) -> f64 {
|
|
let [x, y, z] = self.direction();
|
|
self.intensity * ((normal.dot(V3(x, y, z)) + self.softness) / (1.0 + self.softness)).max(0.0)
|
|
}
|
|
}
|
|
|
|
/// A fixed capacity avoids unbounded work from files or command input. Construction validates
|
|
/// the count and sanitizes every light, including callers of the portable geometry API.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Lights {
|
|
entries: [Option<Light>; MAX_LIGHTS],
|
|
}
|
|
|
|
impl Lights {
|
|
pub fn new(lights: &[Light]) -> Result<Self, String> {
|
|
if lights.is_empty() || lights.len() > MAX_LIGHTS {
|
|
return Err("3D effects require one to ten lights".into());
|
|
}
|
|
let mut entries = [None; MAX_LIGHTS];
|
|
for (slot, light) in entries.iter_mut().zip(lights) {
|
|
*slot = Some(light.sanitized());
|
|
}
|
|
Ok(Self { entries })
|
|
}
|
|
|
|
pub fn iter(&self) -> impl Iterator<Item = &Light> {
|
|
self.entries.iter().flatten()
|
|
}
|
|
}
|