From b46a1ebe5b94d93dcf0fc9b7f855436c9db8b35a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 02:10:11 -0800 Subject: [PATCH] 3D: a gradient fill with transparent stops and its stroke draw their graphic once Under a colored light, a fill in a gradient with transparent stops carries an opacity mask, and a graphic mapped onto Extrude & Bevel's cap or onto a Plane was placed twice: once on the fill's cap with the mask and again on the stroke's cap. The fill and stroke now share one graphic when they have the same opacity and blending mode, as they do without the mask. It is drawn after the last of their faces on that cap and takes the mask of the surface it is drawn after, so an opaque stroke leaves it unmasked. A masked fill without a stroke keeps its masked graphic, and members of a 3D group drawn with a mask keep graphics of their own. Unmasked fills and strokes alone draw as before. --- crates/effects/src/solids.rs | 69 ++++---- crates/effects/src/solids/tests_graphics.rs | 166 +++++++++++++------- docs/target-app-parity.md | 1 + docs/three-d.md | 10 +- 4 files changed, 149 insertions(+), 97 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..89db6f13f4 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1424,31 +1424,31 @@ fn masks_surface(paint: &Paint, colored: bool) -> bool { colored && crate::lighting::opaque_paint(paint).1.is_some() } +/// A graphic of [`placed_graphics`]: the index of the first surface of the solids that share it, +/// the index of the surface it is drawn right after, its face and its art. +type Placement = (usize, usize, PlanarFace, Arc); + /// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is /// drawn right after (the last of those solids holding its face) and its face. Solids in a run -/// with the same opacity and blending mode share their graphics. With `per_member` -/// (a member's opacity mask or blending mode applies to its surfaces) each member's solids get -/// graphics of their own, clipped to that member's caps, so they stay in that member's scopes. -/// All the groups share one limit on the path elements drawn: once it is reached, later groups -/// draw no graphics. -fn placed_graphics( - doc: Option<&Document>, - id: NodeId, - effect: (&str, &Value), - bounds: Rect, - filled: &[Filled], - per_member: bool, -) -> Vec<(usize, PlanarFace, Arc)> { +/// with the same opacity and blending mode share their graphics, one on each face. With +/// `per_member` (a member's opacity mask or blending mode applies to its surfaces) each member's +/// solids get graphics of their own, clipped to that member's caps, so they stay in that member's +/// scopes. All the groups share one limit on the path elements drawn: once it is reached, later +/// groups draw no graphics. +fn placed_graphics(doc: Option<&Document>, id: NodeId, effect: (&str, &Value), bounds: Rect, filled: &[Filled], per_member: bool) -> Vec { let mut groups: Vec> = vec![]; for f in filled { // Solids whose fills or strokes differ in opacity or blending mode get graphics of their - // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask - // gets its own too, which takes that mask ([`with_graphics`]). A solid joins only the - // group of the solid before it, so a graphic is never drawn past a solid painted between. - let joins = |first: &Filled| { - (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked + // own, each drawn as its fill or stroke is. One member's solids share their graphics + // whether or not their surfaces carry an opacity mask, and each graphic takes the mask of + // the surface it is drawn after ([`with_graphics`]). Solids of different members share + // them only when neither surface carries a mask. A solid joins only the group of the solid + // before it, so a graphic is never drawn past a solid painted between. + let joins = |g: &&Filled| { + let member = g.member == f.member; + (member || !per_member && !g.masked && !f.masked) && g.opacity == f.opacity && g.blend == f.blend }; - match groups.last_mut().filter(|g| g.first().is_some_and(|first| joins(first))) { + match groups.last_mut().filter(|g| g.iter().all(joins)) { Some(g) => g.push(f), None => groups.push(vec![f]), } @@ -1457,12 +1457,13 @@ fn placed_graphics( let mut elements = 0usize; for group in groups { let caps: Vec<&Facet> = group.iter().flat_map(|f| &f.caps).collect(); + let first = group.first().map_or(0, |f| f.at); let opacity = group.first().map_or(1.0, |f| f.opacity); let blend = group.first().map_or(BlendMode::Normal, |f| f.blend); for (face, mut art) in graphic_art(doc, id, effect, bounds, &caps, opacity, &mut elements) { if let Some(f) = group.iter().rev().find(|f| f.caps.iter().any(|c| c.end == Some(face))) { Arc::make_mut(&mut art).blend = blend; - out.push((f.at, face, art)); + out.push((first, f.at, face, art)); } } } @@ -1474,23 +1475,19 @@ fn placed_graphics( type BuiltSurface<'s> = (Vec, Node, &'s [Scope], Vec>); /// [`with_graphics`] of built surfaces, each drawn whole. -fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { +fn whole_surfaces<'s>(surfaces: Vec>, arts: &[Placement]) -> Vec<(&'s [Scope], Arc)> { with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), 0, arts) } /// `surfaces` (those from index `first`) drawn whole, each with its scopes, and the graphics `arts` /// ([`placed_graphics`]) of those surfaces each right after its surface, with that surface's scopes /// and its opacity mask (a gradient's transparency), so a graphic is hidden where its cap is. -fn with_graphics<'s>( - surfaces: Vec<(&'s [Scope], Arc)>, - first: usize, - arts: &[(usize, PlanarFace, Arc)], -) -> Vec<(&'s [Scope], Arc)> { +fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, first: usize, arts: &[Placement]) -> Vec<(&'s [Scope], Arc)> { let mut out = Vec::with_capacity(surfaces.len() + arts.len()); for (k, (scopes, s)) in surfaces.into_iter().enumerate() { let mask = s.extras().mask.clone(); out.push((scopes, s)); - out.extend(arts.iter().filter(|(at, ..)| *at == first + k).map(|(.., art)| { + out.extend(arts.iter().filter(|(_, at, ..)| *at == first + k).map(|(.., art)| { let mut art = art.clone(); if let Some(mask) = &mask { Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); @@ -1691,9 +1688,9 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile /// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops /// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of /// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. -fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { +fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[Placement], sealed: bool) -> Vec> { let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); - let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); + let arts: Vec<&Placement> = arts.iter().filter(|(_, at, ..)| (first..first + surfaces.len()).contains(at)).collect(); let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { @@ -1704,24 +1701,18 @@ fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: } let order = draw_order(&placed, sealed); // Each graphic goes right after the last of the faces it is drawn on, among the surfaces that - // share it ([`placed_graphics`]): a masked surface's own, or else the run of surfaces without a - // mask that ends at its surface. + // share it ([`placed_graphics`]): those from the first of them to its own. let last: Vec> = arts .iter() - .map(|(at, face, _)| { - let at = at - first; - let from = if masks.get(at).is_some_and(Option::is_some) { - at - } else { - masks.get(..at).and_then(|before| before.iter().rposition(Option::is_some)).map_or(0, |k| k + 1) - }; + .map(|(from, at, face, _)| { + let (from, at) = (from.saturating_sub(first), at - first); order.iter().rposition(|k| ends.get(*k).copied().flatten().is_some_and(|(surface, end)| end == *face && (from..=at).contains(&surface))) }) .collect(); let mut drawn: Vec<(usize, Arc)> = Vec::with_capacity(order.len() + arts.len()); for (at, k) in order.into_iter().enumerate() { drawn.extend(faces.get_mut(k).and_then(Option::take)); - drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (surface, .., art))| { + drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (_, surface, _, art))| { let mut art = art.clone(); if let Some(mask) = masks.get(surface - first).cloned().flatten() { Arc::make_mut(&mut art).extras_mut().mask = Some(mask); diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index e0935a43e7..f0da6b992b 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -617,10 +617,11 @@ fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { }; // The graphic carries the cap's mask, so it is hidden where the gradient is transparent. assert_eq!(masks(&art), vec![true]); - // With an opaque stroke drawn after the fill, the fill's cap keeps a masked graphic of its own - // and the stroke's outline gets an unmasked one. + // With an opaque stroke drawn after the fill, the fill and the stroke share one graphic, drawn + // after the stroke's cap as for a fill without a mask, and it takes the stroke's surface, + // which carries no mask. n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0))); - assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); + assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![false]); } /// The paths of `art`'s faces in drawing order (a face lit by a colored light is a clip group: its @@ -649,23 +650,56 @@ fn face_paths(art: &Node) -> Vec { out } +/// The graphics in `art` (clip groups of solid-color paths), in drawing order: for each, how many +/// faces ([`face_paths`]) are drawn before it, whether it carries an opacity mask, its opacity and +/// its blending mode. +fn graphic_places(art: &Node) -> Vec<(usize, bool, f32, BlendMode)> { + fn walk(n: &Node, faces: &mut usize, out: &mut Vec<(usize, bool, f32, BlendMode)>) { + match &n.kind { + NodeKind::Group { children, clip: true } => { + let graphic = children.get(1).is_some_and(|inner| match &inner.kind { + NodeKind::Group { children: paths, clip: false } => paths.iter().all(|p| { + p.path_data().is_some() + && p.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint.color().is_some())) + }), + _ => false, + }); + if graphic { + out.push((*faces, n.extras().mask.is_some(), n.opacity, n.blend)); + } else if children.first().and_then(|c| c.path_data()).is_some() { + *faces += 1; + } + } + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, faces, out)), + _ => *faces += usize::from(n.path_data().is_some()), + } + } + let mut out = vec![]; + walk(art, &mut 0, &mut out); + out +} + /// A turned solid in a gradient with transparent stops under a colored light, with a dashed stroke /// and a graphic, draws its fill's and its stroke's faces in the one order it draws them in without -/// the graphic, and the graphic keeps the cap's mask and goes after the fill's cap, not the stroke's. +/// the graphic, and draws the graphic once, where it goes in a gradient without transparent stops: +/// after the stroke's cap, without a mask. #[test] fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; let d = document(); - let mut stop = GradientStop::new(1.0, red()); - stop.opacity = 0.25; - let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); - let solid = |graphics: Value| { + // A red gradient whose last stop has opacity `last`. + let gradient = |last: f32| { + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = last; + GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])) + }; + let solid = |fill: &GradientPaint, graphics: Value| { let mut n = object( EXTRUDE, json!({"rotationX": -20.0, "rotationY": -40.0, "depth": 60.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": graphics}), ); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { - f.paint = Paint::Gradient(Box::new(gradient.clone())); + f.paint = Paint::Gradient(Box::new(fill.clone())); } // A gradient stroke: a solid color under the colored light would be one composite. let blues = Gradient::new( @@ -677,54 +711,76 @@ fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() { n.appearance.items.push(AppearanceItem::Stroke(stroke)); solid_art_in(Some(&d), &n).unwrap() }; - let with = solid(json!([{"symbol": "Mark"}])); - let without = solid(json!([])); + let masked = gradient(0.25); + let with = solid(&masked, json!([{"symbol": "Mark"}])); + let without = solid(&masked, json!([])); assert!(face_paths(&without).len() > 20, "the faces are found"); assert_eq!(face_paths(&with), face_paths(&without), "the faces in one order"); - // The graphic is drawn on the fill's cap and on the stroke's (the clip groups holding the - // symbol's red square); the fill's, whose surface carries the mask, carries it too. - let mut masked = vec![]; - with.walk(&mut |c| { - if let NodeKind::Group { children, clip: true } = &c.kind - && let [_, inner] = children.as_slice() - && let NodeKind::Group { children: paths, clip: false } = &inner.kind - && paths.iter().any(|p| { - p.appearance.items.iter().any(|i| match i { - AppearanceItem::Fill(f) => f.paint.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3), - AppearanceItem::Stroke(_) => false, - }) - }) - { - masked.push(c.extras().mask.is_some()); - } - }); - masked.sort_unstable(); - assert_eq!(masked, [false, true], "the fill's graphic carries the cap's mask"); - // The fill's graphic goes right after the fill's cap, so the dashes on the cap's edge, drawn - // after it, cover it. Drawn in order: the fill's graphic (the masked clip group), and each face, - // the fill's or the stroke's by its gradient's first stop (red or blue), with its bounds. - let mut drawn: Vec<(&str, Rect)> = vec![]; - with.walk(&mut |c| { - if let NodeKind::Group { children, clip: true } = &c.kind - && c.extras().mask.is_some() - && let Some(bounds) = children.first().and_then(|clip| clip.path_data()).and_then(PathData::bounds) - { - drawn.push(("graphic", bounds)); - } - let first_stop = c.appearance.items.iter().find_map(|i| match i { - AppearanceItem::Fill(f) => match &f.paint { - Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_rgb()), - _ => None, - }, - AppearanceItem::Stroke(_) => None, - }); - if let (Some(rgb), Some(bounds)) = (first_stop, c.path_data().and_then(PathData::bounds)) { - drawn.push((if rgb[0] > rgb[2] { "fill" } else { "stroke" }, bounds)); - } - }); - let graphic = drawn.iter().position(|(kind, _)| *kind == "graphic").unwrap(); - let covering = |(kind, b): &(&str, Rect)| *kind == "stroke" && b.intersect(drawn[graphic].1).area() > 0.0; - assert!(drawn[graphic..].iter().any(covering), "a dash on the cap's edge is drawn over the fill's graphic"); + let mut mask = false; + with.walk(&mut |c| mask |= c.extras().mask.is_some()); + assert!(mask, "the fill's faces carry the gradient's mask"); + let opaque = solid(&gradient(1.0), json!([{"symbol": "Mark"}])); + assert_eq!(face_paths(&opaque), face_paths(&with)); + let places = graphic_places(&with); + assert_eq!(places.iter().map(|(_, mask, opacity, blend)| (*mask, *opacity, *blend)).collect::>(), [(false, 1.0, BlendMode::Normal)]); + assert_eq!(places, graphic_places(&opaque), "drawn where a fill without a mask draws it"); +} + +/// A plane in a gradient with transparent stops under a colored light draws its graphic with the +/// fill's mask, and with a dashed stroke draws it once, after the stroke, without a mask. +#[test] +fn a_plane_in_a_transparent_gradient_with_a_stroke_draws_its_graphic_once() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); + let mut n = object(ROTATE, json!({"shade": true, "lights": [{"color": "#ff8080"}], "graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient)); + } + let places = graphic_places(&solid_art_in(Some(&d), &n).unwrap()); + assert_eq!(places.iter().map(|(_, mask, ..)| *mask).collect::>(), [true]); + let mut stroke = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 6.0); + stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + n.appearance.items.push(AppearanceItem::Stroke(stroke)); + let art = solid_art_in(Some(&d), &n).unwrap(); + let places = graphic_places(&art); + assert_eq!(places, [(face_paths(&art).len(), false, 1.0, BlendMode::Normal)], "one graphic, drawn after every face"); +} + +/// Members of a group share a graphic only when neither surface carries an opacity mask, while +/// one member's fill and stroke share theirs. +#[test] +fn members_in_transparent_gradients_keep_graphics_of_their_own() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])))); + let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let (left, right) = (Rect::new(0.0, 0.0, 50.0, 100.0), Rect::new(50.0, 0.0, 100.0, 100.0)); + // Whether each graphic drawn on the group of `members` carries an opacity mask, in order. + let masks = |members: Vec| { + let mut group = Node::group(NodeId(3), members.into_iter().map(Arc::new).collect()); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": [{"symbol": "Mark", "scale": 1000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let mut masks: Vec = graphic_places(&solid_art_in(Some(&d), &group).unwrap()).iter().map(|(_, mask, ..)| *mask).collect(); + masks.sort_unstable(); + masks + }; + assert_eq!(masks(vec![square(1, left, gradient.clone()), square(2, right, gradient.clone())]), [true, true]); + // With a dashed stroke, the first member's fill and stroke share one graphic, without a mask. + let mut stroked = square(1, left, gradient.clone()); + let mut stroke = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 6.0); + stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + stroked.appearance.items.push(AppearanceItem::Stroke(stroke)); + assert_eq!(masks(vec![stroked, square(2, right, gradient.clone())]), [false, true]); + // A member with a gray stroke listed before its fill in the gradient does not share its + // graphic with the gray member after it. + let mut layered = square(1, left, gradient); + layered.appearance.items.insert(0, AppearanceItem::Stroke(StrokeLayer::new(gray.clone(), 6.0))); + assert_eq!(masks(vec![layered, square(2, right, gray)]), [false, true]); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b39f7697c3..fc50abc8d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1259,6 +1259,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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_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`. + - A graphic mapped onto Extrude & Bevel's cap or onto a Plane is drawn once when the fill is a gradient with transparent stops under a colored light and the object has a stroke (dashed or plain) with the same opacity and blending mode: the fill and stroke share it, as they do without the mask, and it is drawn after the stroke's cap with the mask of the stroke's surface (none for an opaque stroke), instead of once on the fill's cap and again on the stroke's. Members of a 3D group drawn with a mask keep graphics of their own. Unmasked fills and strokes alone draw as before. Tests: `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`, `solids::graphics::tests::a_plane_in_a_transparent_gradient_with_a_stroke_draws_its_graphic_once`, `solids::graphics::tests::members_in_transparent_gradients_keep_graphics_of_their_own`. - Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`. - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. - 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). diff --git a/docs/three-d.md b/docs/three-d.md index 6de07fc2fe..cafde1a642 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -153,7 +153,8 @@ The rest is our reading: depths where they overlap, with caps on or off. A fill wall in a gap stays in its own depth order. Inflate's curved surfaces follow this only as closely as their flat facets do. A solid painted with a gradient that has transparent stops under a light that is not white is drawn with an opacity mask: its faces keep their places in - this order, in runs that each carry the mask, and so do its graphics. A member of a 3D group with its own opacity + this order, in runs that each carry the mask, and a graphic drawn after one of its faces carries the mask too + ([Graphics](#graphics)). A member of a 3D group with its own opacity mask or blending mode draws its fill's and strokes' faces in this order inside that mask or blending mode when they are opaque with Normal blending. Otherwise each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not white, a puffed, shaded Inflate surface in a solid color takes its @@ -375,8 +376,11 @@ These rules are VectorCraft's own: turned, foreshortened by the perspective and twisted and tapered with the face, and clipped to it. It is shaded with the face's light: each color is multiplied by the light that falls on the face, except in pattern tiles, which keep their own colors. It is drawn with the - opacity and blending mode of the fill or stroke the face is painted with. A graphic on a face turned away from - the viewer is not drawn. + opacity and blending mode of the fill or stroke the face is painted with. Solids whose fills and strokes have the + same opacity and blending mode share one graphic on each face, clipped to their faces there and drawn after the + last of them. An object's fill and stroke share it when the fill is drawn with an opacity mask too, and the + graphic takes the mask of the solid it is drawn after; members of a 3D group drawn with a mask keep graphics of + their own. A graphic on a face turned away from the viewer is not drawn. - On the back cap, X runs to the right as the back is seen from behind, so the graphic reads the right way round there. - On Revolve a graphic lies on the swept side, which has one face (Front), unrolled flat. X runs around the axis: