From 1fa71e2ab13af7922e6aec3c829210348756dad4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:34:42 -0800 Subject: [PATCH] 3D: solid-color fills and strokes under colored lights draw their faces in one depth order Under a light that is not white, an opaque Inflate fill or stroke in a solid color was drawn as one composite of all its facets, so the object's solids were drawn whole, one after another: the stroke's faces covered every face of the fill, nearer or not. When an object's solids share one order (all opaque with Normal blending, or one member's under its opacity mask), each facet of such a solid is now drawn on its own, with the composite's paint, channel light ramps and seam width, and takes its place in that order as the faces of a gradient already do. A lone solid, and the solids of an object with a translucent or blended fill or stroke, keep the one composite. A face drawn on its own is about 16 nodes, against about 6 per facet in the composite of a whole surface. These faces go in one order while they number at most 30,720 together, counted again after the cuts. Past that, each such solid is drawn as one composite. Docs: docs/three-d.md, docs/target-app-parity.md. --- crates/effects/src/solids.rs | 74 ++++-- crates/effects/src/solids/tests.rs | 243 +++++++++++++++++- crates/engine/tests/it/three_d_light_color.rs | 77 ++++++ docs/target-app-parity.md | 1 + docs/three-d.md | 16 +- 5 files changed, 370 insertions(+), 41 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 2642fca878..699e3f26f0 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -416,6 +416,12 @@ const MAX_MEMBERS: usize = 4096; /// normals): past it the object is too complex to export. const MAX_MESH_TRIANGLES: usize = 2_000_000; +/// The most facets of solid colors under colored lights that one 3D effect draws as faces of their +/// own, to go in one order with other solids' faces. Each such face is about 16 nodes, a composite +/// of a whole surface about 6 per facet, so at this count the faces hold about as many nodes as the +/// composites do at the paint-copy allowance. Past it those solids draw as one composite each. +const MAX_FACE_COMPOSITES: usize = 30_720; + /// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, /// compound path and type in it (after [`resolve`] gives it the art of its symbol instances, blends /// and clipping groups), at any depth of its groups, each through its own geometry @@ -522,9 +528,12 @@ pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { } /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and -/// blend mode (so overlapping facets don't add up their transparency). -fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node { - if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) { +/// blend mode (so overlapping facets don't add up their transparency). Under colored lights a solid +/// color with smooth facets is one composite of all its facets ([`composites_facets`]) unless +/// `faces`: then each facet is a face of its own, as in a gradient, so the faces can go in one order +/// with the faces of other solids. +fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool, faces: bool) -> Node { + if !faces && composites_facets(&facets, paint, colored) { let mut group = crate::lighting::solid_surface(n.id, &facets, paint); group.opacity = opacity; group.blend = blend; @@ -570,6 +579,12 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group } +/// Is a surface of `facets` in `paint` one composite of all its facets when [`surface`] draws it +/// whole: a solid color under colored lights with smooth shading? +fn composites_facets(facets: &[Facet], paint: &Paint, colored: bool) -> bool { + colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) +} + /// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend. fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) { match item { @@ -587,10 +602,10 @@ fn paint_ref(item: &AppearanceItem) -> &Paint { } /// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored -/// lights give each facet derived paints (`lighting::solid_surface`). +/// lights give each facet derived paints (`lighting::solid_surface`, or `lighting::facet_art` for +/// each face). fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { - let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some()); - facets.len().saturating_mul(if derived { 6 } else { 2 }) + facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 }) } /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the @@ -1252,8 +1267,9 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// The 3D art of one effect `fx` on `path` (already through the effects before it). /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, -/// each in its own paint. When every one is opaque with normal blending and no colored light makes -/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as +/// each in its own paint. When every one is opaque with normal blending, their faces are drawn in +/// one back-to-front order, a solid color under colored lights included up to +/// [`MAX_FACE_COMPOSITES`] facets (where depths tie, as /// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's /// fill walls before the faces of its covering strokes that overlap them, and its fill's faces and /// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the @@ -1508,7 +1524,8 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile filled.push(Filled { at: out.len(), member, caps, opacity, blend, masked: masks_surface(&paint, colored) }); } } - let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + let art = + if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored, false) }; out.push((s.scopes.as_slice(), Arc::new(art))); } } @@ -1541,22 +1558,31 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile opacity >= 1.0 && blend == BlendMode::Normal }); let closed = |shape: usize| shapes.get(shape).is_some_and(|s| holds_edge(&fx.id, &s.path)); - // A solid whose surface is one composite of all its facets ([`surface`]: a solid color under - // colored lights with smooth shading) has no faces to order. - let composite = |item: &AppearanceItem, f: &[Facet]| colored && paint_ref(item).color().is_some() && f.iter().any(|f| f.rgb_gradient.is_some()); - // The faces are drawn in one order (below) unless a composite draws the surfaces whole. Under - // a member's opacity mask or blending, each member's faces go in one order of their own when its - // fills and strokes are opaque, blend normally, carry no opacity mask and are not composites. - let one_order = opaque && !solids.iter().any(|(item, _, _, f, _)| composite(item, f)); + // A solid color under colored lights with smooth shading draws as one composite of its facets + // ([`surface`]) unless its facets go in one order with other solids' faces. They do while all + // such facets together number at most MAX_FACE_COMPOSITES. + let composite = |item: &AppearanceItem, f: &[Facet]| composites_facets(f, paint_ref(item), colored); + let colored_faces: usize = solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum(); + let split = colored_faces <= MAX_FACE_COMPOSITES; + // The faces are drawn in one order (below) when every solid is opaque and blends normally and + // no composite draws a surface whole. Under a member's opacity mask or blending, each member's + // faces go in one order of their own when its fills and strokes are opaque, blend normally, + // carry no opacity mask and are not composites. + let one_order = opaque && (split || !solids.iter().any(|(item, _, _, f, _)| composite(item, f))); let shape_order: Vec = (0..shapes.len()) .map(|shape| { solids.iter().filter(|s| s.4 == shape).all(|(item, member, _, f, _)| { let (paint, opacity, blend) = paint_of(item, *member); - opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && !composite(item, f) + opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && (split || !composite(item, f)) }) }) .collect(); let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order }; + // The solids whose faces share one order with another solid's: every solid in the one order of + // the object, or of its member under a mask. A lone solid draws its facets in their own order, + // so a solid color under colored lights stays one composite there ([`surface`]). + let shared: Vec = + (0..shapes.len()).map(|shape| ordered(shape) && solids.iter().filter(|s| !masks || s.4 == shape).nth(1).is_some()).collect(); if solids.iter().any(|s| ordered(s.4)) { // A stroke face that passes through a fill wall or another stroke's face is cut where it // does. The pieces count against the limits as the faces did: cutting stops before the @@ -1592,6 +1618,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile .collect(); placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) }; + // With the pieces of the cuts, solid colors under colored lights past MAX_FACE_COMPOSITES draw + // as one composite each, so their surfaces draw whole (below). + let split = + split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::() <= MAX_FACE_COMPOSITES; let members: Vec = solids.iter().map(|s| s.4).collect(); let surfaces: Vec = solids .into_iter() @@ -1600,7 +1630,11 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect(); let (paint, opacity, blend) = paint_of(item, member); - let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + let art = if classic { + classic::surface(n, f, &paint, opacity, blend, &p) + } else { + surface(n, f, &paint, opacity, blend, colored, split && shared.get(shape).copied().unwrap_or(false)) + }; (depths, art, scopes, ends) }) .collect(); @@ -1609,8 +1643,8 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, _ => extrude_options(&p).cap, }; - // One order takes a plain group of faces from each solid, one face for each facet. With - // colored lights a surface can instead be one clipped composite of all its facets. A composite + // One order takes a plain group of faces from each solid, one face for each facet. A lone + // solid's surface can instead be one clipped composite of all its facets ([`surface`]), which // has no faces to order, so it draws whole. let plain = |(depths, s, _, _): &BuiltSurface| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()); if masks { diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index c825159dda..eb9e81bbc0 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -491,8 +491,8 @@ fn a_masked_members_fill_and_stroke_faces_go_in_one_order() { } /// 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. +/// fill's and the dashed stroke's faces in one order, as it does under a white light, and so does +/// Inflate in solid colors. #[test] fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; @@ -530,9 +530,111 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_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. + // A solid color under the colored light: the fill's and the stroke's faces in one order too. let solid = stroked(&letter_c()); - assert_eq!(drawn(&solid, Some(colored)).0, 2); + assert_eq!(drawn(&solid, Some(colored)).0, 1); +} + +/// The bounds of face `f` of 3D art: its path's, or for a clipped colored composite +/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 1 pt. +fn drawn_face_bounds(f: &Node) -> Rect { + match &f.kind { + NodeKind::Path { .. } => f.path_data().and_then(PathData::bounds).unwrap(), + NodeKind::Group { clip: true, children } => { + let cover = &children[1].children().unwrap()[0]; + cover.path_data().and_then(PathData::bounds).unwrap().inflate(-1.0, -1.0) + } + _ => panic!("a face is a path or a clipped composite"), + } +} + +/// Under a light that is not white, a solid-color fill and a solid-color stroke, plain, dashed or +/// with a width profile, draw their faces in one order, the faces of both solids in the order of +/// their depths where they overlap, in Inflate and in Extrude & Bevel, with and without perspective +/// and in a member with an opacity mask. Drawing each solid whole, the fill's faces before the +/// stroke's, shows farther faces on top. +#[test] +fn colored_solid_fills_and_strokes_share_face_depth_order() { + use vectorcraft_doc::{Dash, WidthProfile}; + type Style<'a> = (&'a str, &'a dyn Fn(&mut StrokeLayer)); + let styles: [Style; 3] = [ + ("plain", &|_| {}), + ("dashed", &|s| s.dash = Some(Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false })), + ("profiled", &|s| s.profile = Some(WidthProfile::lens())), + ]; + let views = [ + json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), + json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}), + ]; + let mut whole_misdrawn = 0; + for id in [INFLATE, EXTRUDE] { + for view in &views { + for (style, set) in &styles { + let mut params = view.clone(); + params["depth"] = json!(60); + params["lights"] = json!([{"color": "#ff8080"}, {"color": "#4060ff", "rotation": 120}]); + let mut n = stroked(&letter_c()); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + set(s); + } + let what = format!("{id} {view} {style}"); + let parts = drawn_parts(&n, id, ¶ms); + if id == INFLATE { + assert!(parts.iter().any(|(f, ..)| f.rgb_gradient.is_some()), "{what}: smooth colored facets"); + } + let mut with = n.clone(); + with.appearance.effects.push(fx(id, params.clone())); + let art = solid_art(&with).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 1, "{what}: one order"); + let faces = surfaces[0].children().unwrap(); + assert_eq!(faces.len(), parts.len(), "{what}: one face per facet"); + for (k, (face, (facet, ..))) in faces.iter().zip(&parts).enumerate() { + let (a, b) = (drawn_face_bounds(face), facet.path().bounds().unwrap()); + let close = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6); + assert!(close, "{what}: face {k} drawn out of the order of the depths"); + } + let allowed = if id == INFLATE { 5 } else { 0 }; + let wrong = misdrawn(&parts); + assert!(wrong.len() <= allowed, "{what}: {} points show a farther face, first {:?}", wrong.len(), wrong.first()); + // The same faces drawn as two whole surfaces, the fill's then the stroke's. + let mut whole = parts.clone(); + whole.sort_by_key(|(_, part, _)| *part); + whole_misdrawn += misdrawn(&whole).len(); + // In a group, under the member's opacity mask, the faces keep the same order. + if id == INFLATE && *style == "plain" { + 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 = n.clone(); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true))); + let mut plain = Node::group(NodeId(2), vec![Arc::new(n.clone())]); + plain.appearance.effects.push(fx(id, params.clone())); + let mut g = Node::group(NodeId(2), vec![Arc::new(masked)]); + g.appearance.effects.push(fx(id, params.clone())); + let bounds = |art: &Node| { + let mut out = vec![]; + art.walk(&mut |n| { + if let NodeKind::Group { clip: true, .. } = &n.kind { + out.push(drawn_face_bounds(n)); + } + }); + out + }; + let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap()); + let mut masks = 0; + scoped.walk(&mut |n| masks += usize::from(n.extras().mask.is_some())); + assert!(masks > 0, "{what}: drawn in the member's mask"); + let ordered = bounds(&plain); + assert!(ordered.len() > 100, "{what}: clipped faces"); + assert_eq!(bounds(&scoped), ordered, "{what}: the member's faces in one order"); + } + } + } + } + assert!(whole_misdrawn > 0, "whole surfaces show farther faces"); } /// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they @@ -1322,11 +1424,12 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. }))); } +/// A solid color under colored lights is one composite of all its facets, with one clip and a fixed +/// number of groups, where its solid's faces draw in no order shared with another solid's: a lone +/// fill, or a fill and a translucent stroke. A fill and an opaque stroke draw each face on its own, +/// in one order. #[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), @@ -1341,13 +1444,125 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() { } (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"); + let lights = json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]}); + let lone = |n: &mut Node| n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_))); + let translucent = |n: &mut Node| { + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.opacity = 0.5; + } + }; + for (what, set, surfaces) in [("a lone fill", &lone as &dyn Fn(&mut Node), 1), ("a translucent stroke", &translucent, 2)] { + let mut n = square_node(); + set(&mut n); + n.appearance.effects.push(fx(INFLATE, lights.clone())); + let art = solid_art(&n).unwrap(); + let (groups, clips, paths) = count(&art); + assert!(paths > 1000, "{what}: exercise a tessellated surface"); + // Each solid one composite with its own clip. + assert_eq!(art.children().unwrap().len(), surfaces, "{what}"); + assert_eq!(clips, surfaces, "{what}"); + assert_eq!(groups, 1 + 6 * surfaces, "{what}: group count must not grow with triangle count"); + } + // An opaque fill and stroke: one order of faces, each its own clipped composite or shaded path. + let mut n = square_node(); + n.appearance.effects.push(fx(INFLATE, lights)); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + assert_eq!(surfaces.len(), 1, "one order"); + let faces = surfaces[0].children().unwrap(); + assert!(faces.len() > 100, "one face per facet"); + assert!(faces.iter().all(|f| matches!(&f.kind, NodeKind::Path { .. } | NodeKind::Group { clip: true, .. }))); + assert!(faces.iter().any(|f| matches!(&f.kind, NodeKind::Group { clip: true, .. })), "colored ramps"); +} + +/// Solid colors under colored lights go in one order of faces while their facets together, with the +/// pieces of the cuts, number at most `MAX_FACE_COMPOSITES`. Past it each solid draws whole, as one +/// composite. +#[test] +fn colored_solids_past_the_face_limit_draw_as_composites() { + let lights = json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff","rotation":90}]}); + let mut base = square_node(); + base.appearance.effects.push(fx(INFLATE, lights.clone())); + let stroke = base.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned().unwrap(); + // The facets each solid draws under these lights. Strokes alike each build the same solid. + let shapes = [Profile { path: profile(&base).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&base), opacity: 1.0, scopes: vec![] }]; + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + let counts: Vec = built_solids(INFLATE, &crate::merged_params(INFLATE, &lights), &parts, bounds, MAX_OBJECT_FACES) + .map(|f| f.unwrap().map_or(0, |f| f.len())) + .collect(); + let [fill, each] = counts[..] else { panic!("a fill's solid and a stroke's") }; + let fits = (MAX_FACE_COMPOSITES - fill) / each; + assert!(fits >= 4, "{fill} + {fits} × {each}"); + // Under the limit; under it before the cuts but past it with their pieces; past it before them. + let mut seen = vec![]; + for strokes in [fits / 2, fits, fits + 1] { + let mut n = base.clone(); + n.appearance.items.extend(std::iter::repeat_n(stroke.clone(), strokes - 1)); + let uncut = fill + strokes * each; + let cut = drawn_parts(&n, INFLATE, &lights).len(); + let one_order = cut <= MAX_FACE_COMPOSITES; + seen.push((uncut <= MAX_FACE_COMPOSITES, one_order)); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + if one_order { + assert_eq!(surfaces.len(), 1, "{uncut} faces, {cut} cut: one order"); + } else { + assert_eq!(surfaces.len(), 1 + strokes, "{uncut} faces, {cut} cut: each solid whole"); + assert!(surfaces.iter().all(|s| s.children().is_some_and(|c| c.len() < 10)), "each solid one composite"); + } + } + assert_eq!(seen, [(true, true), (true, false), (false, false)], "every case"); +} + +/// A solid-color face drawn on its own under colored lights takes the same colors as in the +/// surface's one composite: the paint, multiplied by the same red, green and blue light ramps, and +/// a seam of the same width. +#[test] +fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() { + let n = square_node(); + let p = crate::merged_params(INFLATE, &json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff","rotation":90}]})); + let (path, rule) = profile(&n).unwrap(); + let all = facets(INFLATE, &path, rule, path.bounds().unwrap(), &p).unwrap(); + let face = all + .iter() + .find(|f| f.rgb_gradient.is_some_and(|g| g.channels[0] != g.channels[1] || g.channels[1] != g.channels[2])) + .expect("a facet with colored ramps") + .clone(); + let paint = Paint::solid(RED); + // Each node's own fill paint, and its seam stroke's width, for every node under `n`. + let paints = |n: &Node| { + let mut out = vec![]; + n.walk(&mut |n| { + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(f) => out.push((Some(f.paint.clone()), None)), + AppearanceItem::Stroke(s) => out.push((None, Some(s.width))), + } + } + }); + out + }; + let alone = surface(&n, vec![face.clone()], &paint, 1.0, BlendMode::Normal, true, true); + let batch = surface(&n, vec![face.clone()], &paint, 1.0, BlendMode::Normal, true, false); + let [single] = alone.children().unwrap().as_slice() else { panic!("one face") }; + assert!(matches!(&single.kind, NodeKind::Group { clip: true, .. }), "a clipped face"); + // The face's paint cover and its three channel ramps. + let fills = |n: &Node| paints(n).into_iter().filter_map(|(f, _)| f).collect::>(); + let one = fills(single); + assert_eq!(one.len(), 4); + assert_eq!(one[0], paint, "the paint"); + // The composite: its paint cover, then each channel's black backdrop and the face's ramp. + let all_fills = fills(&batch); + let ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + assert_eq!(all_fills.first(), Some(&paint)); + assert_eq!(ramps, one.iter().skip(1).collect::>(), "the same light ramps"); + // The single face's clip holds its seam: the clip reaches past the facet by half the seam. + let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect(); + assert!(widths.len() == 3 && widths.iter().all(|w| (w - seam).abs() < 1e-12), "{widths:?}"); + let NodeKind::Group { children, .. } = &single.kind else { panic!("a clip group") }; + let (clip, facet) = (children[0].path_data().and_then(PathData::bounds).unwrap(), face.path().bounds().unwrap()); + assert!((clip.x0 - (facet.x0 - seam / 2.0)).abs() < 0.1 && (clip.x1 - (facet.x1 + seam / 2.0)).abs() < 0.1, "{clip:?} {facet:?}"); } /// `square_node` moved right by `dx`, with id `id` and fill `fill`. diff --git a/crates/engine/tests/it/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs index 91ce875796..38013a372b 100644 --- a/crates/engine/tests/it/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -502,3 +502,80 @@ fn batched_colored_inflate_preserves_compound_holes_and_surface_opacity() { s.execute("effect.expandAppearance", &json!({})).unwrap(); assert_eq!(image.pixels, render(doc(&s)).pixels); } + +/// Under colored lights, an Inflate fill and stroke in solid colors draw each face on its own, in +/// one depth order: zoomed in, the fill's faces show the colors the one composite of the surface +/// shows (drawn when the stroke is translucent), with no seam between faces; at 1x, where a pixel +/// holds several faces, all but a few pixels show the average of the zoomed-in colors; and the art +/// keeps its colors through SVG export and Expand Appearance. +#[test] +fn colored_solid_inflate_faces_in_one_order_keep_the_composites_colors_and_seams() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + s.execute("path.create", &json!({"d":"M100 100H300V300H100Z"})).unwrap(); + s.execute("paint.setFill", &json!({"color":"#e0c080"})).unwrap(); + s.execute("paint.setStroke", &json!({"color":"#204060","width":4})).unwrap(); + s.execute( + "effect.apply", + &json!({"effect":"threeD.inflate","item":null,"params":{ + "rotationX":0,"rotationY":0,"rotationZ":0,"lights":[{"color":"#ff4040"},{"color":"#4040ff","rotation":120}] + }}), + ) + .unwrap(); + let at = |d: &Document, scale: f64| { + vectorcraft_render::Renderer::new().render( + d, + 400, + 400, + kurbo::Affine::scale(scale) * kurbo::Affine::translate((-140.0, -140.0)), + &vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() }, + ) + }; + let ordered = doc(&s).clone(); + s.execute("transparency.set", &json!({"item":1,"opacity":99})).unwrap(); + let whole = doc(&s).clone(); + // The fill's puff, inside the stroke's: 150 to 250 pt across, 10 to 110 px at 1x. + let (near, near_whole, far) = (at(&ordered, 3.0), at(&whole, 3.0), at(&ordered, 1.0)); + let (mut off, mut open) = (0, 0); + for y in 10..110u32 { + for x in 10..110u32 { + // At 3x each 1x pixel is nine pixels: the faces against the composite, pixel by pixel. + let mut sum = [0u32; 4]; + for (dx, dy) in (0..9).map(|k| (k % 3, k / 3)) { + let (p, q) = (near.pixel(x * 3 + dx, y * 3 + dy), near_whole.pixel(x * 3 + dx, y * 3 + dy)); + assert!(p[3] >= 254, "no seam at ({}, {}) at 3x: {p:?}", x * 3 + dx, y * 3 + dy); + assert!( + p.into_iter().zip(q).all(|(p, q)| p.abs_diff(q) <= 1), + "({}, {}) at 3x: {p:?} vs the composite's {q:?}", + x * 3 + dx, + y * 3 + dy + ); + for (s, v) in sum.iter_mut().zip(p) { + *s += u32::from(v); + } + } + // At 1x, where a pixel holds several faces, a pixel has about the average of its nine: + // a few pixels where thin faces meet are off by more than 3 or let a little through. + let p = far.pixel(x, y); + assert!(p[3] >= 240, "a seam at ({x},{y}) at 1x: {p:?}"); + open += usize::from(p[3] < 255); + off += usize::from(p.into_iter().zip(sum).any(|(p, s)| u32::from(p).abs_diff(s / 9) > 3)); + } + } + assert!(open <= 500, "{open} of 10,000 pixels at 1x let the background through"); + assert!(off <= 300, "{off} of 10,000 pixels at 1x are off the zoomed-in colors"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc(&s), &ordered); + let live = render(doc(&s)); + let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned(); + assert!(!svg.contains("