Files
vectorcraft/crates/effects/src/tests_solids.rs
T
Ayberk A. 15cef94ba6 3D and Materials: Inflate takes Twist, Taper and Cap
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 %.
2026-10-13 05:22:00 +07:00

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, &params).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));
}