mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17: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.
374 lines
17 KiB
Rust
374 lines
17 KiB
Rust
//! Shared conversion and vector paint shading for the 3D effects' directional lights.
|
|
use serde_json::Value;
|
|
use vectorcraft_doc::color::{Color, Paint};
|
|
use vectorcraft_doc::{AppearanceItem, FillLayer};
|
|
|
|
fn parse(v: &Value) -> Option<Color> {
|
|
let s = v.as_str()?;
|
|
// Match the command's opaque RGB contract, before any parsing or allocation.
|
|
(s.len() == 7 && s.starts_with('#') && s.as_bytes().iter().skip(1).all(u8::is_ascii_hexdigit)).then(|| Color::from_hex(s)).flatten()
|
|
}
|
|
|
|
pub(crate) fn light_color(p: &Value) -> [f64; 3] {
|
|
p.get("lightColor").and_then(parse).unwrap_or(Color::WHITE).to_rgb_uncalibrated().map(f64::from)
|
|
}
|
|
|
|
pub(crate) fn validate(p: &Value) -> Result<(), String> {
|
|
if p.get("lightColor").is_some_and(|v| parse(v).is_none()) {
|
|
return Err("lightColor must be an opaque RGB color in #rrggbb form".into());
|
|
}
|
|
if let Some(list) = p.get("lights").filter(|v| !v.is_null()) {
|
|
parse_lights(list)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn lights(p: &Value) -> Option<vectorcraft_three_d::Lights> {
|
|
p.get("lights").filter(|v| !v.is_null()).and_then(|v| parse_lights(v).ok())
|
|
}
|
|
|
|
fn parse_lights(v: &Value) -> Result<vectorcraft_three_d::Lights, String> {
|
|
use vectorcraft_three_d::{Light, Lights, MAX_LIGHTS};
|
|
let list = v.as_array().filter(|v| !v.is_empty() && v.len() <= MAX_LIGHTS).ok_or("lights must contain one to ten lights")?;
|
|
let mut out = Vec::with_capacity(list.len());
|
|
for item in list {
|
|
let item = item.as_object().ok_or("each light must be an object")?;
|
|
if item.keys().any(|key| !["color", "intensity", "rotation", "height", "softness", "behind"].contains(&key.as_str())) {
|
|
return Err("unknown light setting: use color, intensity, rotation, height, softness or behind".into());
|
|
}
|
|
let defaults = Light::default();
|
|
let number = |key: &str, default: f64, min: f64, max: f64| -> Result<f64, String> {
|
|
match item.get(key) {
|
|
None => Ok(default),
|
|
Some(v) => v
|
|
.as_f64()
|
|
.filter(|n| n.is_finite() && *n >= min && *n <= max)
|
|
.ok_or_else(|| format!("light {key} must be a number from {min} to {max}")),
|
|
}
|
|
};
|
|
let color = match item.get("color") {
|
|
None => [1.0; 3],
|
|
Some(v) => parse(v).ok_or("light color must use opaque #rrggbb")?.to_rgb_uncalibrated().map(f64::from),
|
|
};
|
|
let behind = item.get("behind").map_or(Ok(false), |v| v.as_bool().ok_or("light behind must be true or false"))?;
|
|
out.push(Light {
|
|
color,
|
|
behind,
|
|
intensity: number("intensity", defaults.intensity * 100.0, 0.0, 100.0)? / 100.0,
|
|
rotation: number("rotation", defaults.rotation, -180.0, 180.0)?,
|
|
height: number("height", defaults.height, 0.0, 90.0)?,
|
|
softness: number("softness", defaults.softness * 100.0, 0.0, 100.0)? / 100.0,
|
|
});
|
|
}
|
|
Lights::new(&out)
|
|
}
|
|
|
|
pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
|
|
let [r, g, b] = c.to_rgb();
|
|
Color::rgb(r * rgb[0] as f32, g * rgb[1] as f32, b * rgb[2] as f32)
|
|
}
|
|
|
|
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
|
|
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
|
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
|
|
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
|
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
|
|
Paint::Gradient(Box::new(p))
|
|
}
|
|
|
|
/// Multiply two independently placed vector gradients before applying the face's coverage
|
|
/// once. Separately antialiasing the source and light along each triangle edge leaves seams.
|
|
pub(crate) fn gradient_art(
|
|
id: vectorcraft_doc::NodeId,
|
|
path: &vectorcraft_geom::PathData,
|
|
paint: &Paint,
|
|
light: vectorcraft_three_d::LightGradient,
|
|
) -> vectorcraft_doc::Node {
|
|
composed_art(id, path, paint, Ramp::Single(light), 0.75)
|
|
}
|
|
|
|
pub(crate) fn rgb_gradient_art(
|
|
id: vectorcraft_doc::NodeId,
|
|
path: &vectorcraft_geom::PathData,
|
|
paint: &Paint,
|
|
light: vectorcraft_three_d::RgbGradient,
|
|
) -> vectorcraft_doc::Node {
|
|
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
|
|
}
|
|
|
|
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
|
|
pub(crate) fn facet_art(
|
|
id: vectorcraft_doc::NodeId,
|
|
path: &vectorcraft_geom::PathData,
|
|
paint: &Paint,
|
|
light: vectorcraft_three_d::RgbGradient,
|
|
seam: f64,
|
|
) -> vectorcraft_doc::Node {
|
|
let [r, g, b] = light.channels;
|
|
if r == g && g == b {
|
|
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
|
|
if let Some(color) = paint.color() {
|
|
let paint = smooth_paint(light, color);
|
|
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
|
|
edge.join = vectorcraft_doc::LineJoin::Round;
|
|
return vectorcraft_doc::Node::path(
|
|
id,
|
|
path.clone(),
|
|
vectorcraft_doc::Appearance {
|
|
items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)],
|
|
..Default::default()
|
|
},
|
|
);
|
|
}
|
|
return composed_art(id, path, paint, Ramp::Single(light), seam);
|
|
}
|
|
composed_art(id, path, paint, Ramp::Rgb(light), seam)
|
|
}
|
|
|
|
/// Composite smooth solid-fill facets with one clip and three channel groups per surface.
|
|
/// Black channel backdrops keep shared edges opaque before the final coverage is applied.
|
|
pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node {
|
|
use kurbo::Shape;
|
|
use std::sync::Arc;
|
|
use vectorcraft_doc::color::BlendMode;
|
|
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
|
|
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
|
|
|
|
let mut boundary = BezPath::new();
|
|
let mut bounds = None;
|
|
for face in facets {
|
|
let path = face.path();
|
|
if let Some(b) = path.bounds() {
|
|
bounds = Some(bounds.map_or(b, |r: Rect| r.union(b)));
|
|
}
|
|
let mut contour = path.to_bezpath();
|
|
if contour.area() < 0.0 {
|
|
contour = contour.reverse_subpaths();
|
|
}
|
|
boundary.extend(contour.iter());
|
|
}
|
|
let clip = Node::path(id, PathData::from_bezpath(&boundary), Appearance::default());
|
|
let bounds = bounds.unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
|
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
|
let channels = (0..3)
|
|
.map(|channel| {
|
|
let mut color = [0.0; 3];
|
|
if let Some(value) = color.get_mut(channel) {
|
|
*value = 1.0;
|
|
}
|
|
let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))];
|
|
children.extend(facets.iter().map(|face| {
|
|
let paint = if let Some(ramp) = face.rgb_gradient {
|
|
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
|
|
} else {
|
|
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
|
|
};
|
|
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
|
|
edge.join = vectorcraft_doc::LineJoin::Round;
|
|
Arc::new(Node::path(
|
|
id,
|
|
face.path(),
|
|
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() },
|
|
))
|
|
}));
|
|
let mut group = Node::group(id, children);
|
|
group.isolate = true;
|
|
group.blend = BlendMode::Screen;
|
|
Arc::new(group)
|
|
})
|
|
.collect();
|
|
let mut light = Node::group(id, channels);
|
|
light.isolate = true;
|
|
light.blend = BlendMode::Multiply;
|
|
let mut colors = Node::group(id, vec![Arc::new(cover(paint.clone())), Arc::new(light)]);
|
|
colors.isolate = true;
|
|
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true })
|
|
}
|
|
|
|
enum Ramp {
|
|
Single(vectorcraft_three_d::LightGradient),
|
|
Rgb(vectorcraft_three_d::RgbGradient),
|
|
}
|
|
|
|
/// Separate a gradient's color and coverage so a tessellated surface can apply its opacity
|
|
/// once, instead of building opacity at the overlapping seam strokes of adjacent faces.
|
|
pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option<Paint>) {
|
|
let mut base = paint.clone();
|
|
let mut mask = paint.clone();
|
|
let mut transparent = false;
|
|
if let (Paint::Gradient(b), Paint::Gradient(m)) = (&mut base, &mut mask) {
|
|
b.swatch = None;
|
|
m.swatch = None;
|
|
for (b, m) in b.gradient.stops.iter_mut().zip(&mut m.gradient.stops) {
|
|
transparent |= b.opacity != 1.0;
|
|
b.opacity = 1.0;
|
|
m.color = Color::WHITE;
|
|
m.swatch = None;
|
|
m.tint = 1.0;
|
|
}
|
|
if let (Some(b), Some(m)) = (&mut b.freeform, &mut m.freeform) {
|
|
for (b, m) in b.points.iter_mut().zip(&mut m.points) {
|
|
transparent |= b.opacity != 1.0;
|
|
b.opacity = 1.0;
|
|
m.color = Color::WHITE;
|
|
}
|
|
}
|
|
}
|
|
(base, transparent.then_some(mask))
|
|
}
|
|
|
|
fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node {
|
|
use kurbo::Shape;
|
|
use std::sync::Arc;
|
|
use vectorcraft_doc::color::BlendMode;
|
|
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
|
|
use vectorcraft_geom::{Rect, shapes};
|
|
let (base, mask) = opaque_paint(paint);
|
|
// Clip once to the union of this single-contour face and its round seam stroke.
|
|
// Align their winding so the nonzero fill adds coverage instead of cancelling it.
|
|
let mut boundary = path.to_bezpath();
|
|
let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
|
|
if boundary.area() * footprint.area() < 0.0 {
|
|
boundary = boundary.reverse_subpaths();
|
|
}
|
|
footprint.extend(boundary.iter());
|
|
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
|
|
// Opaque rectangles cover the complete seam stroke, without separately antialiasing
|
|
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks.
|
|
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
|
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
|
let mut illumination = match light {
|
|
Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)),
|
|
Ramp::Rgb(light) => {
|
|
let channels = light
|
|
.channels
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(channel, gradient)| {
|
|
let mut color = [0.0; 3];
|
|
if let Some(value) = color.get_mut(channel) {
|
|
*value = 1.0;
|
|
}
|
|
let mut layer = cover(smooth_paint(vectorcraft_three_d::LightGradient { diffuse: *gradient, ambient: 0.0, color }, Color::WHITE));
|
|
// Screen adds disjoint R, G and B channels exactly; no light is clamped
|
|
// separately. Each ramp already holds the sum of all directional lights.
|
|
layer.blend = BlendMode::Screen;
|
|
Arc::new(layer)
|
|
})
|
|
.collect();
|
|
let mut group = Node::group(id, channels);
|
|
group.isolate = true;
|
|
group
|
|
}
|
|
};
|
|
illumination.blend = BlendMode::Multiply;
|
|
let mut colors = Node::group(id, vec![Arc::new(cover(base)), Arc::new(illumination)]);
|
|
colors.isolate = true;
|
|
// Keep isolation inside the clip: the clip then applies to the finished colors,
|
|
// rather than separately antialiasing each of the two gradient drawings.
|
|
let mut face = Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true });
|
|
if let Some(mask) = mask {
|
|
face.extras_mut().mask = Some(Box::new(OpacityMask::new(cover(mask), true)));
|
|
}
|
|
face
|
|
}
|
|
|
|
pub(crate) fn fills(paint: &Paint, rgb: [f64; 3], brightness: f64, colored: bool) -> Vec<AppearanceItem> {
|
|
let shade = |c: Color| shade(c, rgb);
|
|
if let Some(c) = paint.color() {
|
|
return vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(shade(c))))];
|
|
}
|
|
// Keep the white-light gradient treatment identical to existing documents. Colored light
|
|
// shades the ramp itself, keeping placement, stops, midpoint and opacity as vector data.
|
|
if matches!(paint, Paint::Gradient(_)) && colored {
|
|
let mut shaded = paint.clone();
|
|
vectorcraft_doc::recolor::map_paint(&mut shaded, &shade);
|
|
if let Paint::Gradient(g) = &mut shaded {
|
|
g.swatch = None;
|
|
}
|
|
return vec![AppearanceItem::Fill(FillLayer::new(shaded))];
|
|
}
|
|
// Pattern tiles remain referenced artwork. Retain their existing neutral shadow
|
|
// approximation; tinting arbitrary pattern contents needs document-level evaluation.
|
|
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
|
shadow.opacity = (1.0 - brightness) as f32;
|
|
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Fill(shadow)]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
|
use vectorcraft_geom::Point;
|
|
|
|
#[test]
|
|
fn command_contract_is_strict_and_loaded_invalid_colors_fall_back_to_white() {
|
|
assert_eq!(light_color(&json!({})), [1.0; 3]);
|
|
assert_eq!(light_color(&json!({"lightColor":"#FF0000"})), [1.0, 0.0, 0.0]);
|
|
for v in [json!("#ff0"), json!("#ffffff80"), json!("blue"), json!("#gg0000"), json!(null), json!([1, 0, 0])] {
|
|
let p = json!({"lightColor":v});
|
|
assert!(validate(&p).is_err(), "{p}");
|
|
assert_eq!(light_color(&p), [1.0; 3]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_loaded_lists_keep_the_legacy_light_and_valid_lists_override_it() {
|
|
for value in [json!([]), json!(vec![json!({}); 11]), json!([false]), json!([{"height": 91}]), json!([{"color":"#ff0"}])] {
|
|
let p = json!({"lights": value, "lightColor":"#ff0000"});
|
|
assert!(lights(&p).is_none());
|
|
assert_eq!(light_color(&p), [1.0, 0.0, 0.0]);
|
|
assert!(validate(&p).is_err());
|
|
}
|
|
let p = json!({"lights":[{}, {"color":"#0000ff","intensity":40}], "lightColor":"#ff0000"});
|
|
let list = lights(&p).unwrap();
|
|
assert_eq!(list.iter().count(), 2);
|
|
assert_eq!(*list.iter().next().unwrap(), vectorcraft_three_d::Light::default());
|
|
assert_eq!(list.iter().nth(1).unwrap().color, [0.0, 0.0, 1.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn colored_gradient_keeps_placement_alpha_and_midpoints() {
|
|
let mut stop = GradientStop::new(0.0, Color::WHITE);
|
|
stop.opacity = 0.4;
|
|
stop.midpoint = 0.3;
|
|
let g = GradientPaint {
|
|
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
|
|
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
|
|
angle: 0.0,
|
|
swatch: None,
|
|
freeform: None,
|
|
};
|
|
let paint = Paint::Gradient(Box::new(g.clone()));
|
|
let items = fills(&paint, [1.0, 0.25, 0.25], 1.0, true);
|
|
let Paint::Gradient(shaded) = items[0].paint() else { panic!("vector gradient") };
|
|
assert_eq!(shaded.geom, g.geom);
|
|
assert_eq!(shaded.gradient.stops[0].color.to_rgb(), [1.0, 0.25, 0.25]);
|
|
assert_eq!(shaded.gradient.stops[0].opacity, 0.4);
|
|
assert_eq!(shaded.gradient.stops[0].midpoint, 0.3);
|
|
assert_eq!(shaded.gradient.stops[1].color, Color::BLACK);
|
|
assert_eq!(paint, Paint::Gradient(Box::new(g)));
|
|
let white = fills(&paint, [0.4; 3], 0.4, false);
|
|
assert_eq!(white[0].paint(), &paint, "white retains the previous gradient treatment");
|
|
assert_eq!(white.len(), 2);
|
|
let unlit = fills(&paint, [0.25; 3], 0.25, true);
|
|
assert_eq!(unlit.len(), 1, "neutral ambient under colored light still tints once");
|
|
let mut freeform = if let Paint::Gradient(g) = paint { g } else { panic!("gradient") };
|
|
freeform.gradient.kind = GradientKind::Freeform;
|
|
freeform.gradient.stops = vec![GradientStop::new(0.0, Color::BLACK)];
|
|
freeform.swatch = Some("Original ramp".into());
|
|
freeform.freeform = Some(vectorcraft_doc::color::Freeform {
|
|
points: vec![vectorcraft_doc::color::FreeformPoint::new(Point::new(12.0, 18.0), Color::WHITE)],
|
|
..Default::default()
|
|
});
|
|
let original = Paint::Gradient(freeform);
|
|
let items = fills(&original, [1.0, 0.25, 0.25], 1.0, true);
|
|
let Paint::Gradient(shaded) = items[0].paint() else { panic!("freeform gradient") };
|
|
let point = shaded.freeform.as_ref().unwrap().points[0];
|
|
assert_eq!(point.at, Point::new(12.0, 18.0));
|
|
assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]);
|
|
assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch");
|
|
}
|
|
}
|