From 24155e1f75f84b94699326b83af1c4ec3d25ee29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:30:10 -0800 Subject: [PATCH] 3D: an opacity mask on a group member masks its solids A 3D effect on a group built its solids from the members' paths and paint and left out each member's opacity mask, and the mask of a group inside the 3D group. Clipping groups, blends and symbol instances resolved for the effect lost their masks the same way. A member, or a group inside the 3D group, with an opacity mask now gets a scope like one with its own opacity or blending mode, and the mask goes on the group that holds that member's surfaces. Revolve applies it with the member's opacity and blending mode; Extrude & Bevel, Extrude & Bevel (Classic), Inflate and Rotate draw each solid on its own when a mask is present and put the masked members' solids in a group with the mask. A resolved clipping group, blend or symbol instance keeps its mask. A disabled mask is ignored. --- crates/effects/src/revolve.rs | 17 ++- crates/effects/src/solids.rs | 121 +++++++++++++++------ crates/effects/src/solids/source.rs | 26 ++--- crates/effects/src/solids/tests_sources.rs | 61 +++++++++++ docs/mcp.md | 3 +- docs/revolve.md | 7 +- docs/three-d.md | 6 +- 7 files changed, 181 insertions(+), 60 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index a5004ab996..c42b0c69de 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -463,22 +463,19 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { /// 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)>, level: usize) -> Vec> { - fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option, level: usize, out: &mut Vec>) { +/// 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)>, level: usize) -> Vec> { + fn close(id: NodeId, run: &mut Vec<(&[Scope], Arc)>, scope: Option<&Scope>, level: usize, out: &mut Vec>) { 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; } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4ac4628b17..fed0a9d741 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -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>, +} + +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 { + 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>) -> 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)>) -> Vec> { + let masks: Vec<(Vec, Arc)> = 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, 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>, Rect) { + let parts: Vec = 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>> { 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>> 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>> 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, Node)> = solids + let surfaces: Vec<(Vec, Node, &[Scope])> = solids .into_iter() - .map(|(item, member, f)| { + .map(|(item, member, scopes, f)| { let depths: Vec = 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())), diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 090688c4f9..f87bc39f8d 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -343,25 +343,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, Node)>, level: usize) -> Vec> { let mut out = vec![]; let mut run: Vec<(Vec, Node)> = vec![]; - let mut key = None; + let mut key: Option = None; let close = |run: &mut Vec<(Vec, Node)>, scope: Option, out: &mut Vec>| { 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)), @@ -430,12 +427,15 @@ fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec) - 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>) -> 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 } diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 35e89e61f0..8cb7c6fab3 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -414,6 +414,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(); diff --git a/docs/mcp.md b/docs/mcp.md index 58d235b4c2..80e7acb64a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2595,7 +2595,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` diff --git a/docs/revolve.md b/docs/revolve.md index 18be779d23..e52770fd65 100644 --- a/docs/revolve.md +++ b/docs/revolve.md @@ -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 diff --git a/docs/three-d.md b/docs/three-d.md index 0a9ac897d5..bd55ac1545 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -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