From 1b268a725ecf52338421fb8f778c10fadf05c9d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:38:10 -0800 Subject: [PATCH] 3D: a render with a shadow builds each solid once With Shadows on, Extrude & Bevel, Inflate, Rotate and Revolve built every solid twice: once for the art and again for the shadow. The build that draws a solid now also keeps the faces its shadow is cast from and the projection they were built with, and the shadow is cast from them. The art and the shadow are unchanged. - three-d: the solid builder can keep the projected facets and the view-space faces together (`Keep::Both`); `extrude_shadowed`, `plane_shadowed`, `inflate_shadowed` and `revolve_shadowed` return both, and `ShadowCaster::add_solid` adds the kept faces. Revolve's sweep draws the surface and keeps the shadow's faces in one pass; a shadow past its limits still gives the same error while the surface draws. - effects: the solids drawn hand their faces to the shadow. A stroke's solid left out of the art is still built for the shadow alone, as before. Tests compare each solid and its shadow with the two built apart, to the bit, and count the solids built for a shadowed object. --- crates/effects/src/revolve.rs | 174 +++++++++++-- crates/effects/src/solids.rs | 180 ++++++++++++-- crates/effects/src/solids/tests_shadow.rs | 89 +++++++ crates/three-d/src/lib.rs | 290 ++++++++++++++++------ crates/three-d/src/mesh.rs | 8 +- crates/three-d/src/shadow.rs | 74 +++++- crates/three-d/src/shadow/tests.rs | 241 ++++++++++++++++++ crates/three-d/src/solid.rs | 6 +- crates/three-d/src/solid/build.rs | 108 ++++++-- crates/three-d/src/solid/inflate.rs | 57 +++-- crates/three-d/src/solid/tests.rs | 4 +- docs/target-app-parity.md | 1 + 12 files changed, 1053 insertions(+), 179 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..76829e7ba0 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; -use vectorcraft_three_d::{Edge, Revolve}; +use vectorcraft_three_d::{Edge, Revolve, SolidFaces}; use crate::solids::{ Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, @@ -337,34 +337,80 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// `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. -fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result, String> { +/// +/// `kept` holds the faces the surface drawn kept for its shadow ([`surface`]); without them the +/// surface is swept again for its shadow alone. +fn shadow_of( + id: NodeId, + fx: &Effect, + path: &PathData, + item: &AppearanceItem, + b: Rect, + options: Revolve, + kept: Option, +) -> 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 { return Ok(None); } let mut caster = vectorcraft_three_d::ShadowCaster::new(); - caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options)?; + cast_surface(&mut caster, path, b, options, kept)?; Ok(crate::solids::shadow::art(id, &caster, settings, crate::solids::shadow::Ink { opacity, blend })) } +/// 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() +} + +/// Add the surface of `path` about the axis of `b` to `caster`: the faces it kept when it was +/// drawn, or without them, the surface swept for its shadow alone. +fn cast_surface( + caster: &mut vectorcraft_three_d::ShadowCaster, + path: &PathData, + b: Rect, + options: Revolve, + kept: Option, +) -> Result, String> { + match kept { + Some(kept) => caster.add_solid(kept), + None => { + #[cfg(test)] + crate::solids::SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options) + } + } +} + /// The shadow of group Revolve `fx` cast by the surfaces of the members `shapes` that have one, /// about the axis of `b`, on one plane; each member's part is as dark as its surface is opaque, /// in its paint's blending mode, and its opacity mask and blending mode apply to that part /// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the -/// limits. -fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result>, String> { +/// limits. `kept` holds, member by member, the faces each surface drawn kept for its shadow +/// ([`surface`]); a member without them is swept again for its shadow alone. +fn group_shadow( + id: NodeId, + fx: &Effect, + shapes: &[Profile], + b: Rect, + options: Revolve, + kept: Vec>, +) -> Result>, String> { let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(vec![]) }; let mut caster = vectorcraft_three_d::ShadowCaster::new(); let mut cast = vec![]; + let mut kept = kept.into_iter(); 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. let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend }; if ink.opacity <= 0.0 { continue; } - let faces = caster.revolve(&s.path, vectorcraft_geom::FillRule::NonZero, b, options)?; + let faces = cast_surface(&mut caster, &s.path, b, options, kept)?; cast.push((s.scopes.as_slice(), ink, faces)); } Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes))) @@ -374,7 +420,8 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R /// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face /// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the /// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]): -/// a face with a part of a graphic is a group of the face and that part. +/// a face with a part of a graphic is a group of the face and that part. With `cast`, the sweep +/// that draws the surface also keeps the faces its shadow is cast from ([`SolidFaces`]). #[allow(clippy::too_many_arguments)] fn surface( doc: Option<&Document>, @@ -388,21 +435,32 @@ fn surface( blend: vectorcraft_doc::color::BlendMode, expand: bool, copies: &mut Copies, -) -> Option<(Vec, Node)> { + cast: bool, +) -> Option<(Vec, Node, Option)> { + #[cfg(test)] + crate::solids::SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); // A loaded file or a profile edited past the limits keeps drawing its source path instead // of crashing the frame. Commands validate the same evaluator and report its errors. if fx.id == CLASSIC_REVOLVE { - let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?; + let (facets, kept) = if cast { + vectorcraft_three_d::revolve_facets_shadowed(path, b, options).ok().map(|(f, kept)| (f, Some(kept)))? + } else { + (vectorcraft_three_d::revolve_facets(path, b, options).ok()?, None) + }; copies.surfaces = copies.surfaces.saturating_add(paint_copies(facets.len(), &paint)); if copies.surfaces > MAX_PAINT_COPIES { return None; } let depths = facets.iter().map(|f| f.depth).collect(); let p = merged_params(&fx.id, &fx.params); - return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p), kept)); } let colored = options.light_color != [1.0; 3] || options.lights.is_some(); - let faces = vectorcraft_three_d::revolve(path, b, options).ok()?; + let (faces, kept) = if cast { + vectorcraft_three_d::revolve_shadowed(path, b, options).ok().map(|(f, kept)| (f, Some(kept)))? + } else { + (vectorcraft_three_d::revolve(path, b, options).ok()?, None) + }; copies.surfaces = copies.surfaces.saturating_add(paint_copies(faces.len(), &paint)); if copies.surfaces > MAX_PAINT_COPIES { return None; @@ -517,11 +575,12 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some((depths, group)) + Some((depths, group, kept)) } -/// A surface of one object about the bounds of `path`, built with options derived from `fx`. -/// `copies` is the count [`surface`] adds to; its graphics take the symbols of `doc`. +/// A surface of one object about the bounds of `path`, built with options derived from `fx`, and +/// when `fx` casts a shadow from it, the faces it is cast from ([`surface`]). `copies` is the count +/// [`surface`] adds to; its graphics take the symbols of `doc`. fn object_surface( doc: Option<&Document>, n: &Node, @@ -530,10 +589,11 @@ fn object_surface( item: &AppearanceItem, expand: bool, copies: &mut Copies, -) -> Option { +) -> Option<(Node, Option)> { let b = path.bounds()?; let (paint, opacity, blend) = paint_of(item); - surface(doc, n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art) + let cast = casts(fx, opacity); + surface(doc, n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies, cast).map(|(_, art, kept)| (art, kept)) } /// The paint of a graphic's solid color `c` on `face`, shaded as the face shades a solid fill of @@ -727,9 +787,9 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let surface = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; + let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()? + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? { children.push(Arc::new(shadow)); } @@ -753,10 +813,10 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let mut art = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; + let (mut art, kept) = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() - && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()? + && let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx), kept).ok()? { shadow.appearance.effects = art.appearance.effects.clone(); shadows.push(Arc::new(shadow)); @@ -905,21 +965,28 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = Copies::default(); let mut surfaces = vec![]; + // The faces each member's surface kept for the shadow, member by member. + let mut kept = vec![]; let mut opaque = true; // The graphics go once, on the side of the member with the longest profile. Without graphics // no member is measured for them. let mapped = fx.id == REVOLVE && !crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params)).is_empty(); let longest = if mapped { graphics_member(&shapes, b, options) } else { None }; for (k, s) in shapes.iter().enumerate() { - let Some(item) = surface_item(&s.items) else { continue }; + let Some(item) = surface_item(&s.items) else { + kept.push(None); + continue; + }; // The opacity and blending of the member and the groups it is in apply through its scopes. let (paint, opacity, blend) = paint_of(item); opaque &= opacity >= 1.0 && blend == Default::default() && s.scopes.is_empty(); // A member with transparency of its own keeps every face, as a transparent object does. let expand = expand && s.scopes.is_empty(); let doc = doc.filter(|_| longest == Some(k)); - let (depths, art) = surface(doc, n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies)?; + let cast = casts(fx, opacity * s.opacity); + let (depths, art, faces) = surface(doc, n, &s.path, b, options, fx, paint, opacity, blend, expand, &mut copies, cast)?; surfaces.push((depths, art, s.scopes.as_slice())); + kept.push(faces); } if surfaces.is_empty() { return None; @@ -1017,7 +1084,7 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { }; // One shadow for every member's surface, behind them all. let mut children = children; - children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); + children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options, kept).ok()?); let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; @@ -1642,6 +1709,67 @@ mod tests { } assert_eq!(expand_revolve_art(&sample), revolve_art(&sample)); } + + /// The settings the shadows are compared with: the defaults, a partial sweep turned with + /// perspective, and an open surface away from its axis under a colored light. + fn shadow_settings() -> [Value; 3] { + [ + json!({"shadow": true}), + json!({"shadow": true, "rotationX": -25, "rotationY": 35, "perspective": 50, "angle": 220, "shadowDistance": 40}), + json!({"shadow": true, "shadowDistance": 30, "lightColor": "#ff8040", "offset": 20, "cap": false}), + ] + } + + /// The shadow of the faces Revolve and Revolve (Classic) kept when they swept their surfaces, + /// to the bit: the shadow of surfaces swept for the shadow alone, for one object, for an + /// object whose fill is half transparent, and for a group of two members, one at half opacity. + #[test] + fn the_shadow_cast_from_the_surfaces_drawn_is_the_shadow_of_surfaces_swept_for_it() { + for id in [REVOLVE, CLASSIC_REVOLVE] { + for params in shadow_settings() { + // Revolve (Classic) takes no shadow settings from commands, but a file can carry them. + let mut fx = crate::new_effect(id, &json!({})).unwrap(); + fx.params = params.clone(); + let n = profile_node(); + let (path, _) = profile(&n).unwrap(); + let b = path.bounds().unwrap(); + let mut half = n.clone(); + if let AppearanceItem::Fill(f) = &mut half.appearance.items[0] { + f.opacity = 0.5; + } + for n in [n, half] { + let item = surface_item(&n.appearance.items).unwrap(); + let (_, kept) = object_surface(None, &n, &path, &fx, item, false, &mut Copies::default()).unwrap(); + let drawn = shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), kept).unwrap(); + let alone = shadow_of(n.id, &fx, &path, item, b, fx_options(&fx), None).unwrap(); + assert!(alone.is_some() || id == CLASSIC_REVOLVE, "{id} {params}"); + assert_eq!(format!("{drawn:?}"), format!("{alone:?}"), "{id} {params}"); + } + let mut other = profile_node(); + other.id = NodeId(2); + other.transform(Affine::translate((120.0, 0.0)), false); + other.opacity = 0.5; + let mut g = Node::group(NodeId(9), vec![Arc::new(profile_node()), Arc::new(other)]); + g.appearance.effects.push(fx.clone()); + 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(); + assert!(kept.iter().all(Option::is_some), "{id} {params}"); + let drawn = group_shadow(g.id, &fx, &shapes, b, options, kept).unwrap(); + let alone = group_shadow(g.id, &fx, &shapes, b, options, vec![]).unwrap(); + assert!(!alone.is_empty() || id == CLASSIC_REVOLVE, "{id} {params}"); + assert_eq!(format!("{drawn:?}"), format!("{alone:?}"), "{id} {params}"); + } + } + } } #[cfg(test)] diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7500af0617..7b7ad3b27b 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; use vectorcraft_pathops::BoolOp; -use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, SolidFaces, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -232,6 +232,26 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> } } +/// [`facets`], and with `cast` for Extrude & Bevel or Rotate the faces the solid's shadow is cast +/// from, from the same build. Inflate's solids are built together ([`built_solids_cast`]), and the +/// classic effects cast no shadow: they keep no faces. +fn facets_and_faces( + id: &str, + path: &PathData, + rule: FillRule, + bounds: Rect, + p: &Value, + cast: bool, +) -> Result<(Vec, Option), String> { + let view = view_options(p); + let built = match id { + EXTRUDE if cast => vectorcraft_three_d::extrude_shadowed(path, rule, bounds, extrude_options(p), view), + ROTATE if cast => vectorcraft_three_d::plane_shadowed(path, rule, bounds, view), + _ => return facets(id, path, rule, bounds, p).map(|f| (f, None)), + }; + built.map(|(f, faces)| (f, Some(faces))) +} + /// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not /// need the object. fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { @@ -656,8 +676,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { #[cfg(test)] thread_local! { - /// How many solids [`built_solids`] has built on this thread, for the tests. - static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How many solids [`built_solids`] has built on this thread, for the tests. Rotate's planes + /// drawn, the solids built to cast a shadow ([`cast_shadow`]) and Revolve's surfaces count too. + pub(crate) static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from @@ -673,10 +694,35 @@ fn built_solids<'a>( bounds: Rect, limit: usize, ) -> impl Iterator>, String>> + 'a { + built_solids_cast(id, p, parts, bounds, limit, false).map(|r| r.map(|s| s.map(|(f, _)| f))) +} + +/// One solid's facets and, when its shadow is cast, the faces it is cast from. +type CastingSolid = (Vec, Option); + +/// The surfaces of an object's solids, and the faces each solid kept for its shadow +/// ([`surfaces_for`]). +type SurfacesAndFaces = (Vec>, Vec>); + +/// [`built_solids`], and with `cast` for Extrude & Bevel or Inflate each solid's faces its shadow +/// is cast from, from the same build ([`SolidFaces`]). +fn built_solids_cast<'a>( + id: &'a str, + p: &'a Value, + parts: &'a [Part<'a>], + bounds: Rect, + limit: usize, + cast: bool, +) -> impl Iterator, String>> + 'a { // Inflate's eye is placed for every puff together, as far away as the highest needs. - let mut inflated = (id == INFLATE).then(|| { + let mut inflated = (id == INFLATE).then(|| -> Box>> { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_each(&shapes, bounds, inflate_options(p), view_options(p)) + let (o, view) = (inflate_options(p), view_options(p)); + if cast { + Box::new(vectorcraft_three_d::inflate_shadowed(&shapes, bounds, o, view).map(|r| r.map(|(f, faces)| (f, Some(faces))))) + } else { + Box::new(vectorcraft_three_d::inflate_each(&shapes, bounds, o, view).map(|r| r.map(|f| (f, None)))) + } }); let mut kept = 0usize; let mut ended = false; @@ -687,12 +733,12 @@ fn built_solids<'a>( #[cfg(test)] SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); let built = match inflated.as_mut() { - Some(each) => each.next().unwrap_or_else(|| Ok(vec![])), - None => facets(id, area, *rule, bounds, p), + Some(each) => each.next().unwrap_or_else(|| Ok((vec![], None))), + None => facets_and_faces(id, area, *rule, bounds, p, cast), }; let result = match built { Ok(f) => { - kept = kept.saturating_add(f.len()); + kept = kept.saturating_add(f.0.len()); if kept > limit { Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()) } else { Ok(Some(f)) } } Err(_) if matches!(item, AppearanceItem::Stroke(_)) => Ok(None), @@ -1292,12 +1338,16 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it -/// off), to be drawn behind every surface of the object, and the surfaces. +/// off), cast from the faces the solids drawn kept ([`cast_built`]), to be drawn behind every +/// surface of the object, and the surfaces. fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { - let art = surfaces_for(doc, n, fx, shapes)?; + let p = crate::merged_params(&fx.id, &fx.params); + let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some(); + 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`]). - let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? { + let shadow = if cast { cast_built(&fx.id, &p, shapes, built) } else { Ok(None) }; + let shadow = match shadow.ok()? { Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)), None => vec![], }; @@ -1330,6 +1380,8 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result 0.0) { let (area, rule) = region(&s.path, s.rule, item); + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); let faces = caster.plane(&area, rule, b, view)?; cast.push((s.scopes.as_slice(), ink(item, s.opacity), faces)); } @@ -1341,10 +1393,14 @@ 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(); + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + shapes.len())); 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))); added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) }); } added @@ -1366,6 +1422,76 @@ 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| { + let (_, opacity, blend) = paint_of(item, member); + shadow::Ink { opacity, blend } + }; + if id == ROTATE { + let items: Vec<(&Profile, &AppearanceItem)> = shapes.iter().flat_map(|s| s.items.iter().map(move |item| (s, item))).collect(); + if items.len() != built.len() { + return cast_shadow(id, p, shapes); + } + for ((s, item), kept) in items.into_iter().zip(built) { + let ink = ink(item, s.opacity); + if ink.opacity <= 0.0 { + continue; + } + let faces = match kept { + Some(kept) => 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)); + } + 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())) { + 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) + } + }; + // 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 { + Ok(faces) => faces, + Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue, + Err(e) => return Err(e), + }; + cast.push((*scopes, ink, faces)); + } + Ok(Some((caster, settings, cast))) +} + /// The faces a shadow is cast from, its settings, and the faces of each solid of visible art with /// its member's scopes and ink ([`cast_shadow`]). type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec>); @@ -1507,8 +1633,10 @@ fn seen_ends(f: &[Facet]) -> impl Iterator { } /// [`art_for`] without the shadow: the solids' surfaces, with the graphics mapped onto their planar -/// faces drawn right after the last of the solids' surfaces that holds that face. -fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { +/// faces drawn right after the last of the solids' surfaces that holds that face. With `cast`, each +/// solid built also keeps the faces its shadow is cast from, from the same build, in the order the +/// solids are built ([`cast_built`]); `None` for a stroke's solid left out of the art. +fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile], cast: bool) -> Option { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; @@ -1523,11 +1651,15 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile // the centre of theirs), so they stay together. let mut out = vec![]; let mut filled = vec![]; + let mut built = vec![]; for (member, s) in shapes.iter().enumerate() { for item in &s.items { 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()?; + #[cfg(test)] + SOLIDS_BUILT.with(|n| n.set(n.get() + 1)); + let (f, faces) = facets_and_faces(ROTATE, &area, rule, b, &p, cast).ok()?; + built.push(faces); if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) { return None; } @@ -1544,19 +1676,23 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile } let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks); let out = with_graphics(out, 0, &arts); - return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); + return Some((if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }, built)); } let (parts, bounds) = solid_parts(shapes, b); let mut solids = vec![]; - for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES)) { + let mut built = vec![]; + for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids_cast(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES, cast)) { let item: &AppearanceItem = item; match f { - Ok(Some(f)) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => { + Ok(Some((f, _))) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => { return None; } - Ok(Some(f)) => solids.push((item, *opacity, *scopes, f, *member)), + Ok(Some((f, faces))) => { + solids.push((item, *opacity, *scopes, f, *member)); + built.push(faces); + } // A stroke's outline past the limits is left out; the fill's solid still draws. - Ok(None) => {} + Ok(None) => built.push(None), // A loaded file or a shape edited past the limits (its own, or those of the object's // solids together) keeps drawing its source path instead of failing the frame; // commands validate the same evaluator and report its errors. @@ -1678,12 +1814,12 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); } } - return Some(masked(n.id, out)); + return Some((masked(n.id, out), built)); } if !opaque || !surfaces.iter().all(plain) { - return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); + return Some((whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect(), built)); } - Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) + Some((vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))], built)) } /// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 7e7d2e1595..4d62961612 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -223,3 +223,92 @@ fn expand_appearance_keeps_the_shadow_as_a_path() { assert!((after.x0 - before.x0 - 40.0).abs() < 1e-6, "{id}: {before:?} {after:?}"); } } + +/// A square filled red and stroked blue, its stroke as opaque as `stroke`. +fn stroked_square(id: u64, dx: f64, stroke: f32) -> Node { + let mut n = square_node(id, dx); + n.appearance = Appearance::basic(Paint::solid(RED), Paint::solid(Color::rgb(0.1, 0.2, 0.9)), 6.0).into(); + for item in &mut n.appearance.items { + if let AppearanceItem::Stroke(s) = item { + s.opacity = stroke; + } + } + n +} + +/// Objects with 3D effect `id` and its shadow on (`params`): a filled and stroked square, one whose +/// stroke is half transparent, one whose stroke is fully transparent, and a group of two members, +/// one of them at half opacity. +fn shadowed(id: &str, params: &Value) -> Vec { + let with = |mut n: Node| { + n.appearance.effects.push(crate::new_effect(id, params).unwrap()); + n + }; + let mut half = stroked_square(2, 200.0, 1.0); + half.opacity = 0.5; + let group = Node::group(NodeId(9), vec![Arc::new(stroked_square(1, 0.0, 1.0)), Arc::new(half)]); + vec![with(stroked_square(1, 0.0, 1.0)), with(stroked_square(1, 0.0, 0.5)), with(stroked_square(1, 0.0, 0.0)), with(group)] +} + +/// The settings the shadows of effect `id` are compared with: the defaults, turned with +/// perspective, and under a colored light, below the object for Extrude & Bevel, Inflate and +/// Rotate and behind it for Revolve. +fn shadow_settings(id: &str) -> [Value; 3] { + let position = if id == crate::REVOLVE { "behind" } else { "below" }; + [ + json!({"shadow": true}), + json!({"shadow": true, "rotationX": -25, "rotationY": 35, "perspective": 50, "shadowDistance": 40}), + json!({"shadow": true, "shadowPosition": position, "shadowDistance": 30, "lightColor": "#ff8040", "twist": 30}), + ] +} + +/// How many solids drawing the art of `n` with effect `id` builds. +fn solids_built(n: &Node, id: &str) -> usize { + let start = SOLIDS_BUILT.with(std::cell::Cell::get); + assert!(art(n, id).is_some(), "{id}"); + SOLIDS_BUILT.with(std::cell::Cell::get) - start +} + +/// Drawing a 3D object with its shadow on builds each of its solids once: the shadow is cast from +/// the faces the solids drawn kept, so it builds no more solids than the art without a shadow. +#[test] +fn a_shadowed_3d_object_builds_each_solid_once() { + for id in CASTING { + for params in shadow_settings(id) { + for (lit, mut plain) in shadowed(id, ¶ms).into_iter().zip(shadowed(id, ¶ms)) { + plain.appearance.effects.iter_mut().for_each(|e| e.params["shadow"] = json!(false)); + let mut cast = 0; + art(&lit, id).unwrap().walk(&mut |c| cast += usize::from(shadow_opacity(c).is_some())); + assert!(cast > 0, "{id} {params}"); + let built = solids_built(&plain, id); + assert!(built > 0, "{id}"); + assert_eq!(solids_built(&lit, id), built, "{id} {params}: the shadow builds no solid of its own"); + } + } + } +} + +/// The shadow of the faces Extrude & Bevel, Inflate and Rotate kept when they drew their solids, +/// to the bit: the shadow [`cast_shadow`] casts from solids built for the shadow alone. +#[test] +fn the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it() { + for id in [EXTRUDE, INFLATE, ROTATE] { + for params in shadow_settings(id) { + for n in shadowed(id, ¶ms) { + 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 (drawn, _) = art_for(None, &n, fx, &shapes).unwrap(); + let p = crate::merged_params(&fx.id, &fx.params); + let (caster, settings, cast) = cast_shadow(&fx.id, &p, &shapes).unwrap().unwrap(); + let alone = shadow::scoped_art(n.id, &caster, settings, cast, has_masks(&shapes)); + assert!(!alone.is_empty(), "{id} {params}"); + assert_eq!(format!("{drawn:?}"), format!("{alone:?}"), "{id} {params}"); + } + } + } +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 309aa77409..5c1d11c3ff 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -21,7 +21,10 @@ mod caps; mod mesh; pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; mod shadow; -pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW}; +pub use shadow::{ + MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, SolidFaces, TOO_COMPLEX_SHADOW, extrude_shadowed, inflate_shadowed, + plane_shadowed, revolve_facets_shadowed, revolve_shadowed, +}; use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveDeriv}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2}; @@ -948,21 +951,136 @@ fn revolve_face_limit(o: Revolve) -> usize { /// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn, /// each turned out of the region `rule` fills. -fn revolve_faces( +fn revolve_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Revolve, mesh: Option<&mut Vec>) -> Result, String> { + let draw = mesh.is_none(); + revolve_sweep(path, rule, bounds, options, draw, mesh).map(|(faces, _)| faces) +} + +#[cfg(test)] +thread_local! { + /// How many Revolve surfaces this thread has swept, for the tests. + pub(crate) static REVOLVED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One contour's profile as a sweep takes it: its points, the tangents at the ends of each edge, +/// and whether each edge walls a hole. +#[derive(Default)] +struct Swept { + points: Vec, + tangents: Vec<[Vec2; 2]>, + holes: Vec, +} + +/// Where a sweep places a profile: about the axis at `axis` and the middle height `center_y`, +/// turned through `sweep` radians in `steps` steps, with the sanitized options `o`. +struct Sweeping { + o: Revolve, + axis: f64, + center_y: f64, + sweep: f64, + steps: usize, +} + +impl Sweeping { + /// Profile point `p` turned to the end of step `step`, in view space. + fn vertex(&self, p: Point, step: usize) -> V3 { + let (s, c) = (self.sweep * step as f64 / self.steps as f64).sin_cos(); + self.o.rotate(V3((p.x - self.axis) * c, p.y - self.center_y, (p.x - self.axis) * s)) + } +} + +/// The end `index` (0 the first, 1 the last) of `profile` and the way its cap faces along the axis: +/// the sign of the end's y less its neighbor's, or with the two level, -1 for an end above the +/// middle height `center_y` and 1 otherwise. +fn cap_outward(profile: &[Point], index: usize, center_y: f64) -> Option<(Point, f64)> { + let neighbours = if index == 0 { profile.first().zip(profile.get(1)) } else { profile.last().zip(profile.get(profile.len().saturating_sub(2))) }; + neighbours.map(|(end, neighbour)| { + let dy = end.y - neighbour.y; + let outward = if dy.abs() > 1e-10 { + dy.signum() + } else if end.y < center_y { + -1.0 + } else { + 1.0 + }; + (*end, outward) + }) +} + +/// Keep for a mesh the faces of one contour's profile `s`, which runs round the region it sweeps as +/// `turn` says (its holes' walls turned the other way): a quad for each edge at each step, then for +/// each end in `caps` (0 the first, 1 the last) the disk, or for a sweep of less than a full turn +/// the sector with its center on the axis. `keep` keeps one face with its outward normal; its +/// error, or an edge with invalid coordinates, stops the faces. +fn keep_profile(at: &Sweeping, s: &Swept, turn: f64, caps: &[usize], mut keep: impl FnMut(Vec, V3) -> Result<(), String>) -> Result<(), String> { + let (o, steps) = (at.o, at.steps); + for (pair, hole) in s.points.windows(2).zip(&s.holes) { + let [a, b] = pair else { continue }; + let turn = if *hole { -turn } else { turn }; + if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { + return Err("Revolve profile contains invalid coordinates".into()); + } + for j in 0..steps { + let [v0, v1, v2, v3] = [at.vertex(*a, j), at.vertex(*b, j), at.vertex(*b, j + 1), at.vertex(*a, j + 1)]; + // The edge's outward normal in the profile's plane (distance from the axis, height), + // swept to the middle of the step. + let (dr, dy) = ((b.x - a.x) * turn, (b.y - a.y) * turn); + let (sin, cos) = (at.sweep * (j as f64 + 0.5) / steps as f64).sin_cos(); + keep(vec![v0, v1, v2, v3], o.rotate(V3(dy * cos, -dr, dy * sin)))?; + } + } + for &index in caps { + let Some((end, outward)) = cap_outward(&s.points, index, at.center_y) else { continue }; + let mut cap: Vec = (0..=steps).map(|j| at.vertex(end, j)).collect(); + if o.angle >= 360.0 { + cap.pop(); + } else { + cap.insert(0, o.rotate(V3(0.0, end.y - at.center_y, 0.0))); + } + keep(cap, o.rotate(V3(0.0, outward, 0.0)))?; + } + Ok(()) +} + +/// Keep no more faces for a mesh after error `e`, which is the result when no face is drawn. +fn lose_mesh(draw: bool, mesh: &mut Option<&mut Vec>, lost: &mut Option, e: String) -> Result<(), String> { + if !draw { + return Err(e); + } + *mesh = None; + lost.get_or_insert(e); + Ok(()) +} + +/// [`revolve`]'s faces when `draw`, and with `mesh` the surface's faces before projection kept +/// there from the same sweep, each turned out of the region `rule` fills. While faces are drawn, an +/// error keeping faces for the mesh stops only the mesh: the error comes back beside the faces +/// drawn, and `mesh` keeps no more faces. Without `draw` that error is the result. +fn revolve_sweep( path: &PathData, rule: FillRule, bounds: Rect, options: Revolve, + draw: bool, mut mesh: Option<&mut Vec>, -) -> Result, String> { +) -> Result<(Vec, Option), String> { + #[cfg(test)] + REVOLVED.with(|n| n.set(n.get() + 1)); let o = options.sanitized(); let curve_samples = match o.curve_samples { Some(samples) => samples.clamp(1, CURVE_SAMPLES), None => revolve_curve_samples([path], o)?, }; + let mut lost = None; // For a mesh, each piece of a closed contour's edge is cut where it crosses another contour, so // each part's normal turns toward a hole only where it walls one. - let mut search = if mesh.is_some() { Some(HoleSearch::new(path, rule)?) } else { None }; + let mut search = None; + if mesh.is_some() { + match HoleSearch::new(path, rule) { + Ok(s) => search = Some(s), + Err(e) => lose_mesh(draw, &mut mesh, &mut lost, e)?, + } + } let face_limit = revolve_face_limit(o); // A face kept for a mesh: its normal follows from its corners. let keep = |mesh: &mut Vec, face: Vec, normal: V3| { @@ -1000,10 +1118,8 @@ fn revolve_faces( // Only the longest contour of a compound profile carries the graphics. let surface_contour = o.map_surface.then(|| longest_contour(path, axis)).flatten(); let mut faces = Vec::new(); - let vertex = |p: Point, step: usize| { - let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); - o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s)) - }; + let at = Sweeping { o, axis, center_y, sweep, steps }; + let vertex = |p: Point, step: usize| at.vertex(p, step); let normal = |tangent: Vec2, step: usize| { let (s, c) = (sweep * step as f64 / steps as f64).sin_cos(); o.rotate(V3(tangent.y * c, -tangent.x, tangent.y * s).unit()) @@ -1019,17 +1135,26 @@ fn revolve_faces( } Ok(()) }; - let mut count = 0; + // The points of the profile drawn, and of the profile kept for the mesh, which takes the parts + // its edges are cut into. + let (mut count, mut kept_count) = (0, 0); let mut curve_budget = 0; for (contour, sp) in path.subpaths.iter().enumerate() { - let mut profile = Vec::new(); - let mut tangents = Vec::new(); - let mut hole_edges = Vec::new(); + let (mut drawn, mut kept) = (Swept::default(), Swept::default()); if let Some(first) = sp.anchors.first() { - profile.push(first.p); - count += 1; - if count > MAX_PROFILE_POINTS { - return Err(TOO_COMPLEX_PROFILE.into()); + if draw { + drawn.points.push(first.p); + count += 1; + if count > MAX_PROFILE_POINTS { + return Err(TOO_COMPLEX_PROFILE.into()); + } + } + if mesh.is_some() { + kept.points.push(first.p); + kept_count += 1; + if kept_count > MAX_PROFILE_POINTS { + lose_mesh(draw, &mut mesh, &mut lost, TOO_COMPLEX_PROFILE.into())?; + } } } let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) }; @@ -1061,22 +1186,40 @@ fn revolve_faces( let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2(); if v.hypot2() > 1e-20 { v } else { b - a } }; - let a = profile.last().copied().unwrap_or(b); - let parts = match search.as_mut() { - Some(search) => search.parts(contour, a, b)?, - None => vec![(b, false)], - }; - for (end, hole) in parts { - tangents.push([tangent(t0), tangent(t1)]); - hole_edges.push(hole); - profile.push(end); + let ends = [tangent(t0), tangent(t1)]; + if draw { + drawn.tangents.push(ends); + drawn.holes.push(false); + drawn.points.push(b); count += 1; if count > MAX_PROFILE_POINTS { return Err(TOO_COMPLEX_PROFILE.into()); } } + let Some(search) = search.as_mut().filter(|_| mesh.is_some()) else { continue }; + let a = kept.points.last().copied().unwrap_or(b); + let parts = match search.parts(contour, a, b) { + Ok(parts) => parts, + Err(e) => { + lose_mesh(draw, &mut mesh, &mut lost, e)?; + continue; + } + }; + for (end, hole) in parts { + kept.tangents.push(ends); + kept.holes.push(hole); + kept.points.push(end); + kept_count += 1; + if kept_count > MAX_PROFILE_POINTS { + lose_mesh(draw, &mut mesh, &mut lost, TOO_COMPLEX_PROFILE.into())?; + break; + } + } } } + // The two profiles start and end at the same points: the parts of the last edge end where + // it does. + let profile = if draw { &drawn.points } else { &kept.points }; let ends = o.cap_ends && !sp.closed && profile.len() >= 2 && profile.first().zip(profile.last()).is_some_and(|(a, b)| a.distance(*b) > 1e-10); let mut rings = [None, None]; if ends { @@ -1088,33 +1231,49 @@ fn revolve_faces( } } } - // For a mesh: which way the profile runs round the region it sweeps (closed against the - // axis at its ends when it is open), so the faces' normals point out of the solid. - let turn = { - let mut ring: Vec<(f64, f64)> = profile.iter().map(|p| (p.x - axis, p.y)).collect(); - if let (false, Some(first), Some(last)) = (sp.closed, profile.first(), profile.last()) { - ring.extend([(0.0, last.y), (0.0, first.y)]); + // Which way a profile runs round the region it sweeps (closed against the axis at its ends + // when it is open), so the faces' normals point out of the solid. A closed contour that does + // not touch the axis has its side cut opposite the middle of its outer side; an anchor goes + // there, so no face runs across the cut. That one anchor is not counted against the limit + // on the profile's points. + let prepare = |s: &mut Swept| { + let turn = { + let mut ring: Vec<(f64, f64)> = s.points.iter().map(|p| (p.x - axis, p.y)).collect(); + if let (false, Some(first), Some(last)) = (sp.closed, s.points.first(), s.points.last()) { + ring.extend([(0.0, last.y), (0.0, first.y)]); + } + let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); + if area < 0.0 { -1.0 } else { 1.0 } + }; + if sp.closed + && surface_contour == Some(contour) + && let Some((k, t)) = ring_cut(&s.points, axis) + && let (Some(a), Some(b), Some([ta, tb]), Some(hole)) = + (s.points.get(k), s.points.get(k + 1), s.tangents.get(k).copied(), s.holes.get(k).copied()) + { + let point = *a + (*b - *a) * t; + let middle = ta + (tb - ta) * t; + s.points.insert(k + 1, point); + s.tangents.splice(k..=k, [[ta, middle], [middle, tb]]); + s.holes.insert(k + 1, hole); } - let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); - if area < 0.0 { -1.0 } else { 1.0 } + turn }; - // A closed contour that does not touch the axis has its side cut opposite the middle of its - // outer side; an anchor goes there, so no face runs across the cut. That one anchor is not - // counted against the limit on the profile's points. - if sp.closed - && surface_contour == Some(contour) - && let Some((k, t)) = ring_cut(&profile, axis) - && let (Some(a), Some(b), Some([ta, tb]), Some(hole)) = - (profile.get(k), profile.get(k + 1), tangents.get(k).copied(), hole_edges.get(k).copied()) - { - let point = *a + (*b - *a) * t; - let middle = ta + (tb - ta) * t; - profile.insert(k + 1, point); - tangents.splice(k..=k, [[ta, middle], [middle, tb]]); - hole_edges.insert(k + 1, hole); + if let Some(m) = mesh.as_deref_mut() { + let turn = prepare(&mut kept); + let caps: Vec = rings.iter().enumerate().filter_map(|(i, r)| r.as_ref().map(|_| i)).collect(); + let kept = keep_profile(&at, &kept, turn, &caps, |face, normal| keep(m, face, normal)); + if let Err(e) = kept { + lose_mesh(draw, &mut mesh, &mut lost, e)?; + } } - let along = along_profile(&profile, axis, sp.closed); - for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(tangents).zip(hole_edges).enumerate() { + if !draw { + continue; + } + let turn = prepare(&mut drawn); + let profile = &drawn.points; + let along = along_profile(profile, axis, sp.closed); + for (i, ((pair, [ta, tb]), hole)) in profile.windows(2).zip(drawn.tangents.iter().copied()).zip(drawn.holes.iter().copied()).enumerate() { let [a, b] = pair else { continue }; let turn = if hole { -turn } else { turn }; let [va, vb] = along.get(i).copied().unwrap_or_default(); @@ -1123,14 +1282,6 @@ fn revolve_faces( } for j in 0..steps { let [v0, v1, v2, v3] = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)]; - if let Some(mesh) = mesh.as_deref_mut() { - // The edge's outward normal in the profile's plane (distance from the axis, - // height), swept to the middle of the step. - let (dr, dy) = ((b.x - a.x) * turn, (b.y - a.y) * turn); - let (s, c) = (sweep * (j as f64 + 0.5) / steps as f64).sin_cos(); - keep(mesh, vec![v0, v1, v2, v3], o.rotate(V3(dy * c, -dr, dy * s)))?; - continue; - } let first = (i == 0).then(|| rings.first().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); let last = (i + 2 == profile.len()).then(|| rings.last().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten(); // Where the corners lie on the unrolled side surface, and whether the face shows @@ -1173,28 +1324,7 @@ fn revolve_faces( } } for (index, ring) in rings.iter().enumerate().filter_map(|(i, r)| r.as_ref().map(|r| (i, r))) { - let neighbours = - if index == 0 { profile.first().zip(profile.get(1)) } else { profile.last().zip(profile.get(profile.len().saturating_sub(2))) }; - if let Some((end, neighbour)) = neighbours { - let dy = end.y - neighbour.y; - let outward = if dy.abs() > 1e-10 { - dy.signum() - } else if end.y < center_y { - -1.0 - } else { - 1.0 - }; - if let Some(mesh) = mesh.as_deref_mut() { - // The disk, or for a partial sweep the sector with its centre on the axis. - let mut cap: Vec = (0..=steps).map(|j| vertex(*end, j)).collect(); - if o.angle >= 360.0 { - cap.pop(); - } else { - cap.insert(0, o.rotate(V3(0.0, end.y - center_y, 0.0))); - } - keep(mesh, cap, o.rotate(V3(0.0, outward, 0.0)))?; - continue; - } + if let Some((_, outward)) = cap_outward(profile, index, center_y) { // Keep the original tessellation work budget, even though each cap // now produces only one curved vector face. emit(ring.face(bounds, o, light, outward)?, steps)?; @@ -1202,7 +1332,7 @@ fn revolve_faces( } } faces.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - Ok(faces) + Ok((faces, lost)) } #[cfg(test)] diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs index b12bd315f5..b3d10c4166 100644 --- a/crates/three-d/src/mesh.rs +++ b/crates/three-d/src/mesh.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{FillRule, PathData, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; use crate::{Extrude, Inflate, Revolve, V3, View}; /// Most steps the triangulation of one solid may take: a face's ears and bridges each test the @@ -56,12 +56,12 @@ impl Mesh { /// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { - extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces)) + extrude_faces(path, rule, bounds, options, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Rotate's plane ([`crate::plane`]) as a mesh. pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { - plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces)) + plane_faces(path, rule, bounds, view, Keep::Faces).and_then(|(_, faces, _)| mesh(&faces?)) } /// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one @@ -72,7 +72,7 @@ pub fn inflate_meshes( options: Inflate, view: View, ) -> impl Iterator> + use<> { - inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces))) + inflate_faces(shapes, bounds, options, view, Keep::Faces).map(|r| r.and_then(|(_, faces, _)| mesh(&faces?))) } /// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than diff --git a/crates/three-d/src/shadow.rs b/crates/three-d/src/shadow.rs index d7cd01a869..1c2fc661da 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -11,8 +11,8 @@ use std::ops::Range; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; -use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; -use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; +use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Face, Facet, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; /// Where the shadow falls. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -160,14 +160,14 @@ impl ShadowCaster { /// Add Extrude & Bevel's solid ([`crate::extrude`]). pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Rotate's plane ([`crate::plane`]). pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?; - self.add(faces, projection) + let (_, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Faces)?; + self.add(faces?, projection) } /// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each @@ -182,11 +182,11 @@ impl ShadowCaster { options: Inflate, view: View, ) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, true) + inflate_faces(shapes, bounds, options, view, Keep::Faces) .enumerate() .map(|(i, r)| { let (_, faces, projection) = r?; - if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) } + if casts.get(i).copied().unwrap_or(true) { self.add(faces?, projection) } else { Ok(self.faces.len()..self.faces.len()) } }) .collect() } @@ -199,6 +199,14 @@ impl ShadowCaster { self.add(faces, Some(crate::revolve_projection(bounds, o))) } + /// Add the faces of a solid kept by the build that drew it ([`extrude_shadowed`], + /// [`plane_shadowed`], [`inflate_shadowed`], [`revolve_shadowed`]): the faces + /// [`ShadowCaster::extrude`], [`ShadowCaster::plane`], [`ShadowCaster::inflate`] or + /// [`ShadowCaster::revolve`] adds for that solid, or the error it gives. + pub fn add_solid(&mut self, solid: SolidFaces) -> Result, String> { + self.add(solid.faces?, solid.projection) + } + /// The shadow of the faces gathered, on the page, filled with the nonzero rule: `None` when /// there are none, when the first light lights nothing (Shading is off or its intensity is 0) /// or does not reach the plane (it is behind the object for a shadow behind it, or below the @@ -287,6 +295,56 @@ impl ShadowCaster { } } +/// A solid's faces before projection, kept by the build that drew it, for its shadow +/// ([`ShadowCaster::add_solid`]): the faces, or the error keeping them gave, and the projection the +/// solid was drawn with. +#[derive(Clone, Debug)] +pub struct SolidFaces { + faces: Result, String>, + projection: Option, +} + +/// Extrude & Bevel's solid ([`crate::extrude`]) and, from the same build, the faces +/// [`ShadowCaster::extrude`] adds for it. An error where [`crate::extrude`] gives one. +pub fn extrude_shadowed(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<(Vec, SolidFaces), String> { + let (facets, faces, projection) = extrude_faces(path, rule, bounds, options, view, Keep::Both)?; + Ok((facets, SolidFaces { faces, projection })) +} + +/// Rotate's plane ([`crate::plane`]) and, from the same build, the face [`ShadowCaster::plane`] +/// adds for it. An error where [`crate::plane`] gives one. +pub fn plane_shadowed(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<(Vec, SolidFaces), String> { + let (facets, faces, projection) = plane_faces(path, rule, bounds, view, Keep::Both)?; + Ok((facets, SolidFaces { faces, projection })) +} + +/// Inflate's solids for several shapes of one object ([`crate::inflate_each`]) and, from the same +/// builds, the faces [`ShadowCaster::inflate`] adds for each, each solid built as the iterator +/// reaches it. An error where [`crate::inflate_each`] gives one. +pub fn inflate_shadowed( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator, SolidFaces), String>> + use<> { + inflate_faces(shapes, bounds, options, view, Keep::Both).map(|r| r.map(|(facets, faces, projection)| (facets, SolidFaces { faces, projection }))) +} + +/// Revolve's surface ([`crate::revolve`]) and, from the same sweep, the faces +/// [`ShadowCaster::revolve`] adds for it with the nonzero fill rule. An error where +/// [`crate::revolve`] gives one. +pub fn revolve_shadowed(path: &PathData, bounds: Rect, options: Revolve) -> Result<(Vec, SolidFaces), String> { + let mut kept = vec![]; + let (faces, lost) = crate::revolve_sweep(path, FillRule::NonZero, bounds, options, true, Some(&mut kept))?; + let projection = Some(crate::revolve_projection(bounds, options.sanitized())); + Ok((faces, SolidFaces { faces: lost.map_or(Ok(kept), Err), projection })) +} + +/// [`revolve_shadowed`] with the faces as facets, as [`crate::revolve_facets`] gives them. +pub fn revolve_facets_shadowed(path: &PathData, bounds: Rect, options: Revolve) -> Result<(Vec, SolidFaces), String> { + revolve_shadowed(path, bounds, options).map(|(faces, kept)| (faces.into_iter().map(Facet::of_face).collect(), kept)) +} + /// Whether `area`, the signed area of a polygon with corners `points`, is within rounding of /// nothing for its size (the square of its bounds' diagonal), or not a number, so the polygon draws /// nothing. A long, narrow polygon keeps an area well above that. diff --git a/crates/three-d/src/shadow/tests.rs b/crates/three-d/src/shadow/tests.rs index 3fb3767177..b6401b0626 100644 --- a/crates/three-d/src/shadow/tests.rs +++ b/crates/three-d/src/shadow/tests.rs @@ -185,3 +185,244 @@ fn every_effect_casts_a_shadow() { // The surface turns about the square's left edge, reaching 100 to either side of it. assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}"); } + +/// The faces `caster` gathered and the projection it keeps, every number to the bit. +fn gathered(caster: &ShadowCaster) -> String { + format!("{:?} {:?}", caster.faces, caster.projection) +} + +/// What a solid kept for its shadow casts ([`casts`]). +fn cast_from(solid: SolidFaces) -> Result<(String, Option, Option), String> { + let mut caster = ShadowCaster::new(); + caster.add_solid(solid)?; + Ok(casts(&caster)) +} + +/// The faces `caster` gathered, and the shadows they cast behind and below. +fn casts(caster: &ShadowCaster) -> (String, Option, Option) { + let below = Shadow { position: ShadowPosition::Below, distance: 0.3, ..Shadow::default() }; + (gathered(caster), caster.path(Shadow::default()), caster.path(below)) +} + +/// A star of `points` points about (100, 100), its edges curved, with a square hole. +fn star(points: usize) -> PathData { + let mut b = BezPath::new(); + for k in 0..2 * points { + let (s, c) = (k as f64 * std::f64::consts::PI / points as f64).sin_cos(); + let r = if k % 2 == 0 { 90.0 } else { 45.0 }; + let p = (100.0 + r * c, 100.0 + r * s); + if k == 0 { b.move_to(p) } else { b.curve_to((p.0 + 3.0, p.1), (p.0, p.1 + 3.0), p) } + } + b.close_path(); + rect(&mut b, 90.0, 90.0, 20.0, 20.0); + PathData::from_bezpath(&b) +} + +/// A closed rectangle from (`x`, `y`), `w` wide and `h` high. +fn rect(b: &mut BezPath, x: f64, y: f64, w: f64, h: f64) { + b.move_to((x, y)); + b.line_to((x + w, y)); + b.line_to((x + w, y + h)); + b.line_to((x, y + h)); + b.close_path(); +} + +/// The views the solids are compared in: straight on, turned, with perspective, and with colored +/// lights. +fn views() -> Vec { + let turned = View { rotation_x: -25.0, rotation_y: 35.0, rotation_z: 10.0, ..View::default() }; + vec![front(), turned, View { perspective: 60.0, ..turned }, View { light_color: [1.0, 0.5, 0.25], ..turned }] +} + +/// Extrude & Bevel built once for its facets and its shadow gives the facets [`crate::extrude`] +/// gives and casts what [`ShadowCaster::extrude`] casts, to the bit, errors included. +#[test] +fn an_extruded_solid_and_its_shadow_match_the_two_built_apart() { + use crate::{Bevel, BevelShape}; + let bevel = Bevel { shape: BevelShape::Rounded, width: 6.0, height: 5.0, ..Bevel::default() }; + let options = [ + Extrude::default(), + Extrude { depth: 0.0, ..Extrude::default() }, + Extrude { cap: false, ..Extrude::default() }, + Extrude { hidden: true, ..Extrude::default() }, + Extrude { twist: 90.0, taper: 0.6, ..Extrude::default() }, + Extrude { bevel: Some(bevel), ..Extrude::default() }, + Extrude { bevel: Some(Bevel { both_sides: true, inside: true, ..bevel }), twist: 45.0, ..Extrude::default() }, + ]; + let path = star(7); + let r = path.bounds().unwrap(); + for view in views() { + for o in options { + let (facets, faces) = extrude_shadowed(&path, FillRule::NonZero, r, o, view).unwrap(); + let apart = crate::extrude(&path, FillRule::NonZero, r, o, view).unwrap(); + assert_eq!(format!("{facets:?}"), format!("{apart:?}"), "{o:?} {view:?}"); + let mut caster = ShadowCaster::new(); + caster.extrude(&path, FillRule::NonZero, r, o, view).unwrap(); + assert!(!caster.is_empty()); + assert_eq!(cast_from(faces), Ok(casts(&caster)), "{o:?} {view:?}"); + } + } + let flat = Rect::new(0.0, 0.0, f64::INFINITY, 10.0); + let error = crate::extrude(&path, FillRule::NonZero, flat, Extrude::default(), front()).err(); + assert!(error.is_some()); + assert_eq!(extrude_shadowed(&path, FillRule::NonZero, flat, Extrude::default(), front()).err(), error); +} + +/// Rotate's plane built once for its facet and its shadow matches the two built apart. +#[test] +fn a_rotated_plane_and_its_shadow_match_the_two_built_apart() { + let path = star(5); + let r = path.bounds().unwrap(); + for view in views() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (facets, faces) = plane_shadowed(&path, rule, r, view).unwrap(); + assert_eq!(format!("{facets:?}"), format!("{:?}", crate::plane(&path, rule, r, view).unwrap()), "{view:?}"); + let mut caster = ShadowCaster::new(); + caster.plane(&path, rule, r, view).unwrap(); + assert_eq!(cast_from(faces), Ok(casts(&caster)), "{view:?}"); + } + } +} + +/// Inflate's solids built once for their facets and shadows match the two built apart: several +/// puffs under one eye, both sides, open backs, and twisted sides cut finer than room is left for. +#[test] +fn inflated_solids_and_their_shadows_match_the_two_built_apart() { + let star = star(5); + let box_ = square(200.0, 20.0, 60.0); + let ring: Vec = (0..600) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / 600.0).sin_cos(); + Point::new(100.0 + 90.0 * c, 100.0 + 90.0 * s) + }) + .collect(); + let ring = PathData::single(vectorcraft_geom::SubPath::polyline(&ring, true)); + let sets = [vec![(&star, FillRule::NonZero), (&box_, FillRule::NonZero), (&star, FillRule::EvenOdd)], vec![(&ring, FillRule::NonZero)]]; + let bounds = Rect::new(0.0, 0.0, 280.0, 200.0); + let options = [ + Inflate::default(), + Inflate { depth: 30.0, both_sides: true, ..Inflate::default() }, + Inflate { depth: 20.0, cap: false, volume: 80.0, ..Inflate::default() }, + Inflate { depth: 40.0, twist: 360.0, taper: 0.7, both_sides: true, ..Inflate::default() }, + // Thin and barely twisted: the star's sides want more pieces than room is left after its + // front and back, and the shadow, which keeps the faces turned away, has less room left. + Inflate { depth: 0.1, twist: 0.1, both_sides: true, ..Inflate::default() }, + ]; + for shapes in &sets { + for view in views() { + for o in options { + let built: Vec<_> = inflate_shadowed(shapes, bounds, o, view).collect(); + let facets: Vec<_> = crate::inflate_each(shapes, bounds, o, view).collect(); + let mut caster = ShadowCaster::new(); + let added = caster.inflate(shapes, &vec![true; shapes.len()], bounds, o, view); + assert!(added.iter().all(Result::is_ok)); + assert_eq!(built.len(), shapes.len()); + let mut one = ShadowCaster::new(); + for (b, f) in built.into_iter().zip(&facets) { + let ((facets, faces), f) = (b.unwrap(), f.as_ref().unwrap()); + assert_eq!(format!("{facets:?}"), format!("{f:?}"), "{o:?} {view:?}"); + one.add_solid(faces).unwrap(); + } + assert_eq!(casts(&one), casts(&caster), "{o:?} {view:?}"); + } + } + } +} + +/// Revolve's surface swept once for its faces and its shadow gives the faces [`crate::revolve`] +/// gives and casts what [`ShadowCaster::revolve`] casts, to the bit: an open profile with caps, +/// closed contours that cross (their walls cut where they do), a hole, partial sweeps, smooth and +/// flat shading, and the cut side that graphics take. +#[test] +fn a_revolved_surface_and_its_shadow_match_the_two_swept_apart() { + let mut open = BezPath::new(); + open.move_to((120.0, 0.0)); + open.curve_to((160.0, 20.0), (100.0, 60.0), (140.0, 90.0)); + open.line_to((125.0, 120.0)); + let mut crossing = BezPath::new(); + rect(&mut crossing, 110.0, 0.0, 60.0, 80.0); + rect(&mut crossing, 150.0, 40.0, 40.0, 80.0); + let mut holed = BezPath::new(); + holed.move_to((110.0, 0.0)); + holed.curve_to((200.0, 0.0), (200.0, 120.0), (110.0, 120.0)); + holed.close_path(); + rect(&mut holed, 130.0, 40.0, 20.0, 30.0); + let paths = [open, crossing, holed].map(|b| PathData::from_bezpath(&b)); + let turned = Revolve { rotation_x: -20.0, rotation_y: 30.0, ..Revolve::default() }; + let options = [ + Revolve::default(), + turned, + Revolve { angle: 200.0, perspective: 40.0, ..turned }, + Revolve { smooth_shading: false, segments: 12, ..turned }, + Revolve { cap_ends: false, offset: 15.0, ..turned }, + Revolve { map_surface: true, edge: crate::Edge::Right, ..turned }, + Revolve { map_surface: true, angle: 300.0, curve_samples: Some(5), ..turned }, + ]; + for path in &paths { + let bounds = path.bounds().unwrap(); + for o in options { + let (faces, kept) = revolve_shadowed(path, bounds, o).unwrap(); + assert_eq!(format!("{faces:?}"), format!("{:?}", crate::revolve(path, bounds, o).unwrap()), "{o:?}"); + let (facets, also) = revolve_facets_shadowed(path, bounds, o).unwrap(); + assert_eq!(format!("{facets:?}"), format!("{:?}", crate::revolve_facets(path, bounds, o).unwrap()), "{o:?}"); + assert_eq!(cast_from(also), cast_from(kept.clone()), "{o:?}"); + let mut caster = ShadowCaster::new(); + caster.revolve(path, FillRule::NonZero, bounds, o).unwrap(); + assert!(!caster.is_empty()); + assert_eq!(cast_from(kept), Ok(casts(&caster)), "{o:?}"); + } + } +} + +/// A surface whose shadow is past the limit on the profile's points (its walls cut where they +/// cross another contour) still draws every face, and its shadow gives the error the shadow swept +/// alone gives. +#[test] +fn a_surface_whose_shadow_is_too_complex_still_draws() { + let mut b = BezPath::new(); + b.move_to((110.0, 0.0)); + for i in 1..6000 { + b.line_to((110.0 + f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 10.0 })); + } + b.line_to((110.0, 20.0)); + b.close_path(); + rect(&mut b, 100.0, 4.0, 140.0, 2.0); + let path = PathData::from_bezpath(&b); + let bounds = path.bounds().unwrap(); + let o = Revolve { segments: 4, smooth_shading: false, ..Revolve::default() }; + let (faces, kept) = revolve_shadowed(&path, bounds, o).unwrap(); + let drawn = crate::revolve(&path, bounds, o).unwrap(); + assert!(!drawn.is_empty()); + assert!(faces == drawn); + let alone = ShadowCaster::new().revolve(&path, FillRule::NonZero, bounds, o).unwrap_err(); + assert!(alone.contains("too complex"), "{alone}"); + assert_eq!(cast_from(kept).unwrap_err(), alone); +} + +/// One build gives the facets and the faces of the shadow: each solid is built once. +#[test] +fn a_solid_and_its_shadow_are_built_once() { + let path = star(5); + let r = path.bounds().unwrap(); + let count = |n: &'static std::thread::LocalKey>| n.with(std::cell::Cell::get); + let start = count(&crate::solid::EXTRUDED); + let (facets, faces) = extrude_shadowed(&path, FillRule::NonZero, r, Extrude::default(), front()).unwrap(); + assert!(!facets.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + assert_eq!(count(&crate::solid::EXTRUDED), start + 1); + let start = count(&crate::solid::PLANES); + let (facets, faces) = plane_shadowed(&path, FillRule::NonZero, r, front()).unwrap(); + assert!(!facets.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + assert_eq!(count(&crate::solid::PLANES), start + 1); + let start = count(&crate::solid::INFLATED); + let box_ = square(200.0, 20.0, 60.0); + let shapes = [(&path, FillRule::NonZero), (&box_, FillRule::NonZero)]; + for solid in inflate_shadowed(&shapes, Rect::new(0.0, 0.0, 280.0, 200.0), Inflate::default(), front()) { + let (facets, faces) = solid.unwrap(); + assert!(!facets.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + } + assert_eq!(count(&crate::solid::INFLATED), start + 2); + let start = count(&crate::REVOLVED); + let (drawn, faces) = revolve_shadowed(&path, r, Revolve::default()).unwrap(); + assert!(!drawn.is_empty() && cast_from(faces).is_ok_and(|c| c.1.is_some())); + assert_eq!(count(&crate::REVOLVED), start + 1); +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 49b0738d9e..792e25c8b1 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -20,9 +20,13 @@ mod membrane; mod outline; mod rings; +#[cfg(test)] +pub(crate) use build::EXTRUDED; +#[cfg(test)] +pub(crate) use build::PLANES; #[cfg(test)] use build::{Camera, cuts, edge_distance, newell, section}; -pub(crate) use build::{Face3, extrude_faces, plane_faces}; +pub(crate) use build::{Face3, Keep, extrude_faces, plane_faces}; pub use build::{extrude, plane}; pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 6d21805e74..fe73b9bd3f 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -64,9 +64,25 @@ impl Camera { /// and its outward normal. pub(crate) type Face3 = (Vec>, V3); -/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection, -/// and the projection it used (`None` when nothing was built). -pub(crate) type Built = (Vec, Vec, Option); +/// What a solid's builder makes: its facets, its faces before projection when a mesh is asked for +/// (an error when they pass [`MAX_SOLID_FACES`] while the facets are built too), and the +/// projection it used (`None` when nothing was built). +pub(crate) type Built = (Vec, Result, String>, Option); + +#[cfg(test)] +thread_local! { + /// How many Extrude & Bevel solids this thread has built, for the tests. + pub(crate) static EXTRUDED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// What a solid's builder keeps: its facets, its faces before projection for a mesh, or both +/// from one build. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Keep { + Facets, + Faces, + Both, +} /// Facets being collected for one solid. pub(super) struct Builder<'a> { @@ -80,6 +96,11 @@ pub(super) struct Builder<'a> { pub(super) facets: Vec, /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. pub(super) mesh: Option>, + /// The faces are projected into `facets`; without it only `mesh` is kept. + pub(super) draw: bool, + /// The faces kept for the mesh passed [`MAX_SOLID_FACES`] while the facets were built too: the + /// mesh is too complex, and `mesh` keeps no more faces. + pub(super) lost: bool, } /// Where a face goes in the order the facets are drawn, back to front. @@ -98,20 +119,46 @@ pub(super) enum Order { Last, } -impl Builder<'_> { +impl<'a> Builder<'a> { + /// A builder for one solid seen by `camera`, keeping what `keep` asks for. + pub(super) fn new(camera: &'a Camera, closed: bool, hidden: bool, keep: Keep) -> Self { + Self { camera, closed, hidden, facets: vec![], mesh: (keep != Keep::Facets).then(Vec::new), draw: keep != Keep::Faces, lost: false } + } + /// Add a face of view-space contours with outward normal `n` (view space), drawn in `order`. pub(super) fn face(&mut self, contours: Vec>, n: V3, order: Order) -> Result<(), String> { let count: usize = contours.iter().map(Vec::len).sum(); if count < 3 { return Ok(()); } - if let Some(mesh) = &mut self.mesh { - mesh.push((contours, n.unit())); - if mesh.len() > MAX_SOLID_FACES { + if self.draw { + self.facet(&contours, count, n, order)?; + } + let Some(mesh) = &mut self.mesh else { return Ok(()) }; + mesh.push((contours, n.unit())); + if mesh.len() > MAX_SOLID_FACES { + if !self.draw { return Err(TOO_COMPLEX_SURFACE.into()); } - return Ok(()); + self.lose_mesh(); } + Ok(()) + } + + /// Keep no more faces for the mesh, which is too complex. + pub(super) fn lose_mesh(&mut self) { + self.mesh = None; + self.lost = true; + } + + /// How many faces the mesh holds, while the facets are built too and the mesh is kept. + pub(super) fn faces_kept(&self) -> Option { + self.mesh.as_ref().filter(|_| self.draw).map(Vec::len) + } + + /// Project a face of `count` view-space corners with outward normal `n` into a facet, drawn in + /// `order`, unless it is hidden or has no area. + fn facet(&mut self, contours: &[Vec], count: usize, n: V3, order: Order) -> Result<(), String> { let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); let n = n.unit(); @@ -138,7 +185,7 @@ impl Builder<'_> { let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] - let view = contours.clone(); + let view = contours.to_vec(); let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); if contours.iter().flatten().any(|p| !p.x.is_finite() || !p.y.is_finite()) { return Err("The 3D projection is not finite".into()); @@ -262,7 +309,7 @@ impl Builder<'_> { /// Give the facet that [`Builder::face`] added, when it added one after `before` facets, the /// outline `outline` makes from its projected contours. pub(super) fn outline(&mut self, before: usize, outline: impl FnOnce(&[Vec]) -> Option) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -273,7 +320,7 @@ impl Builder<'_> { /// Mark the facet that [`Builder::face`] added, when it added one after `before` facets, as the /// planar end `end` of the solid. pub(super) fn end(&mut self, before: usize, end: PlanarFace) { - if self.mesh.is_some() || self.facets.len() <= before { + if !self.draw || self.facets.len() <= before { return; } if let Some(f) = self.facets.last_mut() { @@ -281,15 +328,19 @@ impl Builder<'_> { } } - /// How many faces the builder holds: facets, or faces kept for a mesh. + /// How many faces the builder holds: facets, or without them the faces kept for a mesh. pub(super) fn len(&self) -> usize { - self.mesh.as_ref().map_or(self.facets.len(), Vec::len) + match &self.mesh { + Some(mesh) if !self.draw => mesh.len(), + _ => self.facets.len(), + } } /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - (self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection())) + let faces = if self.lost { Err(TOO_COMPLEX_SURFACE.into()) } else { Ok(self.mesh.unwrap_or_default()) }; + (self.facets, faces, Some(self.camera.projection())) } } @@ -536,16 +587,18 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { - extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets) + extrude_faces(path, rule, bounds, options, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. -pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result { +/// [`extrude`], keeping the solid's facets, its faces before projection, or both, as `keep` asks. +pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, keep: Keep) -> Result { + #[cfg(test)] + EXTRUDED.with(|n| n.set(n.get() + 1)); check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane_faces(path, rule, bounds, view, mesh); + return plane_faces(path, rule, bounds, view, keep); } let steps = steps(&o); // One face for each edge of the outline in each band between two steps, and the two caps: @@ -561,7 +614,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio return Err(TOO_COMPLEX_SURFACE.into()); } let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, o.hidden, keep); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = |ring: &[Point], s: &Step| -> Vec { section(&inset(ring, s.inset), bounds, &o, s.t).iter().map(|p| camera.place(*p, z(s.t))).collect() }; @@ -644,18 +697,27 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio Ok(b.finish()) } +#[cfg(test)] +thread_local! { + /// How many Rotate planes (and Extrude & Bevel solids with no depth) this thread has built, for + /// the tests. + pub(crate) static PLANES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { - plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets) + plane_faces(path, rule, bounds, view, Keep::Facets).map(|(facets, ..)| facets) } -/// [`plane`], or with `mesh` its face before projection instead of its facet. -pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { +/// [`plane`], keeping its facet, its face before projection, or both, as `keep` asks. +pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, keep: Keep) -> Result { + #[cfg(test)] + PLANES.with(|n| n.set(n.get() + 1)); check_bounds(bounds)?; let rings = solid_rings_traced(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS, CURVE_PIECES)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, false, false, keep); let contours: Vec> = rings.iter().map(|r| r.points.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d3773a92f9..677e9cc8bc 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,4 +1,4 @@ -use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::build::{Builder, Built, Camera, Keep, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, crossings, filled, material}; use super::rings::{MAX_SOLID_POINTS, TOO_COMPLEX_SURFACE, curve_pieces, signed_area, solid_rings}; use super::{Facet, FillRule, Inflate, MAX_FACES, MAX_SOLID_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -73,26 +73,26 @@ pub fn inflate_each( options: Inflate, view: View, ) -> impl Iterator, String>> + use<> { - inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)) + inflate_faces(shapes, bounds, options, view, Keep::Facets).map(|r| r.map(|(facets, ..)| facets)) } -/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. -/// Every shape's membrane is solved at once (the eye depends on the highest puff); each solid is -/// built as the iterator reaches it. +/// [`inflate_together`], keeping each solid's facets, its faces before projection, or both, as +/// `keep` asks. Every shape's membrane is solved at once (the eye depends on the highest puff); +/// each solid is built as the iterator reaches it. pub(crate) fn inflate_faces( shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, - mesh: bool, + keep: Keep, ) -> impl Iterator> + use<> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max); puffs.into_iter().map(move |p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), - Ok(None) => Ok((vec![], vec![], None)), + Ok(Some(p)) => inflated(p, bounds, o, view, reach, keep), + Ok(None) => Ok((vec![], Ok(vec![]), None)), Err(e) => Err(e), }) } @@ -106,9 +106,9 @@ thread_local! { } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, -/// its faces before projection instead. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. Its faces +/// before projection instead, or both, as `keep` asks. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, keep: Keep) -> Result { #[cfg(test)] INFLATED.with(|n| n.set(n.get() + 1)); let Puff { rings, edges, cell, nx, ny, m, height } = p; @@ -149,7 +149,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder::new(&camera, o.cap, false, keep); let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); @@ -375,15 +375,40 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let turn = Turn::new(bounds, o.twist, o.taper, t); ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() }; - let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let cut: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); + let mut pieces = cut.clone(); fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); let edges = rings.iter().map(Vec::len).fold(0usize, usize::saturating_add); if b.len().saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { return Err(TOO_COMPLEX_SURFACE.into()); } - for (ring, pieces) in rings.iter().zip(&pieces) { - for k in 0..slices { - b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + let sides = |b: &mut Builder<'_>, pieces: &[Vec]| -> Result<(), String> { + for (ring, pieces) in rings.iter().zip(pieces) { + for k in 0..slices { + b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; + } + } + Ok(()) + }; + // A mesh kept with the facets has the sides a mesh alone has: limited, and with a twist + // cut, by the faces the mesh holds, which can be more than the facets. + let mut own_sides = None; + if let Some(kept) = b.faces_kept() { + let mut own = cut; + fit(&mut own, slices, MAX_FACES.saturating_sub(kept)); + if kept.saturating_add(edges.saturating_mul(slices)) > MAX_SOLID_FACES { + b.lose_mesh(); + } else if own != pieces { + own_sides = Some((own, b.mesh.take())); + } + } + sides(&mut b, &pieces)?; + if let Some((own, faces)) = own_sides { + let mut m = Builder::new(&camera, o.cap, false, Keep::Faces); + m.mesh = faces; + match sides(&mut m, &own) { + Ok(()) => b.mesh = m.mesh, + Err(_) => b.lose_mesh(), } } } diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index e89a29afea..335f434419 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -225,12 +225,12 @@ fn a_flat_facet_measures_its_depth_on_its_plane() { /// eye, has no plane: its drawing no longer matches the plane's projection there. #[test] fn a_face_reaching_past_the_eye_has_no_plane() { - use super::build::{Builder, Order}; + use super::build::{Builder, Keep, Order}; let (_, b) = shape(&square(0.0, 0.0, 100.0)); let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0); let eye = camera.center_and_eye().1.unwrap(); for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] { - let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None }; + let mut builder = Builder::new(&camera, false, false, Keep::Facets); let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)]; builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap(); assert_eq!(builder.facets.len(), 1, "{far}"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..3ee60a8230 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -580,6 +580,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. - 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.