3D: colored-light facets close their shared edges over an underlay

Under a colored light, Inflate's facets were antialiased one by one.
Where two facets met, their edge pixels added up to less than full
coverage. The solid-color composite then showed its black channel
backdrop there, so internal edges drew darker at 1:1, and the per-facet
gradient art let the background through.

- The solid-color composite draws, in each channel, an underlay of the
  facets with 2 pt round seams between the black backdrop and the
  facets themselves.
- The per-facet routes put each run of consecutive faces of one surface
  over an underlay of its own faces, clipped to the union of the faces
  and their seams. Runs that carry a gradient's opacity mask are sealed
  with that mask. A face clip's footprint keeps only the outer contours
  of its seam stroke.
- The paint-copy allowance for cut faces counts 12 copies per facet.

At 1:1, inside the surface each color channel of an opaque fill is
within 3 levels of the same art drawn at 3x and averaged down, and
every pixel is opaque. Before, 2,267 channels differed by up to 10
levels on the solid Inflate, and 1,088 pixels of the gradient Inflate
were below full alpha. Under white light, the rendered pixels, the SVG
text, the PDF read back and the expanded art of Inflate, Extrude,
Rotate and Revolve are the same as before.

Docs: docs/three-d.md (Seams), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 16:03:37 -08:00
parent 0b29b2b1f6
commit 5b4e613d0b
6 changed files with 448 additions and 41 deletions
+115 -26
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,19 @@ 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();
}
footprint.extend(boundary.iter());
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
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)),
+97 -12
View File
@@ -599,11 +599,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`).
/// 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 {
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 })
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
@@ -789,14 +804,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()
@@ -1606,6 +1621,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile
placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks)
};
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)| {
@@ -1637,19 +1656,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
@@ -1657,7 +1694,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![]);
@@ -1696,7 +1742,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
+84 -1
View File
@@ -535,6 +535,89 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() {
assert_eq!(drawn(&solid, Some(colored)).0, 2);
}
/// 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
@@ -622,7 +705,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);
@@ -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
@@ -1944,7 +1944,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)
+6 -1
View File
@@ -117,7 +117,12 @@ How a facet carries its seam depends on the lights and the paint:
are drawn the same way.
- Inflate with a colored light and shading on: a solid-color fill draws each facet's light ramps with its seam in three channel
groups clipped to the union of its facets, so no seam reaches past the surface; a gradient fill fills each facet's
ramps through a clip of the facet and its seam, outlined with round joins.
ramps through a clip of the facet and its seam, outlined with round joins. In both, the facets 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. Drawn at 1:1, inside the surface each color channel is within 3 levels of the same art
drawn at 3x and averaged down, and every pixel is opaque (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.