From 7dfe2d9d2aa5675b00f41018917e426d90d3402b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 14:20:41 -0800 Subject: [PATCH] 3D: a symbol's 9-slice guides and overrides count before its art is placed The check that refuses a symbol instance before its art is placed counted only the symbol's own objects. Placing art through 9-slice guides can make up to 28 objects of each one, and an instance's dynamic-symbol overrides can turn a path that would be cut at the guides into clip groups in every cell it reaches. Such an instance passed the check, and its art was placed in full before the work limit refused it. The check now counts the symbol's own objects first, then the most objects placing makes (NineSliceMap::most_placed) of the art with the instance's overrides applied, and refuses the instance with the same message before anything is placed. --- crates/effects/src/solids/source.rs | 23 +++++++---- crates/effects/src/solids/tests_sources.rs | 48 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index 090688c4f..c5f664c68 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -129,16 +129,23 @@ impl Resolver<'_> { } self.spend(1)?; match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => { - // Counted before the art is placed, so a symbol past the limit is never copied. - let held = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == *symbol)).map_or(0, |s| s.art.count_with_masks()); - if self.work.saturating_add(held) > MAX_WORK { + NodeKind::SymbolInstance { symbol, xf } => { + let missing = || format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available"); + let doc = self.doc.ok_or_else(missing)?; + let sym = doc.symbols.iter().find(|s| s.name == *symbol).ok_or_else(missing)?; + // Counted before the art is placed, so a symbol past the limit is never copied: + // first its own objects, which the instance's overrides keep, then the most + // objects placing makes, with those overrides and the symbol's 9-slice guides. + if self.work.saturating_add(sym.art.count_with_masks()) > MAX_WORK { return Err(self.too_much_work()); } - let art = crate::outline_object(self.doc, n).ok_or_else(|| { - format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available") - })?; - Ok(self.list(kids(&art), depth + 1)?.0) + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc)); + if (self.work as u64).saturating_add(most) > MAX_WORK as u64 { + return Err(self.too_much_work()); + } + let placed = doc.place_symbol_art(symbol, &art, *xf).ok_or_else(missing)?; + Ok(self.list(&[Arc::new(placed)], depth + 1)?.0) } NodeKind::Blend { children, spec } => { // Counted before the steps are made, so a blend past the limit is never expanded. diff --git a/crates/effects/src/solids/tests_sources.rs b/crates/effects/src/solids/tests_sources.rs index 35e89e61f..4957f88fa 100644 --- a/crates/effects/src/solids/tests_sources.rs +++ b/crates/effects/src/solids/tests_sources.rs @@ -375,6 +375,54 @@ fn a_symbol_holding_more_objects_than_the_work_limit_is_refused_before_its_art_i assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}"); } +/// A gradient fill: a path filled with one is drawn in clip groups where it crosses 9-slice guides. +fn gradient() -> Paint { + use vectorcraft_doc::color::{Gradient, GradientPaint}; + Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))) +} + +/// A document whose symbol "Sliced" has 9-slice guides and holds `count` paths across all of them, +/// each filled with `fill`, and an Extrude on an instance of it at twice its size. +fn sliced(count: usize, fill: impl Fn() -> Paint) -> (Document, Node) { + let mut d = document(); + let members = (0..count).map(|i| { + Arc::new(Node::path(NodeId(i as u64 + 1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill(), Paint::None, 0.0))) + }); + d.symbols.push(Symbol { dynamic: true, name: "Sliced".into(), art: Arc::new(Node::group(NodeId(0), members.collect())) }); + d.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let n = with(instance(30, "Sliced", Affine::scale(2.0)), EXTRUDE, json!({})); + (d, n) +} + +#[test] +fn a_symbol_whose_9_slice_guides_would_place_too_many_objects_is_refused_before_its_art_is_placed() { + // Each gradient path becomes a group of nine clip groups, so the art placed is far past the + // limit while the symbol itself holds less than a tenth of it. + let (d, n) = sliced(source::MAX_WORK / 20, gradient); + assert!(d.symbols[1].art.count_with_masks() < source::MAX_WORK); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}"); +} + +#[test] +fn a_dynamic_symbol_whose_overrides_would_place_too_many_objects_is_refused_before_its_art_is_placed() { + // Solid paths are cut at the guides and stay one path each; the instance's overrides fill them + // with a gradient, which places each in nine clip groups. + let (d, mut n) = sliced(source::MAX_WORK / 20, || Paint::solid(Color::BLACK)); + n.extras_mut().symbol_overrides = (0..source::MAX_WORK / 20) + .map(|i| vectorcraft_doc::SymbolOverride { path: vec![i as u32], fill: Some(gradient()), ..Default::default() }) + .collect(); + let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("hold too many objects"), "{err}"); + let work = source::WORK.with(std::cell::Cell::get); + assert!(work <= 2, "refused before the instance's art is placed and listed: {work}"); + // A few such paths build. + let (d, n) = sliced(4, || Paint::solid(Color::BLACK)); + assert!(solid_art_in(Some(&d), &n).is_some()); +} + #[test] fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() { let mut d = document();