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.
322 lines
15 KiB
Rust
322 lines
15 KiB
Rust
use serde_json::json;
|
||
use vectorcraft_doc::{Document, NodeId};
|
||
use vectorcraft_geom::{Affine, BezPath};
|
||
|
||
use super::*;
|
||
|
||
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
|
||
|
||
fn square_node() -> Node {
|
||
let mut b = BezPath::new();
|
||
b.move_to((100.0, 100.0));
|
||
b.line_to((200.0, 100.0));
|
||
b.line_to((200.0, 200.0));
|
||
b.line_to((100.0, 200.0));
|
||
b.close_path();
|
||
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0))
|
||
}
|
||
|
||
fn fx(id: &str, params: Value) -> Effect {
|
||
crate::new_effect(id, ¶ms).unwrap()
|
||
}
|
||
|
||
/// The shaded faces of every surface of `art`, bottom first.
|
||
fn faces(art: &Node) -> Vec<&Node> {
|
||
art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect()
|
||
}
|
||
|
||
fn fill_rgb(n: &Node) -> [f32; 3] {
|
||
match &n.appearance.items[0] {
|
||
AppearanceItem::Fill(f) => match &f.paint {
|
||
Paint::Gradient(g) => g.gradient.sample(0.5).0.to_rgb(),
|
||
p => p.color().unwrap().to_rgb(),
|
||
},
|
||
_ => panic!("a fill first"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
|
||
for id in SOLIDS {
|
||
let mut n = square_node();
|
||
let source = n.path_data().unwrap().clone();
|
||
n.appearance.effects.push(fx(id, json!({})));
|
||
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
|
||
assert_eq!(n.path_data(), Some(&source), "{id}");
|
||
assert!(!faces(&art).is_empty(), "{id}");
|
||
// The faces are the fill's red, shaded: never brighter, never another hue.
|
||
for f in faces(&art) {
|
||
let [r, g, b] = fill_rgb(f);
|
||
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
|
||
}
|
||
// The art follows the source when it moves.
|
||
let before = art.visual_bounds().unwrap();
|
||
n.transform(Affine::translate((50.0, -30.0)), false);
|
||
let after = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-6 && (after.y0 - before.y0 + 30.0).abs() < 1e-6, "{id}");
|
||
// Hidden, the effect draws nothing of its own.
|
||
n.appearance.effects[0].visible = false;
|
||
assert!(solid_art(&n).is_none(), "{id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
|
||
let mut n = square_node();
|
||
n.appearance.effects.push(fx(ROTATE, json!({})));
|
||
let art = solid_art(&n).unwrap();
|
||
// The fill's plane and the stroke's outline, each in its own paint.
|
||
let surfaces = art.children().unwrap();
|
||
assert_eq!(surfaces.len(), 2);
|
||
let paint = |s: &Node| fill_rgb(&s.children().unwrap()[0]);
|
||
assert!(paint(&surfaces[0])[0] > paint(&surfaces[0])[1], "the red fill below");
|
||
assert!(paint(&surfaces[1]).iter().all(|c| *c < 0.05), "the black stroke above");
|
||
// Turned 60° about y with no perspective, the stroke's outline spans half its width plus the
|
||
// stroke's own outset, foreshortened as well.
|
||
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
|
||
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
|
||
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
|
||
// Extrude & Bevel paints one surface with the top fill.
|
||
let mut n = square_node();
|
||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
|
||
let mut n = square_node();
|
||
n.opacity = 0.5;
|
||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||
f.opacity = 0.4;
|
||
f.effects.push(fx(INFLATE, json!({})));
|
||
}
|
||
let art = solid_art(&n).unwrap();
|
||
assert_eq!(art.opacity, 0.5);
|
||
let surfaces = art.children().unwrap();
|
||
// The fill inflated, the stroke drawn flat as it was.
|
||
assert_eq!(surfaces.len(), 2);
|
||
assert_eq!(surfaces[0].opacity, 0.4);
|
||
assert!(surfaces[0].children().unwrap().iter().all(|c| c.opacity == 1.0));
|
||
assert!(matches!(surfaces[1].kind, NodeKind::Path { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn export_and_expand_bake_faces_with_their_own_ids() {
|
||
for id in SOLIDS {
|
||
let mut d = Document::new(400.0, 400.0);
|
||
let mut n = square_node();
|
||
n.id = d.alloc_id();
|
||
n.appearance.effects.push(fx(id, json!({"bevel": "rounded"})));
|
||
assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}");
|
||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||
let mut ids = std::collections::HashSet::new();
|
||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||
assert!(ids.insert(n.id), "{:?} twice", n.id);
|
||
assert!(!has_solid(n));
|
||
for c in n.children().into_iter().flatten() {
|
||
visit(c, ids);
|
||
}
|
||
}
|
||
visit(&art, &mut ids);
|
||
assert!(ids.len() > 2, "{id}");
|
||
}
|
||
}
|
||
|
||
/// A twisted, tapered, hollow Inflate (both sides) exports and expands like the others: faces with
|
||
/// their own ids, no 3D effect left on them.
|
||
#[test]
|
||
fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
|
||
let mut d = Document::new(400.0, 400.0);
|
||
let mut n = square_node();
|
||
n.id = d.alloc_id();
|
||
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
|
||
n.appearance.effects.push(fx(INFLATE, params));
|
||
let art = solid_art(&n).unwrap();
|
||
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
|
||
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
|
||
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
||
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||
let mut ids = std::collections::HashSet::new();
|
||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||
assert!(ids.insert(n.id), "{:?} twice", n.id);
|
||
assert!(!has_solid(n));
|
||
for c in n.children().into_iter().flatten() {
|
||
visit(c, ids);
|
||
}
|
||
}
|
||
visit(&expanded, &mut ids);
|
||
assert!(ids.len() > 20, "{} nodes", ids.len());
|
||
}
|
||
|
||
/// Files and scripts from before the Perspective angle give `perspective` as a 0–100 % strength:
|
||
/// each 3D effect draws it exactly as the angle that stands for it, an angle given too wins, and
|
||
/// the current parameters carry only the angle.
|
||
#[test]
|
||
fn an_earlier_perspective_strength_draws_as_its_angle() {
|
||
let art = |n: &Node, id: &str| if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) };
|
||
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
|
||
let view = |extra: Value| {
|
||
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 80});
|
||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||
p
|
||
};
|
||
let with = |params: Value| {
|
||
let mut n = square_node();
|
||
// As read from a file: the stored parameters, not a fresh effect's.
|
||
n.appearance.effects.push(Effect { id: id.into(), params, visible: true });
|
||
art(&n, id).unwrap().visual_bounds().unwrap()
|
||
};
|
||
let angle = vectorcraft_three_d::perspective_from_strength(0.6);
|
||
let old = with(view(json!({"perspective": 60})));
|
||
let new = with(view(json!({"perspectiveAngle": angle})));
|
||
let none = with(view(json!({})));
|
||
for (a, b) in [(old.x0, new.x0), (old.y0, new.y0), (old.x1, new.x1), (old.y1, new.y1)] {
|
||
assert!((a - b).abs() < 1e-9, "{id}: {old:?} against {new:?}, none {none:?}");
|
||
}
|
||
assert!((old.width() - none.width()).abs() > 1.0 || (old.height() - none.height()).abs() > 1.0, "{id}: perspective shows");
|
||
assert_eq!(with(view(json!({"perspective": 60, "perspectiveAngle": 0}))), none, "{id}: the angle wins");
|
||
let merged = crate::merged_params(id, &view(json!({"perspective": 60})));
|
||
assert!(merged.get("perspective").is_none() && (merged["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{id}");
|
||
}
|
||
}
|
||
|
||
/// Effects from before Rotation and Height set the light by azimuth and elevation: each 3D effect
|
||
/// shades its faces exactly as with the Rotation, Height and side that stand for it.
|
||
#[test]
|
||
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
|
||
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
|
||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() {
|
||
if let Paint::Gradient(g) = &f.paint {
|
||
out.extend(g.gradient.stops.iter().map(|s| s.color.to_rgb()));
|
||
} else if let Some(c) = f.paint.color() {
|
||
out.push(c.to_rgb());
|
||
}
|
||
}
|
||
for c in n.children().into_iter().flatten() {
|
||
colours(c, out);
|
||
}
|
||
}
|
||
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
|
||
for (azimuth, elevation) in [(-45.0, 45.0), (150.0, -30.0), (60.0, 80.0), (-120.0, 10.0)] {
|
||
let with = |light: Value| {
|
||
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 60, "lightSoftness": 0});
|
||
p.as_object_mut().unwrap().extend(light.as_object().unwrap().clone());
|
||
let mut n = square_node();
|
||
n.appearance.effects.push(Effect { id: id.into(), params: p, visible: true });
|
||
let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { solid_art(&n) }.unwrap();
|
||
let mut out = vec![];
|
||
colours(&art, &mut out);
|
||
out
|
||
};
|
||
let (r, h, behind) = vectorcraft_three_d::light_from_azimuth_elevation(azimuth, elevation);
|
||
let old = with(json!({"lightAzimuth": azimuth, "lightElevation": elevation}));
|
||
let new = with(json!({"lightRotation": r, "lightHeight": h, "lightBehind": behind}));
|
||
assert!(!old.is_empty() && old.len() == new.len(), "{id}");
|
||
for (a, b) in old.iter().zip(&new) {
|
||
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6), "{id} {azimuth} {elevation}: {a:?} against {b:?}");
|
||
}
|
||
}
|
||
let merged = crate::merged_params(id, &json!({"lightAzimuth": 90, "lightElevation": 0}));
|
||
assert!(
|
||
merged.get("lightAzimuth").is_none() && merged["lightHeight"].as_f64().unwrap().abs() < 1e-9 && merged["lightBehind"] == json!(false),
|
||
"{id}: {merged}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||
let mut n = square_node();
|
||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
|
||
// Straight on, the extrusion shows its front: the offset square.
|
||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
|
||
let art = solid_art(&n).unwrap();
|
||
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
|
||
assert!(art.appearance.effects.iter().all(|e| e.id != "path.offsetPath" && !is_three_d(&e.id)));
|
||
}
|
||
|
||
#[test]
|
||
fn parameters_are_validated_and_lengths_scale_with_the_object() {
|
||
let n = square_node();
|
||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok());
|
||
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be"));
|
||
let text = Node::group(NodeId(2), vec![]);
|
||
assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path"));
|
||
for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] {
|
||
assert!(crate::is_length(id, key, false), "{id} {key}");
|
||
}
|
||
assert!(!crate::is_length(EXTRUDE, "twist", false));
|
||
for id in SOLIDS {
|
||
assert!(!crate::is_geometry(id) && is_three_d(id));
|
||
assert!(crate::default_params(id).is_some());
|
||
}
|
||
// Options read from parameters, with the documented ranges' defaults.
|
||
let o = extrude_options(&json!({"taper": 40, "bevel": "step", "bevelSpace": 30, "bevelRepeat": 99}));
|
||
assert_eq!(o.taper, 0.4);
|
||
let b = o.bevel.unwrap();
|
||
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
|
||
assert!(extrude_options(&json!({})).bevel.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn inflate_reads_twist_taper_and_cap() {
|
||
let o = inflate_options(&json!({"twist": 90, "taper": 40, "cap": false}));
|
||
assert_eq!((o.twist, o.taper, o.cap), (90.0, 0.4, false));
|
||
let d = inflate_options(&json!({}));
|
||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
|
||
let mut n = square_node();
|
||
n.appearance.effects.push(fx(INFLATE, json!({})));
|
||
let art = solid_art(&n).unwrap();
|
||
let original = faces(&art);
|
||
assert!(original.len() > 1000);
|
||
assert!(original.iter().any(|f| f.path_data().unwrap().subpaths.iter().any(|p| p.anchors.len() > 3)), "legacy cells remain polygons");
|
||
for face in original {
|
||
assert!(matches!(face.kind, NodeKind::Path { .. }));
|
||
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
|
||
assert!((0.05..=0.75).contains(&stroke.width));
|
||
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
|
||
}
|
||
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
|
||
n.transform(Affine::scale(0.001), false);
|
||
let art = solid_art(&n).unwrap();
|
||
assert!(faces(&art).iter().any(|face| matches!(&face.appearance.items[1], AppearanceItem::Stroke(s) if s.width == 0.05)));
|
||
// An explicit white light overrides a stale legacy color without switching shading paths.
|
||
n.appearance.effects[0].params = json!({"lightColor":"#ff0000","lights":[{}]});
|
||
let art = solid_art(&n).unwrap();
|
||
assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||
let mut n = square_node();
|
||
n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]})));
|
||
let art = solid_art(&n).unwrap();
|
||
fn count(n: &Node) -> (usize, usize, usize) {
|
||
let (mut groups, mut clips, mut paths) = match &n.kind {
|
||
NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0),
|
||
NodeKind::Path { .. } => (0, 0, 1),
|
||
_ => (0, 0, 0),
|
||
};
|
||
for child in n.children().into_iter().flatten() {
|
||
let (g, c, p) = count(child);
|
||
groups += g;
|
||
clips += c;
|
||
paths += p;
|
||
}
|
||
(groups, clips, paths)
|
||
}
|
||
let (groups, clips, paths) = count(&art);
|
||
assert!(paths > 1000, "exercise a tessellated surface");
|
||
assert_eq!(clips, 1);
|
||
assert_eq!(groups, 7, "group count must not grow with triangle count");
|
||
}
|