3D: colored-light faces keep unplaced gradients fitted to the face

The color cover of a composed face grew past the underlay's seam, and a
source gradient without a placement fitted itself to that larger
rectangle. The face now pins such a gradient to its bounds grown by
1 pt, as before the cover grew. docs/three-d.md states the measured
limits of the 1:1 comparison.
This commit is contained in:
dotCooCoo
2026-11-13 20:06:12 -08:00
parent 941b5fcb14
commit d47cbe5f77
2 changed files with 37 additions and 3 deletions
+33 -1
View File
@@ -326,7 +326,13 @@ fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData,
use vectorcraft_doc::color::BlendMode;
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
let (base, mask) = opaque_paint(paint);
// 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));
}
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
@@ -469,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}");
}
}
}
}
+4 -2
View File
@@ -123,8 +123,10 @@ How a facet carries its seam depends on the lights and the paint:
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).
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.