use serde_json::json; use vectorcraft_doc::{Document, NodeId}; 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, ¶ms).unwrap() } /// The shaded faces of every surface of `art`, bottom first. fn faces(art: &Node) -> Vec<&Node> { art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect() } fn fill_rgb(n: &Node) -> [f32; 3] { match &n.appearance.items[0] { AppearanceItem::Fill(f) => match &f.paint { Paint::Gradient(g) => g.gradient.sample(0.5).0.to_rgb(), p => p.color().unwrap().to_rgb(), }, _ => panic!("a fill first"), } } #[test] fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() { for id in SOLIDS { let mut n = square_node(); let source = n.path_data().unwrap().clone(); n.appearance.effects.push(fx(id, json!({}))); let art = solid_art(&n).unwrap_or_else(|| panic!("{id}")); assert_eq!(n.path_data(), Some(&source), "{id}"); assert!(!faces(&art).is_empty(), "{id}"); // The faces are the fill's red or the stroke's black, shaded: never brighter, never another // hue. for f in faces(&art) { let [r, g, b] = fill_rgb(f); assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}"); } // The art follows the source when it moves. let before = art.visual_bounds().unwrap(); n.transform(Affine::translate((50.0, -30.0)), false); let after = solid_art(&n).unwrap().visual_bounds().unwrap(); assert!((after.x0 - before.x0 - 50.0).abs() < 1e-6 && (after.y0 - before.y0 + 30.0).abs() < 1e-6, "{id}"); // Hidden, the effect draws nothing of its own. n.appearance.effects[0].visible = false; assert!(solid_art(&n).is_none(), "{id}"); } } #[test] fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() { let mut n = square_node(); n.appearance.effects.push(fx(ROTATE, json!({}))); let art = solid_art(&n).unwrap(); // The fill's plane and the stroke's outline, each in its own paint. let surfaces = art.children().unwrap(); assert_eq!(surfaces.len(), 2); let paint = |s: &Node| fill_rgb(&s.children().unwrap()[0]); assert!(paint(&surfaces[0])[0] > paint(&surfaces[0])[1], "the red fill below"); assert!(paint(&surfaces[1]).iter().all(|c| *c < 0.05), "the black stroke above"); // Turned 60° about y with no perspective, the stroke's outline spans half its width plus the // stroke's own outset, foreshortened as well. n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}); let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap(); assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}"); // Extrude & Bevel 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 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() { // 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline. let points: Vec = (0..1500) .map(|k| { let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos(); let r = if k % 2 == 0 { 100.0 } else { 96.0 }; Point::new(200.0 + r * c, 200.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}"); } } /// 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 over its whole surface: the fill's solid and the stroke's stay surfaces of their /// own, and the mask stays on the fill's. Under a white light the same object is one surface. #[test] fn a_masked_surface_under_a_colored_light_stays_whole() { 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, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { 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(), surfaces, "{lights}"); assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}"); } } #[test] fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() { let mut n = square_node(); n.opacity = 0.5; if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] { f.opacity = 0.4; f.effects.push(fx(INFLATE, json!({}))); } let art = solid_art(&n).unwrap(); assert_eq!(art.opacity, 0.5); let surfaces = art.children().unwrap(); // The fill inflated, the stroke drawn flat as it was. assert_eq!(surfaces.len(), 2); assert_eq!(surfaces[0].opacity, 0.4); assert!(surfaces[0].children().unwrap().iter().all(|c| c.opacity == 1.0)); assert!(matches!(surfaces[1].kind, NodeKind::Path { .. })); } #[test] fn export_and_expand_bake_faces_with_their_own_ids() { for id in SOLIDS { let mut d = Document::new(400.0, 400.0); let mut n = square_node(); n.id = d.alloc_id(); n.appearance.effects.push(fx(id, json!({"bevel": "rounded"}))); assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}"); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); let mut ids = std::collections::HashSet::new(); fn visit(n: &Node, ids: &mut std::collections::HashSet) { assert!(ids.insert(n.id), "{:?} twice", n.id); assert!(!has_solid(n)); for c in n.children().into_iter().flatten() { visit(c, ids); } } visit(&art, &mut ids); assert!(ids.len() > 2, "{id}"); } } /// A twisted, tapered, hollow Inflate (both sides) exports and expands like the others: faces with /// their own ids, no 3D effect left on them. #[test] fn a_twisted_tapered_hollow_inflate_exports_and_expands() { let mut d = Document::new(400.0, 400.0); let mut n = square_node(); n.id = d.alloc_id(); let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35}); n.appearance.effects.push(fx(INFLATE, params)); let art = solid_art(&n).unwrap(); assert_eq!(crate::bake_appearance(&n), Some(art.clone())); assert!(faces(&art).len() > 20, "{} faces", faces(&art).len()); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); let mut ids = std::collections::HashSet::new(); fn visit(n: &Node, ids: &mut std::collections::HashSet) { 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 text = Node::group(NodeId(2), vec![]); assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path")); for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] { assert!(crate::is_length(id, key, false), "{id} {key}"); } assert!(!crate::is_length(EXTRUDE, "twist", false)); for id in SOLIDS { assert!(!crate::is_geometry(id) && is_three_d(id)); assert!(crate::default_params(id).is_some()); } // Options read from parameters, with the documented ranges' defaults. let o = extrude_options(&json!({"taper": 40, "bevel": "step", "bevelSpace": 30, "bevelRepeat": 99})); assert_eq!(o.taper, 0.4); let b = o.bevel.unwrap(); assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32)); assert!(extrude_options(&json!({})).bevel.is_none()); } #[test] fn inflate_reads_twist_taper_and_cap() { let o = inflate_options(&json!({"twist": 90, "taper": 40, "cap": false})); assert_eq!((o.twist, o.taper, o.cap), (90.0, 0.4, false)); let d = inflate_options(&json!({})); assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true)); assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true)); } #[test] fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { let mut n = square_node(); n.appearance.effects.push(fx(INFLATE, json!({}))); let art = solid_art(&n).unwrap(); let original = faces(&art); assert!(original.len() > 1000); assert!(original.iter().any(|f| f.path_data().unwrap().subpaths.iter().any(|p| p.anchors.len() > 3)), "legacy cells remain polygons"); for face in original { assert!(matches!(face.kind, NodeKind::Path { .. })); let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") }; assert!((0.05..=0.75).contains(&stroke.width)); assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter); } // A tiny shape exercises the lower bound, which otherwise disappears at grazing angles. n.transform(Affine::scale(0.001), false); let art = solid_art(&n).unwrap(); assert!(faces(&art).iter().any(|face| matches!(&face.appearance.items[1], AppearanceItem::Stroke(s) if s.width == 0.05))); // An explicit white light overrides a stale legacy color without switching shading paths. n.appearance.effects[0].params = json!({"lightColor":"#ff0000","lights":[{}]}); let art = solid_art(&n).unwrap(); assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. }))); } #[test] fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() { let mut n = square_node(); n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]}))); let art = solid_art(&n).unwrap(); fn count(n: &Node) -> (usize, usize, usize) { let (mut groups, mut clips, mut paths) = match &n.kind { NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0), NodeKind::Path { .. } => (0, 0, 1), _ => (0, 0, 0), }; for child in n.children().into_iter().flatten() { let (g, c, p) = count(child); groups += g; clips += c; paths += p; } (groups, clips, paths) } let (groups, clips, paths) = count(&art); assert!(paths > 1000, "exercise a tessellated surface"); // 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"); }