Merge pull request #7338 from dotCooCoo/fix/colored-light-seams

3D: colored-light facets close their shared edges over an underlay
This commit is contained in:
BFlat
2026-11-15 00:40:19 +07:00
committed by GitHub
6 changed files with 532 additions and 51 deletions
+148 -27
View File
@@ -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<vectorcraft_doc::Node>]) -> Option<vectorcraft_doc::Node> {
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<Paint> = 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<Arc<Node>> {
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<Paint>) {
}
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}");
}
}
}
}
+97 -12
View File
@@ -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<Item = &'a AppearanceItem>, 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::<usize>() <= MAX_FACE_COMPOSITES;
let members: Vec<usize> = 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<bool> = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect();
let seal = |range: std::ops::Range<usize>| seals.get(range).is_some_and(|s| s.iter().any(|s| *s));
let surfaces: Vec<BuiltSurface> = 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<BuiltSurface<'_>>, first: usize, arts: &[(usize, PlanarFace, Arc<Node>)], sealed: bool) -> Vec<Arc<Node>> {
/// 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<BuiltSurface<'_>>,
first: usize,
arts: &[(usize, PlanarFace, Arc<Node>)],
sealed: bool,
seals: &[bool],
) -> Vec<Arc<Node>> {
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect();
let arts: Vec<&(usize, PlanarFace, Arc<Node>)> = 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<BuiltSurface<'_>>, 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<Node>)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc<Node>)> {
let mut out = Vec::with_capacity(faces.len());
let mut run: Vec<Arc<Node>> = 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
+133 -11
View File
@@ -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<Node>) -> Vec<Arc<Node>> {
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<Node>| 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<Node>, out: &mut Vec<Rect>) {
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<Arc<Node>> = 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<Color> {
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<Node>], faces: &mut usize, surfaces: &mut Vec<Color>) {
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::<Vec<_>>(), "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<f64> = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect();
let widths: Vec<f64> = 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());
@@ -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<String>) {
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("<clipPath") && svg.contains("linearGradient") && svg.contains("screen") && !svg.contains("<image"), "{fill}");
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|w| w == b"/Subtype /Image"), "{fill}");
let before = doc(&s).clone();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let expanded = render(doc(&s));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc(&s), &before);
for (name, image) in [
("SVG", render(&vectorcraft_svg::import(&svg).unwrap())),
("PDF", render(&vectorcraft_pdf::import(&bytes).unwrap())),
("expanded", expanded),
] {
let mut checked = 0;
for y in 103..197 {
for x in 103..197 {
let (p, q) = (live.pixel(x, y), image.pixel(x, y));
assert!(q[3] == 255 && p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 6), "{name} {fill} ({x},{y}): {p:?} vs {q:?}");
checked += 1;
}
}
assert_eq!(checked, 94 * 94);
}
}
}
#[test]
fn batched_colored_inflate_preserves_compound_holes_and_surface_opacity() {
let mut s = Session::new();
+1 -1
View File
@@ -2048,7 +2048,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
`xtask/src/pngsuite.rs` verifies each file from the pinned PngSuite mirror against `xtask/pngsuite.sha256` before it reaches the corpus. Seven synthetic regressions cover substitution, oversize, cache repair, failure cleanup, nonregular targets, stale staging and manifest limits. The opt-in corpus remains gitignored; no PNGs are committed.
Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs matched main byte for byte when this was measured (2026-10-17); since every 3D seam took round joins (2026-11-12), white-light output is expected to differ from them at facet corners ([Seams](three-d.md#seams)); the four scenes were not exported again to measure it; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost.
Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs matched main byte for byte when this was measured (2026-10-17); since every 3D seam took round joins (2026-11-12), white-light output is expected to differ from them at facet corners ([Seams](three-d.md#seams)); the four scenes were not exported again to measure it; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost. Colored Inflate's facets lie over an underlay of themselves under one clip, so their shared edges show neither the black channel backdrop nor the background ([Seams](three-d.md#seams)); `three_d_light_color::colored_light_surfaces_match_supersampling_at_internal_facet_edges` compares 1:1 with 3x averaged down, and `colored_light_seams_stay_closed_in_svg_pdf_and_expansion` checks SVG, PDF and Expand Appearance.
## Eyedropper character dimensions (2026-11-11)
+8
View File
@@ -121,6 +121,14 @@ How a facet carries its seam depends on the lights and the paint:
groups clipped to the union of its facets, so no seam reaches past the surface; a gradient fill, and a solid-color
fill or stroke whose faces share one depth order with another solid's, fills each facet's ramps through a clip of
the facet and its seam, outlined with round joins.
The facets of a surface drawn this way lie over an underlay
of themselves whose seams are 2 pt wide, reaching 1 pt past each edge, and one clip to the union of the facets and
their seams removes what the underlay paints past the surface. Where two facets' antialiased edges meet, the
underlay fills the pixels they leave partly uncovered with the neighbors' own light, so no black channel backdrop
or background shows there. Compared with the same art drawn at 3x and averaged down, away from the surface's outline
and from places where the light changes sharply, a pixel drawn at 1:1 is opaque where the 3x art is opaque, and
otherwise its alpha is within 3 levels. Inflate seen straight on with an opaque fill also has each color channel
within 3 levels (VectorCraft's own measure).
- Extrude & Bevel (Classic), Rotate (Classic) and Revolve (Classic): a path and a seam for every face; gradient and
pattern faces seam only the base paint, and the shading and highlight layers cover the face alone. The Wireframe
surface draws edges only, with no seam.