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("