3D: caps stay last when overlapping faces form a depth cycle

- When the faces that overlap form a cycle of depth orders, the order
  broke the cycle only after drawing every face with nothing to wait
  for, so a cap could be drawn before the walls and they painted over
  it. A face drawn last now keeps its place: no face moves across it.
- A face with a corner at or past the eye, which the perspective
  projection holds just in front of the eye, no longer gets a plane,
  so its depth is not measured on a plane its drawing does not match.
- docs/three-d.md states the masked member rule in Groups as the
  Fills and strokes section does, and docs/target-app-parity.md cites
  the Inflate test by its name.
This commit is contained in:
dotCooCoo
2026-11-13 07:23:56 -08:00
parent 57d37a6dae
commit 2361d2cb1b
6 changed files with 76 additions and 10 deletions
+17 -5
View File
@@ -980,7 +980,8 @@ fn half(p: &[Point], values: &[f64], sign: f64) -> Vec<Point> {
/// wall and a face of the same shape's covering stroke ([`Paints::Stroke`]) keep their order. Each
/// face otherwise keeps its place in `order`, so a fill wall in the gap between two dashes stays
/// where its depth puts it. A cycle of such pairs is broken at the face that comes first in `order`.
/// Otherwise `order` as it is.
/// A face drawn last (a planar end turned toward the viewer, at depth `f64::MAX`) keeps its place:
/// no face moves across it. Otherwise `order` as it is.
fn local_order(faces: &[Placed], order: Vec<usize>) -> Vec<usize> {
use std::cmp::Reverse;
// The faces of each shape that take part, from left to right for a sweep along x.
@@ -1065,14 +1066,25 @@ fn local_order(faces: &[Placed], order: Vec<usize>) -> Vec<usize> {
// Each step draws the face that comes first in `order` of those whose faces before are drawn.
let mut ready: std::collections::BinaryHeap<Reverse<(usize, usize)>> =
(0..faces.len()).filter(|&k| waits[k] == 0).map(|k| Reverse((position[k], k))).collect();
// The faces drawn last, in `order`: each goes only after every face before it in `order`.
let last: Vec<usize> = order.iter().copied().filter(|&k| faces[k].depth >= f64::MAX).collect();
let mut next_last = 0usize;
let mut drawn = vec![false; faces.len()];
let mut out = Vec::with_capacity(order.len());
let mut first = 0usize;
while out.len() < order.len() {
let k = match ready.pop() {
Some(Reverse((_, k))) => k,
None => {
// A cycle: the first face in `order` not drawn yet goes next.
while last.get(next_last).is_some_and(|&k| drawn[k]) {
next_last += 1;
}
let limit = last.get(next_last).map_or(usize::MAX, |&k| position[k]);
let k = match ready.peek() {
Some(&Reverse((at, k))) if at < limit => {
ready.pop();
k
}
_ => {
// A cycle, or the faces left to draw before a face drawn last wait for faces after
// it: the first face in `order` not drawn yet goes next.
while order.get(first).is_some_and(|&k| drawn[k]) {
first += 1;
}
+31
View File
@@ -684,6 +684,37 @@ fn local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order() {
}
}
/// A cycle of overlapping stroke faces is broken at the face that comes first by depth, and a cap
/// drawn last stays after every wall: 0 at x 0 to 10, flat at z 0; 1 at x 4 to 14, z = x - 8; 2 at
/// x 8 to 18, flat at z 1. Where they overlap, 0 is nearer than 1, 1 nearer than 2 and 2 nearer
/// than 0. 3 is the cap.
#[test]
fn a_depth_cycle_keeps_the_cap_last() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
let at = |depth: f64, x: f64, normal: [f64; 3], offset: f64| Placed {
depth,
side: true,
away: false,
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member: 0,
paints: Paints::Other,
outline: square(x),
plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }),
};
let cap = Placed {
depth: f64::MAX,
side: false,
away: false,
bounds: Rect::new(0.0, 0.0, 18.0, 10.0),
member: 0,
paints: Paints::Other,
outline: vec![],
plane: None,
};
let faces = [at(1.0, 0.0, [0.0, 0.0, 1.0], 0.0), at(2.0, 4.0, [-1.0, 0.0, 1.0], -8.0), at(3.0, 8.0, [0.0, 0.0, 1.0], 1.0), cap];
assert_eq!(draw_order(&faces, true), [0, 2, 1, 3]);
}
/// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a
/// face of the same shape's stroke drawn before it: the wall then goes just before that face. A
/// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an
+5 -2
View File
@@ -130,9 +130,12 @@ impl Builder<'_> {
(V3(-n.0, -n.1, -n.2), false)
};
// A face whose corners lie off one plane (a piece of a twisted wall bent into a saddle) has
// no plane to measure its depth on.
// no plane to measure its depth on. Nor has a face with a corner at or past the eye: the
// projection holds that corner just in front of the eye, so the drawing no longer matches
// the plane's projection there.
let offset = n.dot(centre);
let flat = contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3);
let held = self.camera.eye.is_some_and(|e| contours.iter().flatten().any(|v| e - v.2 < e * 1e-3));
let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3);
let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye });
#[cfg(test)]
let view = contours.clone();
+17
View File
@@ -177,6 +177,23 @@ fn a_flat_facet_measures_its_depth_on_its_plane() {
}
}
/// A flat face with a corner at or past the eye, which the projection holds just in front of the
/// eye, has no plane: its drawing no longer matches the plane's projection there.
#[test]
fn a_face_reaching_past_the_eye_has_no_plane() {
use super::build::{Builder, Order};
let (_, b) = shape(&square(0.0, 0.0, 100.0));
let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0);
let eye = camera.center_and_eye().1.unwrap();
for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] {
let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None };
let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)];
builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap();
assert_eq!(builder.facets.len(), 1, "{far}");
assert_eq!(builder.facets[0].plane.is_some(), has_plane, "{far}");
}
}
#[test]
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
+1 -1
View File
@@ -1176,7 +1176,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)).
- A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`.
- With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`.
- When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_cuts_its_dashes_as_a_white_one_does`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`.
- When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`.
- Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715).
- Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)).
- Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`).
+5 -2
View File
@@ -159,8 +159,11 @@ them all. The members stay editable: moving or editing one reshapes the solids.
inside the 3D group applies to the solids of that group's members together. An opacity mask on
a member masks its solids, and one on a group inside the 3D group masks the solids of that group's members
together. The mask stays where it is on the page; it does not turn with the solids. A disabled mask is ignored.
- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, each
solid is drawn on its own, the backmost member's first, as when a solid is transparent.
- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, the
members' faces are not drawn in one shared order. Each member whose fills and strokes are opaque with Normal
blending draws their faces in one order of its own inside its masks and blending modes, as described under
[Fills and strokes](#fills-and-strokes). Otherwise each solid is drawn on its own, the backmost member's first, as
when a solid is transparent.
- When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn
on its own, the backmost member's first.
- Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a