From 145ac9d30a4539034d53a295f0fcd8fca0b20963 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:40:25 -0800 Subject: [PATCH] 3D: an object's fill and stroke solids share one face limit Extrude & Bevel (both kinds) and Inflate built every fill and stroke solid of an object, each up to 163,840 faces, before any check, so one effect on a path with many strokes or a group with many members could build several times that. The solids are now built in order and their faces counted together: once they pass 163,840 (VectorCraft's own limit), no later solid is built, the object draws its source art, and effect.apply, effect.setParams, Export 3D Object and Expand as Wireframes report "The 3D surface is too complex; simplify the shape". A stroke left out for its own limits is not counted. Export 3D Object now builds Extrude & Bevel (Classic) meshes with the Classic bevel options, so the exported solid matches the drawn one. --- crates/effects/src/solids.rs | 100 +++++++++++++++---- crates/effects/src/solids/tests.rs | 97 ++++++++++++++++++ crates/effects/src/solids/tests_mesh.rs | 16 +++ crates/effects/src/solids/wireframe.rs | 24 ++--- crates/effects/src/solids/wireframe/tests.rs | 2 +- crates/engine/src/cmd/effectcmd/tests.rs | 38 +++++++ crates/three-d/src/lib.rs | 7 +- crates/three-d/src/solid.rs | 3 +- crates/three-d/src/solid/inflate.rs | 13 ++- crates/three-d/src/solid/rings.rs | 4 +- docs/target-app-parity.md | 1 + docs/three-d.md | 5 + 12 files changed, 271 insertions(+), 39 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 91f73049f1..3031228e32 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, PlanarFace, View}; +use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -266,6 +266,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, ite let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?; + check_solids(id, p, &shapes)?; return cast_shadow(id, p, &shapes).map(|_| ()); } let (path, rule) = object_profile(n, label)?; @@ -277,9 +278,19 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, ite None => solid_paints(n), }; let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; + check_solids(id, p, &shapes)?; cast_shadow(id, p, &shapes).map(|_| ()) } +/// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes` +/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not +/// counted. +fn check_solids(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> { + let Some(b) = union_bounds(shapes).filter(|_| !is_plane(id)) else { return Ok(()) }; + let (parts, bounds) = solid_parts(shapes, b); + built_solids(id, p, &parts, bounds, MAX_OBJECT_FACES).try_for_each(|r| r.map(|_| ())) +} + /// [`profile`], or the error a command reports for object `n`: type with no outlines to build from /// (empty, or only spaces) is a shape without anchors, an image is traced first, and any other /// object is the wrong kind. @@ -596,6 +607,55 @@ fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec>, Rect) { (parts, bounds) } +#[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) }; +} + +/// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from +/// `parts` ([`solid_parts`]) about `bounds`, one result for each part in order, each built only as +/// the iterator reaches it. A stroke's solid past its own limits is `None` (left out of the art, its +/// faces not counted); a fill's is an error. The faces of the solids kept count together: once they +/// pass `limit` ([`MAX_OBJECT_FACES`] for the art) the result is an error, and +/// no later part is built. An error ends the iterator. +fn built_solids<'a>( + id: &'a str, + p: &'a Value, + parts: &'a [Part<'a>], + bounds: Rect, + limit: usize, +) -> 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 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 mut kept = 0usize; + let mut ended = false; + parts.iter().map_while(move |(item, _, area, rule, _, _)| { + if ended { + return None; + } + #[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), + }; + let result = match built { + Ok(f) => { + kept = kept.saturating_add(f.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), + Err(e) => Err(e), + }; + ended = result.is_err(); + Some(result) + }) +} + /// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's /// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of /// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its @@ -983,24 +1043,19 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); - let built = if fx.id == INFLATE { - // One eye for every puff, as far away as the highest needs. - let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) - } else { - parts.iter().map(|(_, _, area, rule, _, _)| facets(&fx.id, area, *rule, bounds, &p)).collect() - }; let mut solids = vec![]; - for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built) { + for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES)) { let item: &AppearanceItem = item; match f { - Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None, - Ok(f) => solids.push((item, *opacity, *scopes, f, *member)), + 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)), // A stroke's outline past the limits is left out; the fill's solid still draws. - Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} - // A loaded file or a shape edited past the limits keeps drawing its source path - // instead of failing the frame; commands validate the same evaluator and report its - // errors. + Ok(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. Err(_) => return None, } } @@ -1374,15 +1429,26 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); + // The solids the art draws, past the limits of the object's solids together as it is + // ([`built_solids`]), each solid's faces dropped once counted. + let drawn: Vec = built_solids(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES).map(|r| r.map(|f| f.is_some())).collect::>()?; // Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel // solid and its mesh are built as the loop reaches them. let mut inflated = (fx.id == INFLATE).then(|| { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) }); - let extrude = extrude_options(&p); - for (item, member, area, rule, _, _) in &parts { + let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) }; + for ((item, member, area, rule, _, _), drawn) in parts.iter().zip(drawn) { let item: &AppearanceItem = item; + if !drawn { + // A stroke the art leaves out is left out of the meshes too; Inflate's next puff + // still belongs to the next part. + if let Some(built) = inflated.as_mut() { + built.next(); + } + continue; + } let mesh = match inflated.as_mut() { Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 5041f01bb2..935cfd26e4 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -353,6 +353,103 @@ fn a_stroke_past_the_limits_is_left_out() { } } +/// Each solid [`built_solids`] gives for effect `id` on `n` within `limit` faces together, as its +/// face count (`None` for a stroke left out) or the error, every result the iterator gives. +fn solid_faces(id: &str, n: &Node, limit: usize) -> Vec, String>> { + let shapes = [Profile { path: profile(n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(n), opacity: 1.0, scopes: vec![] }]; + let p = crate::merged_params(id, &json!({})); + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + built_solids(id, &p, &parts, bounds, limit).map(|r| r.map(|f| f.map(|f| f.len()))).collect() +} + +/// The solids of one object count their faces together: at the limit they all build, one face past +/// it is an error, and the solids after the one that passes it are never built. +#[test] +fn an_objects_solids_count_their_faces_together() { + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + let mut n = square_node(); + // A second stroke, so a solid follows the one that passes the limit. + let stroke = n.appearance.items[1].clone(); + n.appearance.items.push(stroke); + let counts: Vec = solid_faces(id, &n, MAX_OBJECT_FACES).into_iter().map(|c| c.unwrap().unwrap()).collect(); + assert!(counts.len() == 3 && counts.iter().all(|c| *c > 0), "{id}: {counts:?}"); + let total: usize = counts.iter().sum(); + assert!(solid_faces(id, &n, total).iter().all(Result::is_ok), "{id}: at the limit"); + let past = solid_faces(id, &n, total - 1); + assert_eq!(past.last(), Some(&Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())), "{id}"); + SOLIDS_BUILT.with(|b| b.set(0)); + let past = solid_faces(id, &n, counts[0] + counts[1] - 1); + assert!(past.len() == 2 && past[1].is_err(), "{id}: {past:?}"); + assert_eq!(SOLIDS_BUILT.with(std::cell::Cell::get), 2, "{id}: the second stroke's solid is never built"); + } +} + +/// A convex polygon of `corners` corners about (20,000, 20,000), filled red, with no stroke. +fn polygon_node(id: u64, corners: usize) -> Node { + let points: Vec = (0..corners) + .map(|k| { + let (s, c) = (k as f64 * std::f64::consts::TAU / corners as f64).sin_cos(); + Point::new(20_000.0 + 10_000.0 * c, 20_000.0 + 10_000.0 * s) + }) + .collect(); + let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Node::path(NodeId(id), path, Appearance::basic(Paint::solid(RED), Paint::None, 0.0)) +} + +/// A group whose members' solids each fit, but not all of them together, draws its source art, and +/// the commands that check the effect report the error, as do its mesh and its wireframe; one +/// member fewer, within the limit, draws. +#[test] +fn a_groups_solids_together_past_the_limit_draw_the_source() { + let member = polygon_node(1, 16_384); + let mut alone = member.clone(); + alone.appearance.effects.push(fx(EXTRUDE, json!({}))); + let [Ok(Some(each))] = solid_faces(EXTRUDE, &alone, MAX_OBJECT_FACES)[..] else { panic!("one solid") }; + // Members stacked in one place each build the same solid. + let fits = MAX_OBJECT_FACES / each; + let group = |count: usize| { + let members = (0..count) + .map(|k| { + let mut m = member.clone(); + m.id = NodeId(k as u64 + 1); + Arc::new(m) + }) + .collect(); + let mut g = Node::group(NodeId(9999), members); + g.appearance.effects.push(fx(EXTRUDE, json!({}))); + g + }; + let within = group(fits); + assert!(validate_solid(&within, EXTRUDE, &json!({})).is_ok(), "{fits} × {each} faces"); + assert!(solid_art(&within).is_some()); + let past = group(fits + 1); + assert_eq!(validate_solid(&past, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); + assert!(solid_art(&past).is_none(), "the source art draws"); + assert_eq!(three_d_meshes(&past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); + assert_eq!(three_d_wireframe_in(None, &past), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); +} + +/// A fill and strokes whose solids each fit, but not all of them together, draw the source art, +/// and the strokes after the one past the limit are not left out to make room. +#[test] +fn a_fill_and_strokes_together_past_the_limit_draw_the_source() { + let mut n = polygon_node(1, 16_384); + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let stroke = AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0)); + n.appearance.items.push(stroke.clone()); + // Strokes alike each build the same solid. + let [Ok(Some(fill)), Ok(Some(each))] = solid_faces(EXTRUDE, &n, MAX_OBJECT_FACES)[..] else { panic!("a fill's solid and a stroke's") }; + let fits = (MAX_OBJECT_FACES - fill) / each; + assert!(fits >= 2, "{fill} + {fits} × {each}"); + n.appearance.items.extend(std::iter::repeat_n(stroke, fits - 1)); + assert!(validate_solid(&n, EXTRUDE, &json!({})).is_ok(), "{fill} + {fits} × {each}"); + let art = solid_art(&n).unwrap(); + assert!(faces(&art).iter().any(|f| blue(f))); + n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(BLUE), 20.0))); + assert_eq!(validate_solid(&n, EXTRUDE, &json!({})), Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string())); + assert!(solid_art(&n).is_none(), "the source art draws"); +} + /// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's, /// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to /// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side. diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs index 1ae347c978..d0e192f550 100644 --- a/crates/effects/src/solids/tests_mesh.rs +++ b/crates/effects/src/solids/tests_mesh.rs @@ -109,6 +109,22 @@ fn an_extruded_mesh_is_as_deep_as_the_effect() { assert!((hi[2] - lo[2] - 30.0).abs() < 1e-9 && (hi[0] - lo[0] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}"); } +/// Extrude & Bevel (Classic) exports the solid it draws: its bevel as wide as it is high. +#[test] +fn a_classic_extrusion_exports_its_own_bevel() { + let mut n = square_node(1, 100.0); + n.appearance.items.truncate(1); + let params = json!({ "bevel": "classic", "bevelHeight": 10.0 }); + n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params.clone())); + let meshes = three_d_meshes(&n).unwrap(); + let p = crate::merged_params(CLASSIC_EXTRUDE, ¶ms); + let path = profile(&n).unwrap().0; + let bounds = path.bounds().unwrap(); + let own = vectorcraft_three_d::extrude_mesh(&path, FillRule::NonZero, bounds, classic_extrude_options(&p), view_options(&p)).unwrap(); + assert_eq!(meshes.len(), 1); + assert_eq!(meshes[0].mesh, own); +} + #[test] fn revolve_gives_a_mesh_in_the_top_fill() { let mut b = BezPath::new(); diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index f1dc324679..00e741bfe6 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -4,13 +4,13 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, - region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, + Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region, + reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, }; use crate::{GeomContext, is_geometry}; @@ -125,7 +125,8 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S /// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and /// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's /// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, -/// as it is left out of the drawn art. +/// as it is left out of the drawn art; past the limits of the object's solids together, the result +/// is an error, as the canvas draws the source art. fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { let label = label_of(&fx.id); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; @@ -141,18 +142,9 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> return Ok(out); } let (parts, bounds) = solid_parts(shapes, b); - let built: Vec, String>> = if fx.id == INFLATE { - let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect(); - vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) - } else { - parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect() - }; - for ((item, ..), f) in parts.iter().zip(built) { - match f { - Ok(f) => out.extend(f.iter().map(Facet::path)), - Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} - Err(e) => return Err(e), - } + // The solids the art draws, each past the limits of the object's solids together as it is. + for f in built_solids(&fx.id, &p, &parts, bounds, vectorcraft_three_d::MAX_OBJECT_FACES) { + out.extend(f?.iter().flatten().map(Facet::path)); } Ok(out) } diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 5ea4423ab3..792fbdb28a 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -1,6 +1,6 @@ use serde_json::{Value, json}; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Node, NodeId}; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId}; use vectorcraft_geom::{BezPath, Rect}; use super::*; diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index 5584073563..882b5c3b20 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -169,6 +169,44 @@ fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); } +/// Extrude & Bevel is refused, and nothing changes, on a path whose fill's and strokes' solids each +/// fit but together pass the limit of their faces; with one stroke the effect applies. +#[test] +fn extrude_is_refused_when_its_solids_together_are_too_complex() { + use vectorcraft_doc::color::{Color, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem}; + let (mut s, id) = session_with_rect(); + let points: Vec = (0..16_384) + .map(|k| { + let (sin, cos) = (k as f64 * std::f64::consts::TAU / 16_384.0).sin_cos(); + vectorcraft_geom::Point::new(20_000.0 + 10_000.0 * cos, 20_000.0 + 10_000.0 * sin) + }) + .collect(); + let polygon = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + let painted = |strokes: usize| { + let mut items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; + let stroke = AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::WHITE), 20.0)); + items.extend(std::iter::repeat_n(stroke, strokes)); + items + }; + let set = |s: &mut Session, strokes: usize| { + let n = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap(); + let NodeKind::Path { path, live, .. } = &mut n.kind else { panic!("a path") }; + *path = polygon.clone(); + *live = None; + n.appearance.items = painted(strokes); + }; + // The fill's solid and each stroke's fit on their own; sixty strokes' together do not. + set(&mut s, 60); + let before = s.doc().unwrap().doc.clone(); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("The 3D surface is too complex; simplify the shape"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before, "nothing changed"); + set(&mut s, 1); + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); + assert!(effects::solid_art(&node(&s, id)).is_some()); +} + /// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session, /// the clipping group and the group. fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) { diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index a807cbb8fa..7b778498e6 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -9,8 +9,8 @@ mod solid; mod visibility; pub use solid::{ - Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, View, extrude, extrude_face_map, inflate, inflate_together, plane, - plane_face_map, + Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, inflate_each, + inflate_together, plane, plane_face_map, }; pub use visibility::{VisibleFace, visible_faces}; mod shading; @@ -32,6 +32,9 @@ const MAX_FACES: usize = 32768; const MAX_REVOLVE_FACES: usize = 163_840; /// Most faces Extrude & Bevel and Inflate build for one solid, as many as Revolve's. const MAX_SOLID_FACES: usize = MAX_REVOLVE_FACES; +/// Most faces the Extrude & Bevel or Inflate solids of one effect on one object keep together: the +/// solid of its fill and those of its strokes, a group's members' all counted, as many as one solid's. +pub const MAX_OBJECT_FACES: usize = MAX_SOLID_FACES; /// Samples a curved profile segment takes, and the fewest it takes on a surface over its budget. const CURVE_SAMPLES: usize = 16; const MIN_CURVE_SAMPLES: usize = 4; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index bfd3149e6e..f3da6f866f 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -30,9 +30,10 @@ use inflate::GRID_WORK; #[cfg(test)] pub(crate) use inflate::INFLATED; pub(crate) use inflate::inflate_faces; -pub use inflate::{inflate, inflate_together}; +pub use inflate::{inflate, inflate_each, inflate_together}; #[cfg(test)] use membrane::{Edges, GRID_BUDGET, MAX_EDGE_ENTRIES, MAX_GRID_WORK, Membrane, cell_triangles}; +pub use rings::TOO_COMPLEX_SURFACE as TOO_COMPLEX_SOLID; use rings::signed_area; #[cfg(test)] use rings::{MAX_SOLID_POINTS, rings}; diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 47ce3371db..d3773a92f9 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -62,7 +62,18 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { - inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)).collect() + inflate_each(shapes, bounds, options, view).collect() +} + +/// [`inflate_together`], each solid built only as the iterator reaches it: a caller that stops early +/// builds none of the solids after. +pub fn inflate_each( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator, String>> + use<> { + inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)) } /// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 7aed5e4191..80dc66407c 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -17,7 +17,9 @@ pub(super) const CURVE_PIECES: u32 = 32; pub(super) const MIN_CURVE_PIECES: u32 = 4; const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; -pub(super) const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; +/// The error a solid past its limits returns, and a 3D effect whose solids together pass +/// [`crate::MAX_OBJECT_FACES`]. +pub const TOO_COMPLEX_SURFACE: &str = "The 3D surface is too complex; simplify the shape"; /// How many straight pieces follow `c` within `tolerance` (Wang's bound on a cubic's distance from /// its chords), 1 to [`CURVE_PIECES`]. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 30b1e883d7..10a9a27747 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1164,6 +1164,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). + - The solids of one Extrude & Bevel (both kinds) or Inflate effect on one object, its fill's, its strokes' and a group's members' together, keep up to 163,840 faces (VectorCraft's own limit). Each solid is built in turn; once their faces pass the limit, no later solid is built, the object draws its source art, and applying or editing the effect, Export 3D Object and Expand as Wireframes report that the 3D surface is too complex. Tests `solids::tests::an_objects_solids_count_their_faces_together`, `a_groups_solids_together_past_the_limit_draw_the_source`, `a_fill_and_strokes_together_past_the_limit_draw_the_source`. - Extrude & Bevel (both kinds) and Rotate keep the art's curves in the faces they draw, export and expand: caps and the edges of walls and bevels where they meet a cap are cubics with smooth anchors where the art's are, fitted within 0.001 pt in perspective and within the sampling tolerance on a bevel's inset. An edge of a twisted wall that is cut into pieces, a piece of curve that does not fit after 8 halvings, and the curves past 1,048,576 for one solid stay straight (VectorCraft's own values; [three-d.md](three-d.md#what-differs-from-the-public-documentation)). Tests `solid::tests::a_cap_keeps_the_curves_of_the_art`, `a_cap_in_perspective_fits_the_projected_curves`, `a_beveled_cap_and_its_walls_share_their_curves`, `twisted_beveled_tiny_and_degenerate_caps_keep_their_curves`. - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). diff --git a/docs/three-d.md b/docs/three-d.md index 38b59023dc..c701585b88 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -127,6 +127,11 @@ The rest is our reading: grid stays flat. - A stroke whose outline takes more than 65,536 points, or whose solid takes more than 163,840 faces, is left out, and the fill's solid still draws. +- The solids of one Extrude & Bevel or Inflate effect keep up to 163,840 faces together: the fill's and every + stroke's, and on a group or layer every member's (VectorCraft's own limit). The solids are built in order, and + once their faces pass it no later solid is built: the object draws its source art, and applying or editing the + effect, Export 3D Object and **Expand as Wireframes** report that the 3D surface is too complex. A stroke left + out for its own limits does not count. ## Groups