diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 47b1c83206..a49e817ef2 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -171,7 +171,7 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) } } } - let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0)); + let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| crate::solids::shadow::casts(paint_of(i).1 * s.opacity))); return check_shadow(p, casting.map(|s| &s.path), b, options); } let (path, _) = object_profile(n, "Revolve")?; @@ -350,8 +350,8 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// The shadow of Revolve `fx` cast by the surface of `path` in `item`'s paint about the axis of /// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with -/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when -/// the surface it is cast from is past the limits. +/// the shadow off, when `item` is fully transparent or its opacity is not a finite number, or when +/// it casts nothing, and an error when the surface it is cast from is past the limits. /// /// `kept` holds the faces the surface drawn kept for its shadow ([`surface`]); without them the /// surface is swept again for its shadow alone. @@ -366,7 +366,7 @@ fn shadow_of( ) -> Result, String> { let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) }; let (_, opacity, blend) = paint_of(item); - if opacity <= 0.0 { + if !crate::solids::shadow::casts(opacity) { return Ok(None); } let mut caster = vectorcraft_three_d::ShadowCaster::new(); @@ -377,7 +377,7 @@ fn shadow_of( /// Does Revolve `fx` cast a shadow, so a surface in paint as opaque as `opacity` keeps the faces /// its shadow is cast from ([`surface`])? fn casts(fx: &Effect, opacity: f32) -> bool { - opacity > 0.0 && crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)).is_some() + crate::solids::shadow::casts(opacity) && crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)).is_some() } /// Add the surface of `path` about the axis of `b` to `caster`: the faces it kept when it was @@ -420,9 +420,9 @@ fn group_shadow( for s in shapes { let kept = kept.next().flatten(); let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue }; - // A member whose surface is fully transparent casts nothing. + // A member whose surface is fully transparent, or whose opacity is not a finite number, casts nothing. let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend }; - if ink.opacity <= 0.0 { + if !ink.casts() { continue; } let faces = cast_surface(&mut caster, &s.path, b, options, kept)?; @@ -1861,6 +1861,55 @@ mod tests { } } } + + /// A surface whose fill, or whose member, is at NaN or infinite opacity casts no shadow, as one + /// at 0 % does, whether the shadow is cast from the surface drawn or swept for it alone; the + /// member beside it still casts its part. + #[test] + fn a_surface_whose_opacity_is_not_a_finite_number_casts_no_shadow() { + let fx = crate::new_effect(REVOLVE, &json!({"shadow": true})).unwrap(); + let group = |opacity: f32| { + let mut other = profile_node(); + other.id = NodeId(2); + other.transform(Affine::translate((120.0, 0.0)), false); + other.opacity = opacity; + let mut g = Node::group(NodeId(9), vec![Arc::new(profile_node()), Arc::new(other)]); + g.appearance.effects.push(fx.clone()); + g + }; + let group_shadows = |g: &Node| { + let shapes = members(g, "").unwrap(); + let b = union_bounds(&shapes).unwrap(); + let options = shared_options(&shapes, fx_options(&fx)).unwrap(); + let kept: Vec> = shapes + .iter() + .map(|s| { + let (paint, opacity, blend) = paint_of(surface_item(&s.items).unwrap()); + let cast = casts(&fx, opacity * s.opacity); + surface(None, g, &s.path, b, options, &fx, paint, opacity, blend, false, &mut Copies::default(), cast).unwrap().2 + }) + .collect(); + [group_shadow(g.id, &fx, &shapes, b, options, kept).unwrap(), group_shadow(g.id, &fx, &shapes, b, options, vec![]).unwrap()] + .map(|art| format!("{art:?}")) + }; + let expected = group_shadows(&group(0.0)); + for bad in [f32::NAN, f32::INFINITY] { + let mut n = profile_node(); + if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] { + f.opacity = bad; + } + let (path, _) = profile(&n).unwrap(); + let b = path.bounds().unwrap(); + let item = surface_item(&n.appearance.items).unwrap(); + assert!(!casts(&fx, bad), "{bad}: the surface keeps no faces for a shadow"); + let (_, kept) = object_surface(None, &n, &path, &fx, item, false, &mut Copies::default()).unwrap(); + assert_eq!(shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), kept).unwrap(), None, "{bad} drawn"); + assert_eq!(shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), None).unwrap(), None, "{bad} alone"); + let shadows = group_shadows(&group(bad)); + assert!(!expected[0].is_empty() && expected[0] != "[]", "the opaque member casts"); + assert_eq!(shadows, expected, "{bad}: only the opaque member casts"); + } + } } #[cfg(test)] diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 953768d298..cdc2376724 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1474,8 +1474,8 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result = vec![]; let ink = |item: &AppearanceItem, member: f32| { let (_, opacity, blend) = paint_of(item, member); @@ -1483,7 +1483,7 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result 0.0) { + for item in s.items.iter().filter(|i| ink(i, s.opacity).casts()) { let (area, rule) = region(&s.path, s.rule, item); #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); @@ -1497,7 +1497,7 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result, String>> = if id == INFLATE { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - let casts: Vec = inks.iter().map(|i| i.opacity > 0.0).collect(); + let casts: Vec = inks.iter().map(|i| i.casts()).collect(); #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + shapes.len())); caster.inflate(&shapes, &casts, bounds, inflate_options(p), view) @@ -1505,13 +1505,13 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result 0.0))); - added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); + SOLIDS_BUILT.with(|n| n.set(n.get() + usize::from(ink.casts()))); + added.push(if ink.casts() { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); } added }; for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) { - if ink.opacity <= 0.0 { + if !ink.casts() { continue; } // A stroke whose own solid is past the limits is left out; past the shadow's limit for @@ -1552,7 +1552,7 @@ fn cast_built<'a>(id: &str, p: &Value, shapes: &'a [Profile], built: Vec(id: &str, p: &Value, shapes: &'a [Profile], built: 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())) { + if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.casts() && kept.is_none())) { 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 { + if !ink.casts() { continue; } let faces = match kept { diff --git a/crates/effects/src/solids/shadow.rs b/crates/effects/src/solids/shadow.rs index 3906ac2d3c..81542a3722 100644 --- a/crates/effects/src/solids/shadow.rs +++ b/crates/effects/src/solids/shadow.rs @@ -50,6 +50,19 @@ pub(crate) struct Ink { pub(crate) blend: BlendMode, } +impl Ink { + /// Whether art in this ink casts part of a shadow ([`casts`]). + pub(crate) fn casts(self) -> bool { + casts(self.opacity) + } +} + +/// Whether art at `opacity` casts part of a shadow: its opacity is a finite number above zero. +/// Art at zero, negative, NaN or infinite opacity casts nothing. +pub(crate) fn casts(opacity: f32) -> bool { + opacity.is_finite() && opacity > 0.0 +} + /// The shadow of the faces in `caster` as a path of object `id`, cast by art in `ink`: filled /// black at [`SHADOW_OPACITY`] times its opacity, in its blending mode, with the nonzero rule; /// `None` when it casts nothing. diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4d62961612..7dfd78f28d 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -312,3 +312,63 @@ fn the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it() } } } + +/// The opacity of each part of the shadow of `n` (its 3D effect's shadow on) and the shadow's art, +/// as [`cast_shadow`] casts it from solids built for the shadow alone and as [`cast_built`] casts +/// it from the faces the solids drawn kept. +fn cast_parts(n: &Node) -> [(Vec, String); 2] { + let (_, fx) = solid(&n.appearance.effects).unwrap(); + let shapes = if is_container(n) { + members(n, "").unwrap() + } else { + let (path, rule) = profile(n).unwrap(); + vec![Profile { path, rule, items: solid_paint_sources(n).1, opacity: 1.0, scopes: vec![] }] + }; + let p = crate::merged_params(&fx.id, &fx.params); + let (_, built) = surfaces_for(None, n, fx, &shapes, true).unwrap(); + [cast_shadow(&fx.id, &p, &shapes), cast_built(&fx.id, &p, &shapes, built)].map(|c| { + let (caster, settings, cast) = c.unwrap().unwrap(); + let inks = cast.iter().map(|(_, ink, _)| ink.opacity).collect(); + (inks, format!("{:?}", shadow::scoped_art(n.id, &caster, settings, cast, has_masks(&shapes)))) + }) +} + +/// A fill, a stroke or a member whose opacity is NaN or infinite casts no part of the shadow, as one +/// at 0 % does, whether the shadow is cast from solids built for it or from the solids drawn; the +/// art beside it still casts its part. +#[test] +fn art_whose_opacity_is_not_a_finite_number_casts_no_shadow() { + // The art with opacity `opacity` in one place: a square's stroke, the fill of a group's second + // member, and a group's second member. + let objects = |opacity: f32| { + let mut fill = square_node(2, 200.0); + for item in &mut fill.appearance.items { + if let AppearanceItem::Fill(f) = item { + f.opacity = opacity; + } + } + let mut member = square_node(2, 200.0); + member.opacity = opacity; + [ + stroked_square(1, 0.0, opacity), + Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(fill)]), + Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(member)]), + ] + }; + for id in [EXTRUDE, INFLATE, ROTATE] { + for bad in [f32::NAN, f32::INFINITY] { + for (i, (mut n, mut clear)) in objects(bad).into_iter().zip(objects(0.0)).enumerate() { + n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + clear.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap()); + let expected = cast_parts(&clear); + for (k, (inks, shadow)) in cast_parts(&n).into_iter().enumerate() { + assert_eq!(inks, vec![1.0], "{id} {bad} object {i} cast {k}: only the opaque art casts"); + assert_eq!(shadow, expected[k].1, "{id} {bad} object {i} cast {k}"); + } + let mut opacities = vec![]; + art(&n, id).unwrap().walk(&mut |c| opacities.extend(shadow_opacity(c))); + assert_eq!(opacities, vec![0.5], "{id} {bad} object {i}"); + } + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3ca89ce655..1a4983f9a5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -592,6 +592,7 @@ since 2026-10-01. - 3D lighting widget: the Lighting tab of the 3D and Materials panel shows every light on a sphere at its Rotation and Height; a drag moves a light in one undo step, a press selects it, Move Light to Back and Move Light to Front change its side, and the arrow keys move the focused light ([three-d.md](three-d.md#lighting-widget)). `crates/ui-egui/src/panels/tests_three_d.rs` covers the drag, selection, side and keyboard. - 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: in Revolve, Extrude & Bevel, Inflate and Rotate, a fill, stroke or member whose opacity is NaN or infinite casts no part of the shadow, as one at 0 % does, whether the shadow is cast from the solids drawn or from solids built for it alone; the art beside it still casts. This is VectorCraft's own rule. Evidence: `solids::tests_shadow::art_whose_opacity_is_not_a_finite_number_casts_no_shadow`, `revolve::tests::a_surface_whose_opacity_is_not_a_finite_number_casts_no_shadow`. 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 and Materials › Render Settings › Wireframe: `render: {mode: "wireframe"}` on Extrude & Bevel, Revolve, Inflate and Rotate draws the live effect as the edges of its faces, 0.5 pt black strokes with no shadow, in every output and in Expand Appearance ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged.