diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index e9a8f2a0a7..a9c9482d1d 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::wireframe::WireFace; use crate::solids::{ @@ -338,34 +338,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))) @@ -377,7 +423,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>, @@ -391,11 +438,18 @@ 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; @@ -405,10 +459,14 @@ fn surface( let drawn = facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect(); let p = merged_params(&fx.id, &fx.params); - return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p))); + return Some((drawn, 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; @@ -532,7 +590,7 @@ fn surface( let mut group = Node::group(n.id, children); group.opacity = opacity; group.blend = blend; - Some((keys, group)) + Some((keys, group, kept)) } /// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the @@ -549,8 +607,9 @@ fn in_order(items: Vec, order: &[usize]) -> Vec { order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect() } -/// 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, @@ -559,10 +618,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 @@ -692,7 +752,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, 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)); } @@ -790,10 +850,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)); @@ -942,21 +1002,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; @@ -1057,7 +1124,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 }; @@ -1682,6 +1749,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 5bba1ea432..c0d44947d7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -7,7 +7,9 @@ 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, MeshedSolid, PlanarFace, View}; +use vectorcraft_three_d::{ + Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, SolidFaces, View, +}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -236,6 +238,30 @@ 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); + #[cfg(test)] + if cast && id == EXTRUDE { + SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1)); + } + 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> { @@ -693,7 +719,9 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { #[cfg(test)] thread_local! { /// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests. - static SOLIDS_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// 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) }; /// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and /// [`built_solids`] have built on this thread, for the tests. static SOLID_GEOMETRY_BUILT: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -712,10 +740,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; @@ -729,13 +782,13 @@ fn built_solids<'a>( Some(each) => { #[cfg(test)] SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1)); - each.next().unwrap_or_else(|| Ok(vec![])) + each.next().unwrap_or_else(|| Ok((vec![], None))) } - None => facets(id, area, *rule, bounds, p), + 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), @@ -1364,12 +1417,19 @@ 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 = shadow_for(n.id, fx, 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![], + }; Some((shadow, art)) } @@ -1408,6 +1468,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)); } @@ -1419,10 +1481,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 @@ -1444,6 +1510,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>); @@ -1582,8 +1718,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; @@ -1598,11 +1736,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; } @@ -1619,19 +1761,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. @@ -1760,14 +1906,14 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile out.extend(with_graphics(run.collect(), first, &arts)); } } - return Some(masked(n.id, out)); + return Some((masked(n.id, out), built)); } if !opaque || !surfaces.iter().all(plain) { let surfaces = surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect(); - 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, &seals)))]) + Some((vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))], built)) } /// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when 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 bb066c0e8a..4290bc1c75 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, MeshedSolid, extrude_mesh, extrude_meshed, inflate_meshed, 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}; @@ -961,21 +964,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| { @@ -1013,10 +1131,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()) @@ -1032,17 +1148,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) }; @@ -1074,22 +1199,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 { @@ -1101,33 +1244,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(); @@ -1136,14 +1295,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 @@ -1186,28 +1337,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)?; @@ -1215,7 +1345,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/shadow.rs b/crates/three-d/src/shadow.rs index 2d32850423..b580ddcd5f 100644 --- a/crates/three-d/src/shadow.rs +++ b/crates/three-d/src/shadow.rs @@ -12,7 +12,7 @@ use std::ops::Range; use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; use crate::solid::{Face3, Keep, extrude_faces, inflate_faces, plane_faces}; -use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale}; +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)] @@ -203,6 +203,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 @@ -291,6 +299,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 00aedc3a53..1e3f5c437b 100644 --- a/crates/three-d/src/shadow/tests.rs +++ b/crates/three-d/src/shadow/tests.rs @@ -186,6 +186,247 @@ fn every_effect_casts_a_shadow() { 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); +} + /// An Inflate shape that casts nothing still places the eye with its puff, but its solid is not /// built (nor, with no volume, its puff solved); the shapes that cast keep the very faces and /// projection they have when every shape casts. diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 6badd8048a..18ef7d6d1b 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -23,6 +23,8 @@ 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, Keep, extrude_faces, plane_faces}; pub use build::{extrude, plane}; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index f3fda46685..d734161343 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -705,6 +705,13 @@ 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> { @@ -713,6 +720,8 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul /// [`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); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7908b5b7c7..06370fbf5a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -584,6 +584,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.