mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 02:32:22 +00:00
Each 3D facet is stroked with a narrow seam of its own paint (0.05 to 0.75 pt; Revolve's is always 0.75 pt) so neighboring facets meet without an antialiasing hairline. In Extrude & Bevel, Inflate and Rotate under a white light, and in every face of the 3D (Classic) effects (Extrude & Bevel, Rotate and Revolve (Classic); shaded, No Shading, gradient and pattern alike), that seam kept the default Miter Join with its limit of 10. Revolve's seams and the colored-light seams were already round. A facet seen almost edge-on is a sharp sliver, and its mitered seam reached up to ten times its half-width past the facet's tip: thorns of up to 3.75 pt past the solid, along the tops of type seen tilted and at every edge-on bevel facet. #2706 (multiple lights) deliberately kept white-light seam joins as they were, so that legacy white-light output stayed byte-identical to main (its comment "Preserve existing white-light paints, alpha composition and seam joins exactly", and four PNGs compared byte for byte). That kept the thorns too. This change reverses that one part on purpose: the paints and alpha composition stay exactly as #2706 left them; only the join changes. - Outputs change, the document model doesn't. Exports draw the new joins (SVG writes stroke-linejoin="round", PDF and .ai draw round joins), and Expand Appearance writes round seams into the document. But nothing a document stores selects the join, and the format is unchanged, so there is no version gate: an old file drawn now simply has no thorns. - Round joins. Projected facets rarely have right-angle corners (a square seen at an angle is a parallelogram with acute corners, and an exact 90° corner sits at a √2 limit), and round is what the seams of colored lights and Revolve already draw. The cost: where a facet has no neighbor (Rotate's plane, every silhouette corner) its convex corners are drawn rounded by up to 0.375 pt instead of sharp. Alternatives weighed and not taken: a Miter Join with a limit near √2 at every seam, the composed clip included, which would bevel slivers but chamfer most projected corners as well; and seaming only the edges facets share, or clipping facets to their union as solid_surface does, which would keep every outer corner sharp but means a new geometry pass for every 3D effect. Round is the smallest change that matches the seams already drawn. - A seam no longer reaches past its facet by more than half its width, so the art's visual bounds shrink by up to 3.375 pt where a thorn stood (10 × 0.375 less the 0.375 a round seam still reaches). White-light output is no longer byte-identical to #2706's comparison; docs/3d-lighting-performance.md says so. Every seam stroke is made by lighting::seam_stroke. The colored clip in composed_art already outlined its seam with round joins and is unchanged. Tests: every seam of Extrude & Bevel, Extrude & Bevel (Classic, each branch), Rotate (Classic), Inflate and Rotate, under white and colored light, has round joins and reaches no more than half its width past its face, at views that make a sliver; Revolve (Classic)'s seams are round on each surface; a mitered seam fails them, and so does the legacy white-light Inflate test, which now expects Round.
226 lines
11 KiB
Rust
226 lines
11 KiB
Rust
use serde_json::json;
|
|
use vectorcraft_doc::color::Color;
|
|
use vectorcraft_doc::{Document, NodeId, StrokeLayer};
|
|
use vectorcraft_geom::{Affine, BezPath};
|
|
|
|
use super::*;
|
|
|
|
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
|
|
|
|
/// The 3D and Materials effects that cast shadows.
|
|
const CASTING: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, crate::REVOLVE];
|
|
|
|
fn square_node(id: u64, dx: f64) -> Node {
|
|
let mut b = BezPath::new();
|
|
b.move_to((100.0 + dx, 100.0));
|
|
b.line_to((200.0 + dx, 100.0));
|
|
b.line_to((200.0 + dx, 200.0));
|
|
b.line_to((100.0 + dx, 200.0));
|
|
b.close_path();
|
|
Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0))
|
|
}
|
|
|
|
fn art(n: &Node, id: &str) -> Option<Node> {
|
|
if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) }
|
|
}
|
|
|
|
/// The opacity of shadow `n`: a path filled black and nothing else; `None` for any other node.
|
|
fn shadow_opacity(n: &Node) -> Option<f32> {
|
|
let [AppearanceItem::Fill(fill)] = n.appearance.items.as_slice() else { return None };
|
|
(matches!(n.kind, NodeKind::Path { .. }) && fill.paint.color() == Some(Color::BLACK)).then_some(fill.opacity)
|
|
}
|
|
|
|
/// Whether `n` is the shadow of opaque art: a path filled black at 50 %, nothing else.
|
|
fn is_shadow(n: &Node) -> bool {
|
|
shadow_opacity(n) == Some(0.5)
|
|
}
|
|
|
|
fn shadows(n: &Node) -> usize {
|
|
let mut count = 0;
|
|
n.walk(&mut |c| count += usize::from(is_shadow(c)));
|
|
count
|
|
}
|
|
|
|
#[test]
|
|
fn each_3d_effect_draws_its_shadow_behind_its_solid_when_shadows_are_on() {
|
|
for id in CASTING {
|
|
let mut n = square_node(1, 0.0);
|
|
n.appearance.effects.push(crate::new_effect(id, &json!({})).unwrap());
|
|
let plain = art(&n, id).unwrap_or_else(|| panic!("{id}"));
|
|
assert_eq!(shadows(&plain), 0, "{id}: off by default");
|
|
n.appearance.effects[0].params = json!({"shadow": true, "shadowDistance": 50});
|
|
let lit = art(&n, id).unwrap_or_else(|| panic!("{id}"));
|
|
let children = lit.children().unwrap();
|
|
assert!(is_shadow(&children[0]), "{id}: the shadow is drawn first");
|
|
assert_eq!(shadows(&lit), 1, "{id}");
|
|
// The default light is up and to the left: the shadow on a plane away from the object
|
|
// reaches past the solid down and right.
|
|
let shadow = children[0].visual_bounds().unwrap();
|
|
let solid = children[1..].iter().filter_map(|c| c.visual_bounds()).reduce(|a, b| a.union(b)).unwrap();
|
|
assert!(shadow.x1 > solid.x1 && shadow.y1 > solid.y1, "{id}: {shadow:?} {solid:?}");
|
|
// The art's bounds take the shadow in.
|
|
assert!(lit.visual_bounds().unwrap().contains_rect(shadow), "{id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_shadow_moves_with_its_settings() {
|
|
for id in CASTING {
|
|
let bounds = |params: Value| {
|
|
let mut n = square_node(1, 0.0);
|
|
n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap());
|
|
let lit = art(&n, id).unwrap();
|
|
let shadow = &lit.children().unwrap()[0];
|
|
assert!(is_shadow(shadow), "{id} {params}");
|
|
shadow.visual_bounds().unwrap()
|
|
};
|
|
let near = bounds(json!({"shadow": true}));
|
|
let far = bounds(json!({"shadow": true, "shadowDistance": 100}));
|
|
assert!(far.x1 > near.x1 + 1.0, "{id}: {near:?} {far:?}");
|
|
// With the light straight in front, the outline falls about the plane's center; at 10 %
|
|
// the plane cuts it to a square 0.4 times the object's size across.
|
|
let tight = bounds(json!({"shadow": true, "shadowBounds": 10, "lightHeight": 90}));
|
|
let wide = bounds(json!({"shadow": true, "shadowBounds": 400, "lightHeight": 90}));
|
|
assert!(wide.width() > tight.width() * 2.0, "{id}: {tight:?} {wide:?}");
|
|
let below = bounds(json!({"shadow": true, "shadowPosition": "below", "rotationX": -30}));
|
|
assert_ne!(below, near, "{id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shadow_settings_out_of_range_or_of_the_wrong_kind_are_refused() {
|
|
for id in CASTING {
|
|
for (params, error) in [
|
|
(json!({"shadow": "yes"}), "shadow must be true or false"),
|
|
(json!({"shadowPosition": "under"}), "shadowPosition must be behind or below"),
|
|
(json!({"shadowPosition": 1}), "shadowPosition must be behind or below"),
|
|
(json!({"shadowDistance": 101}), "shadowDistance must be a number from 0 to 100"),
|
|
(json!({"shadowDistance": -1}), "shadowDistance must be a number from 0 to 100"),
|
|
(json!({"shadowBounds": 5}), "shadowBounds must be a number from 10 to 400"),
|
|
(json!({"shadowBounds": "wide"}), "shadowBounds must be a number from 10 to 400"),
|
|
] {
|
|
let err = validate_three_d_params(id, ¶ms).unwrap_err();
|
|
assert!(err.contains(error), "{id} {params}: {err}");
|
|
}
|
|
let ok = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 100, "shadowBounds": 10});
|
|
assert!(validate_three_d_params(id, &ok).is_ok(), "{id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_group_casts_one_shadow_for_all_its_members() {
|
|
for id in CASTING {
|
|
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 200.0))]);
|
|
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap());
|
|
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
|
|
assert_eq!(shadows(&lit), 1, "{id}");
|
|
let shadow = lit.children().unwrap()[0].clone();
|
|
assert!(is_shadow(&shadow), "{id}");
|
|
// With the light straight in front, the shadow behind spans both members.
|
|
let b = shadow.visual_bounds().unwrap();
|
|
assert!(b.x0 < 200.0 && b.x1 > 300.0, "{id}: {b:?}");
|
|
}
|
|
}
|
|
|
|
/// A member's opacity mask applies to the part of the shadow it casts, as to its solid: that part
|
|
/// is drawn in the mask, the other member's outside it, both on one plane.
|
|
#[test]
|
|
fn a_masked_member_casts_its_part_of_the_shadow_in_its_mask() {
|
|
for id in CASTING {
|
|
let mut masked = square_node(2, 200.0);
|
|
let mut cover = square_node(3, 200.0);
|
|
cover.appearance.items = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0).items;
|
|
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(cover, true)));
|
|
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(masked)]);
|
|
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
|
|
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
|
|
assert_eq!(shadows(&lit), 2, "{id}: one piece for each member");
|
|
let mut in_mask = 0;
|
|
lit.walk(&mut |c| {
|
|
if c.extras().mask.is_some() {
|
|
c.walk(&mut |s| in_mask += usize::from(is_shadow(s)));
|
|
}
|
|
});
|
|
assert_eq!(in_mask, 1, "{id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn transparent_art_and_a_light_that_lights_nothing_cast_no_shadow() {
|
|
for id in CASTING {
|
|
let mut n = square_node(1, 0.0);
|
|
n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "shade": false})).unwrap());
|
|
assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: Shading off");
|
|
n.appearance.effects[0].params = json!({"shadow": true, "lightIntensity": 0});
|
|
assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: no intensity");
|
|
// A member at 0 % opacity casts nothing; the other member still casts its shadow.
|
|
let mut clear = square_node(2, 200.0);
|
|
clear.opacity = 0.0;
|
|
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(clear)]);
|
|
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap());
|
|
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
|
|
let shadow = lit.children().unwrap()[0].clone();
|
|
assert!(is_shadow(&shadow), "{id}");
|
|
let b = shadow.visual_bounds().unwrap();
|
|
assert!(b.x1 < 300.0, "{id}: only the visible member casts: {b:?}");
|
|
// Art at 50 % casts a shadow half as dark.
|
|
let mut half = square_node(1, 0.0);
|
|
for item in &mut half.appearance.items {
|
|
if let AppearanceItem::Fill(fill) = item {
|
|
fill.opacity = 0.5;
|
|
}
|
|
}
|
|
half.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
|
|
let lit = art(&half, id).unwrap();
|
|
assert_eq!(shadow_opacity(&lit.children().unwrap()[0]), Some(0.25), "{id}");
|
|
// Art in a blending mode casts its shadow in that mode.
|
|
for item in &mut half.appearance.items {
|
|
if let AppearanceItem::Fill(fill) = item {
|
|
fill.blend = BlendMode::Screen;
|
|
}
|
|
}
|
|
let lit = art(&half, id).unwrap();
|
|
let AppearanceItem::Fill(fill) = &lit.children().unwrap()[0].appearance.items[0] else { panic!("{id}: a fill") };
|
|
assert_eq!(fill.blend, BlendMode::Screen, "{id}");
|
|
}
|
|
}
|
|
|
|
/// With a 3D effect on each of two fills, both fills' shadows are drawn behind both fills' solids.
|
|
#[test]
|
|
fn the_shadows_of_every_fill_go_behind_every_fill_s_solid() {
|
|
for id in CASTING {
|
|
let mut n = square_node(1, 0.0);
|
|
let fill = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap().clone();
|
|
n.appearance.items = vec![fill.clone(), fill];
|
|
for item in &mut n.appearance.items {
|
|
item.effects_mut().push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
|
|
}
|
|
let lit = art(&n, id).unwrap_or_else(|| panic!("{id}"));
|
|
let children = lit.children().unwrap();
|
|
assert!(children.len() >= 4, "{id}");
|
|
assert!(is_shadow(&children[0]) && is_shadow(&children[1]), "{id}: both shadows first");
|
|
assert!(children[2..].iter().all(|c| !is_shadow(c)), "{id}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn expand_appearance_keeps_the_shadow_as_a_path() {
|
|
for id in CASTING {
|
|
let mut d = Document::new(400.0, 400.0);
|
|
let mut n = square_node(1, 0.0);
|
|
n.id = d.alloc_id();
|
|
n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
|
|
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
|
|
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap_or_else(|| panic!("{id}"));
|
|
assert_eq!(shadows(&expanded), 1, "{id}");
|
|
let mut effects = 0;
|
|
expanded.walk(&mut |c| effects += c.appearance.effects.len());
|
|
assert_eq!(effects, 0, "{id}");
|
|
// It moves with the object, as the solid does.
|
|
let before = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap();
|
|
n.transform(Affine::translate((40.0, 0.0)), false);
|
|
let after = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap();
|
|
assert!((after.x0 - before.x0 - 40.0).abs() < 1e-6, "{id}: {before:?} {after:?}");
|
|
}
|
|
}
|