3D: a fill or stroke without the effect keeps an even-odd path's holes

With a 3D effect on one fill or stroke, the object's other fills and
strokes were drawn as nonzero paths, so an even-odd compound path or
path filled its holes in them. Extrude & Bevel, Inflate, Rotate,
Revolve and their Classic kinds now draw those items with the source's
fill rule, through the group code's shape_node helper.
This commit is contained in:
dotCooCoo
2026-11-12 16:28:54 -08:00
parent 4b344e91ce
commit 9b65bf2708
5 changed files with 115 additions and 4 deletions
+1 -1
View File
@@ -689,7 +689,7 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
}
/// A path node of `id` on `path` (filled under `rule`) painted by `ap`.
fn shape_node(id: NodeId, path: PathData, rule: FillRule, ap: Appearance) -> Node {
pub(crate) fn shape_node(id: NodeId, path: PathData, rule: FillRule, ap: Appearance) -> Node {
let mut n = Node::path(id, path, ap);
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = rule;
+49 -2
View File
@@ -594,7 +594,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
if is_container(n) {
return group_art(n, expand);
}
let (path, _) = profile(n)?;
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let mut children = vec![];
let mut copies = 0;
@@ -640,7 +640,8 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
}
children.push(Arc::new(art));
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
let flat = Appearance { items: vec![item.clone()], ..Default::default() };
children.push(Arc::new(crate::group::shape_node(n.id, path.clone(), rule, flat)));
}
}
children.splice(0..0, shadows);
@@ -1022,6 +1023,52 @@ mod tests {
assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c)));
}
#[test]
fn a_fill_without_the_revolve_keeps_the_even_odd_hole() {
use vectorcraft_geom::FillRule;
use vectorcraft_geom::hit::fill_contains;
// Two nested squares drawn in the same direction: only the even-odd rule leaves a hole.
let [outer, inner] = [(100.0, 200.0), (130.0, 170.0)].map(|(lo, hi)| {
let mut b = BezPath::new();
b.move_to((lo, lo));
b.line_to((hi, lo));
b.line_to((hi, hi));
b.line_to((lo, hi));
b.close_path();
PathData::from_bezpath(&b)
});
let flat = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.1, 0.3, 0.9))));
for id in [REVOLVE, CLASSIC_REVOLVE] {
let mut top = AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.8, 0.2, 0.1))));
top.effects_mut().push(crate::new_effect(id, &json!({})).unwrap());
let appearance = Appearance { items: vec![flat.clone(), top], ..Default::default() };
let members = vec![
Arc::new(Node::path(NodeId(2), outer.clone(), Appearance::default())),
Arc::new(Node::path(NodeId(3), inner.clone(), Appearance::default())),
];
let mut compound = Node::new(NodeId(1), NodeKind::Compound { children: members, rule: FillRule::EvenOdd });
compound.appearance = appearance.clone().into();
let both = PathData::new(outer.subpaths.iter().chain(&inner.subpaths).cloned().collect());
let path = crate::group::shape_node(NodeId(1), both, FillRule::EvenOdd, appearance);
for (n, shape) in [(compound, "compound path"), (path, "path")] {
for (art, how) in [(revolve_art(&n), "live"), (expand_revolve_art(&n), "expanded")] {
let what = format!("{id} on the top fill of a {shape}, {how}");
let art = art.unwrap_or_else(|| panic!("{what}"));
let children = art.children().unwrap();
let plain: Vec<usize> = (0..children.len())
.filter(|&i| children[i].appearance.items == [flat.clone()] && children[i].path_data().is_some())
.collect();
let [index] = plain[..] else { panic!("{what}: one flat child, got {plain:?}") };
let NodeKind::Path { path, rule, .. } = &children[index].kind else { panic!("{what}: a path") };
assert_eq!(*rule, FillRule::EvenOdd, "{what}");
let bez = path.to_bezpath();
assert!(fill_contains(&bez, *rule, Point::new(110.0, 150.0)), "{what}: the ring is filled");
assert!(!fill_contains(&bez, *rule, Point::new(150.0, 150.0)), "{what}: the hole stays open");
assert!(index + 1 < children.len(), "{what}: the revolved art is drawn above");
}
}
}
}
#[test]
fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() {
let mut d = Document::new(300.0, 300.0);
let mut n = profile_node();
+2 -1
View File
@@ -1197,7 +1197,8 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
shadows.extend(shadow.into_iter().map(with_later));
children.extend(surfaces.into_iter().map(with_later));
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
let flat = Appearance { items: vec![item.clone()], ..Default::default() };
children.push(Arc::new(crate::group::shape_node(n.id, path.clone(), rule, flat)));
}
}
children.splice(0..0, shadows);
+62
View File
@@ -457,6 +457,68 @@ fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
assert!(matches!(surfaces[1].kind, NodeKind::Path { .. }));
}
/// Two nested squares drawn in the same direction, so only the even-odd rule leaves a hole.
fn nested_squares() -> [PathData; 2] {
[(100.0, 200.0), (130.0, 170.0)].map(|(lo, hi)| {
let mut b = BezPath::new();
b.move_to((lo, lo));
b.line_to((hi, lo));
b.line_to((hi, hi));
b.line_to((lo, hi));
b.close_path();
PathData::from_bezpath(&b)
})
}
/// The nested squares as an even-odd compound path and as one even-odd path, painted by `items`.
fn even_odd_shapes(items: Vec<AppearanceItem>) -> [Node; 2] {
let [outer, inner] = nested_squares();
let appearance = Appearance { items, ..Default::default() };
let children = vec![
Arc::new(Node::path(NodeId(2), outer.clone(), Appearance::default())),
Arc::new(Node::path(NodeId(3), inner.clone(), Appearance::default())),
];
let mut compound = Node::new(NodeId(1), NodeKind::Compound { children, rule: FillRule::EvenOdd });
compound.appearance = appearance.clone().into();
let both = PathData::new(outer.subpaths.into_iter().chain(inner.subpaths).collect());
[compound, crate::group::shape_node(NodeId(1), both, FillRule::EvenOdd, appearance)]
}
/// Check that `art` draws `item` as one flat child: an even-odd path with a hole in the middle,
/// below the 3D art after it.
fn assert_flat_item_keeps_the_hole(art: &Node, item: &AppearanceItem, what: &str) {
use vectorcraft_geom::hit::fill_contains;
let children = art.children().unwrap();
let flat: Vec<usize> =
(0..children.len()).filter(|&i| children[i].appearance.items == [item.clone()] && children[i].path_data().is_some()).collect();
let [index] = flat[..] else { panic!("{what}: one flat child, got {flat:?}") };
let NodeKind::Path { path, rule, .. } = &children[index].kind else { panic!("{what}: a path") };
assert_eq!(*rule, FillRule::EvenOdd, "{what}");
let bez = path.to_bezpath();
assert!(fill_contains(&bez, *rule, Point::new(110.0, 150.0)), "{what}: the ring is filled");
assert!(!fill_contains(&bez, *rule, Point::new(150.0, 150.0)), "{what}: the hole stays open");
assert!(index + 1 < children.len(), "{what}: the 3D art is drawn above");
}
#[test]
fn a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole() {
let red = AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(RED)));
let blue = AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(BLUE)));
let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 4.0));
for id in SOLIDS {
// The top fill and then the top stroke take the effect; the fill below stays flat.
for (top, on) in [(&red, "fill"), (&stroke, "stroke")] {
let mut lifted = top.clone();
lifted.effects_mut().push(fx(id, json!({})));
for (n, shape) in even_odd_shapes(vec![blue.clone(), lifted]).into_iter().zip(["compound path", "path"]) {
let what = format!("{id} on the top {on} of a {shape}");
let art = solid_art(&n).unwrap_or_else(|| panic!("{what}"));
assert_flat_item_keeps_the_hole(&art, &blue, &what);
}
}
}
}
#[test]
fn export_and_expand_bake_faces_with_their_own_ids() {
for id in SOLIDS {
+1
View File
@@ -1152,6 +1152,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged.
- Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect.
- 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`.
- 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)).