Merge pull request #6478 from dotCooCoo/fix/3d-member-opacity-masks

3D: an opacity mask on a group member masks its solids
This commit is contained in:
BFlat
2026-11-11 07:44:06 +07:00
committed by GitHub
7 changed files with 181 additions and 60 deletions
+7 -10
View File
@@ -463,22 +463,19 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
/// The members' `surfaces`, or runs of their faces, in order, from the [`Scope`] at `level` of each
/// one's `Profile::scopes` inward: consecutive surfaces that share a scope go in one group with its
/// opacity and blending mode, so each applies once, to the faces it holds together.
fn scoped(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>, level: usize) -> Vec<Arc<Node>> {
fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc<Node>)>, scope: Option<Scope>, level: usize, out: &mut Vec<Arc<Node>>) {
/// opacity, blending mode and opacity mask, so each applies once, to the faces it holds together.
pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc<Node>)>, level: usize) -> Vec<Arc<Node>> {
fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc<Node>)>, scope: Option<&Scope>, level: usize, out: &mut Vec<Arc<Node>>) {
if let Some(scope) = scope {
let mut group = Node::group(id, scoped(id, std::mem::take(run), level + 1));
group.opacity = scope.opacity;
group.blend = scope.blend;
out.push(Arc::new(group));
out.push(Arc::new(scope.group(id, scoped(id, std::mem::take(run), level + 1))));
}
}
let mut out = vec![];
let mut run = vec![];
let mut scope = None;
let mut scope: Option<&'a Scope> = None;
for (scopes, s) in surfaces {
let at = scopes.get(level).copied();
if at.map(|a| a.key) != scope.map(|s: Scope| s.key) {
let at = scopes.get(level);
if at.map(|a| a.key) != scope.map(|s| s.key) {
close(id, &mut run, scope, level, &mut out);
scope = at;
}
+89 -32
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View};
@@ -238,20 +238,68 @@ pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRul
})
}
/// A group in a 3D effect's group, or a member of it, whose opacity is below 100% or whose blending
/// mode is not Normal. The members of one group share its `key`.
#[derive(Clone, Copy, Debug, PartialEq)]
/// A group in a 3D effect's group, or a member of it, whose opacity is below 100%, whose blending
/// mode is not Normal or that has an opacity mask. The members of one group share its `key`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Scope {
pub(crate) key: usize,
pub(crate) opacity: f32,
pub(crate) blend: BlendMode,
/// The object's opacity mask, unless it has none or it is disabled. The mask art is in
/// document coordinates, as the solids' faces are.
pub(crate) mask: Option<Arc<OpacityMask>>,
}
impl Scope {
/// The scope of `n` with key `key`, or `None` when `n` is opaque, blends normally and has no
/// opacity mask that applies.
fn of(n: &Node, key: usize) -> Option<Self> {
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
(n.opacity < 1.0 || n.blend != BlendMode::Normal || mask.is_some()).then(|| Scope {
key,
opacity: n.opacity,
blend: n.blend,
mask: mask.map(|m| Arc::new(m.clone())),
})
}
/// A group of `children` with id `id` and this scope's opacity, blending mode and opacity mask.
pub(crate) fn group(&self, id: NodeId, children: Vec<Arc<Node>>) -> Node {
let mut group = Node::group(id, children);
group.opacity = self.opacity;
group.blend = self.blend;
if let Some(mask) = &self.mask {
group.extras_mut().mask = Some(Box::new((**mask).clone()));
}
group
}
}
/// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity
/// masks of those scopes, so each mask applies to the faces of the members it holds. The members'
/// opacity is already in their paint ([`Profile::opacity`]), so these groups are opaque.
fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>) -> Vec<Arc<Node>> {
let masks: Vec<(Vec<Scope>, Arc<Node>)> = surfaces
.into_iter()
.map(|(scopes, s)| {
let masks = scopes.iter().filter(|s| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..s.clone() }).collect();
(masks, s)
})
.collect();
crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0)
}
/// Does any of `shapes` sit in a scope with an opacity mask?
fn has_masks(shapes: &[Profile]) -> bool {
shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some()))
}
/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint
/// it, and the opacity of the objects it is in multiplied together (1 for the object with the
/// effect itself). `scopes` lists those objects with transparency of their own ([`Scope`]), from
/// the outermost group down to the member itself (none for the object itself). Group Revolve
/// applies each once, to the surfaces of the members it holds together.
/// effect itself). `scopes` lists those objects with transparency or an opacity mask of their own
/// ([`Scope`]), from the outermost group down to the member itself (none for the object itself).
/// Group Revolve applies each once, to the surfaces of the members it holds together; the other 3D
/// effects apply each opacity mask that way.
pub(crate) struct Profile {
pub(crate) path: PathData,
pub(crate) rule: FillRule,
@@ -298,9 +346,9 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
}
let opacity = opacity * c.opacity;
let mut scopes = scopes.to_vec();
if c.blend != BlendMode::Normal || c.opacity < 1.0 {
if let Some(scope) = Scope::of(c, w.keys + 1) {
w.keys += 1;
scopes.push(Scope { key: w.keys, opacity: c.opacity, blend: c.blend });
scopes.push(scope);
}
if is_container(c) {
let first = w.out.len();
@@ -445,22 +493,26 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
}
}
/// One part of [`solid_parts`]: a fill or stroke, its shape's opacity, the region it paints and its
/// fill rule, and its shape's scopes.
type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]);
/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes,
/// a group's members from the back up, each with its shape's opacity and the region it paints.
/// The solids share the bounds of all their parts (`fallback` when none has any): one center to
/// turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines.
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32, PathData, FillRule)>, Rect) {
let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes
/// a group's members from the back up, each with its shape's opacity, the region it paints and its
/// shape's scopes. The solids share the bounds of all their parts (`fallback` when none has any):
/// one center to turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines.
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
let parts: Vec<Part> = shapes
.iter()
.flat_map(|s| {
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
let (area, rule) = region(&s.path, s.rule, i);
(i, s.opacity, area, rule)
(i, s.opacity, area, rule, s.scopes.as_slice())
})
})
.collect();
let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
(parts, bounds)
}
@@ -483,12 +535,14 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile
/// 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);
/// otherwise each solid is a surface of its own, in that order.
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of
/// a group it is in, masks the surfaces of the members it holds ([`masked`]).
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let classic = fx.id == CLASSIC_EXTRUDE;
let colored = !classic && view_options(&p).has_colored_lights();
let masks = has_masks(shapes);
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
@@ -498,24 +552,24 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
let (paint, opacity, blend) = paint_of(item, s.opacity);
let (area, rule) = region(&s.path, s.rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored))));
}
}
return Some(out);
return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() });
}
let (parts, bounds) = solid_parts(shapes, b);
let built = if fx.id == INFLATE {
// One eye for every puff, as far away as the highest needs.
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
} else {
parts.iter().map(|(_, _, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
parts.iter().map(|(_, _, area, rule, _)| facets(&fx.id, area, *rule, bounds, &p)).collect()
};
let mut solids = vec![];
for ((item, opacity, _, _), f) in parts.iter().zip(built) {
for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) {
let item: &AppearanceItem = item;
match f {
Ok(f) => solids.push((item, *opacity, f)),
Ok(f) => solids.push((item, *opacity, *scopes, f)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
// A loaded file or a shape edited past the limits keeps drawing its source path
@@ -527,30 +581,33 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
if solids.is_empty() {
return None;
}
let opaque = solids.iter().all(|(item, member, _)| {
let opaque = solids.iter().all(|(item, member, _, _)| {
let (_, opacity, blend) = paint_of(item, *member);
opacity >= 1.0 && blend == BlendMode::Normal
});
let surfaces: Vec<(Vec<f64>, Node)> = solids
let surfaces: Vec<(Vec<f64>, Node, &[Scope])> = solids
.into_iter()
.map(|(item, member, f)| {
.map(|(item, member, scopes, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item, member);
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
(depths, art)
(depths, art, scopes)
})
.collect();
if masks {
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)| {
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())
});
if !opaque || !plain {
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
}
let mut faces = vec![];
for (depths, s) in surfaces {
for (depths, s, _) in surfaces {
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children));
}
@@ -702,11 +759,11 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
// solid and its mesh are built as the loop reaches them.
let mut inflated = (fx.id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
});
let extrude = extrude_options(&p);
for (item, member, area, rule) in &parts {
for (item, member, area, rule, _) in &parts {
let item: &AppearanceItem = item;
let mesh = match inflated.as_mut() {
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
+13 -13
View File
@@ -350,25 +350,22 @@ fn clip_area(first: &Node) -> (PathData, FillRule) {
}
/// `pieces`, each with the [`Scope`]s of the groups it was in, back in groups of `n`'s id with
/// those groups' opacity and blending mode: consecutive pieces sharing the scope at `level` go into
/// one group, so its transparency applies to them together. `level` stays below the nesting the
/// pieces came from ([`nested_past`]).
/// those groups' opacity, blending mode and opacity mask: consecutive pieces sharing the scope at
/// `level` go into one group, so its transparency applies to them together. `level` stays below the
/// nesting the pieces came from ([`nested_past`]).
fn nest(n: &Node, pieces: Vec<(Vec<Scope>, Node)>, level: usize) -> Vec<Arc<Node>> {
let mut out = vec![];
let mut run: Vec<(Vec<Scope>, Node)> = vec![];
let mut key = None;
let mut key: Option<Scope> = None;
let close = |run: &mut Vec<(Vec<Scope>, Node)>, scope: Option<Scope>, out: &mut Vec<Arc<Node>>| {
let Some(scope) = scope else { return };
let mut g = Node::group(n.id, nest(n, std::mem::take(run), level + 1));
g.opacity = scope.opacity;
g.blend = scope.blend;
out.push(Arc::new(g));
out.push(Arc::new(scope.group(n.id, nest(n, std::mem::take(run), level + 1))));
};
for (scopes, piece) in pieces {
let here = scopes.get(level).copied();
if here.map(|s| s.key) != key.map(|s: Scope| s.key) {
close(&mut run, key, &mut out);
key = here;
let here = scopes.get(level).cloned();
if here.as_ref().map(|s| s.key) != key.as_ref().map(|s| s.key) {
close(&mut run, key.take(), &mut out);
key = here.clone();
}
match here {
Some(_) => run.push((scopes, piece)),
@@ -437,12 +434,15 @@ fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec<AppearanceItem>) -
piece
}
/// A plain group of `children` with `n`'s id, visibility, opacity and blending mode.
/// A plain group of `children` with `n`'s id, visibility, opacity, blending mode and opacity mask.
fn wrap(n: &Node, children: Vec<Arc<Node>>) -> Node {
let mut g = Node::group(n.id, children);
g.name = n.name.clone();
g.opacity = n.opacity;
g.blend = n.blend;
if let Some(mask) = &n.extras().mask {
g.extras_mut().mask = Some(mask.clone());
}
g
}
@@ -462,6 +462,67 @@ fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() {
assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01));
}
/// The id of the art of [`masked_by`]'s opacity masks.
const MASK_ART: NodeId = NodeId(900);
/// `n` with an opacity mask of white art covering `r`.
fn masked_by(mut n: Node, r: Rect) -> Node {
let art = Node::path(MASK_ART, shapes::rectangle(r), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
n
}
/// The faces (paths) of `art`, and how many of them are inside an object [`masked_by`] masks with
/// a mask that is not disabled.
fn masked_faces(art: &Node) -> (usize, usize) {
fn walk(n: &Node, masked: bool, out: &mut (usize, usize)) {
let masked = masked || n.extras().mask.as_ref().is_some_and(|m| m.art.id == MASK_ART && !m.disabled);
match n.children() {
Some(children) => children.iter().for_each(|c| walk(c, masked, out)),
None => {
out.0 += 1;
out.1 += usize::from(masked);
}
}
}
let mut out = (0, 0);
walk(art, false, &mut out);
out
}
#[test]
fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() {
let d = document();
let mask = Rect::new(0.0, 0.0, 400.0, 400.0);
let mut members = vec![("a path", rect(1, 100.0, 100.0, 150.0, 200.0, 2.0))];
members.extend(sources());
for (what, member) in members {
let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0));
let in_group = Node::group(NodeId(80), vec![Arc::new(member.clone())]);
let cases = [
("masked", Node::group(NodeId(90), vec![Arc::new(masked_by(member.clone(), mask)), other()])),
("in a masked group", Node::group(NodeId(90), vec![Arc::new(masked_by(in_group, mask)), other()])),
];
for (how, g) in cases {
for id in TYPES {
let g = with(g.clone(), id, json!({}));
let (faces, masked) = masked_faces(&art(&d, &g, id).expect("drawn"));
assert!(masked > 0, "{id}: the faces of {what} {how} are masked");
assert!(masked < faces, "{id}: the other member's faces are not ({masked} of {faces})");
}
}
// A disabled mask masks nothing.
let mut off = masked_by(member.clone(), mask);
if let Some(m) = off.extras_mut().mask.as_mut() {
m.disabled = true;
}
for id in TYPES {
let g = with(Node::group(NodeId(90), vec![Arc::new(off.clone()), other()]), id, json!({}));
assert_eq!(masked_faces(&art(&d, &g, id).expect("drawn")).1, 0, "{id}: {what} with a disabled mask");
}
}
}
#[test]
fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() {
let mut d = document();
+2 -1
View File
@@ -2611,7 +2611,8 @@ Every 3D type, `threeD.classicExtrude` and `threeD.revolve` included, also appli
symbol instance, alone or in a group, and builds from the art it shows, the same way for the canvas, exports and
`assets.add3D` ([three-d.md](three-d.md#clipping-groups-blends-and-symbol-instances)); like a group, it takes the
effect on the object itself, not on a fill or stroke. An image, alone or in a group, is refused with "trace it first
with Object › Image Trace › Make", and nothing changes.
with Object › Image Trace › Make", and nothing changes. On a group, an opacity mask on a member, or on a group inside
it, masks that member's solids ([three-d.md](three-d.md#groups)).
`threeD.extrude`: `depth` (points, 0..2000, default 50), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100),
`cap` (true solid, false hollow), `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, `ogee`), `bevelWidth`
+4 -3
View File
@@ -6,9 +6,10 @@ Select a path, compound path, type, clipping group, blend, symbol instance, or a
On a group or layer, every visible member sweeps about one axis, as [the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)
describes for a group. The following rules are VectorCraft's own: type stays editable and sweeps as its glyph outlines,
the shared axis lies on the chosen edge of the members' bounds together, and each member keeps its own paint.
The faces of all members draw in one back-to-front order. The opacity and blending mode of a member, or of a group
inside the group, apply once to all its faces. Where a face of another member lies between its faces in that order
and overlaps them on the page, its faces are split there, and the opacity and blending mode apply once to each part.
The faces of all members draw in one back-to-front order. The opacity, blending mode and opacity mask of a member,
or of a group inside the group, apply once to all its faces. Where a face of another member lies between its faces
in that order and overlaps them on the page, its faces are split there, and the opacity, blending mode and opacity
mask apply once to each part. The mask stays where it is on the page; it does not turn with the faces.
Two faces overlap when the convex hulls of their outlines come within the 0.75 pt seam stroke of each other. After
4,194,304 overlap tests on one group, each further face of such a member is drawn as a part of its own.
The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right
+5 -1
View File
@@ -122,7 +122,11 @@ them all. The members stay editable: moving or editing one reshapes the solids.
- Each member's own geometry effects shape it first, then those of each group inside the 3D group that holds it,
applied to that group's members together; the 3D group's geometry effects before the 3D effect reshape the
members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and
strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not.
strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. 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, 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