diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 378bfa5621..257576822a 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -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}"); + } + } + } } diff --git a/docs/three-d.md b/docs/three-d.md index 4ba4683142..c0c40d4f24 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -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.