mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 20:36:02 +00:00
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:
+148
-27
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user