Files
vectorcraft/crates/effects/src/solids/tests.rs
T
Drew Dibble 0654aef75b Merge remote-tracking branch 'upstream/main' into seam-round-joins
# Conflicts:
#	crates/effects/src/solids.rs
#	docs/target-app-parity.md
2026-11-13 10:18:23 -07:00

1950 lines
102 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::json;
use vectorcraft_doc::{Document, NodeId, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
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) => 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 or the stroke's black, shaded: never brighter, never another
// hue. The classic Plastic surface, its default, also lightens a face toward white, which
// scales every channel's distance from white by the same factor and so keeps r - g to
// r - b at 6 to 7.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
let shaded = r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4;
let highlighted =
id == CLASSIC_EXTRUDE && [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) && ((r - g) * 7.0 - (r - b) * 6.0).abs() < 1e-4;
assert!(shaded || highlighted || 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 draws the fill's solid and the stroke's, both opaque, as one surface.
let mut n = square_node();
n.appearance.effects.push(fx(EXTRUDE, json!({})));
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
}
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
fn stroked(b: &BezPath) -> Node {
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
}
/// A `width` × 100 pt rectangle at (100, 100), stroked.
fn stroked_rect(width: f64) -> Node {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((100.0 + width, 100.0));
b.line_to((100.0 + width, 200.0));
b.line_to((100.0, 200.0));
b.close_path();
stroked(&b)
}
/// Is `face` in the stroke's blue (rather than the fill's red)?
fn blue(face: &Node) -> bool {
let [r, _, b] = fill_rgb(face);
b > r
}
/// The bounds of the faces' own paths (without their seam strokes).
fn extent(faces: &[&Node]) -> Rect {
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
}
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
}
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
/// fill's, and they are drawn in the stroke's paint.
#[test]
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
for id in [EXTRUDE, INFLATE] {
let mut n = stroked_rect(100.0);
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
let art = solid_art(&n).unwrap();
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
assert!(!strokes.is_empty(), "{id}: the stroke draws");
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
// Its own blue, shaded: never brighter, never another hue.
for f in &strokes {
let [r, g, b] = fill_rgb(f);
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
}
}
}
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
/// drawn below the fill's.
#[test]
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
// caps; the mean of its outline's points is not the mean of the stroke's.
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((400.0, 100.0));
b.line_to((100.0, 200.0));
b.close_path();
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
let mut n = stroked(&b);
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let flat = params["depth"] == json!(0);
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
// The stroke's cap is a ring: its outline and the hole the fill shows through.
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
if !flat {
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
}
}
}
/// A C, open to the right: two concave corners where its walls meet the walls of the notch.
fn letter_c() -> BezPath {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((200.0, 100.0));
b.line_to((200.0, 130.0));
b.line_to((130.0, 130.0));
b.line_to((130.0, 170.0));
b.line_to((200.0, 170.0));
b.line_to((200.0, 200.0));
b.line_to((100.0, 200.0));
b.close_path();
b
}
/// The pairs (blue, red) of `walls`, by index, where the blue face is drawn first and the two
/// faces' bounds overlap.
fn red_after_blue(walls: &[&Node]) -> Vec<(usize, usize)> {
let bounds: Vec<Rect> = walls.iter().map(|f| f.path_data().and_then(PathData::bounds).unwrap()).collect();
let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
let mut out = vec![];
for (i, a) in walls.iter().enumerate() {
for (j, b) in walls.iter().enumerate().skip(i + 1) {
if blue(a) && !blue(b) && overlap(&bounds[i], &bounds[j]) {
out.push((i, j));
}
}
}
out
}
/// An opaque stroke's solid holds the fill's edge, so the fill's walls are drawn before every
/// face of the stroke they overlap, and the stroke's walls cover them at the concave corners of a
/// turned C, in Extrude & Bevel and Extrude & Bevel (Classic). The front caps stay last, the
/// fill's below the stroke's.
#[test]
fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() {
let views = [json!({}), json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10})];
for id in [EXTRUDE, CLASSIC_EXTRUDE] {
for view in &views {
let mut params = view.clone();
params["depth"] = json!(60);
let mut n = stroked(&letter_c());
n.appearance.effects.push(fx(id, params));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let [walls @ .., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{id} {view}: the caps") };
assert!(!blue(fill_cap) && blue(stroke_cap), "{id} {view}: the caps last");
assert!(walls.iter().any(|f| !blue(f)) && walls.iter().any(|f| blue(f)), "{id} {view}: walls of both");
let wrong = red_after_blue(walls);
assert!(wrong.is_empty(), "{id} {view}: fill walls after the stroke faces over them: {wrong:?} of {}", walls.len());
}
}
// Strokes aligned inside or outside keep the fill's walls, in that order. A dashed stroke
// leaves the fill's walls in the gaps between its dashes, and they keep their depths.
use vectorcraft_doc::StrokeAlign;
for (dash, align) in [(true, StrokeAlign::Center), (false, StrokeAlign::Inside), (false, StrokeAlign::Outside)] {
let mut n = stroked(&letter_c());
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.dash = dash.then(|| vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
s.align = align;
}
n.appearance.effects.push(fx(EXTRUDE, views[1].clone()));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let [walls @ .., _, _] = faces.as_slice() else { panic!("{align:?}: the caps") };
assert!(walls.iter().any(|f| !blue(f)), "{align:?}: the fill's walls");
if !dash {
assert!(red_after_blue(walls).is_empty(), "{align:?}");
}
}
}
/// The faces of the solids effect `id` with `params` builds for `n`, in the order they are drawn,
/// as [`surfaces_for`] orders them, each with the index of the part ([`solid_parts`]) it was built
/// from and whether it is a stroke's.
fn drawn_parts(n: &Node, id: &str, params: &Value) -> Vec<(Facet, usize, bool)> {
let (path, rule) = profile(n).unwrap();
let shapes = [Profile { path, rule, items: solid_paints(n), opacity: 1.0, scopes: vec![] }];
let p = crate::merged_params(id, params);
let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap());
let mut solids = vec![];
for (part, ((item, _, _, _, _, member), f)) in parts.iter().zip(built_solids(id, &p, &parts, bounds, MAX_OBJECT_FACES)).enumerate() {
let Some(f) = f.unwrap() else { continue };
let closed = holds_edge(id, &shapes[*member].path);
solids.push((Paints::of(item, closed), *member, f, part, matches!(item, AppearanceItem::Stroke(_))));
}
let mut cuts: Vec<(Paints, usize, &mut Vec<Facet>)> = solids.iter_mut().map(|(paints, member, f, _, _)| (*paints, *member, f)).collect();
cut_stroke_faces(&mut cuts, MAX_OBJECT_FACES);
let (mut placed, mut all) = (vec![], vec![]);
for (paints, member, f, part, stroke) in solids {
for facet in f {
placed.push(Placed::of(&facet, member, paints));
all.push((facet, part, stroke));
}
}
let sealed = match id {
INFLATE => inflate_options(&p).cap,
CLASSIC_EXTRUDE => classic_extrude_options(&p).cap,
_ => extrude_options(&p).cap,
};
draw_order(&placed, sealed).into_iter().map(|k| all[k].clone()).collect()
}
/// Is `p` inside the polygon of `f` (nonzero winding)?
fn facet_contains(f: &Facet, p: Point) -> bool {
let mut winding = 0i32;
for c in &f.contours {
for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) {
let cross = (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y);
if a.y <= p.y && b.y > p.y && cross > 0.0 {
winding += 1;
} else if a.y > p.y && b.y <= p.y && cross < 0.0 {
winding -= 1;
}
}
}
winding != 0
}
/// The points of a 1 pt grid over `faces` (drawn in that order, each with whether it is a stroke's)
/// where the face drawn on top is the fill's while a face of the stroke turned toward the viewer is
/// nearer the viewer there by more than 0.5 pt, or the other way round. Points within 0.3 pt of an
/// edge are left out.
fn wrong_on_top(faces: &[(Facet, bool)]) -> Vec<Point> {
let polygons: Vec<&Facet> = faces.iter().map(|(f, _)| f).collect();
let extent =
polygons.iter().flat_map(|f| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p));
let mut wrong = vec![];
let mut y = extent.y0.floor() + 0.5;
while y < extent.y1 {
let mut x = extent.x0.floor() + 0.5;
while x < extent.x1 {
let p = Point::new(x, y);
let well_inside = |f: &Facet| {
[(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy)))
};
let top = faces.iter().rposition(|(f, _)| facet_contains(f, p));
if let Some((f, stroke)) = top.and_then(|k| faces.get(k)).filter(|(f, _)| !f.away && well_inside(f)) {
let nearest = |of_stroke: bool| {
faces.iter().filter(|(g, s)| *s == of_stroke && !g.away && well_inside(g)).filter_map(|(g, _)| g.depth_at(p)).reduce(f64::max)
};
if let (Some(ds), Some(df)) = (nearest(true), nearest(false))
&& (ds - df).abs() > 0.5
&& (ds > df) != *stroke
&& f.depth_at(p).is_some()
{
wrong.push(p);
}
}
x += 1.0;
}
y += 1.0;
}
wrong
}
/// The points of a 1 pt grid over `faces` (drawn in that order) where the face drawn on top is
/// farther from the viewer than another face turned toward the viewer, of any part, by more than
/// 0.5 pt. Points within 0.3 pt of an edge, and faces without a plane, are left out.
fn misdrawn(faces: &[(Facet, usize, bool)]) -> Vec<Point> {
let extent =
faces.iter().flat_map(|(f, ..)| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p));
let mut wrong = vec![];
let mut y = extent.y0.floor() + 0.5;
while y < extent.y1 {
let mut x = extent.x0.floor() + 0.5;
while x < extent.x1 {
let p = Point::new(x, y);
let well_inside = |f: &Facet| {
[(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy)))
};
let top = faces.iter().rposition(|(f, ..)| facet_contains(f, p)).and_then(|k| faces.get(k)).filter(|(f, ..)| !f.away && well_inside(f));
if let Some(on_top) = top.and_then(|(f, ..)| f.depth_at(p)) {
let nearest = faces.iter().filter(|(g, ..)| !g.away && well_inside(g)).filter_map(|(g, ..)| g.depth_at(p)).reduce(f64::max);
if nearest.is_some_and(|d| d - on_top > 0.5) {
wrong.push(p);
}
}
x += 1.0;
}
y += 1.0;
}
wrong
}
/// The faces of a shape's fill and strokes are drawn in the order of their depths where they
/// overlap: on a turned C, with or without perspective, under a stroke that does not cover the
/// fill's whole edge (dashed, with a width profile, or with a brush), under a plain stroke and
/// under two strokes whose faces pass through each other, no point shows a face farther from the
/// viewer than another, in Extrude & Bevel and Extrude & Bevel (Classic), and only a few in
/// Inflate, where curved puffs cross.
#[test]
fn fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap() {
use vectorcraft_doc::{Dash, WidthProfile};
let views = [
json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}),
json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10}),
json!({"rotationX": -30, "rotationY": 50, "rotationZ": 5, "perspectiveAngle": 40}),
json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}),
];
type Style<'a> = (&'a str, &'a dyn Fn(&mut Node));
let first = |n: &mut Node, set: &dyn Fn(&mut StrokeLayer)| {
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
set(s);
}
};
let dash = |pattern: Vec<f64>, offset: f64| Some(Dash { pattern, offset, align_corners: false });
let styles: [Style; 6] = [
("plain", &|_| {}),
("dashed", &|n| first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0))),
("profiled", &|n| first(n, &|s| s.profile = Some(WidthProfile::lens()))),
("brushed", &|n| first(n, &|s| s.brush = Some("brush".into()))),
("dashed with perspective", &|n| first(n, &|s| s.dash = dash(vec![20.0, 6.0], 3.0))),
("two strokes", &|n| {
first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0));
let mut wide = StrokeLayer::new(Paint::solid(RED), 14.0);
wide.profile = Some(WidthProfile::lens());
n.appearance.items.push(AppearanceItem::Stroke(wide));
}),
];
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
for (v, view) in views.iter().enumerate() {
for (style, set) in &styles {
// The last two views, for the plain and the two strokes only.
if v >= 2 && !["plain", "two strokes"].contains(style) {
continue;
}
let mut params = view.clone();
params["depth"] = json!(60);
if style.contains("perspective") {
params["perspectiveAngle"] = json!(40);
}
let mut n = stroked(&letter_c());
set(&mut n);
let parts = drawn_parts(&n, id, &params);
let faces: Vec<(Facet, bool)> = parts.iter().map(|(f, _, s)| (f.clone(), *s)).collect();
assert!(faces.iter().any(|(_, s)| *s) && faces.iter().any(|(_, s)| !*s), "{id} {view} {style}: faces of both");
// Inflate's curved puffs cross each other along curves its flat facets only follow
// roughly: a few points next to those crossings may still show the farther face.
let allowed = if id == INFLATE { 5 } else { 0 };
let wrong = wrong_on_top(&faces);
assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points drawn in the wrong paint, first {:?}", wrong.len(), wrong.first());
let wrong = misdrawn(&parts);
assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points show a farther face, first {:?}", wrong.len(), wrong.first());
}
}
}
}
/// The paths of `art`'s faces in drawing order, leaving out the graphics (clip groups).
fn face_paths(art: &Node) -> Vec<PathData> {
fn walk(n: &Node, out: &mut Vec<PathData>) {
match &n.kind {
NodeKind::Group { clip: true, .. } => {}
NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)),
_ => out.extend(n.path_data().cloned()),
}
}
let mut out = vec![];
walk(art, &mut out);
out
}
/// A member of a 3D group with an opacity mask or a blending mode of its own draws its fill's and
/// its strokes' faces in one order inside that mask, the order the same member draws them in
/// without it, its dashes cut where they pass through the fill's walls. A member whose stroke is
/// translucent still draws each surface whole.
#[test]
fn a_masked_members_fill_and_stroke_faces_go_in_one_order() {
let mut member = stroked(&letter_c());
if let Some(AppearanceItem::Stroke(s)) = member.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
}
let solid = |member: Node| {
let mut g = Node::group(NodeId(2), vec![Arc::new(member)]);
g.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})));
solid_art(&g).unwrap()
};
let cover = Node::path(
NodeId(900),
vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0),
);
let mut masked = member.clone();
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true)));
let mut multiplied = member.clone();
multiplied.blend = BlendMode::Multiply;
let unmasked = face_paths(&solid(member.clone()));
for (what, scoped) in [("masked", masked.clone()), ("multiplied", multiplied)] {
let art = solid(scoped);
let mut scopes = 0;
art.walk(&mut |n| scopes += usize::from(n.extras().mask.is_some() || n.blend == BlendMode::Multiply));
assert!(scopes > 0, "{what}: drawn in its scope");
assert_eq!(face_paths(&art), unmasked, "{what}: the faces in the member's one order");
}
// A translucent stroke keeps its surface whole: the fill's faces, then the stroke's.
if let Some(AppearanceItem::Stroke(s)) = masked.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.opacity = 0.5;
}
let art = solid(masked);
let mut groups = vec![];
art.walk(&mut |n| {
if n.opacity == 0.5 {
groups.push(n.children().map_or(0, Vec::len));
}
});
assert_eq!(groups.len(), 1, "the stroke's surface is one translucent group");
}
/// Under a light that is not white, Inflate in gradients, whose faces are drawn one by one, draws the
/// fill's and the dashed stroke's faces in one order, as it does under a white light. A solid color,
/// drawn as one composite of each surface's facets, still draws each surface whole.
#[test]
fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = |c: Color| {
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)],
))))
};
let mut n = stroked(&letter_c());
for item in &mut n.appearance.items {
match item {
AppearanceItem::Fill(f) => f.paint = gradient(RED),
AppearanceItem::Stroke(s) => {
s.paint = gradient(BLUE);
s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
}
}
}
// How many surfaces the art draws, and how many faces the first holds.
let drawn = |n: &Node, lights: Option<Value>| {
let mut n = n.clone();
let mut p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60});
if let Some(lights) = lights {
p["lights"] = lights;
}
n.appearance.effects.push(fx(INFLATE, p));
let art = solid_art(&n).unwrap();
let surfaces = art.children().unwrap();
(surfaces.len(), surfaces[0].children().map_or(0, Vec::len))
};
let colored = json!([{"color": "#ff8080"}]);
let white = drawn(&n, None);
assert_eq!(white.0, 1, "one order");
let cut = drawn_parts(&n, INFLATE, &json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})).len();
assert_eq!(white.1, cut, "the white light's faces, cut");
assert_eq!(drawn(&n, Some(colored.clone())).0, 1, "one order under the colored light");
// A solid color under the colored light: a composite for each of the fill and the stroke.
let solid = stroked(&letter_c());
assert_eq!(drawn(&solid, Some(colored)).0, 2);
}
/// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they
/// overlap is cut along the line where their depths meet, into the piece in front of the wall and
/// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke
/// and a cut past the room left are left whole.
#[test]
fn a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet() {
let facet = |x: f64, normal: [f64; 3], offset: f64| Facet {
contours: vec![vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]],
depth: 0.0,
brightness: 1.0,
illumination: [1.0; 3],
normal,
rgb_gradient: None,
outline: None,
side: true,
away: false,
end: None,
plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }),
};
let wall = facet(0.0, [0.0, 0.0, 1.0], 0.0);
// z = x - 8: behind the wall left of x = 8, in front of it right of it.
let r = std::f64::consts::FRAC_1_SQRT_2;
let mut through = facet(5.0, [-r, 0.0, r], -8.0 * r);
// Its curved outline: the top edge bows up to y = -2.
let mut curved = BezPath::new();
curved.move_to((5.0, 0.0));
curved.curve_to((8.0, -8.0 / 3.0), (12.0, -8.0 / 3.0), (15.0, 0.0));
curved.line_to((15.0, 10.0));
curved.line_to((5.0, 10.0));
curved.close_path();
through.outline = Some(PathData::from_bezpath(&curved));
let extent = |f: &Facet| polygon_bounds(&f.contours[0]);
let cut_with = |paints: Paints, member: usize, room: usize| {
let (mut fill, mut stroke) = (vec![wall.clone()], vec![through.clone()]);
let mut solids: Vec<(Paints, usize, &mut Vec<Facet>)> = vec![(Paints::Fill, 0, &mut fill), (paints, member, &mut stroke)];
cut_stroke_faces(&mut solids, room);
stroke
};
let pieces = cut_with(Paints::Other, 0, 10);
assert_eq!(pieces.len(), 2);
let (near, far) = (extent(&pieces[0]), extent(&pieces[1]));
assert!((near.x0 - 8.0).abs() < 1e-9 && near.x1 == 15.0 && far.x0 == 5.0 && (far.x1 - 8.0).abs() < 1e-9, "{near:?} {far:?}");
assert!(pieces[0].depth > 0.0 && pieces[1].depth < 0.0);
// Each piece keeps its side of the curved outline, the bowed edge included.
let curve = |f: &Facet| f.outline.as_ref().and_then(PathData::bounds).unwrap();
let (near, far) = (curve(&pieces[0]), curve(&pieces[1]));
assert!((near.x0 - 8.0).abs() < 1e-6 && (near.x1 - 15.0).abs() < 1e-6 && near.y0 < -1.5, "{near:?}");
assert!((far.x0 - 5.0).abs() < 1e-6 && (far.x1 - 8.0).abs() < 1e-6 && far.y0 < -1.5, "{far:?}");
let whole = std::slice::from_ref(&through);
assert_eq!(cut_with(Paints::Other, 1, 10), whole, "another shape's stroke");
assert_eq!(cut_with(Paints::Stroke, 0, 10), whole, "a covering stroke");
assert_eq!(cut_with(Paints::Other, 0, 0), whole, "no room");
let (mut fill, mut stroke) = (vec![wall.clone()], vec![facet(5.0, [0.0, 0.0, 1.0], 1.0)]);
cut_stroke_faces(&mut [(Paints::Fill, 0, &mut fill), (Paints::Other, 0, &mut stroke)], 10);
assert_eq!(stroke.len(), 1, "in front of the wall wherever they overlap");
// Of two of a shape's strokes whose faces pass through each other, the later stroke's face is
// cut against the earlier one's; a covering stroke's face is never cut, and the other stroke's
// face is cut against it. Two shapes' strokes are left whole.
for (case, (first, second, cut_first)) in
[(Paints::Other, Paints::Other, false), (Paints::Stroke, Paints::Other, false), (Paints::Other, Paints::Stroke, true)].into_iter().enumerate()
{
let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]);
cut_stroke_faces(&mut [(first, 0, &mut a), (second, 0, &mut b)], 10);
assert_eq!((a.len(), b.len()), if cut_first { (2, 1) } else { (1, 2) }, "case {case}");
}
let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]);
cut_stroke_faces(&mut [(Paints::Other, 0, &mut a), (Paints::Other, 1, &mut b)], 10);
assert_eq!((a.len(), b.len()), (1, 1), "two shapes");
}
/// A small polygon far from the origin keeps its area and centroid.
#[test]
fn a_small_polygon_far_away_has_a_centroid() {
let far = 100_000_000.0;
let square = [Point::new(far, far), Point::new(far + 1.0, far), Point::new(far + 1.0, far + 1.0), Point::new(far, far + 1.0)];
let c = centroid(&square).unwrap();
assert!((c.x - far - 0.5).abs() < 1e-6 && (c.y - far - 0.5).abs() < 1e-6, "{c:?}");
assert!(centroid(&[]).is_none() && centroid(&square[..2]).is_none());
}
/// Cutting adds no more faces than the object's face limit and the paint-copy allowance leave room
/// for, so an effect that draws with its faces uncut still draws.
#[test]
fn cutting_stays_within_the_face_limit_and_the_paint_allowance() {
let items = stroked(&letter_c()).appearance.items.clone();
let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap();
let mut one = 0usize;
crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke));
let max = crate::revolve::MAX_PAINT_COPIES;
assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one));
assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0);
assert_eq!(cut_room(items.iter(), 10, max - 3 * one), 3);
assert_eq!(cut_room(items.iter(), 10, max + 1), 0);
}
/// Where a shape's fill wall and a face of its stroke that leaves gaps overlap, the one nearer the
/// viewer there is drawn after the other, whichever their depths put first; a stroke face that
/// overlaps no fill wall keeps its place.
#[test]
fn local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
let at = |depth: f64, x: f64, z: f64, paints: Paints| Placed {
depth,
side: true,
away: false,
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member: 0,
paints,
outline: square(x),
plane: Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }),
};
// 0 a fill wall at z 0; 1 a stroke face over half of it, nearer, sorted before it; 2 a stroke
// face elsewhere; 3 a stroke face over it, farther, sorted after it.
let faces =
[at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 1.0, Paints::Other), at(1.0, 20.0, 1.0, Paints::Other), at(6.0, 2.0, -1.0, Paints::Other)];
assert_eq!(draw_order(&faces, true), [2, 3, 0, 1]);
assert_eq!(draw_order(&faces, false), [2, 3, 0, 1]);
// A covering stroke's faces keep the wall's own rule.
let covering: Vec<Placed> = faces
.iter()
.map(|f| Placed { paints: if f.paints == Paints::Other { Paints::Stroke } else { f.paints }, outline: vec![], plane: None, ..*f })
.collect();
assert_eq!(draw_order(&covering, false), [2, 1, 0, 3]);
// Another shape's stroke, and a stroke face at the same depth where they overlap, move nothing.
let other_shape: Vec<Placed> =
faces[..3].iter().map(|f| Placed { member: usize::from(f.paints == Paints::Other), outline: f.outline.clone(), ..*f }).collect();
assert_eq!(draw_order(&other_shape, true), [2, 1, 0]);
let level = [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 0.0, Paints::Other)];
assert_eq!(draw_order(&level, true), [1, 0]);
// A stroke face moved after its fill's wall does not pass a nearer face of another shape that
// it overlaps: 0 a stroke face, 1 another shape's face in front of both, 2 the fill's wall.
let in_front = Placed { member: 1, ..at(2.0, 5.0, 2.0, Paints::Fill) };
let shapes = [at(1.0, 5.0, 1.0, Paints::Other), in_front, at(3.0, 0.0, 0.0, Paints::Fill)];
assert_eq!(draw_order(&shapes, true), [2, 0, 1]);
// A shape with a covering stroke as well keeps its fill wall before the covering stroke's face,
// even where that face is the farther: 0 the fill's wall, 1 the covering stroke's face behind
// it, 2 the other stroke's face in front.
let both = [at(3.0, 0.0, 0.0, Paints::Fill), at(3.0, 0.0, -1.0, Paints::Stroke), at(2.0, 5.0, 1.0, Paints::Other)];
assert_eq!(draw_order(&both, true), [0, 1, 2]);
// Faces of a shape's strokes go in the order of their depths where they overlap too, with no
// fill face among them: 1 is nearer than 0 there, though sorted before it.
for (case, (back, front)) in
[(Paints::Other, Paints::Other), (Paints::Stroke, Paints::Other), (Paints::Stroke, Paints::Stroke)].into_iter().enumerate()
{
let strokes = [at(5.0, 0.0, 0.0, back), at(2.0, 5.0, 1.0, front)];
assert_eq!(draw_order(&strokes, true), [0, 1], "case {case}");
}
}
/// A cycle of overlapping stroke faces is broken at the face that comes first by depth, and a cap
/// drawn last stays after every wall: 0 at x 0 to 10, flat at z 0; 1 at x 4 to 14, z = x - 8; 2 at
/// x 8 to 18, flat at z 1. Where they overlap, 0 is nearer than 1, 1 nearer than 2 and 2 nearer
/// than 0. 3 is the cap.
#[test]
fn a_depth_cycle_keeps_the_cap_last() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
let at = |depth: f64, x: f64, normal: [f64; 3], offset: f64| Placed {
depth,
side: true,
away: false,
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member: 0,
paints: Paints::Other,
outline: square(x),
plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }),
};
let cap = Placed {
depth: f64::MAX,
side: false,
away: false,
bounds: Rect::new(0.0, 0.0, 18.0, 10.0),
member: 0,
paints: Paints::Other,
outline: vec![],
plane: None,
};
let faces = [at(1.0, 0.0, [0.0, 0.0, 1.0], 0.0), at(2.0, 4.0, [-1.0, 0.0, 1.0], -8.0), at(3.0, 8.0, [0.0, 0.0, 1.0], 1.0), cap];
assert_eq!(draw_order(&faces, true), [0, 2, 1, 3]);
}
/// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a
/// face of the same shape's stroke drawn before it: the wall then goes just before that face. A
/// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an
/// effect without caps keep the order of the depths.
#[test]
fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() {
let at = |depth: f64, x: f64, member: usize, paints: Paints, side: bool, away: bool| Placed {
depth,
side,
away,
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member,
paints,
outline: vec![],
plane: None,
};
// 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke
// wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a
// fill wall at the stroke wall's depth.
let faces = [
at(5.0, 0.0, 0, Paints::Fill, true, false),
at(3.0, 5.0, 0, Paints::Stroke, true, false),
at(2.0, 5.0, 1, Paints::Stroke, true, false),
at(1.0, 50.0, 0, Paints::Stroke, true, false),
at(0.0, 0.0, 0, Paints::Stroke, true, true),
at(4.0, 0.0, 0, Paints::Fill, false, false),
at(6.0, 0.0, 0, Paints::Stroke, false, false),
at(3.0, 0.0, 0, Paints::Fill, true, false),
];
assert_eq!(draw_order(&faces, true), [4, 3, 2, 0, 7, 1, 5, 6]);
assert_eq!(draw_order(&faces, false), [4, 3, 2, 1, 7, 5, 0, 6]);
// A shape with no stroke, and a stroke without a width, move nothing.
// Only a stroke whose solid hides the fill's whole edge counts: an opaque, plain stroke with a
// width on a closed shape.
let stroke = |f: &dyn Fn(&mut StrokeLayer)| {
let mut s = StrokeLayer::new(Paint::solid(BLUE), 6.0);
f(&mut s);
AppearanceItem::Stroke(s)
};
assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke);
assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape");
let closed = profile(&stroked(&letter_c())).unwrap().0;
assert!(holds_edge(EXTRUDE, &closed) && holds_edge(CLASSIC_EXTRUDE, &closed));
assert!(!holds_edge(INFLATE, &closed), "an inflated fill can rise in front of its stroke");
assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width");
let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false };
assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes");
assert!(Paints::of(&stroke(&|s| s.brush = Some("brush".into())), true) == Paints::Other);
assert!(Paints::of(&stroke(&|s| s.paint = Paint::None), true) == Paints::Other);
assert!(Paints::of(&stroke(&|s| s.paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY }), true) == Paints::Other, "a pattern");
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = |opacity: f32| {
let mut end = GradientStop::new(1.0, RED);
end.opacity = opacity;
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end]))))
};
assert!(Paints::of(&stroke(&|s| s.paint = gradient(1.0)), true) == Paints::Stroke, "an opaque gradient");
assert!(Paints::of(&stroke(&|s| s.paint = gradient(0.5)), true) == Paints::Other, "a gradient with a transparent stop");
let other = |p: Paints| if p == Paints::Stroke { Paints::Other } else { p };
let unstroked: Vec<Placed> = faces.iter().map(|f| at(f.depth, f.bounds.x0, f.member, other(f.paints), f.side, f.away)).collect();
assert_eq!(draw_order(&unstroked, true), draw_order(&faces, false));
}
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
#[test]
fn a_stroked_open_path_builds_a_ribbon() {
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));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
assert!(faces.iter().all(|f| blue(f)), "{id}");
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
}
}
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
/// still draws.
#[test]
fn a_stroke_past_the_limits_is_left_out() {
// 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..40_000)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos();
let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 };
Point::new(20_000.0 + r * c, 20_000.0 + r * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
n.appearance.effects.push(fx(id, json!({})));
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
}
}
/// Each solid [`built_solids`] gives for effect `id` on `n` within `limit` faces together, as its
/// face count (`None` for a stroke left out) or the error, every result the iterator gives.
fn solid_faces(id: &str, n: &Node, limit: usize) -> Vec<Result<Option<usize>, String>> {
let shapes = [Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }];
let p = crate::merged_params(id, &json!({}));
let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap());
built_solids(id, &p, &parts, bounds, limit).map(|r| r.map(|f| f.map(|f| f.len()))).collect()
}
/// The solids of one object count their faces together: at the limit they all build, one face past
/// it is an error, and the solids after the one that passes it are never built.
#[test]
fn an_objects_solids_count_their_faces_together() {
for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] {
let mut n = square_node();
// A second stroke, so a solid follows the one that passes the limit.
let stroke = n.appearance.items[1].clone();
n.appearance.items.push(stroke);
let counts: Vec<usize> = solid_faces(id, &n, MAX_OBJECT_FACES).into_iter().map(|c| c.unwrap().unwrap()).collect();
assert!(counts.len() == 3 && counts.iter().all(|c| *c > 0), "{id}: {counts:?}");
let total: usize = counts.iter().sum();
assert!(solid_faces(id, &n, total).iter().all(Result::is_ok), "{id}: at the limit");
let past = solid_faces(id, &n, total - 1);
assert_eq!(past.last(), Some(&Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())), "{id}");
SOLIDS_BUILT.with(|b| b.set(0));
let past = solid_faces(id, &n, counts[0] + counts[1] - 1);
assert!(past.len() == 2 && past[1].is_err(), "{id}: {past:?}");
assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get), 2, "{id}: the second stroke's solid is never built");
}
}
/// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke.
fn polygon_node(id: u64, corners: usize) -> Node {
let points: Vec<Point> = (0..corners)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / corners as f64).sin_cos();
Point::new(20_000.0 + 10_000.0 * c, 20_000.0 + 10_000.0 * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
Node::path(NodeId(id), path, Appearance::basic(Paint::solid(RED), Paint::None, 0.0))
}
/// A group whose members' solids each fit, but not all of them together, draws its source art, and
/// the commands that check the effect report the error, as do its mesh and its wireframe; one
/// member fewer, within the limit, draws.
#[test]
fn a_groups_solids_together_past_the_limit_draw_the_source() {
let member = polygon_node(1, 16_384);
let mut alone = member.clone();
alone.appearance.effects.push(fx(EXTRUDE, json!({})));
let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") };
// Members stacked in one place each build the same solid.
let fits = MAX_OBJECT_FACES / each;
let group = |count: usize| {
let members = (0..count)
.map(|k| {
let mut m = member.clone();
m.id = NodeId(k as u64 + 1);
Arc::new(m)
})
.collect();
let mut g = Node::group(NodeId(9999), members);
g.appearance.effects.push(fx(EXTRUDE, json!({})));
g
};
let within = group(fits);
assert!(validate_solid(&within, EXTRUDE, &json!({})).is_ok(), "{fits} × {each} faces");
assert!(solid_art(&within).is_some());
let past = group(fits + 1);
assert_eq!(validate_solid(&past, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
assert!(solid_art(&past).is_none(), "the source art draws");
assert_eq!(three_d_meshes(&past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
assert_eq!(three_d_wireframe_in(None, &past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
}
/// A fill and strokes whose solids each fit, but not all of them together, draw the source art,
/// and the strokes after the one past the limit are not left out to make room.
#[test]
fn a_fill_and_strokes_together_past_the_limit_draw_the_source() {
let mut n = polygon_node(1, 16_384);
n.appearance.effects.push(fx(EXTRUDE, json!({})));
let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0));
n.appearance.items.push(stroke.clone());
// Strokes alike each build the same solid.
let [Ok(Some(fill)), Ok(Some(each))] = solid_faces(EXTRUDE, &n, MAX_OBJECT_FACES)[..] else { panic!("a fill's solid and a stroke's") };
let fits = (MAX_OBJECT_FACES - fill) / each;
assert!(fits >= 2, "{fill} + {fits} × {each}");
n.appearance.items.extend(std::iter::repeat_n(stroke, fits - 1));
assert!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), "{fill} + {fits} × {each}");
let art = solid_art(&n).unwrap();
assert!(faces(&art).iter().any(|f| blue(f)));
n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0)));
assert_eq!(validate_solid(&n, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()));
assert!(solid_art(&n).is_none(), "the source art draws");
}
/// The check builds the solids from the shapes the art draws them from: after the geometry effects
/// before the 3D effect, on an object, on its fill and on a group. Copies a Transform effect before
/// it makes that take the solids past the limit fail the check, as the art then draws the source.
#[test]
fn the_check_counts_the_faces_after_the_geometry_effects_before() {
let mut alone = polygon_node(1, 16_384);
alone.appearance.effects.push(fx(EXTRUDE, json!({})));
let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") };
// Side by side, each copy builds the same solid.
let copies = |k: usize| fx("distort.transform", json!({"moveH": 30_000.0, "copies": k}));
for (k, fits) in [(1, true), (MAX_OBJECT_FACES / each, false)] {
let mut n = polygon_node(1, 16_384);
n.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))];
assert_eq!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), fits, "on the object, {k} copies");
assert_eq!(solid_art(&n).is_some(), fits, "on the object, {k} copies");
let mut f = polygon_node(1, 16_384);
f.appearance.items[0].effects_mut().extend([copies(k), fx(EXTRUDE, json!({}))]);
assert_eq!(validate_three_d_at(&f, EXTRUDE, &json!({}), (Some(0), 1)).is_ok(), fits, "on the fill, {k} copies");
assert_eq!(solid_art(&f).is_some(), fits, "on the fill, {k} copies");
let mut g = Node::group(NodeId(9999), vec![Arc::new(polygon_node(1, 16_384))]);
g.appearance.effects = vec![copies(k), fx(EXTRUDE, json!({}))];
assert_eq!(validate_solid(&g, EXTRUDE, &json!({})).is_ok(), fits, "on a group, {k} copies");
assert_eq!(solid_art(&g).is_some(), fits, "on a group, {k} copies");
// A new effect goes after the copies.
let mut new = polygon_node(1, 16_384);
new.appearance.effects = vec![copies(k)];
assert_eq!(validate_three_d_at(&new, EXTRUDE, &json!({}), (None, 1)).is_ok(), fits, "a new effect, {k} copies");
}
// A hidden effect checked at its own index counts only the effects before it: copies after it
// copy the drawn art once it is shown again.
let mut hidden = polygon_node(1, 16_384);
let mut extrude = fx(EXTRUDE, json!({}));
extrude.visible = false;
hidden.appearance.effects = vec![extrude, copies(MAX_OBJECT_FACES / each)];
assert!(validate_three_d_at(&hidden, EXTRUDE, &json!({}), (None, 0)).is_ok());
hidden.appearance.effects[0].visible = true;
assert!(solid_art(&hidden).is_some());
}
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
#[test]
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
// 1 pt stroke, so the stroke stays flat.
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
let mut n = stroked_rect(length);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.width = width;
}
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
}
}
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
#[test]
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.align = vectorcraft_doc::StrokeAlign::Outside;
}
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
n.appearance.effects.push(fx(INFLATE, params));
let art = solid_art(&n).unwrap();
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
assert!(!blue(fill_back) && blue(stroke_back));
let fill = fill_back.path_data().unwrap().bounds().unwrap();
// The ring's inner contour: the smaller of its two.
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
}
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
/// opacity applies to the stroke as a whole.
#[test]
fn a_translucent_stroke_is_a_surface_of_its_own() {
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.opacity = opacity;
s.blend = blend;
}
n.appearance.effects.push(fx(EXTRUDE, json!({})));
let art = solid_art(&n).unwrap();
let surfaces = art.children().unwrap();
assert_eq!(surfaces.len(), 2);
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
}
}
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
/// opacity mask: its faces still take their places in the one order of the fill's and the
/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the
/// gradient itself and no mask.
#[test]
fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut half = GradientStop::new(1.0, RED);
half.opacity = 0.5;
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.paint = Paint::Gradient(Box::new(gradient.clone()));
}
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
let art = solid_art(&n).unwrap();
let drawn = art.children().unwrap();
assert_eq!(drawn.len(), 1, "{lights}: one order of faces");
let runs: Vec<&Arc<Node>> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect();
assert_eq!(!runs.is_empty(), masked, "{lights}");
assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces");
}
}
#[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 { .. }));
}
/// Two nested squares drawn in the same direction, so only the even-odd rule leaves a hole.
fn nested_squares() -> [PathData; 2] {
[(100.0, 200.0), (130.0, 170.0)].map(|(lo, hi)| {
let mut b = BezPath::new();
b.move_to((lo, lo));
b.line_to((hi, lo));
b.line_to((hi, hi));
b.line_to((lo, hi));
b.close_path();
PathData::from_bezpath(&b)
})
}
/// The nested squares as an even-odd compound path and as one even-odd path, painted by `items`.
fn even_odd_shapes(items: Vec<AppearanceItem>) -> [Node; 2] {
let [outer, inner] = nested_squares();
let appearance = Appearance { items, ..Default::default() };
let children = vec![
Arc::new(Node::path(NodeId(2), outer.clone(), Appearance::default())),
Arc::new(Node::path(NodeId(3), inner.clone(), Appearance::default())),
];
let mut compound = Node::new(NodeId(1), NodeKind::Compound { children, rule: FillRule::EvenOdd });
compound.appearance = appearance.clone().into();
let both = PathData::new(outer.subpaths.into_iter().chain(inner.subpaths).collect());
[compound, crate::group::shape_node(NodeId(1), both, FillRule::EvenOdd, appearance)]
}
/// Check that `art` draws `item` as one flat child: an even-odd path with a hole in the middle,
/// below the 3D art after it.
fn assert_flat_item_keeps_the_hole(art: &Node, item: &AppearanceItem, what: &str) {
use vectorcraft_geom::hit::fill_contains;
let children = art.children().unwrap();
let flat: Vec<usize> =
(0..children.len()).filter(|&i| children[i].appearance.items == [item.clone()] && children[i].path_data().is_some()).collect();
let [index] = flat[..] else { panic!("{what}: one flat child, got {flat:?}") };
let NodeKind::Path { path, rule, .. } = &children[index].kind else { panic!("{what}: a path") };
assert_eq!(*rule, FillRule::EvenOdd, "{what}");
let bez = path.to_bezpath();
assert!(fill_contains(&bez, *rule, Point::new(110.0, 150.0)), "{what}: the ring is filled");
assert!(!fill_contains(&bez, *rule, Point::new(150.0, 150.0)), "{what}: the hole stays open");
assert!(index + 1 < children.len(), "{what}: the 3D art is drawn above");
}
#[test]
fn a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole() {
let red = AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(RED)));
let blue = AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(BLUE)));
let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 4.0));
for id in SOLIDS {
// The top fill and then the top stroke take the effect; the fill below stays flat.
for (top, on) in [(&red, "fill"), (&stroke, "stroke")] {
let mut lifted = top.clone();
lifted.effects_mut().push(fx(id, json!({})));
for (n, shape) in even_odd_shapes(vec![blue.clone(), lifted]).into_iter().zip(["compound path", "path"]) {
let what = format!("{id} on the top {on} of a {shape}");
let art = solid_art(&n).unwrap_or_else(|| panic!("{what}"));
assert_flat_item_keeps_the_hole(&art, &blue, &what);
}
}
}
}
#[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(None, &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(None, &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 and its 4 pt stroke.
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
assert!((b.width() - 144.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 empty = Node::group(NodeId(2), vec![]);
assert!(validate_solid(&empty, INFLATE, &json!({})).unwrap_err().contains("requires a group holding a shape"));
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));
}
/// Inflate under the legacy white light keeps the polygon faces it had before the colored-light
/// batching (each face a path of its own, not a channel group) and its seam's 0.05–0.75 pt range;
/// its seams' joins are round, as every 3D seam's.
#[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));
// Round, as every seam is: a facet seen almost edge-on is a sliver, and a mitered seam
// reached past the solid as a thorn.
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Round);
}
// 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");
// The fill's solid and the stroke's, each one composite with its own clip.
let surfaces = art.children().unwrap().len();
assert_eq!(surfaces, 2);
assert_eq!(clips, surfaces);
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
}
/// `square_node` moved right by `dx`, with id `id` and fill `fill`.
fn square_at(id: u64, dx: f64, fill: Color) -> Node {
let mut n = square_node();
n.id = NodeId(id);
n.transform(Affine::translate((dx, 0.0)), false);
n.appearance.items = Appearance::basic(Paint::solid(fill), Paint::None, 0.0).items;
n
}
#[test]
fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() {
// Two squares side by side, 100 pt apart, in a group: 100–200 and 300–400 across.
let group = |id: &str, params: Value| {
let mut g = Node::group(NodeId(9), vec![Arc::new(square_at(1, 0.0, RED)), Arc::new(square_at(2, 200.0, BLUE))]);
g.appearance.effects.push(fx(id, params));
g
};
let turned = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
for id in SOLIDS {
let g = group(id, json!({}));
assert!(validate_solid(&g, id, &json!({})).is_ok(), "{id}");
let art = solid_art(&g).unwrap_or_else(|| panic!("{id}"));
assert_eq!(art.id, NodeId(9), "{id}: the art is the group's");
assert!(art.appearance.effects.iter().all(|e| !is_three_d(&e.id)), "{id}");
// Both members draw, each in its own fill.
let mut colours = vec![];
art.walk(&mut |c| {
if c.path_data().is_some() {
colours.push(fill_rgb(c));
}
});
assert!(colours.iter().any(|c| c[2] > c[0]) && colours.iter().any(|c| c[0] > c[2]), "{id}: red and blue faces");
}
// Turned 60° about y, the members turn together about the group's centre (x = 250): the
// pair spans half its width; turned each about its own centre they would stay 100 pt apart.
let b = solid_art(&group(ROTATE, turned.clone())).unwrap().visual_bounds().unwrap();
assert!((b.center().x - 250.0).abs() < 1e-3, "{b:?}");
assert!((b.width() - 300.0 * 0.5).abs() < 1.0, "{b:?}");
let mut alone = square_at(2, 200.0, BLUE);
alone.appearance.effects.push(fx(ROTATE, turned));
let own = solid_art(&alone).unwrap().visual_bounds().unwrap();
assert!((own.center().x - 350.0).abs() < 1e-3, "{own:?}");
// Hidden members and guides are left out; a group holding what can't become a solid is
// refused with what to do.
let mut g = group(EXTRUDE, json!({}));
let mut woven = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]);
woven.kind = NodeKind::Intertwine { children: woven.children().unwrap().clone() };
if let NodeKind::Group { children, .. } = &mut g.kind {
children.push(Arc::new(woven));
}
let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err();
assert!(err.contains("release or expand its other objects"), "{err}");
assert!(err.contains("type, groups, clipping groups, blends and symbol instances"), "{err}");
assert!(solid_art(&g).is_none(), "drawn as the group it is");
}
/// The red square without its stroke, with Extrude & Bevel (Classic) and `params`.
fn classic(params: Value) -> Node {
let mut n = square_node();
n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params));
n
}
fn classic_faces(params: Value) -> Vec<Node> {
let art = solid_art(&classic(params)).unwrap();
faces(&art).into_iter().cloned().collect()
}
fn close3(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4)
}
#[test]
fn classic_surfaces_draw_edges_flat_paint_soft_light_and_a_highlight() {
// Wireframe: the edges of every face, the hidden ones too, in the paint; no fills.
let wire = classic_faces(json!({"surface": "wireframe"}));
let plastic = classic_faces(json!({}));
assert_eq!(plastic.len(), 3, "a box shows three faces");
assert_eq!(wire.len(), 6, "and has six");
for f in &wire {
let [AppearanceItem::Stroke(s)] = &f.appearance.items[..] else { panic!("a stroke alone: {:?}", f.appearance.items) };
assert_eq!(s.paint, Paint::solid(RED));
}
// No Shading: every face in the art's own paint, a CMYK color kept as it is.
assert!(classic_faces(json!({"surface": "none"})).iter().all(|f| close3(fill_rgb(f), RED.to_rgb())));
let cmyk = Paint::solid(Color::Cmyk { c: 0.1, m: 0.9, y: 0.8, k: 0.0 });
let mut n = classic(json!({"surface": "none"}));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.paint = cmyk.clone();
}
for f in faces(&solid_art(&n).unwrap()) {
let AppearanceItem::Fill(fill) = &f.appearance.items[0] else { panic!("a fill first") };
assert_eq!(fill.paint, cmyk);
}
// Diffuse: the red darkened toward black by how far each face turns from the light, so the
// faces differ but keep their hue.
let diffuse: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse"})).iter().map(fill_rgb).collect();
assert!(diffuse.iter().all(|[r, g, b]| *r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4), "{diffuse:?}");
assert!(diffuse.windows(2).any(|w| !close3(w[0], w[1])), "{diffuse:?}");
// Plastic: the same faces, one of them lightened toward white by the highlight.
let shiny: Vec<[f32; 3]> =
classic_faces(json!({"highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60})).iter().map(fill_rgb).collect();
let soft: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse", "lightHeight": 60})).iter().map(fill_rgb).collect();
assert!(shiny.iter().zip(&soft).any(|(s, d)| s[1] > d[1] + 0.01), "{shiny:?} {soft:?}");
assert!(shiny.iter().zip(&soft).all(|(s, d)| s[1] >= d[1] - 1e-4));
// Without highlight, Plastic shades as Diffuse.
let dull: Vec<[f32; 3]> = classic_faces(json!({"highlightIntensity": 0, "lightHeight": 60})).iter().map(fill_rgb).collect();
assert!(dull.iter().zip(&soft).all(|(a, b)| close3(*a, *b)));
}
#[test]
fn classic_shading_color_blend_steps_and_lights() {
let base = json!({"surface": "diffuse", "ambient": 20});
let with = |extra: Value| {
let mut p = base.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
classic_faces(p).iter().map(fill_rgb).collect::<Vec<_>>()
};
// A custom shading color darkens toward that color; None keeps the paint.
assert!(with(json!({"shadingColor": "custom", "shadingCustom": "#0000ff"})).iter().any(|c| c[2] > c[0] / 8.0 + 0.01));
assert!(with(json!({"shadingColor": "none"})).iter().all(|c| close3(*c, RED.to_rgb())));
// One blend step: every face fully lit or fully in shadow.
let stepped = with(json!({"blendSteps": 1, "ambient": 0}));
assert!(stepped.iter().all(|c| close3(*c, RED.to_rgb()) || close3(*c, [0.0; 3])), "{stepped:?}");
// No light: the ambient share of the red. Two lights add up.
let dark = with(json!({"lightIntensity": 0, "blendSteps": 256}));
assert!(dark.iter().all(|c| (c[0] - 0.8 * 0.2).abs() < 0.01), "{dark:?}");
let one = with(json!({"lights": [{"intensity": 30}], "blendSteps": 256}));
let two = with(json!({"lights": [{"intensity": 30}, {"intensity": 30}], "blendSteps": 256}));
assert!(one.iter().zip(&two).all(|(a, b)| b[0] >= a[0] - 1e-4) && one.iter().zip(&two).any(|(a, b)| b[0] > a[0] + 0.01));
// Draw Hidden Faces keeps the faces turned away, drawn first.
assert_eq!(with(json!({"drawHiddenFaces": true})).len(), 6);
}
#[test]
fn classic_bevel_height_extent_and_cap_shape_the_solid() {
let bounds = |p: Value| solid_art(&classic(p)).unwrap().visual_bounds().unwrap();
let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "bevel": "classic", "bevelHeight": 10});
let out = bounds(front.clone());
let mut inside = front.clone();
inside["bevelInside"] = json!(true);
// Straight on, a bevel added around the shape widens it; one carved into it keeps its size.
assert!(out.width() > 100.0 + 15.0, "{out:?}");
let carved = bounds(inside);
assert!((carved.width() - 100.0).abs() < 2.0, "{carved:?}");
// Hollow: no caps, so the walls seen from inside show too.
assert!(classic_faces(json!({"cap": false})).len() > classic_faces(json!({})).len());
// The depth sets how far back the solid goes.
let side = json!({"rotationX": 0, "rotationY": 90, "rotationZ": 0});
let mut deep = side.clone();
deep["depth"] = json!(120);
assert!((bounds(deep).width() - 120.0).abs() < 1.0);
}
/// A gradient fill with a transparent stop is shaded opaque and masked by its own transparency, so
/// the shading does not cover the transparent part; an opaque gradient needs no mask.
#[test]
fn classic_shading_keeps_a_gradients_transparency() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
for (end, masked) in [(0.25, true), (1.0, false)] {
let mut stop = GradientStop::new(1.0, RED);
stop.opacity = end;
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), stop]));
let mut n = classic(json!({}));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.paint = Paint::Gradient(Box::new(gradient));
}
let art = solid_art(&n).unwrap();
let surface = &art.children().unwrap()[0];
let mut has_mask = false;
surface.walk(&mut |c| has_mask |= c.extras().mask.is_some());
assert_eq!(has_mask, masked, "{end}");
let mut opaque = true;
surface.walk(&mut |c| {
for item in &c.appearance.items {
if let AppearanceItem::Fill(f) = item
&& let Paint::Gradient(g) = &f.paint
{
opaque &= g.gradient.stops.iter().all(|s| s.opacity == 1.0);
}
}
});
assert!(opaque, "{end}: the faces are drawn opaque");
}
}
/// A group member in a gradient with transparent stops keeps the shared back-to-front order of
/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's
/// faces are drawn between them where they are in front of some and behind others. Here a square
/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square
/// and its front cap is in front of the square's walls.
#[test]
fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| {
let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)];
let mut points = corners.to_vec();
if !clockwise {
points.reverse();
}
b.move_to(points[0]);
for p in &points[1..] {
b.line_to(*p);
}
b.close_path();
};
let art = |id: &str, params: &Value, end_opacity: f32| {
let mut end = GradientStop::new(1.0, Color::BLACK);
end.opacity = end_opacity;
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end]))));
let mut b = BezPath::new();
square(&mut b, 100.0, 300.0, true);
square(&mut b, 150.0, 250.0, false);
let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0));
let mut b = BezPath::new();
square(&mut b, 185.0, 215.0, true);
let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0));
let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]);
g.appearance.effects.push(fx(id, params.clone()));
solid_art(&g).unwrap_or_else(|| panic!("{id}"))
};
// The drawing order: `M` for a group with an opacity mask, `F` for a face outside one.
fn visit(n: &Node, order: &mut String) {
if n.extras().mask.is_some() {
order.push('M');
return;
}
if n.path_data().is_some() {
order.push('F');
}
for c in n.children().into_iter().flatten() {
visit(c, order);
}
}
let order = |art: &Node| {
let mut order = String::new();
visit(art, &mut order);
order
};
for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] {
assert!(!order(&art(id, &params, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask");
let masked = order(&art(id, &params, 0.5));
let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap());
assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}");
}
}
#[test]
fn classic_parameters_are_checked() {
let n = classic(json!({}));
let check = |p: Value| validate_solid(&n, CLASSIC_EXTRUDE, &crate::merged_params(CLASSIC_EXTRUDE, &p));
assert!(check(json!({})).is_ok());
assert!(check(json!({"surface": "wireframe", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 256, "bevel": "ogee"})).is_ok());
for (p, says) in [
(json!({"surface": "glossy"}), "`surface` must be one of wireframe, none, diffuse, plastic"),
(json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"),
(json!({"shadingCustom": "blue"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"depth": 2001}), "`depth` must be between 0 and 2000 (got 2001)"),
(json!({"bevelHeight": 0}), "`bevelHeight` must be between 1 and 100 (got 0)"),
(json!({"ambient": -1}), "`ambient` must be between 0 and 100 (got -1)"),
(json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number between 1 and 256 (got 2.5)"),
(json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
(json!({"highlightSize": "big"}), "`highlightSize` must be a number"),
(json!({"cap": "yes"}), "`cap` must be true or false"),
(json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"),
(json!({"bevel": "fancy"}), "bevel must be none or one of"),
(json!({"twist": 10}), "unknown parameter `twist`"),
(json!({"lights": []}), "lights must contain one to ten lights"),
] {
let err = check(p.clone()).unwrap_err();
assert!(err.starts_with(says), "{p}: {err}");
}
}
/// The red square, stroked black, with Rotate (Classic) and `params`: its surfaces, the fill's
/// plane first, then the stroke's outline.
fn classic_rotate(params: Value) -> Vec<Node> {
let mut n = square_node();
n.appearance.effects.push(fx(CLASSIC_ROTATE, params));
solid_art(&n).unwrap().children().unwrap().iter().map(|s| (**s).clone()).collect()
}
#[test]
fn classic_rotate_turns_every_fill_and_stroke_as_one_plane() {
let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0});
let surfaces = classic_rotate(front.clone());
assert_eq!(surfaces.len(), 2, "the fill's plane and the stroke's outline");
assert!(surfaces.iter().all(|s| s.children().unwrap().len() == 1), "each is one flat face");
let fill = surfaces[0].visual_bounds().unwrap();
assert!((fill.width() - 100.0).abs() < 1.0 && (fill.height() - 100.0).abs() < 1.0, "{fill:?}");
// Turned 60° about the vertical axis, the square shows half its width and all its height,
// about the center of its bounds.
let turned = classic_rotate(json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}))[0].visual_bounds().unwrap();
assert!((turned.width() - 50.0).abs() < 1.0 && (turned.height() - 100.0).abs() < 1.0, "{turned:?}");
assert!((turned.center().x - 150.0).abs() < 1.0, "{turned:?}");
// Turned past 90°, the back of the plane is drawn.
let back = classic_rotate(json!({"rotationX": 0, "rotationY": 150, "rotationZ": 0}));
assert!(back[0].visual_bounds().is_some_and(|b| (b.width() - 100.0 * 30f64.to_radians().cos()).abs() < 1.0));
}
#[test]
fn classic_rotate_shades_with_no_shading_or_diffuse() {
let rgb = |p: Value| fill_rgb(&classic_rotate(p)[0].children().unwrap()[0]);
// No Shading keeps the paint, the color itself kept.
let mut n = square_node();
n.appearance.effects.push(fx(CLASSIC_ROTATE, json!({"surface": "none"})));
let art = solid_art(&n).unwrap();
let AppearanceItem::Fill(f) = &faces(&art)[0].appearance.items[0] else { panic!("a fill first") };
assert_eq!(f.paint, Paint::solid(RED));
// Diffuse: straight on with the light in front, the face is fully lit; turned away from the
// light, it is the red darkened toward black.
let lit = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "lightHeight": 90});
assert!(close3(rgb(lit), RED.to_rgb()));
let [r, g, b] = rgb(json!({"rotationX": 0, "rotationY": 70, "rotationZ": 0, "lightHeight": 90}));
assert!(r < 0.8 - 0.05 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4, "{r} {g} {b}");
// No light: the ambient share of the red. A custom shading color darkens toward that color;
// None keeps the paint.
let dark = rgb(json!({"lightIntensity": 0, "ambient": 20, "blendSteps": 256}));
assert!((dark[0] - 0.8 * 0.2).abs() < 0.01, "{dark:?}");
let blue = rgb(json!({"lightIntensity": 0, "ambient": 20, "shadingColor": "custom", "shadingCustom": "#0000ff"}));
assert!(blue[2] > blue[0] / 8.0 + 0.01, "{blue:?}");
assert!(close3(rgb(json!({"lightIntensity": 0, "shadingColor": "none"})), RED.to_rgb()));
}
#[test]
fn classic_rotate_parameters_are_checked() {
let n = square_node();
let check = |p: Value| validate_solid(&n, CLASSIC_ROTATE, &crate::merged_params(CLASSIC_ROTATE, &p));
assert!(check(json!({})).is_ok());
assert!(check(json!({"surface": "none", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 1})).is_ok());
for (p, says) in [
(json!({"surface": "plastic"}), "`surface` must be one of none, diffuse"),
(json!({"surface": "wireframe"}), "`surface` must be one of none, diffuse"),
(json!({"surface": 2}), "`surface` must be one of"),
(json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"),
(json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"rotationX": 181}), "`rotationX` must be between -180 and 180 (got 181)"),
(json!({"perspectiveAngle": 161}), "`perspectiveAngle` must be between 0 and 160 (got 161)"),
(json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"),
(json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
(json!({"lightBehind": 1}), "`lightBehind` must be true or false"),
(json!({"highlightSize": 50}), "unknown parameter `highlightSize`"),
(json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"),
(json!({"shadow": true}), "unknown parameter `shadow`"),
(json!({"lights": []}), "lights must contain one to ten lights"),
] {
let err = check(p.clone()).unwrap_err();
assert!(err.starts_with(says), "{p}: {err}");
}
}
/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate, Extrude &
/// Bevel (Classic, each of its shading branches) and Rotate (Classic, its No Shading and Diffuse
/// surfaces and a gradient), a face seen almost edge-on (a sharp sliver) has
/// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its
/// face more than half its width as it is outlined (a mitered seam would reach past the solid as a
/// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a
/// corner a miter would spike from.
#[test]
fn a_faces_seam_stays_within_half_its_width_of_the_face() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)],
))));
// Does `path` have a convex corner sharper than 45° that a miter with `limit` would spike from
// (one sharper than 2·asin(1/limit) is bevelled instead, so it can't thorn; a reflex corner's
// miter points inward)? An open subpath's ends are no corners, and a closing anchor repeating
// the first is one corner.
fn acute(path: &PathData, limit: f64) -> bool {
let floor = 2.0 * (1.0 / limit).asin();
path.subpaths.iter().any(|sp| {
let mut pts: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
pts.dedup_by(|a, b| a.distance(*b) < 1e-9);
if sp.closed && pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) {
pts.pop();
}
let n = pts.len();
let corners: Vec<usize> = if sp.closed { (0..n).collect() } else { (1..n.saturating_sub(1)).collect() };
// Which way the subpath turns as a whole: a convex corner turns the same way.
let area: f64 = (0..n).map(|k| pts[k].to_vec2().cross(pts[(k + 1) % n].to_vec2())).sum();
n >= 3
&& corners.into_iter().any(|k| {
let (a, b, c) = (pts[(k + n - 1) % n], pts[k], pts[(k + 1) % n]);
let (u, v) = (a - b, c - b);
let convex = !sp.closed || (b - a).cross(c - b) * area > 0.0;
let angle = (u.dot(v) / (u.hypot() * v.hypot())).clamp(-1.0, 1.0).acos();
convex && angle > floor && angle < std::f64::consts::FRAC_PI_4
})
})
}
// Views that make slivers whose corners a miter would spike from, in every case.
let views = [(-35.0, 25.0), (-85.0, 15.0), (60.0, 60.0)];
let mut cases = vec![];
for &(x, y) in &views {
let view = json!({"rotationX": x, "rotationY": y, "rotationZ": 0, "depth": 60});
// Rotate (Classic) takes no depth.
let flat = json!({"rotationX": x, "rotationY": y, "rotationZ": 0});
let with = |extra: Value| {
let mut p = view.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), None));
// A gradient under a white light: its paint and the shadow fill over it each have a seam.
cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), Some(gradient.clone())));
cases.push((INFLATE, x, y, with(json!({})), None));
// Under colored lights: one, two, and a gradient's paint.
let one = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}]});
let two = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]});
cases.push((EXTRUDE, x, y, with(one.clone()), None));
cases.push((EXTRUDE, x, y, with(two.clone()), None));
cases.push((EXTRUDE, x, y, with(one), Some(gradient.clone())));
cases.push((INFLATE, x, y, with(json!({"lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]})), None));
// Rotate turns the art flat: a thin triangle, some corner of which stays sharp enough at
// every view; under a white light and a colored one.
cases.push((ROTATE, x, y, with(json!({})), None));
cases.push((ROTATE, x, y, with(json!({"lights": [{"color": "#ff8080"}]})), None));
// Classic: shaded, unshaded (No Shading) and a gradient's paint kept under its shading
// layers each make their seam their own way.
let bevel = json!({"bevel": "classic", "bevelHeight": 1.5});
cases.push((CLASSIC_EXTRUDE, x, y, with(bevel.clone()), None));
cases.push((CLASSIC_EXTRUDE, x, y, with(json!({"bevel": "classic", "bevelHeight": 1.5, "surface": "none"})), None));
cases.push((CLASSIC_EXTRUDE, x, y, with(bevel), Some(gradient.clone())));
// Rotate (Classic) draws its plane through Classic's surfaces: No Shading and Diffuse.
for surface in ["none", "diffuse"] {
let mut p = flat.clone();
p["surface"] = json!(surface);
cases.push((CLASSIC_ROTATE, x, y, p, None));
}
let mut p = flat.clone();
p["surface"] = json!("diffuse");
cases.push((CLASSIC_ROTATE, x, y, p, Some(gradient.clone())));
}
// Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is.
let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 };
// Rotate (Classic)'s one plane is shaded at some views, not at every one (the light can leave
// it as its paint), so its Diffuse case is held to a darkened face at one view at least.
let mut rotate_shaded = false;
for (id, x, y, params, paint) in cases {
let mut n = if id == ROTATE || id == CLASSIC_ROTATE {
let mut b = BezPath::new();
b.move_to((100.0, 90.0));
b.line_to((160.0, 110.0));
b.line_to((100.0, 130.0));
b.close_path();
stroked(&b)
} else {
stroked_rect(4.0)
};
let unshaded = params["surface"] == "none";
for (k, item) in n.appearance.items.iter_mut().enumerate() {
match &paint {
Some(paint) => *item.paint_mut() = paint.clone(),
None if (id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE) && k == 0 => *item.paint_mut() = brick.clone(),
None => {}
}
}
// Each solid on its own (the fill's, then the stroke's), so every check holds for each.
for keep in 0..n.appearance.items.len() {
let mut solo = n.clone();
let item = solo.appearance.items.remove(keep);
solo.appearance.items = vec![item.clone()];
let source = item.paint().clone();
let what = format!("{id} {params} gradient {} view ({x}, {y}) solid {keep}", paint.is_some());
solo.appearance.effects.push(fx(id, params.clone()));
let art = solid_art(&solo).unwrap();
let mut seamed = vec![];
art.walk(&mut |face| {
if let Some(path) = face.path_data()
&& face.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))
{
seamed.push((path.clone(), face.appearance.items.clone()));
}
});
// Each Classic case (Extrude & Bevel and Rotate) reaches its own branch: an unshaded face
// keeps the paint itself (its swatch and color space too), a shaded one is the paint
// darkened, and a gradient's face keeps the gradient.
if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE {
let fills: Vec<&Paint> = seamed
.iter()
.filter_map(|(_, items)| match items.first() {
Some(AppearanceItem::Fill(f)) => Some(&f.paint),
_ => None,
})
.collect();
assert!(!fills.is_empty(), "{what}: faces");
if paint.is_some() || unshaded {
assert!(fills.iter().all(|f| **f == source), "{what}: every face keeps its paint as it is");
} else {
let [r, g, b] = source.color().map(|c| c.to_rgb()).unwrap_or([0.0; 3]);
let darkened = fills.iter().any(|f| match f {
Paint::Solid { color, swatch: None, .. } => {
let [fr, fg, fb] = color.to_rgb();
fr <= r + 1e-4 && fg <= g + 1e-4 && fb <= b + 1e-4 && fr + fg + fb < r + g + b - 1e-3
}
_ => false,
});
if id == CLASSIC_ROTATE {
rotate_shaded |= darkened;
} else {
assert!(darkened, "{what}: some face shaded darker than its paint");
}
}
}
// Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some
// face a miter would spike from.
let (mut checked, mut sliver): (usize, Vec<bool>) = (0, vec![]);
for (path, items) in &seamed {
let outline = path.to_bezpath();
let acute_here = items.iter().find_map(|i| match i {
AppearanceItem::Stroke(s) => Some(acute(path, s.miter_limit)),
_ => None,
});
let mut which = 0;
for item in items {
// A seam paints something (a stroke of no width or no paint draws nothing).
let AppearanceItem::Stroke(seam) = item else { continue };
if seam.width <= 0.0 || matches!(seam.paint, Paint::None) {
continue;
}
if sliver.len() <= which {
sliver.resize(which + 1, false);
}
sliver[which] |= acute_here.unwrap_or(false);
which += 1;
checked += 1;
assert_eq!(seam.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins");
// The seam outlined as it is drawn: no point of it farther from the face than
// half its width.
let drawn = crate::stroke::line_outline(&outline, seam, seam.width, seam.width * 1e-3);
let (mut farthest, mut points): (f64, usize) = (0.0, 0);
kurbo::flatten(drawn.iter(), seam.width * 1e-4, |el| {
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
points += 1;
farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4));
}
});
assert!(points > 0, "{what}: a seam outlined as nothing");
assert!(farthest <= seam.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", seam.width);
}
}
assert!(checked > 0, "{what}: seams checked");
for (k, s) in sliver.iter().enumerate() {
assert!(s, "{what}: no face with an acute corner carries seam {k}");
}
}
}
assert!(rotate_shaded, "Rotate (Classic), Diffuse: no view shades its plane darker than its paint");
}
/// Revolve (Classic) draws its faces through Classic's surfaces: on the plastic, diffuse and
/// unshaded surfaces, and with a gradient, every face's seam has round joins and reaches no more
/// than half its width past its face as it is outlined (the reach Revolve's overlap test counts on).
#[test]
fn classic_revolves_seams_are_round() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)],
))));
let mut b = BezPath::new();
b.move_to((100.0, 40.0));
b.line_to((130.0, 60.0));
b.line_to((130.0, 140.0));
b.line_to((100.0, 160.0));
let cases = [("plastic", Paint::solid(BLUE)), ("diffuse", Paint::solid(BLUE)), ("none", Paint::solid(BLUE)), ("plastic", gradient)];
for (surface, paint) in cases {
let what = format!("{surface} gradient {}", matches!(paint, Paint::Gradient(_)));
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(paint, Paint::None, 0.0));
n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface})));
let art = crate::revolve_art(&n).unwrap();
let mut seams = 0;
art.walk(&mut |face| {
let Some(path) = face.path_data() else { return };
for item in &face.appearance.items {
let AppearanceItem::Stroke(s) = item else { continue };
if s.width <= 0.0 {
continue;
}
seams += 1;
assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins");
let drawn = crate::stroke::line_outline(&path.to_bezpath(), s, s.width, s.width * 1e-3);
let mut farthest: f64 = 0.0;
kurbo::flatten(drawn.iter(), s.width * 1e-4, |el| {
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4));
}
});
assert!(farthest <= s.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", s.width);
}
});
assert!(seams > 0, "{what}: seams checked");
}
}