mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 17:40:54 +00:00
Merge pull request #6127 from dotCooCoo/fix/flatten-region-limit-early
Flattening: stop building a group's regions at the first one past the region limit
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<usize> = 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<bool> = 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<T>(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<RegionFill> {
|
||||
|
||||
@@ -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<usize> {
|
||||
(!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<Value> {
|
||||
|
||||
@@ -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<u64> = (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::<Vec<_>>().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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<usize> = 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<Region> {
|
||||
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<Vec<Region>> {
|
||||
let masks = arr.distinct_masks();
|
||||
let refs: Vec<&[bool]> = masks.iter().map(Vec::as_slice).collect();
|
||||
let mut faces: Vec<Vec<PathData>> = 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<usize> = 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<usize>`
|
||||
@@ -251,8 +279,21 @@ fn each_classified_face(
|
||||
classify: impl Fn(&Multi) -> Option<usize>,
|
||||
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<usize>,
|
||||
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<Region> {
|
||||
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<Vec<Region>> {
|
||||
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<Region> {
|
||||
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<Shape> = (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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user