3D: a solid in a transparent gradient keeps the shared face order

With Extrude & Bevel, Extrude & Bevel (Classic) or Inflate on a group
or an object with fills and strokes, a solid whose gradient has
transparent stops carries an opacity mask, and it was drawn as one
whole surface in the order of the members. It could cover a solid in
front of it or hide behind one it is in front of. Its faces now take
their places in the one back-to-front order of every solid's faces, in
runs that each carry the solid's opacity mask. A run splits only where
a face drawn after it overlaps the next face.
This commit is contained in:
dotCooCoo
2026-11-12 00:37:18 -08:00
parent eb608bb8b5
commit 9c5a2fa2b8
3 changed files with 155 additions and 25 deletions
+6 -6
View File
@@ -20,7 +20,7 @@ const MAX_PAINT_COPIES: usize = 256 << 20;
/// The most face and box tests a group's transparent members' faces take together to rejoin their
/// members' last runs; past them each starts a run of its own.
const MAX_OVERLAP_TESTS: usize = 1 << 22;
pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22;
/// The width of the stroke around each face that covers the seams between faces.
const SEAM_WIDTH: f64 = 0.75;
@@ -576,14 +576,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc<Node>)>, le
/// What a face can paint, to tell whether two faces overlap: the convex hull of the points and
/// control points of its outline, which holds the outline, and the hull's box grown by half the
/// seam stroke.
struct Coverage {
bounds: Rect,
pub(crate) struct Coverage {
pub(crate) bounds: Rect,
hull: Vec<Point>,
}
impl Coverage {
/// `None` for a face with no outline and no box.
fn of(face: &Node) -> Option<Coverage> {
pub(crate) fn of(face: &Node) -> Option<Coverage> {
let points: Vec<Point> = match face.path_data() {
Some(path) => path
.to_bezpath()
@@ -611,7 +611,7 @@ impl Coverage {
/// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the
/// separating axes of their edges and of the page's axes: `true` whenever they overlap, and
/// sometimes near a corner when they don't.
fn meets(&self, other: &Coverage) -> bool {
pub(crate) fn meets(&self, other: &Coverage) -> bool {
fn normals(hull: &[Point]) -> impl Iterator<Item = Vec2> + '_ {
(0..hull.len()).filter_map(|i| {
let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?);
@@ -658,7 +658,7 @@ fn convex_hull(mut points: Vec<Point>) -> Vec<Point> {
}
/// Do boxes `a` and `b` overlap with some area?
fn overlaps(a: Rect, b: Rect) -> bool {
pub(crate) fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
}
+76 -12
View File
@@ -686,10 +686,11 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with
/// caps, a shape's fill walls before the faces of its covering strokes that overlap them:
/// [`draw_order`]);
/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as
/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's
/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the
/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask
/// ([`in_runs`]);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it
@@ -841,19 +842,20 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect()));
}
// One order takes a plain group of faces from each solid, one face for each facet. With
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
// opacity mask, and then it stays whole.
let plain = surfaces.iter().all(|(depths, s, _)| {
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
});
// colored lights a surface can instead be one clipped composite of all its facets, and then it
// stays whole.
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
if !opaque || !plain {
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
}
// A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs
// ([`in_runs`]), and each run carries the mask.
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect();
let (mut placed, mut faces) = (vec![], vec![]);
for (depths, s, _) in surfaces {
for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() {
if let NodeKind::Group { children, .. } = s.kind {
placed.extend(depths);
faces.extend(children.into_iter().map(Some));
faces.extend(children.into_iter().map(|face| Some((surface, face))));
}
}
let sealed = match fx.id.as_str() {
@@ -862,7 +864,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
_ => extrude_options(&p).cap,
};
let order = draw_order(&placed, sealed);
Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))])
let faces: Vec<(usize, Arc<Node>)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect();
let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) };
Some(vec![Arc::new(Node::group(n.id, children))])
}
/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces
/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order
/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a
/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does
/// the surface split, where one group can't be both behind and in front of another face.
fn in_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, masks: &[Option<Box<OpacityMask>>]) -> Vec<Arc<Node>> {
use crate::revolve::{Coverage, overlaps};
struct Run {
surface: Option<usize>,
bounds: Rect,
faces: Vec<Arc<Node>>,
cover: Vec<Coverage>,
}
let mut runs: Vec<Run> = vec![];
let mut last_run = vec![None; masks.len()];
// Past this many tests a face starts a run of its own, which never draws out of order.
let mut budget = crate::revolve::MAX_OVERLAP_TESTS;
for (surface, face) in faces {
let Some(cover) = Coverage::of(&face) else {
// A face with no outline and no box overlaps nothing.
runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] });
continue;
};
if masks.get(surface).is_none_or(Option::is_none) {
runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
continue;
}
let mut clear = |r: &Run| {
budget = budget.saturating_sub(1);
if !overlaps(r.bounds, cover.bounds) {
return budget > 0;
}
budget = budget.saturating_sub(r.cover.len());
budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover))
};
let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear)));
match joins.and_then(|i| runs.get_mut(i)) {
Some(run) => {
run.bounds = run.bounds.union(cover.bounds);
run.faces.push(face);
run.cover.push(cover);
}
None => {
if let Some(last) = last_run.get_mut(surface) {
*last = Some(runs.len());
}
runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
}
}
}
runs.into_iter()
.filter_map(|mut r| {
let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() };
let mut group = Node::group(id, r.faces);
group.extras_mut().mask = Some(mask);
Some(Arc::new(group))
})
.collect()
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
+73 -7
View File
@@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() {
}
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
/// opacity mask: its faces still take their places in the one order of the fill's and the
/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the
/// gradient itself and no mask.
#[test]
fn a_masked_surface_under_a_colored_light_stays_whole() {
fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut half = GradientStop::new(1.0, RED);
half.opacity = 0.5;
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.paint = Paint::Gradient(Box::new(gradient.clone()));
@@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() {
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
let art = solid_art(&n).unwrap();
let drawn = art.children().unwrap();
assert_eq!(drawn.len(), surfaces, "{lights}");
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
assert_eq!(drawn.len(), 1, "{lights}: one order of faces");
let runs: Vec<&Arc<Node>> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect();
assert_eq!(!runs.is_empty(), masked, "{lights}");
assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces");
}
}
@@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() {
}
let art = solid_art(&n).unwrap();
let surface = &art.children().unwrap()[0];
assert_eq!(surface.extras().mask.is_some(), masked, "{end}");
let mut has_mask = false;
surface.walk(&mut |c| has_mask |= c.extras().mask.is_some());
assert_eq!(has_mask, masked, "{end}");
let mut opaque = true;
surface.walk(&mut |c| {
for item in &c.appearance.items {
@@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() {
}
}
/// A group member in a gradient with transparent stops keeps the shared back-to-front order of
/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's
/// faces are drawn between them where they are in front of some and behind others. Here a square
/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square
/// and its front cap is in front of the square's walls.
#[test]
fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| {
let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)];
let mut points = corners.to_vec();
if !clockwise {
points.reverse();
}
b.move_to(points[0]);
for p in &points[1..] {
b.line_to(*p);
}
b.close_path();
};
let art = |id: &str, params: &Value, end_opacity: f32| {
let mut end = GradientStop::new(1.0, Color::BLACK);
end.opacity = end_opacity;
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end]))));
let mut b = BezPath::new();
square(&mut b, 100.0, 300.0, true);
square(&mut b, 150.0, 250.0, false);
let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0));
let mut b = BezPath::new();
square(&mut b, 185.0, 215.0, true);
let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0));
let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]);
g.appearance.effects.push(fx(id, params.clone()));
solid_art(&g).unwrap_or_else(|| panic!("{id}"))
};
// The drawing order: `M` for a group with an opacity mask, `F` for a face outside one.
fn visit(n: &Node, order: &mut String) {
if n.extras().mask.is_some() {
order.push('M');
return;
}
if n.path_data().is_some() {
order.push('F');
}
for c in n.children().into_iter().flatten() {
visit(c, order);
}
}
let order = |art: &Node| {
let mut order = String::new();
visit(art, &mut order);
order
};
for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] {
assert!(!order(&art(id, &params, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask");
let masked = order(&art(id, &params, 0.5));
let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap());
assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}");
}
}
#[test]
fn classic_parameters_are_checked() {
let n = classic(json!({}));