From 4cf010c1305bbe464f5ca062abd3e537a3183254 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:15:33 -0800 Subject: [PATCH] Flatten: grouping sweeps skip boxes that a box already holds or cannot join The sweep filtered by transparency kept every open box in one tree, so a box without transparency met every open box without transparency it overlapped, though such pairs never join. Both sweeps closed an open box early only when a later box held it, so in a nest of boxes that share their left and top edges and get smaller to the right and bottom, each box met every earlier box around it. One see-through object inside 512 nested opaque objects took 130,817 meets in the unfiltered sweep and 131,328 in the filtered one. The filtered sweep now keeps open see-through boxes and open opaque boxes in separate trees. An opaque box looks only in the see-through tree; a see-through box looks in both. In both sweeps, a box inside an open box it met, ending no further right, is not opened when that box joins everything it overlaps (any box without the filter, a see-through box with it). Whatever later overlaps the inner box overlaps the outer one. The nest above now takes 512 meets in each sweep, and a nest of 512 boxes alone takes 511 in the unfiltered sweep where it took 130,816. The groups are unchanged. --- crates/engine/src/cmd/flatten/analysis.rs | 222 ++++++++++++++++++---- docs/target-app-parity.md | 1 + 2 files changed, 191 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index 4ad89c773b..0adb06132a 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -135,6 +135,9 @@ fn touch(a: &Rect, b: &Rect) -> bool { struct Sets { parent: Vec, seeded: Vec, + /// How many open boxes the sweep's boxes met. + #[cfg(test)] + meets: usize, } impl Sets { @@ -180,17 +183,25 @@ impl Sets { /// tops that knows the lowest bottom below each of its branches, so each box meets only the open /// boxes it overlaps, not every open one (many objects stacked in a column, sharing their /// horizontal extent, would otherwise compare every pair). An open box inside a later box of -/// its group, ending no further right, is closed early: whatever overlaps it overlaps that box. -/// Fails only when cancelled ([`vectorcraft_doc::cancel`]). +/// its group, ending no further right, is closed early, and a box inside an open box of its group, +/// ending no further right, isn't opened: whatever overlaps it overlaps that box. Fails only when +/// cancelled ([`vectorcraft_doc::cancel`]). pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { Ok(sweep(bounds, None)?.groups()) } /// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is -/// see-through, and an open box is closed early only inside a later see-through box. +/// see-through, and an open box is closed early, or a box isn't opened, only inside a see-through +/// box. The open +/// see-through boxes and the open boxes without transparency are then in trees of their own, and +/// a box without transparency meets only the see-through ones. fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { - let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() }; - let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]); + let mut sets = Sets { + parent: (0..bounds.len()).collect(), + seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect(), + #[cfg(test)] + meets: 0, + }; let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect(); let n = boxes.len(); // Each box's place in the tree: by top (then index). @@ -201,9 +212,11 @@ fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result], see_through: Option<&[bool]>) -> Result &[0], + Some(_) if shadows => &[0, 1], + Some(_) => &[1], + }; let below = tops.partition_point(|t| *t <= b.y1); met.clear(); - stack.clear(); - stack.push((1, 0, size)); - while let Some((node, lo, hi)) = stack.pop() { - work += 1; - if work.is_multiple_of(4096) { - vectorcraft_doc::cancel::check()?; + for &t in looks { + stack.clear(); + stack.push((1, 0, size)); + while let Some((node, lo, hi)) = stack.pop() { + work += 1; + if work.is_multiple_of(4096) { + vectorcraft_doc::cancel::check()?; + } + if lo >= below || low[t][node] < b.y0 { + continue; + } + if hi - lo == 1 { + met.push(by_top[lo]); + continue; + } + let mid = (lo + hi) / 2; + stack.push((2 * node + 1, mid, hi)); + stack.push((2 * node, lo, mid)); } - if lo >= below || low[node] < b.y0 { - continue; - } - if hi - lo == 1 { - met.push(by_top[lo]); - continue; - } - let mid = (lo + hi) / 2; - stack.push((2 * node + 1, mid, hi)); - stack.push((2 * node, lo, mid)); } + #[cfg(test)] + { + sets.meets += met.len(); + } + // An open box it met that holds it, ending no further right, and that joins whatever it + // overlaps: whatever later overlaps this box overlaps that one and joins it, so this box + // isn't opened. + let holder = met.iter().copied().find(|&o| { + let ob = boxes[o].1; + see_through.is_none_or(|s| s[boxes[o].0]) && b.x1 <= ob.x1 && ob.y0 <= b.y0 && b.y1 <= ob.y1 + }); // Whatever overlaps a box inside this one overlaps this one, and joins it when this one is // see-through. - let shadows = see_through.is_none_or(|s| s[boxes[k].0]); for &o in &met { - if joins(boxes[k].0, boxes[o].0) { - sets.join(boxes[k].0, boxes[o].0); - } + sets.join(boxes[k].0, boxes[o].0); let ob = boxes[o].1; - if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { - set(&mut low, leaf[o], f64::NEG_INFINITY); + if shadows && Some(o) != holder && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { + set(&mut low[tree(o)], leaf[o], f64::NEG_INFINITY); } } - set(&mut low, leaf[k], b.y1); + if holder.is_none() { + set(&mut low[tree(k)], leaf[k], b.y1); + } } Ok(sets) } @@ -392,6 +425,11 @@ mod tests { /// What [`overlapping`] gives, comparing every pair. fn every_pair(bounds: &[Option]) -> Vec> { + every_pair_joining(bounds, |_, _| true) + } + + /// The groups of `bounds` comparing every pair, joining overlapping pairs that `joins`. + fn every_pair_joining(bounds: &[Option], joins: impl Fn(usize, usize) -> bool) -> Vec> { let n = bounds.len(); let mut group: Vec = (0..n).collect(); let hit = |a: &Rect, b: &Rect| a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1; @@ -401,6 +439,7 @@ mod tests { for j in 0..n { if let (Some(a), Some(b)) = (bounds[i], bounds[j]) && hit(&a, &b) + && joins(i, j) && group[j] < group[i] { group[i] = group[j]; @@ -464,6 +503,69 @@ mod tests { assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]); } + /// `n` boxes sharing their left and top edges, each inside the one before. + fn shrinking_nest(n: usize) -> Vec> { + (0..n).map(|i| Some(Rect::new(0.0, 0.0, 2.0 * (n - i) as f64, 2.0 * (n - i) as f64))).collect() + } + + /// Each box of a nest that shrinks to the right and bottom meets only the outermost box, which + /// stays open and holds it, not every box around it. + #[test] + fn a_shrinking_nest_meets_only_its_outermost_box() { + let n = 512; + let bounds = shrinking_nest(n); + let mut sets = sweep(&bounds, None).unwrap(); + assert_eq!(sets.meets, n - 1); + assert_eq!(sets.groups(), every_pair(&bounds)); + assert_eq!(sets.groups(), vec![(0..n).collect::>()]); + // Equal boxes: the later one isn't opened and the earlier one stays open for the third. + let same = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(9.0, 9.0, 20.0, 20.0))]; + assert_eq!(overlapping(&same).unwrap(), vec![vec![0, 1, 2]]); + // With transparency, an opaque box isn't opened inside a see-through box, and boxes inside + // an opaque box are opened: an opaque box that overlaps only the inner one still joins it + // when that one is see-through. + let see = [true, false]; + let mut sets = sweep(&bounds[..2], Some(&see)).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1]]); + let held = [Some(Rect::new(0.0, 0.0, 20.0, 20.0)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 8.0, 8.0))]; + let mut sets = sweep(&held, Some(&[false, true, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[true, false, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[false, false, true])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0, 1, 2]]); + let mut sets = sweep(&held, Some(&[false, false, false])).unwrap(); + assert_eq!(sets.groups(), vec![vec![0], vec![1], vec![2]]); + } + + /// Boxes nested at shared corners and placed at random, some see-through, group with and + /// without transparency as comparing every pair groups them. + #[test] + fn nested_and_scattered_boxes_group_as_comparing_every_pair() { + let mut seed = 0x51_7cc1_b727_220au64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..40 { + let n = 6 + round * 3; + let bounds: Vec> = (0..n) + .map(|_| { + // Corners on a coarse grid so that edges are often shared and boxes often nest. + let (x, y) = ((next(6.0).floor()) * 10.0, (next(6.0).floor()) * 10.0); + let (w, h) = ((next(5.0).floor() + 1.0) * 10.0, (next(5.0).floor() + 1.0) * 10.0); + Some(Rect::new(x, y, x + w, y + h)) + }) + .collect(); + assert_eq!(overlapping(&bounds).unwrap(), every_pair(&bounds), "round {round}"); + let see: Vec = (0..n).map(|_| next(1.0) < 0.3).collect(); + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + assert_eq!(sets.groups(), every_pair_joining(&bounds, |i, j| see[i] || see[j]), "round {round}, with transparency"); + } + } + /// A filled rectangle, unstroked. fn filled(id: u64, r: Rect) -> Node { let look = vectorcraft_doc::Appearance::basic( @@ -583,6 +685,62 @@ mod tests { assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n); } + /// A small see-through box, then `n` opaque boxes nested around it, each inside the one + /// before and sharing its left and top edges. + fn nest_around_a_see_through_box(n: usize) -> (Vec>, Vec) { + let mut bounds = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0))]; + bounds.extend((0..n).map(|i| Some(Rect::new(0.0, 0.0, 2.0 * (n - i) as f64 + 1.0, 2.0 * (n - i) as f64 + 1.0)))); + let mut see = vec![false; n + 1]; + see[0] = true; + (bounds, see) + } + + /// Opaque boxes nested in each other around a see-through box each meet only the see-through + /// box, not the open opaque boxes they can't join, and all join it. + #[test] + fn nested_opaque_boxes_meet_only_the_see_through_box() { + let n = 512; + let (bounds, see) = nest_around_a_see_through_box(n); + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + assert_eq!(sets.meets, n); + assert_eq!(sets.groups(), vec![(0..=n).collect::>()]); + // The unfiltered sweep that runs before it meets each box once too. + let mut sets = sweep(&bounds, None).unwrap(); + assert_eq!(sets.meets, n); + assert_eq!(sets.groups(), vec![(0..=n).collect::>()]); + let (painted, see) = nest_around_a_see_through_box(40); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + for fringe in [0.0, 0.7] { + let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap(); + check(&reach, &painted, &see, fringe, &groups, &format!("nest, fringe {fringe}")); + assert_eq!(groups, vec![(0..=40).collect::>()]); + } + } + + /// An opaque box inside a later see-through box is closed: later boxes meet the see-through + /// box instead, and join the opaque one through it. + #[test] + fn a_see_through_box_closes_an_opaque_box_inside_it() { + // An opaque box, a see-through box around it, a see-through box over both and an opaque + // box over the first. + let bounds = [ + Some(Rect::new(0.0, 0.0, 10.0, 10.0)), + Some(Rect::new(0.0, 0.0, 20.0, 20.0)), + Some(Rect::new(5.0, 5.0, 30.0, 30.0)), + Some(Rect::new(8.0, 8.0, 12.0, 12.0)), + ]; + let see = [false, true, true, false]; + let mut sets = sweep(&bounds, Some(&see)).unwrap(); + // The second box meets the first; the third meets the second; the fourth meets the second + // and the third. + assert_eq!(sets.meets, 4); + assert_eq!(sets.groups(), vec![vec![0, 1, 2, 3]]); + // Opaque boxes alone meet nothing. + let mut sets = sweep(&bounds, Some(&[false; 4])).unwrap(); + assert_eq!(sets.meets, 0); + assert_eq!(sets.groups().len(), 4); + } + /// An opaque box between a member and its group's top that paints over the member joins, and /// so on along a chain; one under the member stays, but for one within the fringe. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..f7a9e971be 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1251,6 +1251,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency leave art without transparency as it is when it overlaps only other art without transparency, unless it overlaps a member of a see-through object's group and lies above that member and below the group's topmost object. Such art joins the group, and the group takes the place of its topmost object, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under the group within one image pixel of a member joins it too. The 1 pt antialiasing margin goes around a group's art once, however deeply its groups nest. On a grid of 3,000 rectangles with a 0.5 pt stroke, 60 of them with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. These rules are VectorCraft's own (`tests::flatten::opaque_art_that_touches_only_opaque_art_stays`, `tests::flatten::opaque_art_the_group_would_move_under_joins_it`). + When they group art for flattening, Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency compare the box of an object without transparency only with the boxes of see-through objects in the pass that splits art by transparency. In both passes, a box that lies inside an earlier box that ends no further right and joins everything it overlaps is not compared with later boxes. With one see-through object inside 512 nested objects without transparency, the grouping compares 1,024 pairs of boxes where it compared 262,145, and the groups stay the same (`cmd::flatten::analysis::tests::nested_opaque_boxes_meet_only_the_see_through_box`, `a_shrinking_nest_meets_only_its_outermost_box`). New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type › Type on a Path › Center on Top and Center on Bottom (`type.pathCenter.top`, `type.pathCenter.bottom`, also on the Contextual Task Bar for type on a closed path) slide type on a closed path so the middle of its span lands on the path's topmost or bottommost point as drawn, keep the span's length, and set the first paragraph to Center unless it is Center or Justify Center. This is VectorCraft's own behavior (`crates/geom/src/arc.rs`, `crates/engine/src/tests/pathtype.rs`).