From dcdd871a4d7880473498a304908059d5f0d80f4e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 16:42:47 -0800 Subject: [PATCH 1/2] 3D: shadow validation casts from the solids it checks With Shadows on, the check a command runs for Extrude & Bevel, Inflate and Rotate built every solid a second time to check its shadow. It now casts the shadow from the faces kept by the builds that check the solids, as drawing does, so each solid is built once. A stroke whose solid is left out of the art past its own limits no longer gets a solid built for the shadow alone: it casts nothing, as it draws nothing. --- crates/effects/src/solids.rs | 122 +++++++++++++--------- crates/effects/src/solids/tests.rs | 8 +- crates/effects/src/solids/tests_shadow.rs | 49 +++++++++ docs/target-app-parity.md | 1 + docs/three-d.md | 4 +- 5 files changed, 133 insertions(+), 51 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index c0d44947d7..44372b1e01 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -323,8 +323,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: reshape_members(n, before, &mut shapes); let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; check_profiles(id, p, &shapes, b)?; - check_solids(id, p, &shapes)?; - return cast_shadow(id, p, &shapes).map(|_| ()); + return check_solids(id, p, &shapes); } let (path, rule) = object_profile(n, label)?; let anchors = || format!("{label} requires a shape with at least two anchors"); @@ -341,29 +340,68 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let b = path.bounds().ok_or_else(anchors)?; let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; check_profiles(id, p, &shapes, b)?; - check_solids(id, p, &shapes)?; - cast_shadow(id, p, &shapes).map(|_| ()) + check_solids(id, p, &shapes) +} + +/// Does 3D effect `id` with parameters `p` cast a shadow: is it Extrude & Bevel, Inflate or Rotate +/// with Shadows on? +fn casts_shadow(id: &str, p: &Value) -> bool { + !is_classic_three_d(id) && shadow::options(p).is_some() +} + +/// Does `item` of a shape with opacity `member` paint anything, and so cast a shadow? +fn visible(item: &AppearanceItem, member: f32) -> bool { + paint_of(item, member).1 > 0.0 } /// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b` -/// build? Only the profiles no solid of [`check_solids`] is built from are built here: every -/// shape's for Rotate, and for Extrude & Bevel and Inflate the profile of a shape with no fill, -/// whose solids are its strokes' outlines or none. A filled shape's profile is its fill's solid, -/// which [`check_solids`] builds once, about the bounds the effect draws it about. +/// build? Only the profiles no solid of [`check_solids`] is built from are built here: for Rotate +/// every shape's, except with its shadow cast that of a shape with a visible fill, whose plane is +/// the fill's; for Extrude & Bevel and Inflate the profile of a shape with no fill, whose solids +/// are its strokes' outlines or none. A filled shape's profile is its fill's solid, which +/// [`check_solids`] builds once, about the bounds the effect draws it about. fn check_profiles(id: &str, p: &Value, shapes: &[Profile], b: Rect) -> Result<(), String> { - shapes - .iter() - .filter(|s| is_plane(id) || !s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)))) - .try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())) + let cast = casts_shadow(id, p); + let fill = |s: &Profile| s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) && (!is_plane(id) || visible(i, s.opacity))); + shapes.iter().filter(|s| if is_plane(id) { !(cast && fill(s)) } else { !fill(s) }).try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())) } /// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes` -/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not -/// counted. +/// fit, each on its own and all of them together ([`built_solids`]), and with its shadow cast, does +/// the shadow fit? The shadow is cast from the faces those same builds kept ([`cast_built`]), as +/// [`art_for`] casts it: Extrude & Bevel and Inflate build each solid once, and Rotate builds the +/// plane of each visible fill and stroke once. Rotate's planes are not counted, and without its +/// shadow Rotate builds no plane here. fn check_solids(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> { - let Some(b) = union_bounds(shapes).filter(|_| !is_plane(id)) else { return Ok(()) }; + let Some(b) = union_bounds(shapes) else { return Ok(()) }; + let cast = casts_shadow(id, p); + if is_plane(id) { + if !cast { + return Ok(()); + } + let mut built = vec![]; + for s in shapes { + for item in &s.items { + if !visible(item, s.opacity) { + built.push(None); + continue; + } + let (area, rule) = region(&s.path, s.rule, item); + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + built.push(facets_and_faces(ROTATE, &area, rule, b, p, true)?.1); + } + } + return cast_built(id, p, shapes, built).map(|_| ()); + } let (parts, bounds) = solid_parts(shapes, b); - built_solids(id, p, &parts, bounds, MAX_OBJECT_FACES).try_for_each(|r| r.map(|_| ())) + if !cast { + return built_solids(id, p, &parts, bounds, MAX_OBJECT_FACES).try_for_each(|r| r.map(|_| ())); + } + let built = built_solids_cast(id, p, &parts, bounds, MAX_OBJECT_FACES, true) + .map(|r| r.map(|solid| solid.and_then(|(_, faces)| faces))) + .collect::, String>>()?; + cast_built(id, p, shapes, built).map(|_| ()) } /// [`profile`], or the error a command reports for object `n`: type with no outlines to build from @@ -723,7 +761,8 @@ thread_local! { /// surfaces count too. pub(crate) static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; /// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and - /// [`built_solids`] have built on this thread, for the tests. + /// [`built_solids`] have built on this thread, for the tests. The solids built to cast a shadow + /// ([`cast_shadow`]) count too. static SOLID_GEOMETRY_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -1421,7 +1460,7 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// surface of the object, and the surfaces. fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { let p = crate::merged_params(&fx.id, &fx.params); - let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some(); + let cast = casts_shadow(&fx.id, &p); let (art, built) = surfaces_for(doc, n, fx, shapes, cast)?; // A shadow past the limits draws the source path, as a solid past them does; commands report // the error ([`validate_solid_in`]). @@ -1483,12 +1522,16 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result = inks.iter().map(|i| i.opacity > 0.0).collect(); #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + shapes.len())); + #[cfg(test)] + SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + casts.iter().filter(|c| **c).count())); caster.inflate(&shapes, &casts, bounds, inflate_options(p), view) } else { let mut added = vec![]; for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) { #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + usize::from(ink.opacity > 0.0))); + #[cfg(test)] + SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + usize::from(ink.opacity > 0.0))); added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); } added @@ -1512,16 +1555,14 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result(id: &str, p: &Value, shapes: &'a [Profile], built: Vec>) -> Result>, String> { if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { return Ok(None); } let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) }; - let view = view_options(p); let mut caster = vectorcraft_three_d::ShadowCaster::new(); let mut cast: Vec = vec![]; let ink = |item: &AppearanceItem, member: f32| { @@ -1538,39 +1579,22 @@ fn cast_built<'a>(id: &str, p: &Value, shapes: &'a [Profile], built: Vec caster.add_solid(kept)?, - None => { - let (area, rule) = region(&s.path, s.rule, item); - #[cfg(test)] - SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); - caster.plane(&area, rule, b, view)? - } - }; - cast.push((s.scopes.as_slice(), ink, faces)); + let Some(kept) = kept else { continue }; + cast.push((s.scopes.as_slice(), ink, caster.add_solid(kept)?)); } return Ok(Some((caster, settings, cast))); } - let (parts, bounds) = solid_parts(shapes, b); - let inks: Vec = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect(); - if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.opacity > 0.0 && kept.is_none())) { + let (parts, _) = solid_parts(shapes, b); + if parts.len() != built.len() { return cast_shadow(id, p, shapes); } - for (((item, _, area, rule, scopes, _), ink), kept) in parts.iter().zip(inks).zip(built) { - if ink.opacity <= 0.0 { - continue; - } - let faces = match kept { - Some(kept) => caster.add_solid(kept), - None => { - #[cfg(test)] - SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); - caster.extrude(area, *rule, bounds, extrude_options(p), view) - } - }; + for ((item, member, _, _, scopes, _), kept) in parts.iter().zip(built) { + let ink = ink(item, *member); + // A fully transparent solid, or a stroke's solid left out of the art, casts nothing. + let Some(kept) = kept.filter(|_| ink.opacity > 0.0) else { continue }; // A stroke whose own solid is past the limits is left out; past the shadow's limit for all // the solids together, the shadow is refused. - let faces = match faces { + let faces = match caster.add_solid(kept) { Ok(faces) => faces, Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue, Err(e) => return Err(e), diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index ab163e45cd..b0d2570289 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1082,7 +1082,8 @@ fn geometry_builds(check: impl FnOnce() -> Result<(), String>) -> (usize, Result /// The check builds each solid the effect draws once, about the bounds it draws it about: one for /// a fill and one for each stroke, on an object, on a group's members and on an appearance item, -/// with the shadow on or off. +/// with the shadow on or off. With the shadow on, the shadow is cast from the faces those builds +/// kept, so no solid is built again for it. #[test] fn the_check_builds_each_drawn_solid_once() { for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { @@ -1098,6 +1099,11 @@ fn the_check_builds_each_drawn_solid_once() { assert_eq!(geometry_builds(|| validate_three_d_at(&n, id, &json!({}), (Some(0), 0))), (1, Ok(())), "{id}: on the fill"); let shadow = json!({"shadow": true}); assert_eq!(geometry_builds(|| validate_solid(&n, id, &shadow)), (2, Ok(())), "{id}: with the shadow on"); + let mut other = square_node(); + other.id = NodeId(2); + let g = Node::group(NodeId(9999), vec![Arc::new(square_node()), Arc::new(other)]); + assert_eq!(geometry_builds(|| validate_solid(&g, id, &shadow)), (4, Ok(())), "{id}: a group with the shadow on"); + assert_eq!(geometry_builds(|| validate_three_d_at(&n, id, &shadow, (Some(0), 0))), (1, Ok(())), "{id}: the fill with the shadow on"); } } diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4d62961612..47c5ac9261 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -312,3 +312,52 @@ fn the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it() } } } + +/// A stroke's solid left out of the art (past its own limits) keeps no faces, and the shadow cast +/// from the faces the solids kept leaves it out without building it: it casts nothing, as it draws +/// nothing, and the fill's solid still casts. +#[test] +fn a_stroke_left_out_of_the_art_casts_no_shadow() { + for id in [EXTRUDE, INFLATE] { + let n = stroked_square(1, 0.0, 1.0); + let (path, rule) = profile(&n).unwrap(); + let shapes = [Profile { path, rule, items: solid_paints(&n), opacity: 1.0, scopes: vec![] }]; + let p = crate::merged_params(id, &json!({"shadow": true})); + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + let mut built: Vec> = + built_solids_cast(id, &p, &parts, bounds, MAX_OBJECT_FACES, true).map(|r| r.unwrap().and_then(|(_, faces)| faces)).collect(); + let stroke = parts.iter().position(|(item, ..)| matches!(item, AppearanceItem::Stroke(_))).unwrap(); + assert!(built.len() == 2 && built.iter().all(Option::is_some), "{id}"); + let (both, _, cast) = cast_built(id, &p, &shapes, built.clone()).unwrap().unwrap(); + assert_eq!(cast.len(), 2, "{id}: the fill and the stroke cast"); + built[stroke] = None; + let start = SOLIDS_BUILT.with(std::cell::Cell::get); + let (fill, _, cast) = cast_built(id, &p, &shapes, built).unwrap().unwrap(); + assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get), start, "{id}: no solid is built for the shadow"); + assert_eq!(cast.len(), 1, "{id}: the stroke left out casts nothing"); + assert!(!fill.is_empty() && fill.len() < both.len(), "{id}: only the fill's faces are cast"); + } +} + +/// A command accepts each shadowed object that draws with its shadow, and with Rotate builds the +/// plane of each visible fill and stroke once for the shadow: a fully transparent one casts nothing. +#[test] +fn the_check_accepts_the_shadowed_objects_that_draw() { + for id in [EXTRUDE, INFLATE, ROTATE] { + for params in shadow_settings(id) { + for n in shadowed(id, ¶ms) { + let mut drawn = 0; + art(&n, id).unwrap().walk(&mut |c| drawn += usize::from(shadow_opacity(c).is_some())); + assert!(drawn > 0, "{id} {params}"); + assert_eq!(validate_solid(&n, id, ¶ms), Ok(()), "{id} {params}"); + } + } + } + let shadow = json!({"shadow": true}); + for (stroke, planes) in [(1.0, 2), (0.5, 2), (0.0, 1)] { + let n = stroked_square(1, 0.0, stroke); + let start = SOLIDS_BUILT.with(std::cell::Cell::get); + assert_eq!(validate_solid(&n, ROTATE, &shadow), Ok(()), "{stroke}"); + assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get) - start, planes, "Rotate, a stroke at {stroke}"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..efa2e13175 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -585,6 +585,7 @@ since 2026-10-01. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - 3D shadows: with Shadows on, Revolve, Extrude & Bevel, Inflate and Rotate cast the shadow from the faces kept by the build that draws each solid, so drawing an object builds each of its solids once. The art and the shadow are unchanged. Evidence: `crates/three-d/src/shadow/tests.rs` (`a_solid_and_its_shadow_are_built_once` and the tests that compare each solid and its shadow with the two built apart), `solids::tests_shadow::a_shadowed_3d_object_builds_each_solid_once`, `solids::tests_shadow::the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it`, `revolve::tests::the_shadow_cast_from_the_surfaces_drawn_is_the_shadow_of_surfaces_swept_for_it`. Scores unchanged. +- 3D shadows: a command that applies or edits Extrude & Bevel, Inflate or Rotate with Shadows on checks the shadow from the faces kept by the builds that check the solids, as drawing casts it, so it builds each solid once instead of building it again for the shadow. A stroke whose solid is left out of the art past its own limits casts no shadow. Evidence: `solids::tests::the_check_builds_each_drawn_solid_once`, `solids::tests_shadow::a_stroke_left_out_of_the_art_casts_no_shadow`, `solids::tests_shadow::the_check_accepts_the_shadowed_objects_that_draw`. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index f12ca4675c..29d920d255 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -354,7 +354,9 @@ Our reading: - Softness is not applied to the shadow, and only the first light casts one. - A shadow is cast from at most 327,680 faces, the solids of one object together (our own limit). Past it, a command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded - file draws the source path. + file draws the source path. A command checks the shadow from the same builds that check the solids, as + drawing casts it, so each solid is built once. A stroke whose solid is left out of the art (past its own + limits) casts no shadow. - Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a path below the faces. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. From 90bcbd7129022b7a90cf1226c8ee4c5a0f1fe02e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 15 Nov 2026 01:52:32 -0800 Subject: [PATCH 2/2] 3D: a wireframe effect's check casts no shadow In wireframe mode the effect draws no shadow, so the command check builds no shadow from its solids either: with Shadows on, Rotate builds no plane for it, and Extrude & Bevel and Inflate build each solid once. --- crates/effects/src/solids/tests_shadow.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 47c5ac9261..6ec127ccbd 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -361,3 +361,16 @@ fn the_check_accepts_the_shadowed_objects_that_draw() { assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get) - start, planes, "Rotate, a stroke at {stroke}"); } } + +/// In wireframe mode no shadow is drawn, so the check casts none: with Shadows on, Rotate builds no +/// plane for a shadow, and Extrude & Bevel and Inflate build each solid once, as with the shadow off. +#[test] +fn the_check_casts_no_shadow_in_wireframe_mode() { + let n = stroked_square(1, 0.0, 1.0); + let wire = json!({"shadow": true, "render": {"mode": "wireframe"}}); + for (id, solids) in [(ROTATE, 0), (EXTRUDE, 2), (INFLATE, 2)] { + let start = SOLIDS_BUILT.with(std::cell::Cell::get); + assert_eq!(validate_solid(&n, id, &wire), Ok(()), "{id}"); + assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get) - start, solids, "{id}"); + } +}