From 96252edb74650ae139bbce516d043c94c02d224d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 06:55:07 -0800 Subject: [PATCH] Flattening: stop building a group's regions at the first one past the region limit With an unclipped raster/vector balance between 0 and 100, a group that splits into more than 10^(balance/20) regions is rasterized whole and none of its regions are used. The region build now stops at the first region past that limit (pathops `regions_within`) instead of building every region and then discarding them. Groups at or under the limit, and every group with Clip Complex Regions on, still build every region. Flatten Transparency, the flattened document and EPS export come out the same as with every region built, for groups under the limit, at it, just over it and far over it. --- crates/engine/src/cmd/flatten.rs | 2 + crates/engine/src/cmd/flatten/apply.rs | 36 +++++++- crates/engine/src/cmd/flatten/options.rs | 9 +- crates/engine/src/tests/flatten.rs | 56 ++++++++++++ crates/pathops/src/lib.rs | 2 +- crates/pathops/src/pathfinder.rs | 108 ++++++++++++++++++++--- docs/target-app-parity.md | 1 + 7 files changed, 199 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index ccbe9c049..ba99c4beb 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -49,6 +49,8 @@ mod presets; mod report; pub(crate) use analysis::{outlined_text, see_through_images}; +#[cfg(test)] +pub(crate) use apply::with_every_region; pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; pub use commands::specs; pub use options::FlattenOptions; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index e4113b47b..2b0b1d072 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -150,9 +150,15 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg } // An unclipped full raster needs no regions. let whole = o.balance <= 0.0 && !o.clip_complex_regions; - let regions = if whole { vec![] } else { po::regions(&b.shapes) }; + // Nor does one that splits into more regions than the balance allows: the build stops at the + // first region past the limit. + let (regions, over) = match o.region_limit().filter(|_| !every_region()) { + _ if whole => (vec![], false), + Some(limit) => po::regions_within(&b.shapes, limit).map_or((vec![], true), |r| (r, false)), + None => (po::regions(&b.shapes), false), + }; // An arrangement that failed (degenerate geometry) leaves only rasterizing. - let all = regions.is_empty() || o.rasterize_all(regions.len()); + let all = over || regions.is_empty() || o.rasterize_all(regions.len()); let mut vector: Vec<(usize, RegionFill)> = vec![]; let mut complex: Vec = vec![]; let mut gradients_only = !all; @@ -195,6 +201,32 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg Some((vector, area)) } +#[cfg(test)] +thread_local! { + /// Groups flattened on this thread build all of their regions ([`with_every_region`]). + static EVERY_REGION: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether groups build all of their regions, past the balance's limit too. +#[cfg(test)] +fn every_region() -> bool { + EVERY_REGION.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn every_region() -> bool { + false +} + +/// `f` with every group it flattens building all of its regions, past the balance's limit too. +#[cfg(test)] +pub(crate) fn with_every_region(f: impl FnOnce() -> T) -> T { + EVERY_REGION.with(|e| e.set(true)); + let out = f(); + EVERY_REGION.with(|e| e.set(false)); + out +} + /// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where /// nothing paints it. fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index 52a900289..a5881c76e 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -169,9 +169,16 @@ impl FlattenOptions { } /// Whether a group that splits into `regions` atomic regions is rasterized whole. - pub(super) fn rasterize_all(&self, regions: usize) -> bool { + pub(crate) fn rasterize_all(&self, regions: usize) -> bool { self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0)) } + + /// The most atomic regions a group can split into without being rasterized whole, when a group + /// with more needs none of its regions: `None` at a balance of 0 or 100, and with Clip Complex + /// Regions (a group rasterized whole is clipped to all of its regions). + pub(crate) fn region_limit(&self) -> Option { + (!self.clip_complex_regions && self.balance > 0.0 && self.balance < 100.0).then(|| 10f64.powf(self.balance / 20.0).floor() as usize) + } } pub(super) fn flatten(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index d4671c11e..2e08b99c8 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -272,6 +272,62 @@ fn balance_trades_vector_regions_for_an_image() { assert_eq!(flatten(&mut s, json!({"ids": [a, b], "balance": 5})).2, 1); } +/// The balance's region limit, `10^(balance/20)` rounded down, is the most regions a group keeps. +#[test] +fn the_region_limit_is_the_most_regions_the_balance_keeps() { + use crate::cmd::FlattenOptions; + for balance in [0.0, 1.0, 5.0, 16.0, 17.0, 20.0, 40.0, 60.0, 75.0, 99.0, 99.99, 100.0] { + for clip in [false, true] { + let o = FlattenOptions::from_params(&json!({"balance": balance, "clipComplexRegions": clip})).unwrap(); + match o.region_limit() { + Some(n) => { + assert!(!o.rasterize_all(n) && o.rasterize_all(n + 1), "balance {balance}: limit {n}"); + assert!(!clip, "balance {balance}: a limit with Clip Complex Regions"); + } + None => assert!(clip || balance <= 0.0 || balance >= 100.0, "balance {balance}: no limit"), + } + } + } +} + +/// A row of four half-transparent rectangles splits into 7 regions. Under the balance's region +/// limit (10), at it (7), just over it (6) and far over it (1), Flatten Transparency, the flattened +/// document and the EPS file come out as they do when every region is built. +#[test] +fn groups_over_the_region_limit_flatten_as_with_every_region_built() { + use crate::cmd::FlattenOptions; + use crate::cmd::flatten::{flatten_document, with_every_region as every}; + let _settings = settings_lock(); + let setup = || { + let mut s = session(); + let ids: Vec = (0..4).map(|k| rect(&mut s, 10.0 + 30.0 * f64::from(k), 10.0, 40.0, 60.0, "#ff8000")).collect(); + run(&mut s, "transparency.set", json!({"ids": ids, "opacity": 50})); + (s, ids) + }; + let eps = |s: &mut Session, balance: u32| { + let r = run(s, "document.exportEps", json!({"flattener": {"balance": balance}, "previewFormat": "none", "thumbnails": false})); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + String::from_utf8_lossy(&bytes).lines().filter(|l| !l.contains("CreationDate")).collect::>().join("\n") + }; + for (balance, vector, rasterized) in [(20, 7, 0), (17, 7, 0), (16, 0, 1), (5, 0, 1)] { + let (mut s, _) = setup(); + let o = FlattenOptions::from_params(&json!({"balance": balance})).unwrap(); + let src = s.doc().unwrap().doc.clone(); + let flat = flatten_document(&src, &o).unwrap(); + assert!(flat == every(|| flatten_document(&src, &o).unwrap()), "balance {balance}: flattened document"); + assert_eq!(eps(&mut s, balance), every(|| eps(&mut s, balance)), "balance {balance}: EPS"); + let (mut s, ids) = setup(); + let got = flatten(&mut s, json!({"ids": ids, "balance": balance})); + assert_eq!((got.1, got.2), (vector, rasterized), "balance {balance}"); + let (mut t, ids) = setup(); + let want = every(|| flatten(&mut t, json!({"ids": ids, "balance": balance}))); + assert_eq!(got, want, "balance {balance}"); + // The two documents were made apart, so their creation times can differ. + let (a, b) = (&s.doc().unwrap().doc, &t.doc().unwrap().doc); + assert!(a.layers == b.layers && a.images == b.images, "balance {balance}: Flatten Transparency"); + } +} + #[test] fn gradients_rasterize_and_flat_areas_stay_vector() { let _settings = settings_lock(); diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 7f55e44b2..b7af34c89 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -27,7 +27,7 @@ pub use edit::{ split_into_grid, }; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; -pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions}; +pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within}; pub use planar::{ BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap, diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index f0c5ab505..d6d85c9ad 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -2,6 +2,7 @@ //! (index 0 = back-most, last = front-most). use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; use kurbo::{BezPath, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Shape as _}; use linesweeper::topology::{ContourIdx, Contours}; @@ -12,6 +13,12 @@ use crate::boolean::{ to_seg, unite_all, unite_all_with, }; +#[cfg(test)] +thread_local! { + /// The face outlines [`regions_of_within`] has built on this thread. + static FACE_PATHS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); /// results carry the key of the object whose paint they keep. #[derive(Clone, Debug, PartialEq)] @@ -221,20 +228,41 @@ pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Ve /// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face an outer contour /// and the holes of its mask directly inside it ([`face_groups`]). fn regions_of(arr: &Arrangement) -> Vec { + regions_of_within(arr, usize::MAX).unwrap_or_default() +} + +/// [`regions_of`] while there are at most `limit` faces: `None` as soon as one more is found, +/// without building the faces after it. +fn regions_of_within(arr: &Arrangement, limit: usize) -> Option> { let masks = arr.distinct_masks(); let refs: Vec<&[bool]> = masks.iter().map(Vec::as_slice).collect(); let mut faces: Vec> = vec![Vec::new(); masks.len()]; - each_face(arr, &refs, |m, c, group| { - if let Some(f) = faces.get_mut(m) { - f.push(contours_to_path(c, group.iter().copied(), &arr.tidy)); + let mut found = 0usize; + let walk = each_face_until(arr, &refs, |m, c, group| { + #[cfg(test)] + FACE_PATHS.with(|n| n.set(n.get() + 1)); + let path = contours_to_path(c, group.iter().copied(), &arr.tidy); + if path.is_empty() { + return ControlFlow::Continue(()); } + found += 1; + if found > limit { + return ControlFlow::Break(()); + } + if let Some(f) = faces.get_mut(m) { + f.push(path); + } + ControlFlow::Continue(()) }); + if walk.is_break() { + return None; + } let mut out = Vec::new(); for (mask, paths) in masks.iter().zip(faces) { let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); - out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); + out.extend(paths.into_iter().map(|path| Region { path, sources: sources.clone() })); } - out + Some(out) } /// Calls `f` with each face of `arr` classified by `classify: impl Fn(&Multi) -> Option` @@ -251,8 +279,21 @@ fn each_classified_face( classify: impl Fn(&Multi) -> Option, mut f: impl FnMut(usize, &Contours, &[ContourIdx]), ) { + let _ = each_classified_face_until(arr, num_classes, classify, |m, c, group| { + f(m, c, group); + ControlFlow::Continue(()) + }); +} + +/// [`each_classified_face`] until `f` breaks: no face after that one is walked. +fn each_classified_face_until( + arr: &Arrangement, + num_classes: usize, + classify: impl Fn(&Multi) -> Option, + mut f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>, +) -> ControlFlow<()> { if num_classes == 0 { - return; + return ControlFlow::Continue(()); } let top = &arr.top; // The classes of the faces around each vertex. @@ -304,15 +345,20 @@ fn each_classified_face( let c = arr.contours_where(|w| classify(w) == Some(m)); let owner = vec![m; c.contours().count()]; for (_, group) in face_groups(&c, &owner) { - f(m, &c, &group); + if f(m, &c, &group).is_break() { + return ControlFlow::Break(()); + } } } continue; }; for (m, group) in face_groups(&c, &owner) { - f(m, &c, &group); + if f(m, &c, &group).is_break() { + return ControlFlow::Break(()); + } } } + ControlFlow::Continue(()) } /// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct): the index of @@ -325,13 +371,21 @@ fn each_classified_face( /// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as /// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first /// point. -fn each_face(arr: &Arrangement, masks: &[&[bool]], f: impl FnMut(usize, &Contours, &[ContourIdx])) { +fn each_face(arr: &Arrangement, masks: &[&[bool]], mut f: impl FnMut(usize, &Contours, &[ContourIdx])) { + let _ = each_face_until(arr, masks, |m, c, group| { + f(m, c, group); + ControlFlow::Continue(()) + }); +} + +/// [`each_face`] until `f` breaks: no face after that one is walked. +fn each_face_until(arr: &Arrangement, masks: &[&[bool]], f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>) -> ControlFlow<()> { if masks.is_empty() { - return; + return ControlFlow::Continue(()); } // Each mask is found by the inputs it covers. let ids = CoverIds::new(masks.iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(k, _)| k).collect()).collect()); - each_classified_face(arr, masks.len(), |w| ids.of(arr, w), f); + each_classified_face_until(arr, masks.len(), |w| ids.of(arr, w), f) } /// Lists of inputs, each ascending as [`Arrangement::covered`] gives them, found by the inputs @@ -463,6 +517,15 @@ pub(crate) fn regions_with(shapes: &[Shape], precision: f64) -> Vec { arrangement(shapes, precision).map(|a| regions_of(&a)).unwrap_or_default() } +/// [`regions`] when there are at most `limit` of them; `None` when there are more, found as soon +/// as the face past `limit` is built (the faces after it are not built). +pub fn regions_within(shapes: &[Shape], limit: usize) -> Option> { + match arrangement(shapes, DEFAULT_PRECISION) { + Some(a) => regions_of_within(&a, limit), + None => Some(Vec::new()), + } +} + /// The face under `point`, if any (Shape Builder hover/click). pub fn region_at(shapes: &[Shape], point: Point) -> Option { regions(shapes).into_iter().find(|r| r.contains(point)) @@ -1273,6 +1336,29 @@ mod tests { assert!((area - 490.0 * 40.0).abs() < 1e-6, "{area}"); } + /// The row of 49 rectangles makes 97 faces: within a limit of 97 or more they are [`regions`]'s + /// faces, and over a lower one the build stops at the face past it. + #[test] + fn regions_within_a_limit_stop_at_the_face_past_it() { + let v: Vec = (0..49) + .map(|k| { + let x = 10.0 * f64::from(k); + Shape::new(rect(x, 0.0, x + 15.0, 40.0), FillRule::NonZero, 1) + }) + .collect(); + let all = regions(&v); + assert_eq!(all.len(), 97); + for limit in [97, 98, usize::MAX] { + assert_eq!(regions_within(&v, limit).as_ref(), Some(&all), "limit {limit}"); + } + for limit in [0, 1, 10, 96] { + FACE_PATHS.with(|n| n.set(0)); + assert_eq!(regions_within(&v, limit), None, "limit {limit}"); + assert_eq!(FACE_PATHS.with(std::cell::Cell::get), limit + 1, "limit {limit}: faces built"); + } + assert_eq!(regions_within(&[], 0), Some(vec![])); + } + /// The lists of inputs a winding covers are found by those inputs, also among lists that share /// a hash: here every list is filed under every hash. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 61b07fa97..2b1294c82 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1024,6 +1024,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Select › Same › Fill Color, Stroke Color, Fill & Stroke, Stroke Weight, Opacity, Blending Mode, Appearance, Shape, Graphic Style, Appearance Attribute and Symbol Instance compare the other objects with the first selected object that can be selected, of any kind: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated (except new art while it stays selected), or outside the isolated group is passed over. This is VectorCraft's own behavior. Select › Object › All on Same Layers takes the layers only of the selected objects that can be selected: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated, or outside the isolated group adds no layer. This is VectorCraft's own behavior. 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. 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. The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.