mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
The public 3D and Materials panel options list Depth, Twist, Taper and Cap for every 3D type; Inflate had only Depth. - Twist (0° to 360°) and Taper (1–100 %) turn and scale the back about the shape's centre, as Extrude & Bevel's do: a puffed back is turned and lowered in proportion, and the sides follow in slices. - Cap: Solid closes the back; Hollow leaves it off, so the inflated front is a shell open behind. The documentation doesn't say what Cap means for Inflate; this reading is stated in docs/three-d.md. - The dialog's Object tab shows them in the documented order (Depth, Twist, Taper, Cap), then Volume and Both sides. At 0 % volume a twisted, tapered Inflate has the extrusion's outline from every side (tested). A twisted, tapered, hollow Inflate draws on the canvas exactly as it exports, and expands to faces with their own ids (tested). ROADMAP and the parity pages list the new options; the 3D score stays at ~25 %.
194 lines
8.5 KiB
Rust
194 lines
8.5 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) => f.paint.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());
|
|
}
|
|
|
|
#[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, "perspective": 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));
|
|
}
|