Effect › 3D and Materials: Extrude & Bevel, Inflate and Rotate

The three 3D types besides Revolve, as live effects in the three-d crate,
drawn as shaded vector faces by the canvas, SVG/PDF export and Expand
Appearance alike, from the public 3D and Materials documentation:

- Extrude & Bevel: depth (0-2000 pt), twist, taper, solid or hollow caps,
  and bevels in six original cross-sections (classic, rounded, cove, step,
  ogee) with width, height, repeat, spacing, inside and both sides.
- Inflate: the shape as a membrane under pressure (a Poisson solve held at
  zero along the outline and holes), so it rises vertically from the edge
  and rounds without creases; volume, depth and both sides. Steep cells
  near the outline are refined without cracks.
- Rotate: every fill and stroke turned in 3D and shaded as one plane.

All three share Revolve's view and lighting parameters, the rotation and
light gizmos (turning about the art's centre) and a dialog with Object,
Rotation and Lighting tabs, with Position presets (Front ... Isometric
Bottom, our own values). An object takes one 3D effect. Inflate cuts the
grid cells that straddle a hole's edge with pathops, hence the new
pathops -> three-d layer order.

Docs: docs/three-d.md, mcp.md, and the 3D rows of the parity docs.
This commit is contained in:
Ayberk A.
2026-10-13 00:35:07 +07:00
committed by bflatastic
parent f4e72fe6a5
commit 8a3458a974
38 changed files with 2615 additions and 57 deletions
+4 -3
View File
@@ -40,6 +40,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
crate::has_revolve(n)
|| crate::has_solid(n)
|| has_adjustment(n)
|| has_crop_marks(n)
|| has_pathfinder(n)
@@ -93,7 +94,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if let Some(m) = crop_marks_art(n) {
return Some(bake_pieces(d, m));
}
if let Some(m) = crate::revolve_art(n) {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
let m = evaluate_container(&m).unwrap_or(m);
return Some(bake_pieces(d, m));
}
@@ -232,7 +233,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
/// effects are dropped. `None` for other kinds of objects.
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
if let Some(m) = crate::revolve::expand_revolve_art(n) {
if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) {
let m = evaluate_container(&m).unwrap_or(m);
return Some(bake_pieces(d, m));
}
@@ -299,7 +300,7 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
if let Some(m) = crate::revolve_art(n) {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
return Some(m);
}
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
+29 -1
View File
@@ -43,6 +43,7 @@ pub mod pixel;
mod raster;
mod reshape;
mod revolve;
mod solids;
pub mod stroke;
mod stylize;
mod util;
@@ -70,6 +71,10 @@ pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
view_options,
};
pub use warp::{WarpStyle, warp_point};
/// Catalogue entry for one effect.
@@ -125,6 +130,8 @@ fn lengths_of(id: &str) -> Lengths {
"distort.transform" => always(&["moveH", "moveV"]),
"path.offsetPath" => always(&["offset"]),
"threeD.revolve" => always(&["offset"]),
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
@@ -196,6 +203,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
),
g(
EXTRUDE,
"Extrude & Bevel…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 50), twist: degrees (-360..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
),
g(
INFLATE,
"Inflate…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 0; flat depth between front and back), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
),
g(
ROTATE,
"Rotate…",
&["Effect", "3D and Materials"],
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspective: % (0..100, 0), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspective":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
),
g(
"convertToShape.rectangle",
"Rectangle…",
@@ -669,7 +697,7 @@ pub fn is_geometry(id: &str) -> bool {
&& !is_pathfinder(id)
&& !is_adjustment(id)
&& id != CROP_MARKS
&& id != REVOLVE
&& !is_three_d(id)
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
}
+262
View File
@@ -0,0 +1,262 @@
//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
/// Is `id` one of the 3D and Materials effects (Revolve included)?
pub fn is_three_d(id: &str) -> bool {
id == crate::REVOLVE || SOLIDS.contains(&id)
}
fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> {
fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str()))
}
/// Does `n` carry a visible Extrude & Bevel, Inflate or Rotate effect?
pub fn has_solid(n: &Node) -> bool {
solid(&n.appearance.effects).is_some() || n.appearance.items.iter().any(|i| i.visible() && solid(i.effects()).is_some())
}
/// The Position presets: (id, [rotation X, Y, Z]). Front, Back, Left, Right, Top and Bottom face
/// the art that way; Off-Axis tilts each 20° down and 25° aside (showing the top and right);
/// Isometric lays the art on the left, right, top or bottom plane of an isometric view (the
/// visible axes foreshortened equally: atan(1/√2) ≈ 35.26° and 45°). Our own values, from the
/// geometry of each view.
pub const ROTATION_PRESETS: [(&str, [f64; 3]); 16] = [
("front", [0.0, 0.0, 0.0]),
("back", [0.0, 180.0, 0.0]),
("left", [0.0, 90.0, 0.0]),
("right", [0.0, -90.0, 0.0]),
("top", [-90.0, 0.0, 0.0]),
("bottom", [90.0, 0.0, 0.0]),
("offAxisFront", [-21.88, -23.4, 9.06]),
("offAxisBack", [-158.12, 23.4, -170.94]),
("offAxisLeft", [-40.74, 58.39, -36.26]),
("offAxisRight", [-139.26, -58.39, 143.74]),
("offAxisTop", [-111.88, -23.4, 9.06]),
("offAxisBottom", [68.12, -23.4, 9.06]),
("isometricLeft", [-45.0, -35.26, 30.0]),
("isometricRight", [-45.0, 35.26, -30.0]),
("isometricTop", [45.0, -35.26, 30.0]),
("isometricBottom", [-45.0, -35.26, -30.0]),
];
/// The view parameters every 3D effect shares, named as Revolve's.
pub fn view_options(p: &Value) -> View {
View {
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
perspective: num(p, "perspective", 0.0) / 100.0,
light_azimuth: num(p, "lightAzimuth", -45.0),
light_elevation: num(p, "lightElevation", 45.0),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
}
}
/// Extrude & Bevel's parameters. `bevel` is `"none"` or a [`BevelShape`] id.
pub fn extrude_options(p: &Value) -> Extrude {
Extrude {
depth: num(p, "depth", 50.0),
twist: num(p, "twist", 0.0),
taper: num(p, "taper", 100.0) / 100.0,
cap: flag(p, "cap", true),
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
shape,
width: num(p, "bevelWidth", 4.0),
height: num(p, "bevelHeight", 4.0),
repeat: num(p, "bevelRepeat", 1.0).clamp(1.0, 32.0) as usize,
space: num(p, "bevelSpace", 0.0) / 100.0,
inside: flag(p, "bevelInside", false),
both_sides: flag(p, "bevelBothSides", false),
}),
}
}
pub fn inflate_options(p: &Value) -> Inflate {
Inflate { depth: num(p, "depth", 0.0), volume: num(p, "volume", 50.0), both_sides: flag(p, "bothSides", false) }
}
/// The shape a solid is made from: a path's or a compound path's outline.
fn profile(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
_ => None,
}
}
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, bounds, view),
}
}
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let (path, _) =
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
facets(id, &path, b, p).map(|_| ())
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
let children = facets
.into_iter()
.map(|face| {
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let s = face.brightness as f32;
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
} else {
// Gradients and patterns keep their paint, darkened by a black layer.
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
shadow.opacity = (1.0 - face.brightness) as f32;
items.push(AppearanceItem::Fill(shadow));
}
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
// paint over its neighbours.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
let items = items
.into_iter()
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
.collect();
Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
})
.collect();
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
group
}
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
match item {
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
}
}
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
match item {
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
_ => path.clone(),
}
}
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
let b = path.bounds()?;
let p = crate::merged_params(&fx.id, &fx.params);
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds, so they stay together.
let mut out = vec![];
for item in items {
let (paint, opacity, blend) = paint_of(item);
let f = facets(ROTATE, &region(path, rule, item), b, &p).ok()?;
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
}
return Some(out);
}
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
let (paint, opacity, blend) = paint_of(item);
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
// failing the frame; commands validate the same evaluator and report its errors.
let f = facets(&fx.id, path, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
/// the object's id and transparency, the source path untouched. `None` without such an effect.
pub fn solid_art(n: &Node) -> Option<Node> {
if !has_solid(n) {
return None;
}
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
let mut children = vec![];
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
children.extend(art_for(n, fx, &path, rule, &painted)?);
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
.effects
.iter()
.enumerate()
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
.map(|(_, e)| e.clone())
.collect();
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
for item in &painted {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
for mut art in art_for(n, fx, &p, rule, &[*item])? {
Arc::make_mut(&mut art).appearance.effects =
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
children.push(art);
}
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() })));
}
}
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
#[cfg(test)]
#[path = "tests_solids.rs"]
mod tests;
+158
View File
@@ -0,0 +1,158 @@
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}");
}
}
#[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());
}