diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 8592776a95..257576822a 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -136,8 +136,86 @@ pub(crate) fn facet_art( composed_art(id, path, paint, Ramp::Rgb(light), seam) } -/// Composite smooth solid-fill facets with one clip and three channel groups per surface. -/// Black channel backdrops keep shared edges opaque before the final coverage is applied. +/// The width of the seam stroke of a facet's underlay: a round stroke that reaches 1 pt past each +/// edge, over the pixels that the antialiased edges of two neighboring facets each cover only in +/// part at 1:1. A clip to the surface's outline removes what it paints past the surface. +pub(crate) const UNDERLAY_SEAM: f64 = 2.0; + +/// The area facet `path` and its round seam stroke `seam` wide paint together: the stroke's outer +/// contours and the facet, all wound one way with a positive area. The stroke's inner contours, +/// wound the other way, are left out: the facet covers them, and contours that cancel each other +/// leave partial coverage where a union of footprints meets at a pixel. +pub(crate) fn footprint(path: &vectorcraft_geom::BezPath, seam: f64) -> vectorcraft_geom::BezPath { + use kurbo::{PathEl, Shape}; + let stroke = kurbo::stroke(path.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); + let sign = if stroke.area() < 0.0 { -1.0 } else { 1.0 }; + let mut footprint = vectorcraft_geom::BezPath::new(); + let mut contour = vectorcraft_geom::BezPath::new(); + for el in stroke.elements().iter().chain(std::iter::once(&PathEl::MoveTo(kurbo::Point::ZERO))) { + if matches!(el, PathEl::MoveTo(_)) && !contour.elements().is_empty() { + if contour.area() * sign >= 0.0 { + footprint.extend(contour.iter()); + } + contour = vectorcraft_geom::BezPath::new(); + } + contour.push(*el); + } + let mut boundary = path.clone(); + if boundary.area() * sign < 0.0 { + boundary = boundary.reverse_subpaths(); + } + footprint.extend(boundary.iter()); + if sign < 0.0 { footprint.reverse_subpaths() } else { footprint } +} + +/// Colored-light facets `faces` (in drawing order) drawn over an underlay of themselves, the same +/// facets grown to seams [`UNDERLAY_SEAM`] wide, all clipped once to the union of the facets' +/// footprints. Where two facets' antialiased edges meet, the underlay fills what they leave +/// uncovered with the neighbors' own colors, so the background does not show through. `None` unless +/// every face is a facet a surface draws: a path filled and stroked by its seam, or a facet composed +/// in a clip of its footprint, neither with an opacity mask. +pub(crate) fn sealed(id: vectorcraft_doc::NodeId, faces: &[std::sync::Arc]) -> Option { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeKind}; + let mut outline = vectorcraft_geom::BezPath::new(); + let mut underlay = Vec::with_capacity(faces.len()); + for face in faces { + if face.extras().mask.is_some() { + return None; + } + match &face.kind { + NodeKind::Path { clipping: false, .. } => { + let path = face.path_data()?.to_bezpath(); + let mut seam = None; + let mut grown = (**face).clone(); + for item in &mut grown.appearance.items { + if let AppearanceItem::Stroke(s) = item { + seam = Some(seam.map_or(s.width, |w: f64| w.max(s.width))); + s.width = UNDERLAY_SEAM; + } + } + outline.extend(footprint(&path, seam?).iter()); + underlay.push(Arc::new(grown)); + } + NodeKind::Group { children, clip: true } if children.len() == 2 => { + let clip = children.first()?.path_data()?.to_bezpath(); + let wider = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&clip, UNDERLAY_SEAM)), Appearance::default()); + let colors = children.get(1)?.clone(); + outline.extend(clip.iter()); + underlay.push(Arc::new(Node::new(id, NodeKind::Group { children: vec![Arc::new(wider), colors], clip: true }))); + } + _ => return None, + } + } + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&outline), Appearance::default()); + underlay.extend(faces.iter().cloned()); + Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, underlay))], clip: true })) +} + +/// Composite smooth solid-fill facets with one clip and three channel groups per surface. In each +/// channel, an underlay of the facets with wide seams lies under the facets themselves, so a +/// shared edge is opaque in that channel's own light before the channels are screened together; +/// the black backdrop shows only outside the surface, which the clip removes. pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node { use kurbo::Shape; use std::sync::Arc; @@ -167,20 +245,36 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t if let Some(value) = color.get_mut(channel) { *value = 1.0; } + let paints: Vec = facets + .iter() + .map(|face| { + if let Some(ramp) = face.rgb_gradient { + smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) + } else { + Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) + } + }) + .collect(); + let draw = |seam: fn(&vectorcraft_three_d::Facet) -> f64| -> Vec> { + facets + .iter() + .zip(&paints) + .map(|(face, paint)| { + let edge = seam_stroke(paint.clone(), seam(face)); + Arc::new(Node::path( + id, + face.path(), + Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(edge)], + ..Default::default() + }, + )) + }) + .collect() + }; let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))]; - children.extend(facets.iter().map(|face| { - let paint = if let Some(ramp) = face.rgb_gradient { - smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) - } else { - Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) - }; - let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - Arc::new(Node::path( - id, - face.path(), - Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() }, - )) - })); + children.extend(draw(|_| UNDERLAY_SEAM)); + children.extend(draw(|face| (face.thickness() / 2.0).clamp(0.05, 0.75))); let mut group = Node::group(id, children); group.isolate = true; group.blend = BlendMode::Screen; @@ -228,24 +322,25 @@ pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option) { } fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node { - use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask}; use vectorcraft_geom::{Rect, shapes}; - let (base, mask) = opaque_paint(paint); - // Clip once to the union of this single-contour face and its round seam stroke. - // Align their winding so the nonzero fill adds coverage instead of cancelling it. - let mut boundary = path.to_bezpath(); - let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); - if boundary.area() * footprint.area() < 0.0 { - boundary = boundary.reverse_subpaths(); + // An unplaced gradient (and a freeform gradient without points) stays fitted to the face's + // bounds grown by 1 pt, not to the larger cover below. + let mut paint = paint.clone(); + if let Paint::Gradient(g) = &mut paint { + g.pin(path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0)); } - footprint.extend(boundary.iter()); - let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default()); + let (base, mask) = opaque_paint(&paint); + // Clip once to the union of this single-contour face and its round seam stroke. + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&path.to_bezpath(), seam)), Appearance::default()); // Opaque rectangles cover the complete seam stroke, without separately antialiasing - // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. - let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. They + // reach at least half a point past the seam stroke grown for an underlay ([`sealed`]), so + // their own edges stay outside either clip. + let margin = seam.max(1.0) + UNDERLAY_SEAM / 2.0; + let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(margin, margin); let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0)); let mut illumination = match light { Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)), @@ -380,4 +475,30 @@ mod tests { assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]); assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch"); } + + /// Under colored light, a face's unplaced source gradient is fitted to the face's bounds grown by + /// 1 pt, whatever the size of the cover rectangle that draws it; a placed one keeps its placement. + #[test] + fn composed_faces_fit_unplaced_gradients_to_the_face_not_the_cover() { + use vectorcraft_doc::NodeKind; + use vectorcraft_geom::{Rect, shapes}; + use vectorcraft_three_d::{RgbGradient, ShadeGradient}; + let face = shapes::rectangle(Rect::new(10.0, 20.0, 50.0, 40.0)); + let ramp = |b| ShadeGradient { start: Point::new(10.0, 20.0), end: Point::new(50.0, 20.0), brightness: [b, 1.0] }; + let light = RgbGradient { channels: [ramp(0.2), ramp(0.5), ramp(0.8)] }; + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let unplaced = GradientPaint { gradient: Gradient::new(GradientKind::Linear, stops), geom: None, angle: 0.0, swatch: None, freeform: None }; + let placed = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(80.0, 0.0), aspect: 1.0, focal: None }; + let fitted = unplaced.resolve(Rect::new(9.0, 19.0, 51.0, 41.0)); + for (geom, expected) in [(None, fitted), (Some(placed), placed)] { + let paint = Paint::Gradient(Box::new(GradientPaint { geom, ..unplaced.clone() })); + for seam in [0.05, 0.75] { + let art = facet_art(vectorcraft_doc::NodeId(1), &face, &paint, light, seam); + let NodeKind::Group { children, clip: true } = &art.kind else { panic!("a clipped composite") }; + let base = &children[1].children().unwrap()[0]; + let Paint::Gradient(g) = base.appearance.items[0].paint() else { panic!("the source gradient") }; + assert_eq!(g.geom, Some(expected), "seam {seam}"); + } + } + } } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 1536f33bb3..1fae38c611 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -635,11 +635,26 @@ 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`, or `lighting::facet_art` for -/// each face). +/// Are `facets` drawn over an underlay of themselves ([`crate::lighting::sealed`], +/// [`crate::lighting::solid_surface`])? Under colored lights, when any facet takes a smooth light +/// ramp. +fn underlaid(facets: &[Facet], colored: bool) -> bool { + colored && facets.iter().any(|f| f.rgb_gradient.is_some()) +} + +/// The most paint copies `surface` makes for one facet ([`facet_copies`]). +const MAX_FACET_COPIES: usize = 12; + +/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), twice that when +/// the facets lie over an underlay of themselves, and [`MAX_FACET_COPIES`] when colored lights also +/// give each facet derived paints in three channels (`lighting::solid_surface`). fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { - facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 }) + let per_facet = match (underlaid(facets, colored), paint.color().is_some()) { + (true, true) => MAX_FACET_COPIES, + (true, false) => 4, + (false, _) => 2, + }; + facets.len().saturating_mul(per_facet) } /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the @@ -832,14 +847,14 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { /// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces /// and whose paint copies take `paint_bytes` so far: as many as keep the faces within -/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies -/// of the costliest stroke's paint. +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as +/// [`MAX_FACET_COPIES`] copies of the costliest stroke's paint. fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { let per_face = items .filter(|item| matches!(item, AppearanceItem::Stroke(_))) .map(|item| { let mut copies = 0usize; - crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + crate::revolve::paint_copies_add(&mut copies, MAX_FACET_COPIES, paint_ref(item)); copies }) .max() @@ -1693,6 +1708,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile 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(); + // Which surfaces lie over an underlay of their facets, in one order or whole + // ([`in_one_order`], [`underlaid_surface`]). + let seals: Vec = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect(); + let seal = |range: std::ops::Range| seals.get(range).is_some_and(|s| s.iter().any(|s| *s)); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { @@ -1728,19 +1747,37 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile run.push(s); } let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + let range = first..first + run.len(); if ordered(member) && flat { let scopes = run.first().map_or(&[][..], |s| s.2); - out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + let seals = seals.get(range).unwrap_or_default(); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed, seals))))); } else { - out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + let run = run.into_iter().zip(range).map(|((_, s, scopes, _), k)| (scopes, Arc::new(underlaid_surface(n.id, s, seal(k..k + 1))))); + out.extend(with_graphics(run.collect(), first, &arts)); } } return Some(masked(n.id, out)); } if !opaque || !surfaces.iter().all(plain) { + let surfaces = + surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect(); return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); } - Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))]) +} + +/// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when +/// `seal`, keeping the surface's opacity, blend mode and opacity mask. +fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node { + let NodeKind::Group { children, clip: false } = &surface.kind else { return surface }; + let Some(mut out) = seal.then(|| crate::lighting::sealed(id, children)).flatten() else { return surface }; + out.opacity = surface.opacity; + out.blend = surface.blend; + if let Some(mask) = surface.extras().mask.clone() { + out.extras_mut().mask = Some(mask); + } + out } /// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in @@ -1748,7 +1785,16 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile /// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops /// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of /// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. -fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { +/// The faces of the surfaces that `seals` marks lie over underlays of themselves +/// ([`underlaid_runs`], [`underlaid_surface`]). +fn in_one_order( + id: NodeId, + surfaces: Vec>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], + sealed: bool, + seals: &[bool], +) -> Vec> { let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); @@ -1787,7 +1833,46 @@ fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: (masks.len(), art) })); } - if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } + let takes_underlay = |surface: usize| seals.get(surface) == Some(&true); + if masks.iter().any(Option::is_some) { + // The faces of a surface without a mask are sealed run by run before the masked surfaces' + // faces go in runs of their own, each holding one surface's faces, sealed with its mask. + let unmasked = underlaid_runs(id, drawn, |s| takes_underlay(s) && masks.get(s).is_some_and(Option::is_none)); + return in_runs(id, unmasked, &masks) + .into_iter() + .map(|run| match &run.kind { + NodeKind::Group { clip: false, .. } if seals.iter().any(|s| *s) && run.extras().mask.is_some() => { + Arc::new(underlaid_surface(id, (*run).clone(), true)) + } + _ => run, + }) + .collect(); + } + underlaid_runs(id, drawn, takes_underlay).into_iter().map(|(_, face)| face).collect() +} + +/// `faces` in their drawing order, each with the index of its surface. Each run of consecutive +/// faces of one surface that `seal` takes lies over an underlay of its own faces, clipped to their +/// outline ([`crate::lighting::sealed`]), so no underlay reaches under another surface's faces or a +/// graphic drawn between them. A sealed run keeps its surface's index. +fn underlaid_runs(id: NodeId, faces: Vec<(usize, Arc)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc)> { + let mut out = Vec::with_capacity(faces.len()); + let mut run: Vec> = vec![]; + let mut faces = faces.into_iter().peekable(); + while let Some((surface, face)) = faces.next() { + run.push(face); + if faces.peek().is_some_and(|(next, _)| *next == surface) { + continue; + } + match seal(surface).then(|| crate::lighting::sealed(id, &run)).flatten() { + Some(sealed) => { + out.push((surface, Arc::new(sealed))); + run.clear(); + } + None => out.extend(run.drain(..).map(|face| (surface, face))), + } + } + out } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 8222484c20..ab163e45cd 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -535,14 +535,53 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { assert_eq!(drawn(&solid, Some(colored)).0, 1); } +/// The faces `run` stands for: a run of faces over an underlay of themselves ([`crate::lighting::sealed`]: +/// a clip and a group of the underlay's copies followed by the faces) gives its faces, any other +/// node gives itself. +fn unsealed(run: &Arc) -> Vec> { + if let NodeKind::Group { clip: true, children } = &run.kind + && let [clip, inner] = children.as_slice() + && matches!(clip.kind, NodeKind::Path { .. }) + && let NodeKind::Group { clip: false, children: kids } = &inner.kind + && !kids.is_empty() + && kids.len() % 2 == 0 + { + let (under, faces) = kids.split_at(kids.len() / 2); + let underlay = |k: &Arc| match &k.kind { + NodeKind::Group { clip, .. } => *clip, + _ => k.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.width == crate::lighting::UNDERLAY_SEAM)), + }; + if under.first().is_some_and(underlay) { + return faces.to_vec(); + } + } + vec![run.clone()] +} + +/// The bounds of every face under `n`, a sealed run's faces one by one ([`unsealed`]). +fn face_bounds_under(n: &Arc, out: &mut Vec) { + let faces = unsealed(n); + if faces.len() != 1 || !Arc::ptr_eq(&faces[0], n) { + out.extend(faces.iter().map(|f| drawn_face_bounds(f))); + return; + } + if let NodeKind::Group { clip: true, .. } = &n.kind { + out.push(drawn_face_bounds(n)); + } + for child in n.children().into_iter().flatten() { + face_bounds_under(child, out); + } +} + /// 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. +/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 2 pt (a +/// point past the seam and half the underlay's seam). 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) + cover.path_data().and_then(PathData::bounds).unwrap().inflate(-2.0, -2.0) } _ => panic!("a face is a path or a clipped composite"), } @@ -587,7 +626,7 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { 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(); + let faces: Vec> = surfaces[0].children().unwrap().iter().flat_map(unsealed).collect(); 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()); @@ -616,11 +655,9 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { 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)); - } - }); + for child in art.children().into_iter().flatten() { + face_bounds_under(child, &mut out); + } out }; let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap()); @@ -637,6 +674,89 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { assert!(whole_misdrawn > 0, "whole surfaces show farther faces"); } +/// Under a colored light, the faces of an Inflate in gradients, in one order, lie over underlays +/// run by run: each run of consecutive faces of one surface (the fill's or the dashed stroke's) +/// over an underlay of its own faces, so no underlay reaches under the other surface's faces. +#[test] +fn a_colored_inflate_in_gradients_lies_over_underlays_of_each_surfaces_runs() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + let p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + let cut = drawn_parts(&n, INFLATE, &p).len(); + let mut lit = p.clone(); + lit["lights"] = json!([{"color": "#ff8080"}]); + n.appearance.effects.push(fx(INFLATE, lit)); + // Which surface a face belongs to, by the first stop of its paint (a composed face's own paint, + // or a path face's shaded one): red for the fill's, blue for the stroke's. + fn surface_of(face: &Node) -> Option { + let item = match &face.kind { + NodeKind::Path { .. } => face.appearance.items.first()?, + _ => face.children()?.get(1)?.children()?.first()?.appearance.items.first()?, + }; + let Paint::Gradient(g) = item.paint() else { return None }; + let [r, _, b] = g.gradient.stops.first()?.color.to_rgb(); + Some(if r > b { RED } else { BLUE }) + } + // The faces in `order`, and the surface of each run over an underlay, looking into the groups + // that carry a surface's opacity mask. + fn runs(order: &[Arc], faces: &mut usize, surfaces: &mut Vec) { + for child in order { + let NodeKind::Group { children, clip } = &child.kind else { + assert!(surface_of(child).is_some(), "a face"); + *faces += 1; + continue; + }; + if !clip { + assert!(child.extras().mask.is_some(), "a masked run"); + runs(children, faces, surfaces); + continue; + } + // A composed face isolates its colors; a run's faces are not isolated together. + if children[1].isolate { + assert!(surface_of(child).is_some(), "a composed face"); + *faces += 1; + continue; + } + let stack = children[1].children().unwrap(); + let (under, own) = stack.split_at(stack.len() / 2); + assert_eq!(under.len(), own.len(), "one underlay face for each face"); + let colors: Vec<_> = own.iter().map(|f| surface_of(f).unwrap()).collect(); + assert!(colors.iter().all(|c| *c == colors[0]), "a run holds one surface's faces"); + surfaces.push(colors[0]); + *faces += own.len(); + } + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut, "{faces} faces, {cut} under a white light, whose cells are not triangulated"); + assert!(surfaces.contains(&RED) && surfaces.contains(&BLUE), "{surfaces:?}"); + // With transparent stops in the fill's gradient, the fill's faces go in masked runs; the + // stroke's faces, without a mask, still lie over underlays of their own runs. + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) + && let Paint::Gradient(g) = &mut f.paint + { + g.gradient.stops.iter_mut().for_each(|s| s.opacity = 0.5); + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut && surfaces.contains(&RED) && surfaces.contains(&BLUE), "{faces} faces, {surfaces:?}"); +} + /// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they /// overlap is cut along the line where their depths meet, into the piece in front of the wall and /// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke @@ -724,7 +844,7 @@ fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { let items = stroked(&letter_c()).appearance.items.clone(); let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); let mut one = 0usize; - crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + crate::revolve::paint_copies_add(&mut one, MAX_FACET_COPIES, paint_ref(stroke)); let max = crate::revolve::MAX_PAINT_COPIES; assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); @@ -1595,12 +1715,14 @@ fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() { 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(); + let mut ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + // Each channel's ramp is drawn twice, in the underlay and in the facet itself. + ramps.dedup(); 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(); + let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0 && *w != crate::lighting::UNDERLAY_SEAM).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()); diff --git a/crates/engine/tests/it/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs index 38013a372b..381bc92f15 100644 --- a/crates/engine/tests/it/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -481,6 +481,151 @@ fn solid_gradient_opacity_is_applied_once_even_without_shading() { } } +/// How `image`, `d` drawn at 1:1 on 400 × 400 pixels, differs from `d` drawn at 3x and averaged +/// down, on the pixels inside the art: those whose 3x average has some alpha, the same as every +/// pixel within 3 of them. Left out too are pixels where the 3x average bends sharply (a channel's +/// second difference across the pixel, along x or y, over 4 levels), such as the rim where a cap +/// meets a body: a gradient sampled at each pixel's center matches an area average only where the +/// light changes close to linearly across the pixel. Returns (pixels measured, premultiplied RGB +/// channels more than 3 levels off, the largest RGB difference, alphas off: below 255 where the 3x +/// average is opaque, otherwise more than 3 levels off, the first few pixels off). +fn seams_against_supersampled(d: &Document, image: &vectorcraft_render::Rendered) -> (usize, usize, u8, usize, Vec) { + let fine = vectorcraft_render::Renderer::new().render( + d, + 1200, + 1200, + kurbo::Affine::scale(3.0), + &vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() }, + ); + let average = fine.area_resized(400, 400).unwrap(); + let (mut measured, mut color, mut max, mut holes) = (0, 0, 0, 0); + let mut worst = vec![]; + for y in 3..397 { + for x in 3..397 { + let q = average.pixel(x, y); + if q[3] == 0 || !(y - 3..=y + 3).all(|y| (x - 3..=x + 3).all(|x| average.pixel(x, y)[3] == q[3])) { + continue; + } + let bend = |a: [u8; 4], b: [u8; 4]| (0..3).any(|c| (i32::from(a[c]) + i32::from(b[c]) - 2 * i32::from(q[c])).abs() > 4); + if bend(average.pixel(x - 1, y), average.pixel(x + 1, y)) || bend(average.pixel(x, y - 1), average.pixel(x, y + 1)) { + continue; + } + measured += 1; + let p = image.pixel(x, y); + for c in 0..3 { + let diff = p[c].abs_diff(q[c]); + max = max.max(diff); + color += usize::from(diff > 3); + } + let hole = if q[3] == 255 { p[3] < 255 } else { p[3].abs_diff(q[3]) > 3 }; + holes += usize::from(hole); + if (p.into_iter().zip(q).any(|(a, b)| a.abs_diff(b) > 3) || hole) && worst.len() < 4 { + worst.push(format!("({x},{y}) {p:?} vs {q:?}")); + } + } + } + (measured, color, max, holes, worst) +} + +/// Colored lights on smooth 3D surfaces, compared at 1:1 with a 3x render averaged down, on every +/// pixel inside the art ([`seams_against_supersampled`]). A solid fill draws as one batched surface; +/// a source gradient composes each facet on its own, and one with transparent stops applies their +/// opacity once over the surface. Inflate seen straight on, one layer of facets, has no alpha off, and +/// in opaque fills no RGB channel more than 3 levels off. Inflate turned so facets narrow to slivers +/// at the edges, and Revolve with a cap over its body, draw facets over other facets, and have no +/// alpha off. +#[test] +fn colored_light_surfaces_match_supersampling_at_internal_facet_edges() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + let fills = |start: [u32; 2], end: [u32; 2]| { + [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6} + ]}}), + ] + }; + let mut cases = vec![]; + for rotation in [(0, 0), (50, 35)] { + let (mut s, _) = setup("threeD.inflate"); + let change = json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights,"rotationX":rotation.0,"rotationY":rotation.1}}); + s.execute("effect.setParams", &change).unwrap(); + cases.push((format!("inflate {rotation:?}"), s, fills([100, 150], [200, 150]), rotation == (0, 0))); + } + let (mut s, _) = capped_profile(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + cases.push(("revolve".into(), s, fills([80, 200], [240, 200]), false)); + let mut failures = vec![]; + for (name, mut s, fills, one_layer) in cases { + for fill in fills { + s.execute("paint.setFill", &fill).unwrap(); + let image = render(doc(&s)); + let (measured, color, max, holes, worst) = seams_against_supersampled(doc(&s), &image); + assert!(measured > 2_000, "{name} {fill}: {measured} pixels inside"); + let opaque = !fill.to_string().contains("opacity"); + if holes > 0 || (one_layer && opaque && color > 0) { + failures.push(format!( + "{name} {fill}: of {measured} pixels, {color} channels off by more than 3 (at most {max}), {holes} alphas off: {worst:?}" + )); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// The closed facet edges of colored-light surfaces survive SVG and PDF, read back and drawn at +/// 1:1, and Expand Appearance: inside the art, every pixel stays opaque and within 6 levels of the +/// live art. +#[test] +fn colored_light_seams_stay_closed_in_svg_pdf_and_expansion() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + for fill in [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + ] { + let (mut s, _) = setup("threeD.inflate"); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + let live = render(doc(&s)); + let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned(); + assert!(svg.contains("