From f1d4969864f1db89110855414efbb94d5d08d211 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:43:17 -0500 Subject: [PATCH 1/2] DXF export: bound curve flattening work and expanded symbol art Flattening a path for DXF capped the points it kept but kept flattening every remaining curve after the cap, each curve at a fixed tolerance however large it was. Symbol instances placed inside symbols were cloned and walked once per instance with only the nesting depth bounded, so a small symbol graph of empty symbols could fan out into billions of visits that write nothing. - Flatten through vectorcraft_geom::flatten (a bounded number of segments per curve) and stop at the first curve past the point or segment budget. - Charge each symbol instance's art to MAX_SYMBOL_NODES with charge_symbol_art before placing it, as the document does elsewhere; once spent, no further instances are expanded. --- crates/cad/src/scene.rs | 83 +++++++++++++++++++++++++++++++++++------ crates/cad/src/tests.rs | 18 +++++++++ 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index b7aeb5c6c..a82a891d1 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -271,7 +271,11 @@ const MAX_ENTITIES: u32 = 10_000_000; /// The largest x or y a path may have to be flattened, and the most points its polygons keep. const MAX_COORD: f64 = 1e9; -const MAX_POINTS: usize = 4_000_000; +const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 4_000_000 }; +/// The most segments flattening one path makes, kept or not (repeats of a point are dropped). +const MAX_FLATTEN_SEGMENTS: usize = 4 * MAX_POINTS; + +const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects for DXF: the instances past that were left out"; /// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of /// odd length repeats twice in its linetype. @@ -296,14 +300,19 @@ fn polygons(bp: &BezPath) -> Vec> { } let mut out: Vec> = vec![]; let mut cur: Vec = vec![]; - let mut points = 0usize; - let mut push = |p: Point, cur: &mut Vec| { - if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { - points += 1; + // Points kept, and segments made: once either budget is spent, no further curve is flattened. + let points = std::cell::Cell::new(0usize); + let segments = std::cell::Cell::new(0usize); + let push = |p: Point, cur: &mut Vec| { + segments.set(segments.get() + 1); + if points.get() < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + points.set(points.get() + 1); cur.push(p); } }; - kurbo::flatten(bp.iter(), FLATNESS, |el| match el { + let elements = bp.iter().take_while(|_| points.get() < MAX_POINTS && segments.get() < MAX_FLATTEN_SEGMENTS); + // Each curve is flattened into a bounded number of segments, however large it is. + vectorcraft_geom::flatten(elements, FLATNESS, |el| match el { PathEl::MoveTo(p) => { out.push(std::mem::take(&mut cur)); push(p, &mut cur); @@ -412,6 +421,9 @@ pub(crate) struct Scene<'a> { opacity: f32, /// Symbols and brush art being written inside one another. nest: u32, + /// Objects the symbol instances written held, together + /// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]). + symbol_nodes: u64, } impl<'a> Scene<'a> { @@ -441,6 +453,8 @@ impl<'a> Scene<'a> { extents: None, opacity: 1.0, nest: 0, + // Tests spend all but a million of it up front. + symbol_nodes: if cfg!(test) { vectorcraft_doc::MAX_SYMBOL_NODES - 1_000_000 } else { 0 }, } } @@ -542,13 +556,35 @@ impl<'a> Scene<'a> { NodeKind::Text(t) => self.text(n, t), NodeKind::Image(im) => self.image(im), NodeKind::SymbolInstance { symbol, xf } => { - if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + let doc = self.doc; + if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES { + // Spent: no more instances are looked at, so what's left costs nothing. + self.warn(SYMBOLS_LEFT_OUT); + } else if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST - && let Some(art) = self.doc.place_symbol_art_unclipped(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf) { - self.nest += 1; - self.node(&art, true); - self.nest -= 1; + // Symbols holding instances of symbols are written in full for each instance, + // empty or not: every instance's art counts against one budget for the export. + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let count = art.count_with_masks() as u64; + let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed(&art, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let placed = doc.place_symbol_art_unclipped(symbol, &art, *xf); + let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); + (placed, n) + }); + match placed { + Some(Some(art)) => { + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + Some(None) => {} + None => { + self.symbol_nodes = vectorcraft_doc::MAX_SYMBOL_NODES; + self.warn(SYMBOLS_LEFT_OUT); + } + } } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. @@ -1262,3 +1298,28 @@ fn first_solid(n: &Node) -> Option { }); own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn flattening_a_path_of_huge_curves_stops_at_its_budget() { + // Each curve, a hundred million points across, flattens to thousands of segments at + // FLATNESS; a small square after them is past the budget and left out. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for i in 0..20_000 { + let y = if i % 2 == 0 { 1e8 } else { -1e8 }; + bp.curve_to((1e8, y), (-1e8, y), (0.0, 0.0)); + } + bp.move_to((10.0, 10.0)); + bp.line_to((20.0, 10.0)); + bp.line_to((20.0, 20.0)); + bp.close_path(); + let polys = polygons(&bp); + let points: usize = polys.iter().map(Vec::len).sum(); + assert!(points <= MAX_POINTS, "{points} points"); + assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out"); + } +} diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index f1b537b70..b09a8f080 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1007,3 +1007,21 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { let o = DxfOptions { crop: true, ..opts(&d) }; assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } + +#[test] +fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { + // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty + // instances, which write no entities, past the million objects tests export with. + let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "L5".into(), xf: Affine::IDENTITY })]); + let empty = Node::group(d.alloc_id(), vec![]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "L0".into(), art: Arc::new(empty) }); + for level in 1..=5 { + let below = format!("L{}", level - 1); + let children = (0..100).map(|_| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: below.clone(), xf: Affine::IDENTITY }))).collect(); + let art = Node::group(d.alloc_id(), children); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(art) }); + } + let out = export(&d, &opts(&d)).unwrap(); + assert!(entities(&out).is_empty()); + assert!(out.warnings.iter().any(|w| w.contains("symbols placed inside symbols")), "{:?}", out.warnings); +} From ed4a663986ab739711058ac80b9dc813d11e5690 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:33:39 -0500 Subject: [PATCH 2/2] DXF export: charge symbol art as it is placed unclipped, test the segment budget Count nine-slice symbol art with clips off, as place_symbol_art_unclipped places it, so art crossing the guides is not over-charged; add a test that the flattening segment budget stops a path independently of the point budget; format. --- crates/cad/src/scene.rs | 22 +++++++++++++++++++++- crates/cad/src/tests.rs | 3 ++- crates/doc/src/symbol_nine_slice.rs | 13 +++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index a82a891d1..d7e20654a 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -567,7 +567,8 @@ impl<'a> Scene<'a> { // empty or not: every instance's art counts against one budget for the export. let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); let count = art.count_with_masks() as u64; - let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed(&art, doc)); + // Counted as `place_symbol_art_unclipped` places it: no clip groups. + let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc)); let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { let placed = doc.place_symbol_art_unclipped(symbol, &art, *xf); let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64); @@ -1322,4 +1323,23 @@ mod budget_tests { assert!(points <= MAX_POINTS, "{points} points"); assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out"); } + + #[test] + fn flattening_a_path_of_repeated_points_stops_at_its_segment_budget() { + // Repeats of a point are dropped, so these keep one point but still count as segments: + // the segment budget, not the point budget, leaves out the square after them. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for _ in 0..MAX_FLATTEN_SEGMENTS { + bp.line_to((0.0, 0.0)); + } + bp.move_to((10.0, 10.0)); + bp.line_to((20.0, 10.0)); + bp.line_to((20.0, 20.0)); + bp.close_path(); + let polys = polygons(&bp); + let points: usize = polys.iter().map(Vec::len).sum(); + assert!(points < MAX_POINTS, "{points} points"); + assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out"); + } } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index b09a8f080..e4984c333 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1017,7 +1017,8 @@ fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "L0".into(), art: Arc::new(empty) }); for level in 1..=5 { let below = format!("L{}", level - 1); - let children = (0..100).map(|_| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: below.clone(), xf: Affine::IDENTITY }))).collect(); + let children = + (0..100).map(|_| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: below.clone(), xf: Affine::IDENTITY }))).collect(); let art = Node::group(d.alloc_id(), children); d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(art) }); } diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index a37b92eef..faa9afe6f 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -364,6 +364,11 @@ impl NineSliceMap { self.most_at(art, &boxes, 0) } + /// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`. + pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 { + Self { clips: false, ..*self }.most_placed(art, doc) + } + /// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`]. fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let whole = n.count_with_masks() as u64; @@ -642,10 +647,7 @@ impl Document { /// ([`NineSliceMap::place_unclipped`]). pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option { match self.symbol_slice_map(name, xf) { - Some(m) => { - let m = NineSliceMap { clips: false, ..m }; - (m.most_placed(art, self) <= MAX_SYMBOL_NODES).then(|| m.place(art, self)) - } + Some(m) => (m.most_placed_unclipped(art, self) <= MAX_SYMBOL_NODES).then(|| m.place_unclipped(art, self)), None => Some(transformed(art, xf)), } } @@ -1072,6 +1074,9 @@ mod tests { let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); assert_eq!(m.most_placed(&bar, &d), 10); assert_eq!(m.place(&bar, &d).count(), 10); + // Unclipped, as DXF writes it, the bar is cut at the guides and stays one path. + assert_eq!(m.most_placed_unclipped(&bar, &d), 1); + assert_eq!(m.place_unclipped(&bar, &d).count(), 1); // Art nested past the depth limit, clipped in all nine cells. let mut deep = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); for _ in 0..=MAX_SLICE_DEPTH {