mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge main into fix/graph-label-paint-recolor
This commit is contained in:
@@ -123,6 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
|
||||
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
|
||||
- 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged.
|
||||
- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged.
|
||||
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
|
||||
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
|
||||
|
||||
+336
-28
@@ -1464,7 +1464,7 @@ pub fn pin_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Vec<
|
||||
}
|
||||
|
||||
/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length
|
||||
/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)?
|
||||
/// (within a hundredth of a point, as [`SpreadLengths::anchor_at_length`] matches anchors)?
|
||||
fn keys_spread_along(keys: &[Arc<Node>], spine: &Spine) -> bool {
|
||||
let k = keys.len();
|
||||
keys.iter().enumerate().all(|(i, key)| {
|
||||
@@ -1489,40 +1489,204 @@ fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) {
|
||||
}
|
||||
|
||||
/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit
|
||||
/// on, splitting segments where none is.
|
||||
/// on, splitting segments where none is. `None`, leaving `path` as it was, when the subpath has no
|
||||
/// length to spread along, when its length is not finite (a path so large its length overflows),
|
||||
/// or when an arc length it would split at is not finite.
|
||||
fn spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> {
|
||||
let total = Spine::new(path)?.length();
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
spread_anchors_with(path, k, |_| {})
|
||||
}
|
||||
|
||||
/// [`spread_anchors`], calling `work` for each step of work it does.
|
||||
fn spread_anchors_with(path: &mut PathData, k: usize, mut work: impl FnMut(SpreadWork)) -> Option<Vec<usize>> {
|
||||
let sp = path.subpaths.first_mut()?;
|
||||
let lengths = SpreadLengths::new(sp, &mut work);
|
||||
let total = lengths.total().filter(|t| t.is_finite())?;
|
||||
let targets = (1..k.saturating_sub(1)).map(|i| total * i as f64 / (k - 1) as f64);
|
||||
let mut out = vec![0usize];
|
||||
for i in 1..k.saturating_sub(1) {
|
||||
let s = total * i as f64 / (k - 1) as f64;
|
||||
out.push(anchor_at_length(path, s)?);
|
||||
}
|
||||
let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) });
|
||||
out.push(last);
|
||||
out.extend(anchors_at_lengths(sp, lengths, targets, &mut work)?);
|
||||
out.push(if sp.closed { 0 } else { sp.anchors.len().saturating_sub(1) });
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a
|
||||
/// point, else a new one splitting the segment there.
|
||||
fn anchor_at_length(path: &mut PathData, s: f64) -> Option<usize> {
|
||||
let spine = Spine::new(path)?;
|
||||
let sp = path.subpaths.first_mut()?;
|
||||
let n = sp.segment_count();
|
||||
let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0);
|
||||
let from = spine.anchor_length(seg).unwrap_or(0.0);
|
||||
let to = spine.anchor_length(seg + 1).unwrap_or(spine.length());
|
||||
if s - from < 0.01 {
|
||||
return Some(seg);
|
||||
/// The anchors of `sp` at each arc length in `targets`, in order, splitting segments where none is
|
||||
/// (see [`SpreadLengths::anchor_at_length`]); `lengths` measures `sp`. The splits are made on a copy
|
||||
/// of `sp`, which replaces it only when every target has its anchor: `None` leaves `sp` as it was.
|
||||
fn anchors_at_lengths(
|
||||
sp: &mut SubPath,
|
||||
mut lengths: SpreadLengths,
|
||||
targets: impl IntoIterator<Item = f64>,
|
||||
work: &mut impl FnMut(SpreadWork),
|
||||
) -> Option<Vec<usize>> {
|
||||
let mut split = sp.clone();
|
||||
let mut out = Vec::new();
|
||||
for s in targets {
|
||||
out.push(lengths.anchor_at_length(&mut split, s, work)?);
|
||||
}
|
||||
if to - s < 0.01 {
|
||||
return Some((seg + 1) % sp.anchors.len().max(1));
|
||||
*sp = split;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A step of the work [`spread_anchors_with`] does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SpreadWork {
|
||||
/// Flattening every segment of the subpath.
|
||||
WholePath,
|
||||
/// Flattening one segment after a split.
|
||||
Segment,
|
||||
/// Adding one segment's flattened points to the arc lengths.
|
||||
Measure,
|
||||
/// Comparing an anchor's arc length with the one looked for.
|
||||
Compare,
|
||||
}
|
||||
|
||||
/// Where the measurement of a subpath's flattened points stands at the start of a segment: the last
|
||||
/// point kept, the arc length there (0 before any point), and how many points were kept.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Measured {
|
||||
last: Option<Point>,
|
||||
len: f64,
|
||||
kept: usize,
|
||||
}
|
||||
|
||||
impl Measured {
|
||||
const START: Self = Self { last: None, len: 0.0, kept: 0 };
|
||||
|
||||
/// The measurement after `pts`, keeping each point more than 1e-9 from the last one kept, as
|
||||
/// [`Spine::new`] does.
|
||||
fn then(mut self, pts: &[Point]) -> Self {
|
||||
for &p in pts {
|
||||
if self.last.is_none_or(|q| q.distance(p) > 1e-9) {
|
||||
self.len = match self.last {
|
||||
Some(q) => self.len + q.distance(p),
|
||||
None => 0.0,
|
||||
};
|
||||
self.last = Some(p);
|
||||
self.kept += 1;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The arc lengths of a subpath's anchors, as [`Spine::new`] measures them, kept up to date while
|
||||
/// [`spread_anchors`] splits its segments. Each segment is flattened once, and again only when it
|
||||
/// is split. A split leaves the arc lengths of the anchors before it as they are; those after it
|
||||
/// are measured again, in order from the split, only as far as the next arc length looked for, so
|
||||
/// the sums are the same as [`Spine::new`]'s on the split subpath.
|
||||
struct SpreadLengths {
|
||||
/// The finite points the segments flatten to, each segment's in one run.
|
||||
pts: Vec<Point>,
|
||||
/// Where each segment's run is in `pts`. A split segment's halves get runs at the end, and its
|
||||
/// old run is not used again.
|
||||
flat: Vec<std::ops::Range<usize>>,
|
||||
/// The measurement at the start of each segment, up to the first one not measured since the
|
||||
/// last split; after the last segment too once every segment is measured.
|
||||
marks: Vec<Measured>,
|
||||
}
|
||||
|
||||
impl SpreadLengths {
|
||||
/// Flatten and measure every segment of `sp`.
|
||||
fn new(sp: &SubPath, work: &mut impl FnMut(SpreadWork)) -> Self {
|
||||
work(SpreadWork::WholePath);
|
||||
let n = sp.segment_count();
|
||||
let mut lengths = Self { pts: Vec::new(), flat: Vec::with_capacity(n), marks: Vec::with_capacity(n + 1) };
|
||||
for i in 0..n {
|
||||
let run = lengths.flatten(sp, i);
|
||||
lengths.flat.push(run);
|
||||
}
|
||||
lengths.marks.push(Measured::START);
|
||||
lengths.measure_to(f64::INFINITY, work);
|
||||
lengths
|
||||
}
|
||||
|
||||
/// Flatten segment `i` of `sp` as [`Spine::new`] does, adding its finite points to `pts`, and
|
||||
/// return where they are.
|
||||
fn flatten(&mut self, sp: &SubPath, i: usize) -> std::ops::Range<usize> {
|
||||
let start = self.pts.len();
|
||||
let c = sp.segment(i);
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(c.p0);
|
||||
if sp.segment_is_line(i) {
|
||||
bp.line_to(c.p3);
|
||||
} else {
|
||||
bp.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
vectorcraft_geom::kurbo::flatten(bp.iter(), 0.1, |el| {
|
||||
if let vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) = el
|
||||
&& p.x.is_finite()
|
||||
&& p.y.is_finite()
|
||||
{
|
||||
self.pts.push(p);
|
||||
}
|
||||
});
|
||||
start..self.pts.len()
|
||||
}
|
||||
|
||||
/// The subpath's flattened length, `None` when it flattens to fewer than two points.
|
||||
fn total(&self) -> Option<f64> {
|
||||
self.marks.last().filter(|m| self.marks.len() > self.flat.len() && m.kept >= 2).map(|m| m.len)
|
||||
}
|
||||
|
||||
/// Measure the segments that are not measured, from the first, until one starts past `s` (with
|
||||
/// two points kept before it) or every one is measured.
|
||||
fn measure_to(&mut self, s: f64, work: &mut impl FnMut(SpreadWork)) {
|
||||
while let Some(run) = self.flat.get(self.marks.len() - 1) {
|
||||
let Some(&m) = self.marks.last() else { return };
|
||||
if self.marks.len() >= 2 && m.kept >= 2 && m.len > s {
|
||||
return;
|
||||
}
|
||||
work(SpreadWork::Measure);
|
||||
self.marks.push(m.then(self.pts.get(run.clone()).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor of `sp` at arc length `s`: an existing one within a hundredth of a point, else a
|
||||
/// new one splitting the segment there. The segment is the last one starting at or before `s`
|
||||
/// (the first one when none does), found by a binary search. `None`, leaving `sp` as it was,
|
||||
/// when `s` or the arc length at the end of the segment is not finite, or when `sp` flattens
|
||||
/// to fewer than two points.
|
||||
fn anchor_at_length(&mut self, sp: &mut SubPath, s: f64, work: &mut impl FnMut(SpreadWork)) -> Option<usize> {
|
||||
if !s.is_finite() {
|
||||
return None;
|
||||
}
|
||||
self.measure_to(s, work);
|
||||
let n = self.flat.len();
|
||||
if self.marks.last().is_none_or(|m| m.kept < 2) {
|
||||
return None;
|
||||
}
|
||||
let starts = &self.marks[..self.marks.len().min(n)];
|
||||
let seg = starts
|
||||
.partition_point(|m| {
|
||||
work(SpreadWork::Compare);
|
||||
m.len <= s
|
||||
})
|
||||
.saturating_sub(1);
|
||||
let from = self.marks.get(seg)?.len;
|
||||
let to = self.marks.get(seg + 1)?.len;
|
||||
if s - from < 0.01 {
|
||||
return Some(seg);
|
||||
}
|
||||
if to - s < 0.01 {
|
||||
return Some((seg + 1) % sp.anchors.len().max(1));
|
||||
}
|
||||
if !from.is_finite() || !to.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let c = sp.segment(seg);
|
||||
// The flattened length and the curve's own differ slightly: scale into the curve's.
|
||||
let arc = c.arclen(1e-4).max(1e-12);
|
||||
let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4);
|
||||
if !t.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let at = sp.insert_anchor(seg, t);
|
||||
work(SpreadWork::Segment);
|
||||
work(SpreadWork::Segment);
|
||||
let halves = [self.flatten(sp, seg), self.flatten(sp, seg + 1)];
|
||||
self.flat.splice(seg..=seg, halves);
|
||||
self.marks.truncate(seg + 1);
|
||||
Some(at)
|
||||
}
|
||||
let c = sp.segment(seg);
|
||||
// The flattened length and the curve's own differ slightly: scale into the curve's.
|
||||
let arc = c.arclen(1e-4).max(1e-12);
|
||||
let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4);
|
||||
Some(sp.insert_anchor(seg, t))
|
||||
}
|
||||
|
||||
/// Where a blend's keys and steps go along its spine (see [`blend_spine`]).
|
||||
@@ -2012,6 +2176,150 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`spread_anchors`] by measuring the whole spine again for each key and walking its segments
|
||||
/// from the last, as a reference for the measurement kept up to date between keys.
|
||||
fn rebuilt_spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> {
|
||||
let total = Spine::new(path)?.length();
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
let mut out = vec![0usize];
|
||||
for i in 1..k.saturating_sub(1) {
|
||||
let s = total * i as f64 / (k - 1) as f64;
|
||||
let spine = Spine::new(path)?;
|
||||
let sp = path.subpaths.first_mut()?;
|
||||
let n = sp.segment_count();
|
||||
let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0);
|
||||
let from = spine.anchor_length(seg).unwrap_or(0.0);
|
||||
let to = spine.anchor_length(seg + 1).unwrap_or(spine.length());
|
||||
out.push(if s - from < 0.01 {
|
||||
seg
|
||||
} else if to - s < 0.01 {
|
||||
(seg + 1) % sp.anchors.len().max(1)
|
||||
} else {
|
||||
let c = sp.segment(seg);
|
||||
let arc = c.arclen(1e-4).max(1e-12);
|
||||
let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4);
|
||||
sp.insert_anchor(seg, t)
|
||||
});
|
||||
}
|
||||
let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) });
|
||||
out.push(last);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Spreading keys along a spine measured once and kept up to date gives the same anchors and
|
||||
/// the same spine as measuring it again for each key: on open and closed polylines and curves,
|
||||
/// zero-length segments, keys exactly on anchors, keys within, at and just past a hundredth of
|
||||
/// a point from an anchor, a closed curve whose keys go round past its first anchor, many keys
|
||||
/// in one segment, and paths with no segment. The closed spines open as [`pin_spine`] opens
|
||||
/// them.
|
||||
#[test]
|
||||
fn spreading_keys_matches_measuring_the_spine_for_each_key() {
|
||||
let p = Point::new;
|
||||
let bow = SubPath::new(
|
||||
vec![Anchor::smooth(p(0.0, 0.0), p(50.0, 50.0)), Anchor::smooth(p(100.0, 0.0), p(150.0, -50.0)), Anchor::corner(p(200.0, 0.0))],
|
||||
false,
|
||||
);
|
||||
let mut spines = vec![
|
||||
PathData::default(),
|
||||
PathData::single(SubPath::default()),
|
||||
PathData::single(SubPath::polyline(&[p(5.0, 5.0)], false)),
|
||||
PathData::single(SubPath::polyline(&[p(5.0, 5.0), p(5.0, 5.0)], false)),
|
||||
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], false)),
|
||||
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], true)),
|
||||
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(20.0, 0.0), p(30.0, 0.0)], false)),
|
||||
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(30.0, 5.0), p(30.0, 5.0)], false)),
|
||||
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0)], true)),
|
||||
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0), p(0.0, 0.0)], true)),
|
||||
PathData::single(bow.clone()),
|
||||
PathData::single(SubPath { closed: true, ..bow }),
|
||||
shapes::ellipse(Rect::new(0.0, 0.0, 120.0, 80.0)),
|
||||
];
|
||||
// An anchor near where the middle of three keys goes, 10 along a spine 20 long.
|
||||
for d in [0.0, 0.005, 0.0099, 0.01, 0.0101, 0.02] {
|
||||
for x in [10.0 - d, 10.0 + d] {
|
||||
spines.push(PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(x, 0.0), p(20.0, 0.0)], false)));
|
||||
}
|
||||
}
|
||||
let mut compared = 0;
|
||||
for spine in &spines {
|
||||
for k in (0..=12).chain([17, 50, 200]) {
|
||||
let (mut a, mut b) = (spine.clone(), spine.clone());
|
||||
let (ka, kb) = (spread_anchors(&mut a, k), rebuilt_spread_anchors(&mut b, k));
|
||||
assert_eq!(ka, kb, "{k} keys on {spine:?}");
|
||||
assert_eq!(a, b, "{k} keys on {spine:?}");
|
||||
if let (Some(mut ka), Some(mut kb)) = (ka, kb) {
|
||||
let (mut sa, mut sb) = (a.subpaths[0].clone(), b.subpaths[0].clone());
|
||||
open_at_start(&mut sa, &mut ka);
|
||||
open_at_start(&mut sb, &mut kb);
|
||||
assert_eq!((sa, ka), (sb, kb), "{k} keys on {spine:?}, opened");
|
||||
compared += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(compared > 200, "{compared} spreads compared");
|
||||
}
|
||||
|
||||
/// Spreading 1,000 keys along a spine of 4,096 segments flattens the spine once and then only
|
||||
/// the two halves of each split segment, finds each key's segment in a few comparisons, and
|
||||
/// measures about as many segments as the spine ends up with, not the whole spine per key.
|
||||
#[test]
|
||||
fn spreading_keys_measures_the_spine_once() {
|
||||
let pts: Vec<Point> = (0..=4096).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.0 } else { 1.0 })).collect();
|
||||
let mut path = PathData::single(SubPath::polyline(&pts, false));
|
||||
let (mut whole, mut segments, mut measured, mut compared) = (0, 0, 0, 0);
|
||||
let ka = spread_anchors_with(&mut path, 1000, |w| match w {
|
||||
SpreadWork::WholePath => whole += 1,
|
||||
SpreadWork::Segment => segments += 1,
|
||||
SpreadWork::Measure => measured += 1,
|
||||
SpreadWork::Compare => compared += 1,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(ka.len(), 1000);
|
||||
let n = path.subpaths[0].segment_count();
|
||||
assert!(n > 4096 + 900, "{n} segments");
|
||||
assert_eq!(whole, 1);
|
||||
assert!(segments <= 2 * 998, "{segments} segments flattened");
|
||||
assert!(compared <= 998 * 14, "{compared} comparisons");
|
||||
assert!(measured <= 4096 + n + 3 * 1000, "{measured} segments measured for {n}");
|
||||
let mut reference = PathData::single(SubPath::polyline(&pts, false));
|
||||
assert_eq!(rebuilt_spread_anchors(&mut reference, 1000), Some(ka));
|
||||
assert_eq!(reference, path);
|
||||
}
|
||||
|
||||
/// A spine so large that its length overflows spreads no keys, and is left as it was, rather
|
||||
/// than getting an anchor split at a parameter that is not a number. Two keys, which split
|
||||
/// nothing, get no anchors either.
|
||||
#[test]
|
||||
fn spreading_keys_along_a_spine_whose_length_overflows_changes_nothing() {
|
||||
let p = Point::new;
|
||||
for closed in [false, true] {
|
||||
for k in [2, 3, 5] {
|
||||
let spine = PathData::single(SubPath::polyline(&[p(-1e308, 0.0), p(0.0, 0.0), p(1e308, 0.0)], closed));
|
||||
let mut path = spine.clone();
|
||||
assert_eq!(spread_anchors(&mut path, k), None, "closed {closed}, {k} keys");
|
||||
assert_eq!(path, spine);
|
||||
assert!(path.subpaths[0].anchors.iter().all(|a| a.p.x.is_finite() && a.h_in.x.is_finite() && a.h_out.x.is_finite()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// When a later arc length has no anchor (here one that is not finite, as when a spine's length
|
||||
/// overflows only after earlier splits), the splits already made are dropped with it.
|
||||
#[test]
|
||||
fn a_failed_anchor_keeps_none_of_the_earlier_splits() {
|
||||
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false);
|
||||
for bad in [f64::NAN, f64::INFINITY] {
|
||||
let mut split = sp.clone();
|
||||
let lengths = SpreadLengths::new(&split, &mut |_| {});
|
||||
assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0, bad], &mut |_| {}), None);
|
||||
assert_eq!(split, sp);
|
||||
}
|
||||
let mut split = sp.clone();
|
||||
let lengths = SpreadLengths::new(&split, &mut |_| {});
|
||||
assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0], &mut |_| {}), Some(vec![1, 2]));
|
||||
assert_eq!(split.anchors.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn easing_keeps_the_ends_and_stays_monotone() {
|
||||
use crate::live::{BlendEase, BlendEaseType as K};
|
||||
|
||||
+237
-44
@@ -3,6 +3,7 @@
|
||||
//! the ruler origin and the objects an edit touches), so a file holding something beyond it could be
|
||||
//! opened but not edited: one object, artboard or guide out there made every move fail.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
@@ -72,27 +73,41 @@ fn clipping(n: &Node) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by
|
||||
/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its
|
||||
/// clipping path.
|
||||
fn reaches_beyond(n: &Node) -> bool {
|
||||
match clipping(n) {
|
||||
None => beyond(n),
|
||||
Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))),
|
||||
/// The objects of a layer or group that a walk picked, by index in order, each with what was
|
||||
/// picked inside it (nothing for an object that is not a container).
|
||||
#[derive(Debug, Default)]
|
||||
struct Picked(Vec<(usize, Picked)>);
|
||||
|
||||
/// What in `n` reaches beyond the canvas, by `far`, or `None` when nothing does. A container is
|
||||
/// looked into object by object: its own bounds leave out a layer's hidden objects and, when it
|
||||
/// clips, all but its clipping path. Each object is measured once, and the objects a clipping
|
||||
/// path beyond the canvas clips are not measured: they go with it (see [`clips_beyond`]).
|
||||
fn reach(n: &Node, far: &impl Fn(&Node) -> bool) -> Option<Picked> {
|
||||
let Some(clip) = clipping(n) else { return far(n).then(Picked::default) };
|
||||
let mut inner = Vec::new();
|
||||
for (i, c) in n.children().into_iter().flatten().enumerate() {
|
||||
if let Some(p) = reach(c, far) {
|
||||
inner.push((i, p));
|
||||
if clip && i == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(!inner.is_empty()).then_some(Picked(inner))
|
||||
}
|
||||
|
||||
/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips
|
||||
/// go together, so no other object becomes the clipping path.
|
||||
fn clips_beyond(n: &Node) -> bool {
|
||||
clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c))
|
||||
/// Does `n`, whose objects beyond the canvas are `found`, clip by a path that reaches beyond it?
|
||||
/// Then the path and the objects it clips go together, so no other object becomes the clipping
|
||||
/// path.
|
||||
fn clips_beyond(n: &Node, found: &Picked) -> bool {
|
||||
clipping(n) == Some(true) && found.0.first().is_some_and(|(i, _)| *i == 0)
|
||||
}
|
||||
|
||||
/// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go
|
||||
/// with them.
|
||||
fn layers_in(n: &Node, layers: &mut Vec<NodeId>) {
|
||||
fn layers_in(n: &Node, layers: &mut BTreeSet<NodeId>) {
|
||||
if matches!(n.kind, NodeKind::Layer { .. }) {
|
||||
layers.push(n.id);
|
||||
layers.insert(n.id);
|
||||
}
|
||||
if clipping(n).is_some() {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
@@ -109,22 +124,22 @@ fn guides_in(n: &Node) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its
|
||||
/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything
|
||||
/// else goes whole. Only containers holding such an object are copied. Recursion follows the
|
||||
/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and
|
||||
/// the ruler guides they held to `guides`.
|
||||
fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut Vec<NodeId>, guides: &mut usize) {
|
||||
if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) {
|
||||
return;
|
||||
}
|
||||
/// Remove the objects in `node` that reach beyond the canvas, which [`reach`] `found`: a layer or
|
||||
/// group keeps those of its objects within it, unless its clipping path reaches beyond (see
|
||||
/// [`clips_beyond`]); anything else goes whole. Only containers holding such an object are
|
||||
/// copied, and only those are visited again. Recursion follows the nesting, which
|
||||
/// [`Document::limit_depth`] bounds. The layers removed are added to `layers`, and the ruler
|
||||
/// guides they held to `guides`.
|
||||
fn drop_children_beyond(node: &mut Arc<Node>, found: Picked, dropped: &mut usize, layers: &mut BTreeSet<NodeId>, guides: &mut usize) {
|
||||
let Some(children) = Arc::make_mut(node).children_mut() else { return };
|
||||
let mut found = found.0.into_iter().peekable();
|
||||
let mut next = 0;
|
||||
children.retain_mut(|c| {
|
||||
if !reaches_beyond(c) {
|
||||
return true;
|
||||
}
|
||||
if clipping(c).is_some() && !clips_beyond(c) {
|
||||
drop_children_beyond(c, dropped, layers, guides);
|
||||
let at = next;
|
||||
next += 1;
|
||||
let Some((_, inner)) = found.next_if(|(i, _)| *i == at) else { return true };
|
||||
if clipping(c).is_some() && !clips_beyond(c, &inner) {
|
||||
drop_children_beyond(c, inner, dropped, layers, guides);
|
||||
return true;
|
||||
}
|
||||
layers_in(c, layers);
|
||||
@@ -134,21 +149,49 @@ fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut
|
||||
});
|
||||
}
|
||||
|
||||
/// Is `id` unset, or not one of `ids`? The check takes time logarithmic in the number of ids.
|
||||
fn not_in<T: Ord>(id: Option<T>, ids: &BTreeSet<T>) -> bool {
|
||||
id.is_none_or(|i| !ids.contains(&i))
|
||||
}
|
||||
|
||||
/// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many
|
||||
/// to `dropped`. Only layers holding one are copied.
|
||||
/// to `dropped`. `out` looks at each object in the layers once. Only layers holding one are
|
||||
/// copied.
|
||||
fn drop_guides(layer: &mut Arc<Node>, out: &impl Fn(&Node) -> bool, dropped: &mut usize) {
|
||||
fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool {
|
||||
n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out)))
|
||||
/// The objects of `n`, if it is a layer, that `out` picks (with nothing inside), and its
|
||||
/// sublayers holding one (with what they hold), or `None` when it holds none.
|
||||
fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> Option<Picked> {
|
||||
if !n.is_layer() {
|
||||
return None;
|
||||
}
|
||||
let inner: Vec<(usize, Picked)> = n
|
||||
.children()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| if out(c) { Some((i, Picked::default())) } else { holds(c, out).map(|p| (i, p)) })
|
||||
.collect();
|
||||
(!inner.is_empty()).then_some(Picked(inner))
|
||||
}
|
||||
if !holds(layer, out) {
|
||||
return;
|
||||
fn drop(layer: &mut Arc<Node>, held: Picked, dropped: &mut usize) {
|
||||
let Some(children) = Arc::make_mut(layer).children_mut() else { return };
|
||||
let mut held = held.0.into_iter().peekable();
|
||||
let mut next = 0;
|
||||
children.retain_mut(|c| {
|
||||
let at = next;
|
||||
next += 1;
|
||||
let Some((_, inner)) = held.next_if(|(i, _)| *i == at) else { return true };
|
||||
// A sublayer holds something; an object picked itself has nothing inside.
|
||||
if inner.0.is_empty() {
|
||||
*dropped += 1;
|
||||
return false;
|
||||
}
|
||||
drop(c, inner, dropped);
|
||||
true
|
||||
});
|
||||
}
|
||||
let Some(children) = Arc::make_mut(layer).children_mut() else { return };
|
||||
let before = children.len();
|
||||
children.retain(|c| !out(c));
|
||||
*dropped += before - children.len();
|
||||
for c in children.iter_mut().filter(|c| c.is_layer()) {
|
||||
drop_guides(c, out, dropped);
|
||||
if let Some(held) = holds(layer, out) {
|
||||
drop(layer, held, dropped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,9 +205,10 @@ impl Document {
|
||||
/// whose artboards were all removed has none left.
|
||||
pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas {
|
||||
let mut gone = BeyondCanvas::default();
|
||||
let mut layers = vec![];
|
||||
let mut layers = BTreeSet::new();
|
||||
for layer in &mut self.layers {
|
||||
if clips_beyond(layer) {
|
||||
let Some(found) = reach(layer, &beyond) else { continue };
|
||||
if clips_beyond(layer, &found) {
|
||||
let layer = Arc::make_mut(layer);
|
||||
if let NodeKind::Layer { children, clip, .. } = &mut layer.kind {
|
||||
for c in children.iter() {
|
||||
@@ -177,7 +221,7 @@ impl Document {
|
||||
*clip = false;
|
||||
}
|
||||
} else {
|
||||
drop_children_beyond(layer, &mut gone.objects, &mut layers, &mut gone.guides);
|
||||
drop_children_beyond(layer, found, &mut gone.objects, &mut layers, &mut gone.guides);
|
||||
}
|
||||
}
|
||||
if gone.objects > 0 {
|
||||
@@ -192,13 +236,13 @@ impl Document {
|
||||
self.retire_artboard_id(*id);
|
||||
}
|
||||
gone.artboards = removed.len();
|
||||
let removed: BTreeSet<u32> = removed.into_iter().collect();
|
||||
// The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]).
|
||||
let guides = self.saved_guides.len();
|
||||
self.saved_guides
|
||||
.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l)));
|
||||
self.saved_guides.retain(|g| in_range(g.pos) && not_in(g.artboard, &removed) && not_in(g.layer, &layers));
|
||||
gone.guides += guides - self.saved_guides.len();
|
||||
let out = |n: &Node| match n.kind {
|
||||
NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)),
|
||||
NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || !not_in(artboard, &removed),
|
||||
_ => false,
|
||||
};
|
||||
for layer in &mut self.layers {
|
||||
@@ -392,4 +436,153 @@ mod tests {
|
||||
assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::<Vec<_>>(), [(2, vec![NodeId(10)])]);
|
||||
assert_eq!(d.variables.bindings.keys().copied().collect::<Vec<_>>(), [NodeId(10)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_of_many_removed_artboards_and_layers_go_and_the_rest_keep_their_order() {
|
||||
let probe = Document::new(1.0, 1.0);
|
||||
// Sublayers 40 to 43 clip by a path beyond the canvas, so they go whole; 50 stays.
|
||||
let far: Vec<Arc<Node>> = (40..44).map(|id| sublayer(&probe, id, vec![rect(id * 10, 1e17), rect(id * 10 + 1, 0.0)], true)).collect();
|
||||
let near = sublayer(&probe, 50, vec![rect(500, 0.0)], false);
|
||||
let mut d = doc(far.into_iter().chain([near, rect(10, 0.0)]).collect());
|
||||
let kept = d.artboards[0].id;
|
||||
for id in 9..13 {
|
||||
d.artboards.push(Artboard { id, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() });
|
||||
}
|
||||
let top = d.layers[0].id;
|
||||
let guide = |pos: f64, artboard: Option<u32>, layer: Option<NodeId>| Guide { artboard, layer, ..Guide::new(false, pos) };
|
||||
d.saved_guides = vec![
|
||||
guide(1.0, Some(9), None),
|
||||
guide(2.0, None, None),
|
||||
guide(3.0, Some(kept), Some(NodeId(41))),
|
||||
guide(4.0, Some(kept), None),
|
||||
guide(5.0, Some(12), None),
|
||||
guide(6.0, None, Some(NodeId(50))),
|
||||
guide(7.0, None, Some(NodeId(43))),
|
||||
guide(8.0, Some(10), Some(top)),
|
||||
guide(9.0, None, Some(top)),
|
||||
];
|
||||
d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(42), NodeId(10), NodeId(500)] }].into();
|
||||
let mut nodes = d.clone();
|
||||
assert_eq!(nodes.guides_into_nodes(), 9);
|
||||
let gone = d.drop_beyond_canvas();
|
||||
assert_eq!(gone, BeyondCanvas { objects: 4, artboards: 4, guides: 5, ruler_origin: false });
|
||||
assert_eq!(d.artboards.iter().map(|a| a.id).collect::<Vec<_>>(), [kept]);
|
||||
let left: Vec<f64> = d.saved_guides.iter().map(|g| g.pos).collect();
|
||||
assert_eq!(left, [2.0, 4.0, 6.0, 9.0], "the guides that stay keep their order");
|
||||
assert_eq!(d.saved_selections[0].objects, [NodeId(10), NodeId(500)]);
|
||||
assert_eq!(nodes.drop_beyond_canvas(), gone, "guide nodes go as saved guides do");
|
||||
let mut left: Vec<f64> = nodes.ruler_guides().iter().map(|g| g.pos).collect();
|
||||
left.sort_by(f64::total_cmp);
|
||||
assert_eq!(left, [2.0, 4.0, 6.0, 9.0]);
|
||||
assert_eq!(nodes.saved_selections, d.saved_selections);
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static COMPARISONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// An id that counts how often it is compared.
|
||||
#[derive(Debug)]
|
||||
struct Counted(u32);
|
||||
|
||||
impl Ord for Counted {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
COMPARISONS.with(|c| c.set(c.get() + 1));
|
||||
self.0.cmp(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Counted {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Counted {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == std::cmp::Ordering::Equal
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Counted {}
|
||||
|
||||
#[test]
|
||||
fn a_guide_is_checked_without_scanning_every_removed_id() {
|
||||
let per_guide = |removed: u32| {
|
||||
let ids: BTreeSet<Counted> = (0..removed).map(|i| Counted(i * 2)).collect();
|
||||
COMPARISONS.with(|c| c.set(0));
|
||||
// Guides on removed ids, on ids between them, on ids past them and on none.
|
||||
let guides: Vec<Option<Counted>> = (0..64).map(|i| (i % 4 != 3).then(|| Counted(i * 31 % (removed * 2 + 8)))).collect();
|
||||
let kept = guides.into_iter().filter(|g| not_in(g.as_ref().map(|c| Counted(c.0)), &ids)).count();
|
||||
assert!(kept > 0 && kept < 64);
|
||||
COMPARISONS.with(|c| c.get()) as f64 / 64.0
|
||||
};
|
||||
let small = per_guide(16);
|
||||
let large = per_guide(16_384);
|
||||
// A sorted lookup grows with the depth of the tree; a scan of 16,384 ids compares thousands per guide.
|
||||
assert!(large < small * 32.0 && large < 256.0, "{small} comparisons per guide among 16 ids, {large} among 16,384");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_object_is_measured_once_however_deep_the_nesting() {
|
||||
// 32 groups, each inside the one before and holding an object within the canvas; the
|
||||
// innermost also holds one beyond it. Beside them, a group with nothing beyond and a clip
|
||||
// group whose clipping path reaches beyond.
|
||||
let mut chain = Arc::new(Node::group(NodeId(100), vec![rect(200, 0.0), rect(201, 1e17)]));
|
||||
for depth in 1..32 {
|
||||
chain = Arc::new(Node::group(NodeId(100 + depth), vec![rect(300 + depth, 0.0), chain]));
|
||||
}
|
||||
let still = Arc::new(Node::group(NodeId(400), vec![rect(401, 0.0), rect(402, 5.0)]));
|
||||
let clip = Arc::new(Node::new(NodeId(600), NodeKind::Group { children: vec![rect(601, 1e17), rect(602, 0.0), rect(603, 0.0)], clip: true }));
|
||||
let mut d = doc(vec![chain, Arc::clone(&still), clip]);
|
||||
let mut whole = d.clone();
|
||||
let measured = std::cell::Cell::new(0_usize);
|
||||
let far = |n: &Node| {
|
||||
measured.set(measured.get() + 1);
|
||||
beyond(n)
|
||||
};
|
||||
let layer = &mut d.layers[0];
|
||||
let found = reach(layer, &far).unwrap();
|
||||
assert_eq!(
|
||||
measured.get(),
|
||||
36,
|
||||
"the 33 objects in the groups, the 2 beside them and the clipping path, each measured once; not what the path clips"
|
||||
);
|
||||
assert!(!clips_beyond(layer, &found));
|
||||
let (mut dropped, mut layers, mut guides) = (0, BTreeSet::new(), 0);
|
||||
drop_children_beyond(layer, found, &mut dropped, &mut layers, &mut guides);
|
||||
assert_eq!(measured.get(), 36, "removing measures nothing again");
|
||||
assert_eq!((dropped, layers.len(), guides), (2, 0, 0));
|
||||
assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a group with nothing beyond is not copied");
|
||||
assert_eq!(whole.drop_beyond_canvas(), BeyondCanvas { objects: 2, ..Default::default() });
|
||||
assert_eq!(ids(&whole), ids(&d));
|
||||
assert!(!ids(&d).contains(&201) && !ids(&d).contains(&600) && ids(&d).contains(&200) && ids(&d).contains(&331));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_object_is_looked_at_once_when_guides_go_from_nested_layers() {
|
||||
let guide = |id: u64, pos: f64| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos, artboard: None }));
|
||||
let probe = Document::new(1.0, 1.0);
|
||||
// 32 sublayers, each inside the one before, each holding a guide within the canvas and one
|
||||
// beyond it. Beside them, a sublayer with nothing to remove.
|
||||
let mut chain = sublayer(&probe, 100, vec![guide(200, 5.0), guide(201, 1e17)], false);
|
||||
for depth in 1..32 {
|
||||
chain = sublayer(&probe, 100 + depth, vec![guide(300 + depth * 2, 5.0), guide(301 + depth * 2, 1e17), chain], false);
|
||||
}
|
||||
let still = sublayer(&probe, 500, vec![guide(501, 5.0), rect(502, 0.0)], false);
|
||||
let mut d = doc(vec![chain, Arc::clone(&still)]);
|
||||
let looked = std::cell::Cell::new(0_usize);
|
||||
let out = |n: &Node| {
|
||||
looked.set(looked.get() + 1);
|
||||
matches!(n.kind, NodeKind::RulerGuide { pos, .. } if !in_range(pos))
|
||||
};
|
||||
let mut dropped = 0;
|
||||
drop_guides(&mut d.layers[0], &out, &mut dropped);
|
||||
assert_eq!(dropped, 32);
|
||||
assert_eq!(looked.get(), 2 + 2 + 31 * 3 + 2, "each object in the layers is looked at once");
|
||||
assert!(d.layers[0].children().is_some_and(|c| Arc::ptr_eq(&c[1], &still)), "a sublayer with nothing to remove is not copied");
|
||||
let left = d.ruler_guides();
|
||||
assert_eq!(left.len(), 33);
|
||||
assert!(left.iter().all(|g| g.pos == 5.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::FontHeightReference;
|
||||
pub use text::GlyphChoice;
|
||||
pub use text::WarichuAlign;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
|
||||
JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign,
|
||||
|
||||
@@ -17,7 +17,10 @@ mod wrap;
|
||||
|
||||
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
|
||||
use char_style::yes;
|
||||
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, ScriptMetrics};
|
||||
pub use char_style::{
|
||||
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
|
||||
WARICHU_SCALE, WarichuAlign,
|
||||
};
|
||||
pub use composite::{
|
||||
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart,
|
||||
CompositeParts,
|
||||
|
||||
@@ -145,6 +145,29 @@ pub struct CharStyle {
|
||||
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
|
||||
#[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub standard_vertical_roman: bool,
|
||||
/// Warichu (Character panel menu, East Asian options): these characters are set at
|
||||
/// [`Self::warichu_scale`] percent of their size and stacked on [`Self::warichu_lines`] lines
|
||||
/// inside their line, one above the other in horizontal type and side by side in vertical type.
|
||||
/// A stretch of marked characters is one block that takes the settings of its first character
|
||||
/// and stays whole on one line. Type on a path sets them at their own size on the path.
|
||||
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub warichu: bool,
|
||||
/// Warichu Settings › Lines: how many lines a warichu block is stacked on, within
|
||||
/// [`WARICHU_LINES`]. Saved only when not 2.
|
||||
#[serde(default = "two", rename = "warichuLines", skip_serializing_if = "is_two")]
|
||||
pub warichu_lines: u8,
|
||||
/// Warichu Settings › Line Gap, in points from 0 to [`MAX_WARICHU_GAP`]: the space between the
|
||||
/// lines of a warichu block. Saved only when not 0.
|
||||
#[serde(default, rename = "warichuGap", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub warichu_gap: f64,
|
||||
/// Warichu Settings › Scale: the size of warichu characters as a percentage of their size,
|
||||
/// within [`WARICHU_SCALE`]. Saved only when not 50.
|
||||
#[serde(default = "fifty", rename = "warichuScale", skip_serializing_if = "is_fifty")]
|
||||
pub warichu_scale: f64,
|
||||
/// Warichu Settings › Alignment: how the lines of a warichu block line up along the block.
|
||||
#[serde(default, rename = "warichuAlign", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub warichu_align: WarichuAlign,
|
||||
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
|
||||
/// these characters are drawn in, whatever digits were typed.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
@@ -417,6 +440,82 @@ fn hundred() -> f64 {
|
||||
/// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em.
|
||||
pub const MAX_AKI: f64 = 1000.0;
|
||||
|
||||
/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]).
|
||||
pub const WARICHU_LINES: std::ops::RangeInclusive<u8> = 1..=6;
|
||||
|
||||
/// The largest Warichu Settings Line Gap ([`CharStyle::warichu_gap`]), in points.
|
||||
pub const MAX_WARICHU_GAP: f64 = 100.0;
|
||||
|
||||
/// The Warichu Settings Scale ([`CharStyle::warichu_scale`]) range, in percent of the size.
|
||||
pub const WARICHU_SCALE: std::ops::RangeInclusive<f64> = 10.0..=100.0;
|
||||
|
||||
fn two() -> u8 {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_two(v: &u8) -> bool {
|
||||
*v == 2
|
||||
}
|
||||
|
||||
fn fifty() -> f64 {
|
||||
50.0
|
||||
}
|
||||
|
||||
fn is_fifty(v: &f64) -> bool {
|
||||
*v == 50.0
|
||||
}
|
||||
|
||||
/// How the lines of a warichu block line up along the block ([`CharStyle::warichu_align`]):
|
||||
/// each line set flush left (top, in vertical type), centered or flush right (bottom), or spread
|
||||
/// to the block's length with the last line set flush left, centered or flush right, or spread as
|
||||
/// well. Auto is the default and sets the lines as [`WarichuAlign::JustifyLeft`] does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WarichuAlign {
|
||||
#[default]
|
||||
Auto,
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
JustifyLeft,
|
||||
JustifyCenter,
|
||||
JustifyRight,
|
||||
JustifyAll,
|
||||
}
|
||||
|
||||
impl WarichuAlign {
|
||||
/// Every alignment, in the order Warichu Settings lists them.
|
||||
pub const ALL: [WarichuAlign; 8] = [
|
||||
WarichuAlign::Auto,
|
||||
WarichuAlign::Left,
|
||||
WarichuAlign::Center,
|
||||
WarichuAlign::Right,
|
||||
WarichuAlign::JustifyLeft,
|
||||
WarichuAlign::JustifyCenter,
|
||||
WarichuAlign::JustifyRight,
|
||||
WarichuAlign::JustifyAll,
|
||||
];
|
||||
|
||||
/// The name files and commands give this alignment (as it is saved).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
WarichuAlign::Auto => "auto",
|
||||
WarichuAlign::Left => "left",
|
||||
WarichuAlign::Center => "center",
|
||||
WarichuAlign::Right => "right",
|
||||
WarichuAlign::JustifyLeft => "justifyLeft",
|
||||
WarichuAlign::JustifyCenter => "justifyCenter",
|
||||
WarichuAlign::JustifyRight => "justifyRight",
|
||||
WarichuAlign::JustifyAll => "justifyAll",
|
||||
}
|
||||
}
|
||||
|
||||
/// The alignment [`WarichuAlign::id`] names; `None` for any other name.
|
||||
pub fn from_id(id: &str) -> Option<WarichuAlign> {
|
||||
WarichuAlign::ALL.into_iter().find(|a| a.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -464,6 +563,11 @@ impl Default for CharStyle {
|
||||
tcy_up_down: 0.0,
|
||||
tcy_left_right: 0.0,
|
||||
standard_vertical_roman: false,
|
||||
warichu: false,
|
||||
warichu_lines: 2,
|
||||
warichu_gap: 0.0,
|
||||
warichu_scale: 50.0,
|
||||
warichu_align: WarichuAlign::Auto,
|
||||
small_caps: None,
|
||||
digits: Digits::Default,
|
||||
font_height_reference: FontHeightReference::EmBox,
|
||||
@@ -484,7 +588,10 @@ impl CharStyle {
|
||||
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
|
||||
/// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
|
||||
/// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to
|
||||
/// [`MAX_AKI`], and one that is not a number becomes 0.
|
||||
/// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale
|
||||
/// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to
|
||||
/// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its
|
||||
/// own units.
|
||||
pub fn keep_dimensions_in_range(&mut self) {
|
||||
let default = Self::default();
|
||||
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
|
||||
@@ -499,6 +606,10 @@ impl CharStyle {
|
||||
let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) };
|
||||
self.aki_left = aki(self.aki_left);
|
||||
self.aki_right = aki(self.aki_right);
|
||||
self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end());
|
||||
self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) };
|
||||
self.warichu_scale =
|
||||
if self.warichu_scale.is_nan() { default.warichu_scale } else { self.warichu_scale.clamp(*WARICHU_SCALE.start(), *WARICHU_SCALE.end()) };
|
||||
}
|
||||
/// Do the characters draw a stroke (a paint and a positive weight)?
|
||||
pub fn has_stroke(&self) -> bool {
|
||||
|
||||
@@ -34,10 +34,19 @@ impl TextObject {
|
||||
style.baseline_shift *= scale.points;
|
||||
style.tcy_up_down *= scale.points;
|
||||
style.tcy_left_right *= scale.points;
|
||||
style.warichu_gap *= scale.points;
|
||||
style.h_scale *= scale.horizontal;
|
||||
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
if ![
|
||||
style.size,
|
||||
style.leading.unwrap_or(0.0),
|
||||
style.baseline_shift,
|
||||
style.tcy_up_down,
|
||||
style.tcy_left_right,
|
||||
style.warichu_gap,
|
||||
style.h_scale,
|
||||
]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -114,6 +123,12 @@ impl TextObject {
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
/// Does layout stack some of this type's characters as warichu ([`CharStyle::warichu`])? Not
|
||||
/// on a path, which sets them as they are. Exports that write live text write such type as
|
||||
/// outlines, as they do ruby: their text can't place the stacked lines.
|
||||
pub fn stacks_warichu(&self) -> bool {
|
||||
!matches!(self.kind, TextKind::OnPath { .. }) && self.runs.iter().any(|r| r.style.warichu && !r.text.is_empty())
|
||||
}
|
||||
/// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them.
|
||||
///
|
||||
/// Called after loading and editing text: ranges from a file or an edit are untrusted, and a
|
||||
@@ -492,8 +507,23 @@ impl crate::Document {
|
||||
st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right);
|
||||
st.aki_left = num("akiLeft").unwrap_or(st.aki_left);
|
||||
st.aki_right = num("akiRight").unwrap_or(st.aki_right);
|
||||
st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap);
|
||||
st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale);
|
||||
st.keep_dimensions_in_range();
|
||||
// Lines is a whole number: a stored number outside the range, or with a fraction,
|
||||
// becomes the nearest whole number within it.
|
||||
if let Some(old) = def.attrs.get_mut("warichuLines")
|
||||
&& old.is_number()
|
||||
&& old.as_u64().is_none_or(|v| !super::WARICHU_LINES.contains(&u8::try_from(v).unwrap_or(u8::MAX)))
|
||||
{
|
||||
let (lo, hi) = (*super::WARICHU_LINES.start(), *super::WARICHU_LINES.end());
|
||||
let v = old.as_f64().filter(|v| v.is_finite()).map_or(2.0, |v| v.round().clamp(f64::from(lo), f64::from(hi)));
|
||||
// Within 1..6 after the clamp, so the cast keeps the value.
|
||||
*old = (v as u8).into();
|
||||
}
|
||||
let kept = [
|
||||
("warichuGap", Some(st.warichu_gap)),
|
||||
("warichuScale", Some(st.warichu_scale)),
|
||||
("size", Some(st.size)),
|
||||
("h_scale", Some(st.h_scale)),
|
||||
("v_scale", Some(st.v_scale)),
|
||||
|
||||
@@ -357,7 +357,7 @@ impl ParaList {
|
||||
|
||||
/// The style the marker is set in: `first` (the style of the paragraph's first character)
|
||||
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
|
||||
/// Insert Aki, tate-chu-yoko, underline and strikethrough.
|
||||
/// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough.
|
||||
pub fn marker_style(first: &CharStyle) -> CharStyle {
|
||||
CharStyle {
|
||||
baseline_shift: 0.0,
|
||||
@@ -370,6 +370,7 @@ impl ParaList {
|
||||
aki_right: 0.0,
|
||||
optical_kerning: first.optical_kerning,
|
||||
tate_chu_yoko: false,
|
||||
warichu: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
..first.clone()
|
||||
|
||||
@@ -209,6 +209,19 @@ pub(crate) fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
|
||||
runs
|
||||
}
|
||||
|
||||
/// [`solid_paints`] with, for each paint, the item of `n` it copies: the item a live-effect plug-in
|
||||
/// before the paint's own 3D effect is told it sits on. The paints of a type's first run copy no
|
||||
/// item of `n` and have none.
|
||||
pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Vec<AppearanceItem>) {
|
||||
let own: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
|
||||
if own.is_empty() {
|
||||
let paints = solid_paints(n);
|
||||
return (vec![None; paints.len()], paints);
|
||||
}
|
||||
let paints = own.iter().map(|i| (*i).clone()).collect();
|
||||
(own.into_iter().map(Some).collect(), paints)
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
@@ -1771,7 +1784,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
}
|
||||
let (path, rule) = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
let (sources, paints) = solid_paint_sources(n);
|
||||
let mut children = vec![];
|
||||
let remaining: Vec<Effect>;
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
@@ -1789,9 +1802,9 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
crate::raster::follow_paints(&mut paints, &n.appearance.effects);
|
||||
// Every item's shadow goes behind every item's art.
|
||||
let mut shadows = vec![];
|
||||
for item in &paints {
|
||||
for (source, item) in sources.into_iter().zip(&paints) {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(source.unwrap_or(item)));
|
||||
let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
|
||||
let later: Vec<Effect> = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
||||
let with_later = |mut art: Arc<Node>| {
|
||||
@@ -2086,16 +2099,16 @@ fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usi
|
||||
} else {
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
let (sources, paints) = solid_paint_sources(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)?
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
for (source, item) in sources.into_iter().zip(&paints) {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item)));
|
||||
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet;
|
||||
|
||||
use super::{
|
||||
Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region,
|
||||
reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds,
|
||||
reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds,
|
||||
};
|
||||
use crate::{GeomContext, is_geometry};
|
||||
|
||||
@@ -105,16 +105,16 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, S
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
let (sources, paints) = solid_paint_sources(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]);
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
for (source, item) in sources.into_iter().zip(&paints) {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item)));
|
||||
let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
|
||||
out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod imageoutline;
|
||||
mod painted;
|
||||
mod rasterfollow;
|
||||
mod scale;
|
||||
mod solidcontext;
|
||||
mod transformfx;
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! A live-effect plug-in before a fill's or stroke's own 3D effect is told which fill or stroke it
|
||||
//! is on, in the drawn art, the exported meshes and the wireframe.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeId};
|
||||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
|
||||
use crate::{EXTRUDE, solid_art, three_d_meshes_in, three_d_wireframe_in};
|
||||
|
||||
/// Widths the plug-in's squares are drawn at: `LARGE` when it is told it is on the item it looks
|
||||
/// for, `SMALL` otherwise.
|
||||
const LARGE: f64 = 600.0;
|
||||
const SMALL: f64 = 6.0;
|
||||
|
||||
/// A rectangle with a fill, a 1 pt stroke and a second fill on top. The item at `at` (an index in
|
||||
/// its appearance items) has, in its own effects, live-effect plug-in `name` and then Extrude &
|
||||
/// Bevel. The plug-in returns a `LARGE` square when it is given `item`, and a `SMALL` one otherwise.
|
||||
fn plugin_then_extrude(name: &str, at: usize, item: &str) -> Node {
|
||||
let square = |w: f64| shapes::rectangle(Rect::new(0.0, 0.0, w, w));
|
||||
let id = format!("test.effects.solidcontext.{name}");
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#);
|
||||
let (large, small) = (json!({"objects": [{"path": square(LARGE)}]}).to_string(), json!({"objects": [{"path": square(SMALL)}]}).to_string());
|
||||
let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, item, &large, &small)).unwrap();
|
||||
vectorcraft_plugins::registry::install_bytes(&module).unwrap();
|
||||
let mut appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0);
|
||||
appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))));
|
||||
let effects = appearance.items[at].effects_mut();
|
||||
effects.push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true });
|
||||
effects.push(crate::new_effect(EXTRUDE, &json!({})).unwrap());
|
||||
Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), appearance)
|
||||
}
|
||||
|
||||
/// The width of the union of `paths`' bounds.
|
||||
fn width(paths: impl IntoIterator<Item = PathData>) -> f64 {
|
||||
paths.into_iter().filter_map(|p| p.bounds()).reduce(|a, b| a.union(b)).map_or(0.0, |b| b.width())
|
||||
}
|
||||
|
||||
/// Whether each of the art, the meshes and the wireframe `n`'s 3D effect draws is wider than half
|
||||
/// of `LARGE`, with the widths.
|
||||
fn drawn_large(n: &Node) -> ([bool; 3], [f64; 3]) {
|
||||
let art = solid_art(n).unwrap();
|
||||
let mut paths = vec![];
|
||||
art.walk(&mut |c| paths.extend(c.path_data().cloned()));
|
||||
let meshes = three_d_meshes_in(None, n).unwrap();
|
||||
let xs = meshes.iter().flat_map(|m| m.mesh.positions.iter().map(|p| p[0]));
|
||||
let mesh_width = xs.clone().fold(f64::NEG_INFINITY, f64::max) - xs.fold(f64::INFINITY, f64::min);
|
||||
let widths = [width(paths), mesh_width, width(three_d_wireframe_in(None, n).unwrap())];
|
||||
(widths.map(|w| w > LARGE / 2.0), widths)
|
||||
}
|
||||
|
||||
/// A plug-in before the second fill's Extrude & Bevel is given `{"fill": 1}`, not `"object"`, so
|
||||
/// the art, meshes and wireframe are all built from the square it returns for that fill.
|
||||
#[test]
|
||||
fn a_plugin_before_a_fills_extrude_is_told_its_fill() {
|
||||
let (large, widths) = drawn_large(&plugin_then_extrude("fill", 2, r#""item":{"fill":1}"#));
|
||||
assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide");
|
||||
}
|
||||
|
||||
/// A plug-in before the stroke's Extrude & Bevel is given `{"stroke": 0}`.
|
||||
#[test]
|
||||
fn a_plugin_before_a_strokes_extrude_is_told_its_stroke() {
|
||||
let (large, widths) = drawn_large(&plugin_then_extrude("stroke", 1, r#""item":{"stroke":0}"#));
|
||||
assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide");
|
||||
}
|
||||
|
||||
/// A plug-in on a fill is not told it is on the object: the square it returns otherwise is the one
|
||||
/// extruded.
|
||||
#[test]
|
||||
fn a_plugin_on_a_fill_is_not_told_it_is_on_the_object() {
|
||||
let (large, widths) = drawn_large(&plugin_then_extrude("object", 2, r#""item":"object""#));
|
||||
assert_eq!(large, [false; 3], "the art, meshes and wireframe are {widths:?} wide");
|
||||
}
|
||||
@@ -46,7 +46,7 @@ pub mod importmatches;
|
||||
pub(crate) mod inline;
|
||||
mod interactivity;
|
||||
mod intertwine;
|
||||
mod layer;
|
||||
pub(crate) mod layer;
|
||||
mod layerpanel;
|
||||
pub mod library;
|
||||
pub mod linkfiles;
|
||||
|
||||
@@ -20,7 +20,9 @@ use vectorcraft_doc::{Node, NodeId, OrientedBox};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::super::appearance::series_children_in_paint_order;
|
||||
use super::super::edit::selected_roots;
|
||||
use super::super::graph::capture_series_object_opacity;
|
||||
use super::super::menucmds::whole_or;
|
||||
use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns};
|
||||
use super::super::typecmd::refresh_bounds_of;
|
||||
@@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Randomize Opacity",
|
||||
["Object", "Plug-ins", "Arrange Tools"],
|
||||
None,
|
||||
"{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}",
|
||||
"{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}",
|
||||
has_selection,
|
||||
randomize_opacity
|
||||
),
|
||||
@@ -99,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sort Objects",
|
||||
["Object", "Plug-ins", "Arrange Tools"],
|
||||
None,
|
||||
"{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}",
|
||||
"{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. With apply \"opacity\", on paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md). A run that changes nothing makes no step → {count}",
|
||||
has_multi,
|
||||
sort
|
||||
),
|
||||
@@ -396,17 +398,30 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32);
|
||||
s.edit("Randomize Opacity", |d, _| {
|
||||
let mut changed = vec![];
|
||||
for id in &ids {
|
||||
let o = rng.range(lo, hi);
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& n.opacity != o
|
||||
{
|
||||
n.opacity = o;
|
||||
changed.push(*id);
|
||||
}
|
||||
}
|
||||
keep_series_opacity(d, &changed);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
/// Store on their graphs the opacity just given to the paths among `changed` (objects whose opacity changed) that are
|
||||
/// directly in a graph series, with their blend mode, so that every path of the series has them when the graph is drawn
|
||||
/// again; the frontmost of them in a series sets them.
|
||||
fn keep_series_opacity(d: &mut vectorcraft_doc::Document, changed: &[NodeId]) {
|
||||
let marks = series_children_in_paint_order(d, changed);
|
||||
capture_series_object_opacity(d, &marks);
|
||||
}
|
||||
|
||||
/// An object Sort Objects sorts: its layer or group, its place in it, its box as
|
||||
/// `object.setBounds` measures it and its opacity (%).
|
||||
struct Sorted {
|
||||
@@ -510,6 +525,8 @@ fn sort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.opacity = (v / 100.0) as f32;
|
||||
}
|
||||
}
|
||||
let changed: Vec<NodeId> = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect();
|
||||
keep_series_opacity(d, &changed);
|
||||
Ok(())
|
||||
})?;
|
||||
return Ok(count);
|
||||
|
||||
@@ -10,7 +10,7 @@ use vectorcraft_geom::{PathData, Point, Rect};
|
||||
use vectorcraft_pathops::addons as po;
|
||||
|
||||
use super::super::super::edit::outermost;
|
||||
use super::super::super::layer::empty_artboards;
|
||||
use super::super::super::layer::{empty_artboards, remove_artboards};
|
||||
use crate::Result;
|
||||
|
||||
/// Sine of the turn below which an anchor on a straight run is collinear.
|
||||
@@ -508,35 +508,6 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Out
|
||||
if empty.is_empty() {
|
||||
return Ok(Outcome::default());
|
||||
}
|
||||
// `empty` is in artboard order: one walk drops them and the others keep their order.
|
||||
let mut gone: HashSet<u32> = HashSet::with_capacity(empty.len());
|
||||
let mut i = 0;
|
||||
d.artboards.retain(|a| {
|
||||
let keep = empty.binary_search(&i).is_err();
|
||||
i += 1;
|
||||
if !keep {
|
||||
gone.insert(a.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
count_artboard_pass();
|
||||
for &id in &gone {
|
||||
d.retire_artboard_id(id);
|
||||
}
|
||||
d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a)));
|
||||
count_artboard_pass();
|
||||
remove_artboards(d, sel, &empty)?;
|
||||
Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this
|
||||
/// thread.
|
||||
pub(super) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: count one walk over the artboards or the ruler guides.
|
||||
fn count_artboard_pass() {
|
||||
#[cfg(test)]
|
||||
ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1);
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@ fn removing_many_selected_objects_looks_at_each_root_once_a_pass() {
|
||||
|
||||
#[test]
|
||||
fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() {
|
||||
use super::checks::ARTBOARD_PASSES;
|
||||
use crate::cmd::layer::ARTBOARD_PASSES;
|
||||
let mut s = session();
|
||||
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
|
||||
let (keep, gone) = s
|
||||
|
||||
@@ -206,11 +206,31 @@ struct Source {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many times [`thread_index`] went through the document's threads on this thread.
|
||||
static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Each object the document's text threads list, with the index of the first thread that lists it.
|
||||
fn thread_index(d: &Document) -> std::collections::HashMap<NodeId, usize> {
|
||||
#[cfg(test)]
|
||||
THREAD_SCANS.with(|c| c.set(c.get() + 1));
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for (i, thread) in d.text_threads.iter().enumerate() {
|
||||
for id in thread {
|
||||
out.entry(*id).or_insert(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise.
|
||||
fn of(d: &Document, id: NodeId) -> Option<Self> {
|
||||
/// Horizontal point or area type that isn't threaded (`threaded` false) and whose text all
|
||||
/// fits; None otherwise.
|
||||
fn of(d: &Document, id: NodeId, threaded: bool) -> Option<Self> {
|
||||
let t = text_of(d, id)?;
|
||||
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) {
|
||||
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || threaded {
|
||||
return None;
|
||||
}
|
||||
let lay = layout(t);
|
||||
@@ -376,8 +396,10 @@ fn break_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut items, mut skipped, mut count) = (vec![], vec![], 0);
|
||||
for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? {
|
||||
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else {
|
||||
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
|
||||
let threads = thread_index(d);
|
||||
for id in ids {
|
||||
let (Some(src), Some(node)) = (Source::of(d, id, threads.contains_key(&id)), d.node(id)) else {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
};
|
||||
@@ -405,7 +427,8 @@ fn split_text(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("Click in type with a Type tool to split it".into()));
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else {
|
||||
let threaded = d.text_threads.iter().any(|th| th.contains(&id));
|
||||
let (Some(src), Some(node)) = (Source::of(d, id, threaded), d.node(id).filter(|_| d.is_editable(id))) else {
|
||||
return Err(EngineError::Other("Select horizontal point or area type whose text fits".into()));
|
||||
};
|
||||
let text = &src.text;
|
||||
@@ -821,13 +844,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
|
||||
// A threaded story whose frames are all among them is read as one text, once.
|
||||
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
|
||||
let threads = thread_index(d);
|
||||
let mut done = std::collections::HashSet::new();
|
||||
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
|
||||
for &id in &ids {
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let story = crate::cmd::threads::story_of(d, id);
|
||||
let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) {
|
||||
Some(thread) => crate::cmd::threads::story_in(d, thread, id),
|
||||
None => vec![],
|
||||
};
|
||||
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
|
||||
done.extend(story.iter().copied());
|
||||
out.extend(story_cuts(d, &story, brk));
|
||||
|
||||
@@ -158,6 +158,62 @@ fn break_text_refuses_what_it_cannot_break() {
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
|
||||
}
|
||||
|
||||
/// Two area frames threaded together, with `story` flowing through them → (first, second).
|
||||
fn threaded(s: &mut Session, story: &str) -> (NodeId, NodeId) {
|
||||
let a = text(s, json!({"x": 10, "y": 300, "text": story, "size": 12, "area": {"width": 150, "height": 60}}));
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 200, "y": 300, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap());
|
||||
select(s, &[a, b]);
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
(a, b)
|
||||
}
|
||||
|
||||
fn thread_scans() -> usize {
|
||||
super::THREAD_SCANS.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// Threaded frames in the selection stay as they are and are reported as skipped; the other type
|
||||
/// is broken. The document's threads are gone through once for the whole selection, not once
|
||||
/// per object.
|
||||
#[test]
|
||||
fn break_text_skips_threaded_frames_and_reads_the_threads_once() {
|
||||
let mut s = session();
|
||||
let (a, b) = threaded(&mut s, "Threaded story text");
|
||||
let mut plain = vec![];
|
||||
for i in 0..6 {
|
||||
plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "Two words", "size": 14})));
|
||||
}
|
||||
let was = glyphs(&s, &plain);
|
||||
let frames = (obj(&s, a), obj(&s, b));
|
||||
let before = doc_json(&s);
|
||||
let steps = undo_steps(&s);
|
||||
let mut all = vec![a, b];
|
||||
all.extend(&plain);
|
||||
select(&mut s, &all);
|
||||
let scans = thread_scans();
|
||||
let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap();
|
||||
assert_eq!(thread_scans() - scans, 1, "the threads are gone through once");
|
||||
assert_eq!(ids(&json!({"ids": r["skipped"]})), [a, b]);
|
||||
assert_eq!((obj(&s, a), obj(&s, b)), frames, "the threaded frames stay as they are");
|
||||
let pieces = ids(&r);
|
||||
assert_eq!(pieces.len(), 12);
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 12, "the pieces are selected");
|
||||
assert_same_places(&was, &glyphs(&s, &pieces));
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc_json(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_text_refuses_a_threaded_frame() {
|
||||
let mut s = session();
|
||||
let (a, _) = threaded(&mut s, "Threaded story text");
|
||||
select(&mut s, &[a]);
|
||||
edit_text(&mut s, a, 9, 9);
|
||||
let before = doc_json(&s);
|
||||
assert!(other(s.execute("addon.cleanup.splitText", &json!({}))).contains("point or area type"));
|
||||
assert_eq!(doc_json(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_puts_glyph_pieces_back_together_where_they_were() {
|
||||
let mut s = session();
|
||||
@@ -300,6 +356,29 @@ fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
|
||||
}
|
||||
|
||||
/// Remove Breaks reads a threaded story whose frames are all selected as one text and each other
|
||||
/// object on its own, going through the document's threads once for the whole selection.
|
||||
#[test]
|
||||
fn remove_breaks_reads_the_threads_once() {
|
||||
let mut s = session();
|
||||
let (a, b) = threaded(&mut s, "one\ntwo");
|
||||
let mut plain = vec![];
|
||||
for i in 0..6 {
|
||||
plain.push(text(&mut s, json!({"x": 20, "y": 30 + 30 * i, "text": "a\nb", "size": 12})));
|
||||
}
|
||||
let mut all = vec![a, b];
|
||||
all.extend(&plain);
|
||||
select(&mut s, &all);
|
||||
let scans = thread_scans();
|
||||
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
|
||||
assert_eq!(thread_scans() - scans, 1, "the threads are gone through once");
|
||||
assert_eq!(r["removed"], 7);
|
||||
assert_eq!(obj(&s, a).plain_text(), "one two");
|
||||
for id in plain {
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_at_the_cursor_and_around_a_selection() {
|
||||
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Cutting Tools (`addon.cutting.*`), a built-in add-on that prepares art for cutting. Stencil
|
||||
//! Bridges cuts bridges out of the selected art so that a stencil cut from it holds together
|
||||
//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art
|
||||
//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are
|
||||
//! the UI's.
|
||||
//! for vinyl cutting ([`weed`]). Banner Hems and Grommets marks the hems and grommets of a banner
|
||||
//! ([`banner`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are the UI's.
|
||||
|
||||
mod banner;
|
||||
mod stencil;
|
||||
mod weed;
|
||||
|
||||
@@ -12,5 +13,6 @@ use super::super::CommandSpec;
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = stencil::specs();
|
||||
v.extend(weed::specs());
|
||||
v.extend(banner::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Banner Hems and Grommets (`addon.cutting.banner`), a command of the built-in Cutting Tools
|
||||
//! add-on for finishing a printed banner: on each chosen side of the banner it adds a hem line,
|
||||
//! where the edge is folded back and sewn (<https://en.wikipedia.org/wiki/Hem>), and grommet
|
||||
//! marks, where rings are set into the material to hang it
|
||||
//! (<https://en.wikipedia.org/wiki/Grommet>), spread evenly at most a set spacing apart. The
|
||||
//! banner is the selected art's bounds, or each artboard.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
use super::super::super::edit::selected_roots;
|
||||
use super::super::super::paint::swatch_solid;
|
||||
use super::super::super::pathops::length_given;
|
||||
use super::super::super::{CommandSpec, bad, cmd, has_doc, switch_or, text_param};
|
||||
use super::weed::outside_clips;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Banner Hems and Grommets' command.
|
||||
pub const BANNER: &str = "addon.cutting.banner";
|
||||
/// The sides of a banner, in the order the grommets go round it.
|
||||
const SIDES: [&str; 4] = ["top", "right", "bottom", "left"];
|
||||
/// The largest hem, inset or spacing, in points.
|
||||
const MAX_DISTANCE: f64 = 100_000.0;
|
||||
/// The most grommets one run adds.
|
||||
const MAX_GROMMETS: usize = 100_000;
|
||||
/// Two grommet centers closer than this are one grommet (the corner that two sides share).
|
||||
const SAME: f64 = 1e-6;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.cutting.banner",
|
||||
"Banner Hems and Grommets…",
|
||||
["Object", "Plug-ins", "Cutting Tools"],
|
||||
None,
|
||||
"{hem?: pt 0..100000 (72: from each edge to its hem line; 0: no hem lines), grommets?: bool (true), spacing?: pt more than 0 and at most 100000 (1728: the most the grommets on a side are apart), grommetInset?: pt 0..100000 (36: from each edge to the grommets' centers), diameter?: pt 0.01..1000 (27: each grommet mark's circle), top?, right?, bottom?, left?: bool (true: the sides that get a hem and grommets), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the marks are stroked in (\"[Registration]\"), allArtboards?: bool (default: true when nothing is selected)} mark the hems and grommets of a banner, the selected art's bounds or each artboard: a hem line `hem` inside each chosen edge, running the edge's whole length, and grommet marks (a circle with a cross at its center) `grommetInset` inside each chosen edge, one at each end of the side and the rest spread evenly between them, as few as keep them at most `spacing` apart. One group named Banner Marks per banner, stroked with no fill, on top of the current layer, selected, as one undo step → {id: the first group, ids, hems (hem lines), grommets (grommet marks)}",
|
||||
has_doc,
|
||||
banner
|
||||
)]
|
||||
}
|
||||
|
||||
/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`.
|
||||
fn distance(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match length_given(p, key, BANNER)? {
|
||||
None => Ok(default),
|
||||
Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v),
|
||||
Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The length `p` gives for `key` (0.01..1000), or `default`.
|
||||
fn size(p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
match length_given(p, key, BANNER)? {
|
||||
None => Ok(default),
|
||||
Some(v) if (0.01..=1000.0).contains(&v) => Ok(v),
|
||||
Some(_) => Err(bad(BANNER, format!("`{key}` must be from 0.01 to 1000 pt"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sides are on, in [`SIDES`] order.
|
||||
type Sides = [bool; 4];
|
||||
|
||||
/// Whether marks `depth` inside a banner's near and far edges, `len` apart, fit: they stay inside
|
||||
/// the banner, and when both edges are chosen the two sets stay apart.
|
||||
fn fits(depth: f64, len: f64, near: bool, far: bool) -> bool {
|
||||
match (near, far) {
|
||||
(true, true) => 2.0 * depth < len,
|
||||
(true, false) | (false, true) => depth < len,
|
||||
(false, false) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The hem lines of banner `r`: one `hem` inside each chosen edge, from one end of the edge to the
|
||||
/// other.
|
||||
fn hem_lines(r: Rect, hem: f64, sides: Sides) -> Vec<(Point, Point)> {
|
||||
if hem <= 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
let lines = [
|
||||
(Point::new(r.x0, r.y0 + hem), Point::new(r.x1, r.y0 + hem)),
|
||||
(Point::new(r.x1 - hem, r.y0), Point::new(r.x1 - hem, r.y1)),
|
||||
(Point::new(r.x0, r.y1 - hem), Point::new(r.x1, r.y1 - hem)),
|
||||
(Point::new(r.x0 + hem, r.y0), Point::new(r.x0 + hem, r.y1)),
|
||||
];
|
||||
lines.into_iter().zip(sides).filter(|(_, on)| *on).map(|(l, _)| l).collect()
|
||||
}
|
||||
|
||||
/// The grommet centers of banner `r`: on each chosen side, `inset` inside the edge, one at each end
|
||||
/// (`inset` from the edges across it) and the rest spread evenly between them, as few as keep them
|
||||
/// at most `spacing` apart. They go round the banner clockwise from the top left; a corner that two
|
||||
/// chosen sides share has one grommet. `None` when there would be more than `limit`.
|
||||
fn grommet_centers(r: Rect, inset: f64, spacing: f64, sides: Sides, limit: usize) -> Option<Vec<Point>> {
|
||||
let (l, t, rt, b) = (r.x0 + inset, r.y0 + inset, r.x1 - inset, r.y1 - inset);
|
||||
// Each side from its start to its end, clockwise.
|
||||
let runs = [
|
||||
(Point::new(l, t), Point::new(rt, t)),
|
||||
(Point::new(rt, t), Point::new(rt, b)),
|
||||
(Point::new(rt, b), Point::new(l, b)),
|
||||
(Point::new(l, b), Point::new(l, t)),
|
||||
];
|
||||
let mut out: Vec<Point> = vec![];
|
||||
for ((a, z), on) in runs.into_iter().zip(sides) {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let gaps = ((z - a).hypot() / spacing - 1e-9).ceil().max(1.0);
|
||||
if !gaps.is_finite() || gaps > limit as f64 {
|
||||
return None;
|
||||
}
|
||||
let gaps = gaps as usize;
|
||||
for i in 0..=gaps {
|
||||
let p = a.lerp(z, i as f64 / gaps as f64);
|
||||
// A side starts where the one before it ends, and the last one ends where the first
|
||||
// starts.
|
||||
let shared = |q: Option<&Point>| q.is_some_and(|q| (*q - p).hypot() < SAME);
|
||||
if !shared(out.last()) && !shared(out.first()) {
|
||||
out.push(p);
|
||||
}
|
||||
if out.len() > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn banner(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hem = distance(p, "hem", 72.0)?;
|
||||
let inset = distance(p, "grommetInset", 36.0)?;
|
||||
let spacing = match length_given(p, "spacing", BANNER)? {
|
||||
None => 1728.0,
|
||||
Some(v) if v > 0.0 && v <= MAX_DISTANCE => v,
|
||||
Some(_) => return Err(bad(BANNER, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))),
|
||||
};
|
||||
let diameter = size(p, "diameter", 27.0)?;
|
||||
let weight = size(p, "weight", 0.3)?;
|
||||
let with_grommets = switch_or(p, "grommets", true, BANNER)?;
|
||||
let mut sides = [true; 4];
|
||||
for (on, key) in sides.iter_mut().zip(SIDES) {
|
||||
*on = switch_or(p, key, true, BANNER)?;
|
||||
}
|
||||
if !sides.contains(&true) {
|
||||
return Err(bad(BANNER, "turn on at least one side"));
|
||||
}
|
||||
if hem == 0.0 && !with_grommets {
|
||||
return Err(bad(BANNER, "with no hem and no grommets there is nothing to mark"));
|
||||
}
|
||||
let swatch = text_param(p, "swatch", BANNER)?.unwrap_or(REGISTRATION);
|
||||
let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(BANNER, e))?;
|
||||
let all = switch_or(p, "allArtboards", s.doc()?.selection.is_empty(), BANNER)?;
|
||||
let banners: Vec<Rect> = if all {
|
||||
s.doc()?.doc.artboards.iter().map(|a| a.rect).collect()
|
||||
} else {
|
||||
let roots = selected_roots(s)?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(BANNER, "select the banner, or mark every artboard"));
|
||||
}
|
||||
s.doc()?.doc.bounds_of(&roots, false).into_iter().collect()
|
||||
};
|
||||
if banners.is_empty() {
|
||||
return Err(bad(BANNER, "there is no banner to mark"));
|
||||
}
|
||||
let [top, right, bottom, left] = sides;
|
||||
let mut marks = vec![];
|
||||
let mut total = 0;
|
||||
for r in &banners {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
if !(w.is_finite() && h.is_finite()) || w <= 0.0 || h <= 0.0 {
|
||||
return Err(bad(BANNER, "the banner has no width or height"));
|
||||
}
|
||||
// A hem line must stay inside the banner and apart from the hem line on the far side.
|
||||
if hem > 0.0 && !(fits(hem, h, top, bottom) && fits(hem, w, left, right)) {
|
||||
return Err(bad(BANNER, "the hem is too deep for the banner"));
|
||||
}
|
||||
let centers = if with_grommets {
|
||||
// The grommets on a side run from `inset` past one end to `inset` before the other,
|
||||
// stay inside the banner and stay apart from the grommets on the far side.
|
||||
let runs_fit = (!(top || bottom) || 2.0 * inset <= w) && (!(left || right) || 2.0 * inset <= h);
|
||||
if !(runs_fit && fits(inset, h, top, bottom) && fits(inset, w, left, right)) {
|
||||
return Err(bad(BANNER, "the grommet inset is too large for the banner"));
|
||||
}
|
||||
grommet_centers(*r, inset, spacing, sides, MAX_GROMMETS - total)
|
||||
.ok_or_else(|| bad(BANNER, format!("the spacing makes more than {MAX_GROMMETS} grommets")))?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
total += centers.len();
|
||||
marks.push((hem_lines(*r, hem, sides), centers));
|
||||
}
|
||||
let parent = outside_clips(&s.doc()?.doc, s.doc()?.target_parent()?);
|
||||
let ap = Appearance::basic(Paint::None, stroke, weight);
|
||||
let half = diameter / 2.0;
|
||||
let (ids, hems) = s.edit("Banner Hems and Grommets", |d, sel| {
|
||||
let (mut ids, mut hems) = (vec![], 0);
|
||||
for (lines, centers) in &marks {
|
||||
let mut children = vec![];
|
||||
for (a, b) in lines {
|
||||
let mut n = Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone());
|
||||
n.name = Some("Hem".into());
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
hems += lines.len();
|
||||
for c in centers {
|
||||
let ring = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(c.x - half, c.y - half, c.x + half, c.y + half)), ap.clone());
|
||||
let across = Node::path(d.alloc_id(), shapes::line(Point::new(c.x - half, c.y), Point::new(c.x + half, c.y)), ap.clone());
|
||||
let down = Node::path(d.alloc_id(), shapes::line(Point::new(c.x, c.y - half), Point::new(c.x, c.y + half)), ap.clone());
|
||||
let mut g = Node::group(d.alloc_id(), vec![Arc::new(ring), Arc::new(across), Arc::new(down)]);
|
||||
g.name = Some("Grommet".into());
|
||||
children.push(Arc::new(g));
|
||||
}
|
||||
let mut g = Node::group(d.alloc_id(), children);
|
||||
g.name = Some("Banner Marks".into());
|
||||
ids.push(g.id);
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
}
|
||||
sel.set(ids.iter().copied());
|
||||
Ok((ids, hems))
|
||||
})?;
|
||||
let first = ids.first().map(|i| i.0);
|
||||
Ok(json!({ "id": first, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "hems": hems, "grommets": total }))
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Tests for `addon.cutting.banner`: hem lines and grommet marks on the sides of a banner.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
use super::{grommet_centers, hem_lines};
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
/// Runs Banner Hems and Grommets with `p` and gives the first group it made.
|
||||
fn banner(s: &mut Session, p: Value) -> (Value, Node) {
|
||||
let r = s.execute("addon.cutting.banner", &p).unwrap();
|
||||
let g = node(s, id_of(&r));
|
||||
(r, g)
|
||||
}
|
||||
|
||||
fn round(p: Point) -> (f64, f64) {
|
||||
let r = |v: f64| (v * 1000.0).round() / 1000.0;
|
||||
(r(p.x), r(p.y))
|
||||
}
|
||||
|
||||
/// The hem lines in group `g` as their two ends.
|
||||
fn hems(g: &Node) -> Vec<((f64, f64), (f64, f64))> {
|
||||
g.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c.name.as_deref() == Some("Hem"))
|
||||
.map(|c| match &c.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
let a: Vec<Point> = path.anchors().map(|(_, _, a)| a.p).collect();
|
||||
assert_eq!(a.len(), 2, "a hem line is one straight segment");
|
||||
(round(a[0]), round(a[1]))
|
||||
}
|
||||
other => panic!("not a path: {other:?}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The grommet marks in group `g` as their centers.
|
||||
fn grommets(g: &Node) -> Vec<(f64, f64)> {
|
||||
g.children().unwrap().iter().filter(|c| c.name.as_deref() == Some("Grommet")).map(|c| round(c.geometric_bounds().unwrap().center())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hems_and_grommets_go_round_a_selected_banner() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 400.0, 200.0);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let (r, g) = banner(&mut s, json!({"hem": 20, "grommetInset": 10, "spacing": 150, "diameter": 8}));
|
||||
assert_eq!(g.name.as_deref(), Some("Banner Marks"));
|
||||
assert_eq!(
|
||||
hems(&g),
|
||||
vec![((0.0, 20.0), (400.0, 20.0)), ((380.0, 0.0), (380.0, 200.0)), ((0.0, 180.0), (400.0, 180.0)), ((20.0, 0.0), (20.0, 200.0))]
|
||||
);
|
||||
// 380 pt along the top and bottom need 3 gaps at most 150 apart, 180 down the sides 2; the
|
||||
// corners are shared.
|
||||
assert_eq!(
|
||||
grommets(&g),
|
||||
vec![
|
||||
(10.0, 10.0),
|
||||
(136.667, 10.0),
|
||||
(263.333, 10.0),
|
||||
(390.0, 10.0),
|
||||
(390.0, 100.0),
|
||||
(390.0, 190.0),
|
||||
(263.333, 190.0),
|
||||
(136.667, 190.0),
|
||||
(10.0, 190.0),
|
||||
(10.0, 100.0)
|
||||
]
|
||||
);
|
||||
assert_eq!(r["hems"], 4);
|
||||
assert_eq!(r["grommets"], 10);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grommet_is_a_circle_with_a_cross_stroked_in_registration() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let (_, g) = banner(&mut s, json!({"hem": 0, "grommetInset": 10, "diameter": 6, "weight": 0.5}));
|
||||
let first = g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Grommet")).cloned().unwrap();
|
||||
assert_eq!(first.geometric_bounds(), Some(Rect::new(7.0, 7.0, 13.0, 13.0)));
|
||||
let parts = first.children().unwrap();
|
||||
assert_eq!(parts.len(), 3, "a circle and two lines");
|
||||
for c in parts.iter() {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }), "a path");
|
||||
assert_eq!(c.appearance.fill_paint(), Paint::None);
|
||||
assert!(c.appearance.stroke_paint().is_registration());
|
||||
assert_eq!(c.appearance.stroke_width(), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_artboard_is_marked_when_nothing_is_selected() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let r = s.execute("addon.cutting.banner", &json!({"grommets": false})).unwrap();
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
let second = node(&s, ids[1]);
|
||||
assert_eq!(hems(&second)[0], ((900.0, 72.0), (1100.0, 72.0)), "the default hem is 72 pt");
|
||||
assert!(grommets(&second).is_empty());
|
||||
assert_eq!(r["grommets"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_chosen_sides_are_marked() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
|
||||
let (_, g) = banner(&mut s, json!({"hem": 10, "grommetInset": 5, "spacing": 1000, "right": false, "bottom": false, "left": false}));
|
||||
assert_eq!(hems(&g), vec![((0.0, 10.0), (300.0, 10.0))]);
|
||||
assert_eq!(grommets(&g), vec![(5.0, 5.0), (295.0, 5.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_are_read_and_bad_params_are_refused() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 400.0, 400.0);
|
||||
let (_, g) = banner(&mut s, json!({"hem": "1 in", "grommetInset": "0.5 in", "spacing": "10 in"}));
|
||||
assert_eq!(hems(&g)[0], ((0.0, 72.0), (400.0, 72.0)));
|
||||
assert_eq!(grommets(&g)[0], (36.0, 36.0));
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for p in [
|
||||
json!({"hem": -1}),
|
||||
json!({"hem": "x"}),
|
||||
json!({"spacing": 0}),
|
||||
json!({"diameter": 0}),
|
||||
json!({"weight": 1e9}),
|
||||
json!({"top": "yes"}),
|
||||
json!({"top": false, "right": false, "bottom": false, "left": false}),
|
||||
json!({"hem": 0, "grommets": false}),
|
||||
json!({"hem": 250}),
|
||||
json!({"grommetInset": 250}),
|
||||
json!({"spacing": 0.001}),
|
||||
json!({"swatch": "no such swatch"}),
|
||||
] {
|
||||
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "a refused run changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hems_and_grommets_on_one_side_may_go_past_the_middle() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
|
||||
let one = json!({"right": false, "bottom": false, "left": false, "spacing": 1000});
|
||||
let with = |extra: Value| {
|
||||
let mut p = one.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let (_, g) = banner(&mut s, with(json!({"hem": 60, "grommetInset": 70})));
|
||||
assert_eq!(hems(&g), vec![((0.0, 60.0), (300.0, 60.0))]);
|
||||
assert_eq!(grommets(&g), vec![(70.0, 70.0), (230.0, 70.0)]);
|
||||
for p in [with(json!({"hem": 100})), with(json!({"hem": 0, "grommetInset": 100})), with(json!({"grommetInset": 151}))] {
|
||||
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} reaches the far edge or past the ends");
|
||||
}
|
||||
// A grommet inset of half the width puts the grommets of a side on one point: one grommet.
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 300.0);
|
||||
let (_, g) = banner(&mut s, with(json!({"hem": 0, "grommetInset": 50, "spacing": 10})));
|
||||
assert_eq!(grommets(&g), vec![(50.0, 50.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hems_and_grommets_on_opposite_sides_stay_apart() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 300.0, 100.0);
|
||||
let pair = |p: Value| {
|
||||
let mut q = json!({"right": false, "left": false, "spacing": 1000});
|
||||
q.as_object_mut().unwrap().extend(p.as_object().unwrap().clone());
|
||||
q
|
||||
};
|
||||
// Half the height would put the top and bottom marks on the same line.
|
||||
let refused = [
|
||||
pair(json!({"hem": 50})),
|
||||
pair(json!({"hem": 0, "grommetInset": 50})),
|
||||
json!({"hem": 0, "grommetInset": 150, "top": false, "bottom": false}),
|
||||
];
|
||||
for p in refused {
|
||||
assert!(s.execute("addon.cutting.banner", &p).is_err(), "{p} is refused");
|
||||
}
|
||||
let (_, g) = banner(&mut s, pair(json!({"hem": 49.5, "grommetInset": 49.5})));
|
||||
assert_eq!(hems(&g), vec![((0.0, 49.5), (300.0, 49.5)), ((0.0, 50.5), (300.0, 50.5))]);
|
||||
assert_eq!(grommets(&g), vec![(49.5, 49.5), (250.5, 49.5), (250.5, 50.5), (49.5, 50.5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marks_go_outside_a_clip_group_edited_in_isolation() {
|
||||
let mut s = session();
|
||||
let parent = s.doc().unwrap().target_parent().unwrap();
|
||||
let (g, art) = s
|
||||
.edit("clip group", |d, _| {
|
||||
let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()));
|
||||
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 200.0)), Appearance::default());
|
||||
let art_id = art.id;
|
||||
let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(art)]);
|
||||
if let NodeKind::Group { clip, .. } = &mut g.kind {
|
||||
*clip = true;
|
||||
}
|
||||
let id = g.id;
|
||||
d.insert(parent, usize::MAX, g)?;
|
||||
Ok((id, art_id))
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
|
||||
let (_, marks) = banner(&mut s, json!({"hem": 20, "grommets": false}));
|
||||
// The clipping path would hide marks inside the clip group.
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(marks.id), parent);
|
||||
assert_eq!(hems(&marks)[0], ((0.0, 20.0), (400.0, 20.0)), "the selected art is the banner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_artboards_marks_the_artboards_even_with_a_selection() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0, 10.0, 100.0, 100.0);
|
||||
let (_, g) = banner(&mut s, json!({"allArtboards": true, "grommets": false}));
|
||||
assert_eq!(hems(&g)[0], ((0.0, 72.0), (800.0, 72.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_document_has_no_banner_to_mark_when_artboards_are_off() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("addon.cutting.banner", &json!({"allArtboards": false})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_side_shorter_than_the_spacing_has_a_grommet_at_each_end() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
let c = grommet_centers(r, 0.0, 1000.0, [true, false, false, false], 10).unwrap();
|
||||
assert_eq!(c, vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)]);
|
||||
// Spacing that divides the side exactly does not add a grommet, even when the division
|
||||
// rounds up (2.1 / 0.3 is 7.000000000000001).
|
||||
let c = grommet_centers(Rect::new(0.0, 0.0, 2.1, 1.0), 0.0, 0.3, [true, false, false, false], 100).unwrap();
|
||||
assert_eq!(c.len(), 8);
|
||||
assert!(grommet_centers(r, 0.0, 1.0, [true; 4], 10).is_none(), "over the limit");
|
||||
assert!(hem_lines(r, 0.0, [true; 4]).is_empty());
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fn lines_across(length: f64, count: Option<usize>, spacing: Option<f64>) -> Resu
|
||||
/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping
|
||||
/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping
|
||||
/// path would hide the lines and the box, which lie outside the art.
|
||||
fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
|
||||
pub(super) fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
|
||||
let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }));
|
||||
let (mut at, mut out) = (parent, parent);
|
||||
while let Some(p) = at {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
|
||||
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
|
||||
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's
|
||||
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes
|
||||
//! into Paths.
|
||||
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes, Expand Dashes
|
||||
//! into Paths and Trim at Crossings ([`trim`]).
|
||||
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
|
||||
//! and the UI's.
|
||||
|
||||
@@ -14,6 +14,7 @@ mod shape;
|
||||
mod shared_edge;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod trim;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -143,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Points at Crossings",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}",
|
||||
"{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too, a curved segment that loops over itself included), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}",
|
||||
has_selection,
|
||||
crossings
|
||||
),
|
||||
@@ -178,6 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v.extend(shape::specs());
|
||||
v.extend(corners::specs());
|
||||
v.extend(shared_edge::specs());
|
||||
v.extend(trim::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -609,7 +611,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet<usize>) -> BTreeSet<usize> {
|
||||
let n = sp.anchors.len().max(1);
|
||||
match segments.filter(|set| !set.is_empty()) {
|
||||
Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(),
|
||||
Some(set) => super::super::path::subpath_refs(set, si).collect(),
|
||||
None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(),
|
||||
}
|
||||
}
|
||||
@@ -681,6 +683,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- Split / Add Points at Crossings ----------
|
||||
|
||||
/// The crossing places `on` of one path, as `(segment, t)` for each of its `subpaths` subpaths in
|
||||
/// the order they come in `on`. A place on a subpath past the last is left out.
|
||||
fn places_by_subpath(on: &[(usize, usize, f64)], subpaths: usize) -> Vec<Vec<(usize, f64)>> {
|
||||
let mut by_sub = vec![vec![]; subpaths];
|
||||
for &(si, seg, t) in on {
|
||||
if let Some(here) = by_sub.get_mut(si) {
|
||||
here.push((seg, t));
|
||||
}
|
||||
}
|
||||
by_sub
|
||||
}
|
||||
|
||||
fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.crossings";
|
||||
let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?);
|
||||
@@ -702,8 +716,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut subs = vec![];
|
||||
let mut marks = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
|
||||
for (sp, here) in path.subpaths.iter().zip(places_by_subpath(&on, path.subpaths.len())) {
|
||||
let (out, m) = pt::insert_at(sp, &here);
|
||||
count += m.len();
|
||||
subs.push(out);
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LiveCorners, LiveShape, NodeId};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point};
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::{Session, ViewInfo};
|
||||
@@ -275,6 +275,152 @@ fn crossings_add_anchors_or_cut_in_one_step() {
|
||||
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
|
||||
}
|
||||
|
||||
/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an
|
||||
/// empty subpath.
|
||||
fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId {
|
||||
let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false);
|
||||
let pd =
|
||||
PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), false)).collect());
|
||||
s.edit("Test", |d, _| {
|
||||
super::set_path(d, id, pd);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
select(s, &[id]);
|
||||
id
|
||||
}
|
||||
|
||||
/// The anchor points of each subpath of `pd`, rounded to a millionth.
|
||||
fn points(pd: &PathData) -> Vec<Vec<(f64, f64)>> {
|
||||
let r = |v: f64| (v * 1e6).round() / 1e6;
|
||||
pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect()
|
||||
}
|
||||
|
||||
/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last).
|
||||
const SUBS: [&[(f64, f64)]; 4] =
|
||||
[&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]];
|
||||
|
||||
/// Direct-select `segments` of `id` and the anchors `anchors`.
|
||||
fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) {
|
||||
s.select(|_, sel| {
|
||||
sel.set([id]);
|
||||
sel.anchors.insert(id, anchors.iter().copied().collect());
|
||||
sel.segments.insert(id, segments.iter().copied().collect());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picked_segments_reads_only_its_own_subpath() {
|
||||
let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into();
|
||||
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false);
|
||||
let all: BTreeSet<usize> = (0..4).collect();
|
||||
let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::<Vec<_>>();
|
||||
assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]);
|
||||
// Without direct-selected segments, the segments between two selected anchors.
|
||||
let anchors: BTreeSet<usize> = [0, 1, 3].into();
|
||||
for none in [None, Some(&BTreeSet::new())] {
|
||||
assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::<Vec<_>>(), [0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() {
|
||||
let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)];
|
||||
let by_sub = super::places_by_subpath(&on, 4);
|
||||
assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]);
|
||||
// The same places as filtering the whole list for each subpath.
|
||||
for (si, here) in by_sub.iter().enumerate() {
|
||||
let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect();
|
||||
assert_eq!(*here, filtered);
|
||||
}
|
||||
assert!(super::places_by_subpath(&on, 0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_points_adds_to_the_selected_segments_of_each_subpath() {
|
||||
let mut s = session();
|
||||
let c = compound(&mut s, &SUBS);
|
||||
let before = path(&s, c);
|
||||
// The first segment of A and the second of D; a segment of C without its anchors selected.
|
||||
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2));
|
||||
let want = vec![
|
||||
vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)],
|
||||
vec![],
|
||||
vec![(0.0, 50.0), (100.0, 50.0)],
|
||||
vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)],
|
||||
];
|
||||
let after = path(&s, c);
|
||||
assert_eq!(points(&after), want);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, c), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, c), after);
|
||||
// Without selected segments: those between two selected anchors, here on the last subpath only.
|
||||
for segments in [None, Some(BTreeSet::new())] {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.select(|_, sel| {
|
||||
sel.set([c]);
|
||||
sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into());
|
||||
sel.segments.remove(&c);
|
||||
if let Some(set) = segments.clone() {
|
||||
sel.segments.insert(c, set);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1));
|
||||
assert_eq!(
|
||||
points(&path(&s, c)),
|
||||
[points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_add_anchors_on_each_subpath_of_a_compound_path() {
|
||||
let mut s = session();
|
||||
// A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not.
|
||||
let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]);
|
||||
let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false);
|
||||
let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false);
|
||||
select(&mut s, &[c, q, r]);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8));
|
||||
let want = vec![
|
||||
vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)],
|
||||
vec![],
|
||||
vec![(0.0, 150.0), (40.0, 150.0)],
|
||||
vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)],
|
||||
];
|
||||
assert_eq!(points(&path(&s, c)), want);
|
||||
assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths.
|
||||
let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap());
|
||||
assert_eq!(out.len(), 7);
|
||||
assert_eq!(out[0], c);
|
||||
let pieces = vec![
|
||||
vec![(0.0, 50.0), (100.0, 50.0)],
|
||||
vec![(100.0, 50.0), (150.0, 50.0)],
|
||||
vec![(150.0, 50.0), (200.0, 50.0)],
|
||||
vec![],
|
||||
vec![(0.0, 150.0), (40.0, 150.0)],
|
||||
vec![(0.0, 250.0), (100.0, 250.0)],
|
||||
vec![(100.0, 250.0), (150.0, 250.0)],
|
||||
vec![(150.0, 250.0), (400.0, 250.0)],
|
||||
];
|
||||
assert_eq!(points(&path(&s, c)), pieces);
|
||||
let qs: Vec<Vec<Vec<(f64, f64)>>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect();
|
||||
assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
}
|
||||
|
||||
// ---------- Parametric Shape Tool ----------
|
||||
|
||||
/// The parametric shape object `id` is now, as the tool reads it.
|
||||
@@ -887,6 +1033,35 @@ fn divide_into_dashes_divides_only_the_selected_segments() {
|
||||
assert_eq!(layer, pieces, "in the path's place, in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() {
|
||||
let mut s = session();
|
||||
let c = compound(&mut s, &SUBS);
|
||||
let before = path(&s, c);
|
||||
// The first segment of A and the second of D; a segment of C without its anchors selected.
|
||||
select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(r["dashes"], json!(8));
|
||||
let made: Vec<Vec<Vec<(f64, f64)>>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect();
|
||||
let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]];
|
||||
let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]];
|
||||
want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0)));
|
||||
want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]);
|
||||
want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]);
|
||||
want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0)));
|
||||
assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, ids(&r));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, c), before);
|
||||
// With no segments selected: those between two selected anchors, here on the last subpath only.
|
||||
select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]);
|
||||
let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap();
|
||||
assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4)));
|
||||
assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Trim at Crossings: remove the stretch of a path between the places where other paths, or the
|
||||
//! path itself, cross it. The command takes the stretch at a point, every stretch a rectangle
|
||||
//! touches, or every stretch a line through some points crosses. It works on every path that can
|
||||
//! be edited, selected or not.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind};
|
||||
use vectorcraft_geom::kurbo::{Line, PathSeg};
|
||||
use vectorcraft_geom::{PathData, Point, Rect, SubPath, hit};
|
||||
use vectorcraft_pathops::addons::pathtools as pt;
|
||||
|
||||
use super::super::super::draw2::replace_with_pieces;
|
||||
use super::super::super::pathops::Beside;
|
||||
use super::super::super::{CommandSpec, bad, cmd, finite_numbers, has_doc, number_or};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// The undo step's name.
|
||||
const LABEL: &str = "Trim at Crossings";
|
||||
/// How near `at` a path counts as under it by default (pt), and the most.
|
||||
const TOLERANCE: f64 = 3.0;
|
||||
const MAX_TOLERANCE: f64 = 100.0;
|
||||
/// Most points of `points`.
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
/// The farthest coordinate the command takes.
|
||||
const MAX_COORD: f64 = 1e7;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.pathtools.trim",
|
||||
"Trim at Crossings",
|
||||
[],
|
||||
None,
|
||||
"{at: [x, y], tolerance?: pt 0..100 (3; how near `at` a path counts as under it)} or {rect: [x, y, width, height]} or {points: [[x, y]…] (2 to 100000 points)} remove stretches of paths, a stretch running between two neighboring places where the path is crossed by another path or by itself, or from such a place to an open end: `at` removes the stretch of the nearest path at that point, `rect` every stretch the rectangle touches, `points` every stretch the line through the points crosses. Give exactly one of the three. Every path that can be edited takes part, selected or not, compound path members included; hidden and locked paths, guides and clipping paths (compound ones' members included) are neither trimmed nor counted as crossing; in isolation mode only paths inside the isolated object are trimmed. A path that ends on another marks a place on the other; paths that lie along each other don't cross. A closed path that loses a stretch becomes open, an open path that loses a middle stretch becomes two paths with its appearance in its place in the stacking order, and a path nothing crosses is removed whole; a live shape becomes a plain path. One undo step, none when nothing is removed → {ids (the paths left of those trimmed), removed (stretches)}",
|
||||
has_doc,
|
||||
trim
|
||||
)]
|
||||
}
|
||||
|
||||
/// What picks the stretches to remove.
|
||||
enum Pick {
|
||||
/// The stretch of the nearest path within the tolerance of the point.
|
||||
At(Point, f64),
|
||||
/// Every stretch the rectangle touches.
|
||||
Rect(Rect),
|
||||
/// Every stretch the line through the points crosses.
|
||||
Line(Vec<Point>, Rect),
|
||||
}
|
||||
|
||||
fn pick(p: &Value) -> Result<Pick> {
|
||||
const C: &str = "addon.pathtools.trim";
|
||||
let given = |key: &str| p.get(key).filter(|v| !v.is_null());
|
||||
let within = |v: &[f64]| v.iter().all(|x| x.abs() <= MAX_COORD);
|
||||
match (given("at"), given("rect"), given("points")) {
|
||||
(Some(at), None, None) => {
|
||||
let [x, y] = finite_numbers(at).filter(|v| within(v)).ok_or_else(|| bad(C, "`at` must be a point [x, y]"))?;
|
||||
let tolerance = number_or(p, "tolerance", TOLERANCE, C)?;
|
||||
if !(0.0..=MAX_TOLERANCE).contains(&tolerance) {
|
||||
return Err(bad(C, "`tolerance` must be from 0 to 100 pt"));
|
||||
}
|
||||
Ok(Pick::At(Point::new(x, y), tolerance))
|
||||
}
|
||||
(None, Some(rect), None) => {
|
||||
let [x, y, w, h] = finite_numbers(rect).filter(|v| within(v)).ok_or_else(|| bad(C, "`rect` must be [x, y, width, height]"))?;
|
||||
Ok(Pick::Rect(Rect::new(x, y, x + w, y + h).abs()))
|
||||
}
|
||||
(None, None, Some(points)) => {
|
||||
let list = points.as_array().ok_or_else(|| bad(C, "`points` must be a list of [x, y] points"))?;
|
||||
if !(2..=MAX_POINTS).contains(&list.len()) {
|
||||
return Err(bad(C, "`points` must hold from 2 to 100000 points"));
|
||||
}
|
||||
let pts = list
|
||||
.iter()
|
||||
.map(|v| {
|
||||
finite_numbers(v)
|
||||
.filter(|xy| within(xy))
|
||||
.map(|[x, y]| Point::new(x, y))
|
||||
.ok_or_else(|| bad(C, format!("bad point {v}: expected [x, y]")))
|
||||
})
|
||||
.collect::<Result<Vec<Point>>>()?;
|
||||
let bounds = pts.iter().fold(None, |r: Option<Rect>, q| Some(r.map_or(Rect::from_points(*q, *q), |r| r.union_pt(*q))));
|
||||
Ok(Pick::Line(pts, bounds.unwrap_or_default()))
|
||||
}
|
||||
_ => Err(bad(C, "give exactly one of `at`, `rect` and `points`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A path that takes part: its id, geometry and control-point box.
|
||||
type Part<'a> = (NodeId, &'a PathData, Rect);
|
||||
|
||||
/// The paths that take part, bottom to top: the ones that can be edited, without guides and
|
||||
/// clipping paths. The clipping path of a clip group or a clipped layer is left out whole, the
|
||||
/// members of a compound clipping path included.
|
||||
fn parts(d: &Document) -> Vec<Part<'_>> {
|
||||
let editable = d.editable();
|
||||
let mut masks = HashSet::new();
|
||||
d.walk(|n| {
|
||||
if n.clips()
|
||||
&& let Some(first) = n.children().and_then(|c| c.first())
|
||||
{
|
||||
first.walk(&mut |m| {
|
||||
masks.insert(m.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
|
||||
&& !masks.contains(&n.id)
|
||||
&& let Some(b) = path.control_bounds()
|
||||
&& editable(n.id)
|
||||
{
|
||||
out.push((n.id, path, b));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Most box tests one command makes between a line's pieces and the paths' segments.
|
||||
const MAX_LINE_TESTS: usize = 60_000_000;
|
||||
/// Most tests one command makes between the boxes of the paths to trim and of the other paths.
|
||||
const MAX_BOX_TESTS: usize = 50_000_000;
|
||||
|
||||
/// Does the line through `pts` cross `sp`? Each piece of the line compared costs one of `budget`;
|
||||
/// `None` once it is spent.
|
||||
fn crossed_by_line(sp: &SubPath, pts: &[Point], budget: &mut usize) -> Option<bool> {
|
||||
let segs: Vec<_> = (0..sp.segment_count())
|
||||
.map(|i| {
|
||||
let c = sp.segment(i);
|
||||
(c, Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3).inflate(1e-9, 1e-9))
|
||||
})
|
||||
.collect();
|
||||
let Some(reach) = segs.iter().map(|s| s.1).reduce(|a, b| a.union(b)) else { return Some(false) };
|
||||
for w in pts.windows(2) {
|
||||
let [a, b] = w else { continue };
|
||||
let piece = Rect::from_points(*a, *b);
|
||||
*budget = budget.checked_sub(1)?;
|
||||
if !reach.overlaps(piece) {
|
||||
continue;
|
||||
}
|
||||
for (c, r) in &segs {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
if r.overlaps(piece) && !PathSeg::Cubic(*c).intersect_line(Line::new(*a, *b)).is_empty() {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// Does the rectangle or line of `pick` touch the outline of `piece`? Never for `Pick::At`.
|
||||
fn touches(piece: &SubPath, pick: &Pick, budget: &mut usize) -> Result<bool> {
|
||||
match pick {
|
||||
Pick::At(..) => Ok(false),
|
||||
Pick::Rect(r) => Ok(hit::intersects_rect(&PathData::single(piece.clone()), *r)),
|
||||
Pick::Line(pts, _) => crossed_by_line(piece, pts, budget)
|
||||
.ok_or_else(|| bad("addon.pathtools.trim", "the line is too long, or the paths it passes too many, to check")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` without the stretches `pick` takes (`on`: the place of `Pick::At` on it): its subpaths
|
||||
/// then, and how many stretches went. `None` when none does. A rectangle or a line that touches no
|
||||
/// part of `path` leaves it out before its crossings are looked for.
|
||||
fn trimmed(
|
||||
path: &PathData,
|
||||
others: &[&PathData],
|
||||
pick: &Pick,
|
||||
on: Option<pt::Place>,
|
||||
budget: &mut usize,
|
||||
work: &mut pt::Work,
|
||||
) -> Result<Option<(Vec<SubPath>, usize)>> {
|
||||
const C: &str = "addon.pathtools.trim";
|
||||
let reached = match pick {
|
||||
Pick::At(..) => true,
|
||||
Pick::Rect(r) => hit::intersects_rect(path, *r),
|
||||
Pick::Line(..) => {
|
||||
let mut any = false;
|
||||
for sp in &path.subpaths {
|
||||
if touches(sp, pick, budget)? {
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
};
|
||||
if !reached {
|
||||
return Ok(None);
|
||||
}
|
||||
let places =
|
||||
pt::crossings_on_within(path, others, work).ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross"))?;
|
||||
let mut subs = vec![];
|
||||
let mut removed = 0;
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
// The places come in subpath order: this subpath's are one run of them.
|
||||
let run = places.partition_point(|p| p.0 < si)..places.partition_point(|p| p.0 <= si);
|
||||
let here: Vec<(usize, f64)> = places.get(run).unwrap_or_default().iter().map(|&(_, seg, t)| (seg, t)).collect();
|
||||
let spans = pt::spans(sp, &here);
|
||||
let marks: Vec<bool> = match pick {
|
||||
Pick::At(..) => {
|
||||
let at = on.filter(|(s, ..)| *s == si).and_then(|(_, seg, t)| pt::span_at(sp, &spans, seg, t));
|
||||
(0..spans.len()).map(|i| Some(i) == at).collect()
|
||||
}
|
||||
_ => spans.iter().map(|s| touches(&pt::slice(sp, *s), pick, budget)).collect::<Result<_>>()?,
|
||||
};
|
||||
match pt::without_spans(sp, &spans, &marks) {
|
||||
Some(pieces) => {
|
||||
removed += marks.iter().filter(|m| **m).count();
|
||||
subs.extend(pieces);
|
||||
}
|
||||
None => subs.push(sp.clone()),
|
||||
}
|
||||
}
|
||||
Ok((removed > 0).then_some((subs, removed)))
|
||||
}
|
||||
|
||||
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.trim";
|
||||
let pick = pick(p)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let parts = parts(d);
|
||||
// In isolation mode only the isolated object's paths are trimmed; paths outside it still cross them.
|
||||
let root = crate::cmd::ordinary_isolation(st);
|
||||
let in_scope = |id: NodeId| root.is_none_or(|r| id == r || crate::cmd::inside_isolation(d, r, id));
|
||||
// The paths to trim, each with the place `at` falls on.
|
||||
let targets: Vec<(usize, Option<pt::Place>)> = match &pick {
|
||||
Pick::At(at, tolerance) => {
|
||||
let mut best: Option<(usize, pt::Place, f64)> = None;
|
||||
for (i, (id, path, b)) in parts.iter().enumerate() {
|
||||
if !in_scope(*id) {
|
||||
continue;
|
||||
}
|
||||
let b = b.inflate(*tolerance, *tolerance);
|
||||
if !(b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1) {
|
||||
continue;
|
||||
}
|
||||
// Of paths equally near, the one on top.
|
||||
if let Some((si, seg, t, _, dist)) = path.nearest(*at)
|
||||
&& dist <= *tolerance
|
||||
&& best.is_none_or(|b| dist <= b.2)
|
||||
{
|
||||
best = Some((i, (si, seg, t), dist));
|
||||
}
|
||||
}
|
||||
best.map(|(i, on, _)| (i, Some(on))).into_iter().collect()
|
||||
}
|
||||
Pick::Rect(r) | Pick::Line(_, r) => {
|
||||
parts.iter().enumerate().filter(|(_, (id, _, b))| in_scope(*id) && b.overlaps(*r)).map(|(i, _)| (i, None)).collect()
|
||||
}
|
||||
};
|
||||
// Each path to trim has its box tested against every other path's.
|
||||
if targets.len().saturating_mul(parts.len()) > MAX_BOX_TESTS {
|
||||
return Err(bad(C, "the rectangle or line reaches too many paths to check"));
|
||||
}
|
||||
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
|
||||
let mut removed = 0;
|
||||
let mut budget = MAX_LINE_TESTS;
|
||||
// One budget of crossing work for every path the command trims.
|
||||
let mut work = pt::Work::default();
|
||||
for (i, on) in targets {
|
||||
let Some((id, path, b)) = parts.get(i) else { continue };
|
||||
let reach = b.inflate(1e-3, 1e-3);
|
||||
let others: Vec<&PathData> =
|
||||
parts.iter().enumerate().filter(|(j, (_, _, ob))| *j != i && reach.overlaps(*ob)).map(|(_, (_, o, _))| *o).collect();
|
||||
let Some((subs, n)) = trimmed(path, &others, &pick, on, &mut budget, &mut work)? else { continue };
|
||||
removed += n;
|
||||
// A path of one subpath becomes a path per piece; the pieces of one with more stay its subpaths.
|
||||
let pieces = match (path.subpaths.len(), subs.is_empty()) {
|
||||
(_, true) => vec![],
|
||||
(1, false) => subs.into_iter().map(PathData::single).collect(),
|
||||
_ => vec![PathData::new(subs)],
|
||||
};
|
||||
plan.push((*id, pieces));
|
||||
}
|
||||
if plan.is_empty() {
|
||||
return Ok(json!({ "ids": [], "removed": 0 }));
|
||||
}
|
||||
let ids = s.edit(LABEL, |d, sel| {
|
||||
let trimmed: Vec<NodeId> = plan.iter().map(|(id, _)| *id).collect();
|
||||
// The compound paths that may lose their last member.
|
||||
let mut compounds: Vec<NodeId> = trimmed
|
||||
.iter()
|
||||
.filter_map(|id| d.parent_of(*id))
|
||||
.filter(|c| matches!(d.node(*c).map(|n| &n.kind), Some(NodeKind::Compound { .. })))
|
||||
.collect();
|
||||
compounds.dedup();
|
||||
let mut beside = Beside::replacing(d, &trimmed);
|
||||
let mut all = vec![];
|
||||
for (id, pieces) in plan {
|
||||
let selected = sel.contains(id);
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
let made = replace_with_pieces(d, &mut beside, id, pieces)?;
|
||||
// The pieces of a selected path are selected; what was not selected stays so.
|
||||
if made.is_empty() {
|
||||
sel.remove(id);
|
||||
} else if selected {
|
||||
for n in &made {
|
||||
if !sel.contains(*n) {
|
||||
sel.add(*n);
|
||||
}
|
||||
}
|
||||
}
|
||||
all.extend(made);
|
||||
}
|
||||
beside.finish(d)?;
|
||||
for c in compounds {
|
||||
if d.node(c).is_some_and(|n| n.children().is_some_and(|ch| ch.is_empty())) {
|
||||
d.remove(c)?;
|
||||
sel.remove(c);
|
||||
}
|
||||
}
|
||||
Ok(all)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "removed": removed }))
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
//! Trim at Crossings: the stretch at a point, the stretches a rectangle touches or a line crosses,
|
||||
//! one undo step, and what takes no part.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const TRIM: &str = "addon.pathtools.trim";
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// A line across at y = 100 from x = 0 to 300, and two uprights across it at x = 100 and x = 200.
|
||||
fn across_and_uprights(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let h = line(s, &[(0.0, 100.0), (300.0, 100.0)], false);
|
||||
let v1 = line(s, &[(100.0, 50.0), (100.0, 150.0)], false);
|
||||
let v2 = line(s, &[(200.0, 50.0), (200.0, 150.0)], false);
|
||||
(h, v1, v2)
|
||||
}
|
||||
|
||||
fn path(s: &Session, id: NodeId) -> PathData {
|
||||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
|
||||
}
|
||||
|
||||
fn exists(s: &Session, id: NodeId) -> bool {
|
||||
s.doc().unwrap().doc.node(id).is_some()
|
||||
}
|
||||
|
||||
/// The first and last anchor of the path's only subpath.
|
||||
fn ends(s: &Session, id: NodeId) -> (Point, Point) {
|
||||
let p = path(s, id);
|
||||
assert_eq!(p.subpaths.len(), 1);
|
||||
(p.subpaths[0].anchors.first().unwrap().p, p.subpaths[0].anchors.last().unwrap().p)
|
||||
}
|
||||
|
||||
fn near(p: Point, x: f64, y: f64) -> bool {
|
||||
p.distance(Point::new(x, y)) < 1e-6
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// The ids of the first layer's objects, bottom to top.
|
||||
fn stack(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect()
|
||||
}
|
||||
|
||||
fn set_flags(s: &mut Session, id: NodeId, locked: bool, visible: bool) {
|
||||
s.edit("flags", |d, _| {
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.locked = locked;
|
||||
n.visible = visible;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_between_two_crossings_removes_that_stretch_and_splits_the_path() {
|
||||
let mut s = session();
|
||||
let (h, v1, v2) = across_and_uprights(&mut s);
|
||||
let look = s.doc().unwrap().doc.node(h).unwrap().appearance.clone();
|
||||
let (before, n) = (stack(&s), steps(&s));
|
||||
let r = s.execute(TRIM, &json!({"at": [150, 101]})).unwrap();
|
||||
assert_eq!(r["removed"], json!(1));
|
||||
let made = ids(&r);
|
||||
assert_eq!(made.len(), 2, "{r}");
|
||||
assert_eq!(made[0], h, "the first piece keeps the id");
|
||||
let (a, b) = (ends(&s, made[0]), ends(&s, made[1]));
|
||||
assert!(near(a.0, 0.0, 100.0) && near(a.1, 100.0, 100.0), "{a:?}");
|
||||
assert!(near(b.0, 200.0, 100.0) && near(b.1, 300.0, 100.0), "{b:?}");
|
||||
// Both pieces look as the path did and stand where it stood, under the uprights.
|
||||
assert!(made.iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().appearance == look));
|
||||
assert_eq!(stack(&s), [made[0], made[1], v1, v2]);
|
||||
assert_eq!(before, [h, v1, v2]);
|
||||
// The uprights are as they were, nothing got selected, and it is one undo step.
|
||||
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
|
||||
assert!(s.doc().unwrap().selection.is_empty());
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(stack(&s), before);
|
||||
assert!(near(ends(&s, h).1, 300.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_stretch_runs_to_the_open_end_and_the_rest_stays_one_path() {
|
||||
let mut s = session();
|
||||
let (h, ..) = across_and_uprights(&mut s);
|
||||
let r = s.execute(TRIM, &json!({"at": [40, 100]})).unwrap();
|
||||
assert_eq!((ids(&r), r["removed"].clone()), (vec![h], json!(1)));
|
||||
let e = ends(&s, h);
|
||||
assert!(near(e.0, 100.0, 100.0) && near(e.1, 300.0, 100.0), "{e:?}");
|
||||
// No anchor is added at the crossing the rest runs through.
|
||||
assert_eq!(path(&s, h).anchor_count(), 2);
|
||||
// A point on no path within the tolerance removes nothing and makes no step.
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110]})).unwrap(), json!({"ids": [], "removed": 0}));
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 0})).unwrap()["removed"], json!(0));
|
||||
assert_eq!(steps(&s), n);
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 12})).unwrap()["removed"], json!(1));
|
||||
assert!(near(ends(&s, h).1, 200.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_path_that_loses_a_stretch_opens() {
|
||||
let mut s = session();
|
||||
let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
line(&mut s, &[(50.0, 150.0), (250.0, 150.0)], false);
|
||||
// The top half of the square's outline, between the two places the line crosses it.
|
||||
let r = s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
|
||||
assert_eq!((ids(&r), r["removed"].clone()), (vec![sq], json!(1)));
|
||||
let p = path(&s, sq);
|
||||
assert_eq!(p.subpaths.len(), 1);
|
||||
assert!(!p.subpaths[0].closed);
|
||||
let pts: Vec<Point> = p.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts.len(), 4, "{pts:?}");
|
||||
assert!(pts.iter().all(|q| q.y >= 150.0 - 1e-6), "only the lower half is left: {pts:?}");
|
||||
assert!(pts.iter().any(|q| near(*q, 100.0, 200.0)) && pts.iter().any(|q| near(*q, 200.0, 200.0)));
|
||||
// It is a plain path now.
|
||||
assert!(matches!(&s.doc().unwrap().doc.node(sq).unwrap().kind, NodeKind::Path { live: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_removes_every_stretch_it_touches_as_one_step() {
|
||||
let mut s = session();
|
||||
let (h, v1, v2) = across_and_uprights(&mut s);
|
||||
let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false);
|
||||
let n = steps(&s);
|
||||
// Over the middle of both lines across, touching neither upright.
|
||||
let r = s.execute(TRIM, &json!({"rect": [140, 90, 20, 40]})).unwrap();
|
||||
assert_eq!(r["removed"], json!(2));
|
||||
assert_eq!(ids(&r).len(), 4);
|
||||
assert_eq!(steps(&s), n + 1, "one undo step");
|
||||
assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0));
|
||||
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(ends(&s, h).1, 300.0, 100.0) && near(ends(&s, h2).1, 300.0, 120.0));
|
||||
// A rectangle given from its far corner is the same one; one over empty space removes nothing.
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [160, 130, -20, -40]})).unwrap()["removed"], json!(2));
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [300, 200, 50, 50]})).unwrap()["removed"], json!(0));
|
||||
assert_eq!(steps(&s), n);
|
||||
// An upright's stretches: above the upper line, between the two, below the lower. A rectangle
|
||||
// over its top end takes the first only.
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [95, 45, 10, 10]})).unwrap()["removed"], json!(1));
|
||||
assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v1).1, 100.0, 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_through_points_removes_the_stretches_it_crosses() {
|
||||
let mut s = session();
|
||||
let (h, v1, v2) = across_and_uprights(&mut s);
|
||||
let n = steps(&s);
|
||||
// Down through the middle of the line across, then right through the lower end of the second upright.
|
||||
let r = s.execute(TRIM, &json!({"points": [[150, 80], [150, 130], [250, 130]]})).unwrap();
|
||||
assert_eq!(r["removed"], json!(2));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert!(near(ends(&s, h).1, 100.0, 100.0));
|
||||
assert!(near(ends(&s, v2).0, 200.0, 50.0) && near(ends(&s, v2).1, 200.0, 100.0));
|
||||
assert_eq!(path(&s, v1).anchor_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_paths_are_not_trimmed_and_do_not_count_as_crossing() {
|
||||
let mut s = session();
|
||||
let (h, v1, v2) = across_and_uprights(&mut s);
|
||||
set_flags(&mut s, v1, true, true);
|
||||
set_flags(&mut s, v2, false, false);
|
||||
let n = steps(&s);
|
||||
// On the locked upright, and a rectangle over the hidden one's top end: nothing goes.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [195, 45, 10, 10]})).unwrap()["removed"], json!(0));
|
||||
assert_eq!(steps(&s), n);
|
||||
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
|
||||
// Neither marks a place on the line across: with nothing else crossing it, it goes whole.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(), json!({"ids": [], "removed": 1}));
|
||||
assert!(!exists(&s, h) && exists(&s, v1) && exists(&s, v2));
|
||||
// A guide doesn't count either.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
set_flags(&mut s, v1, false, true);
|
||||
s.execute("select.set", &json!({"ids": [v1.0]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
|
||||
assert!(!exists(&s, h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_of_two_subpaths_loses_the_stretch_on_the_subpath_under_the_point() {
|
||||
let mut s = session();
|
||||
// Lines of different lengths, so the places on one fall at other parameters than on the other.
|
||||
let line_at = |x0: f64, x1: f64, y: f64| vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y), Point::new(x1, y)], false);
|
||||
let two = s
|
||||
.edit("two", |d, _| {
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let both = PathData::new(vec![line_at(0.0, 300.0, 100.0), line_at(50.0, 250.0, 120.0)]);
|
||||
d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, both, Default::default()))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false);
|
||||
line(&mut s, &[(200.0, 50.0), (200.0, 150.0)], false);
|
||||
// The middle of the lower subpath goes; the upper one stays whole, both in the one path.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 120]})).unwrap()["removed"], json!(1));
|
||||
let p = path(&s, two);
|
||||
let spans: Vec<(Point, Point)> = p.subpaths.iter().map(|sp| (sp.anchors[0].p, sp.anchors.last().unwrap().p)).collect();
|
||||
assert_eq!(spans.len(), 3, "{spans:?}");
|
||||
assert!(near(spans[0].0, 0.0, 100.0) && near(spans[0].1, 300.0, 100.0), "{spans:?}");
|
||||
assert!(near(spans[1].0, 50.0, 120.0) && near(spans[1].1, 100.0, 120.0), "{spans:?}");
|
||||
assert!(near(spans[2].0, 200.0, 120.0) && near(spans[2].1, 250.0, 120.0), "{spans:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_path_is_not_trimmed_and_does_not_count_as_crossing() {
|
||||
let mut s = session();
|
||||
let (h, v1, _) = across_and_uprights(&mut s);
|
||||
s.edit("clip", |d, _| {
|
||||
if let NodeKind::Path { clipping, .. } = &mut d.node_mut(v1).unwrap().kind {
|
||||
*clipping = true;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
|
||||
assert_eq!(path(&s, v1).anchor_count(), 2);
|
||||
// The line across is crossed by the second upright only: its stretch from the start goes.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
|
||||
assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
|
||||
// Type over the rest of it takes no part either: nothing crosses the rest, and it goes whole.
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 220, "y": 115, "text": "HHHH", "size": 40})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [215, 70, 70, 50]})).unwrap(), json!({"ids": [], "removed": 1}));
|
||||
assert!(!exists(&s, h) && exists(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_members_of_a_compound_clipping_path_are_not_trimmed_and_do_not_count_as_crossing() {
|
||||
let mut s = session();
|
||||
let (h, v1, _) = across_and_uprights(&mut s);
|
||||
let extra = line(&mut s, &[(250.0, 250.0), (260.0, 260.0)], false);
|
||||
s.execute("select.set", &json!({"ids": [v1.0, extra.0]})).unwrap();
|
||||
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [h.0, compound.0]})).unwrap();
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let n = steps(&s);
|
||||
// A click on the clipping path's member changes nothing, and the rectangle over it neither.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [245, 245, 20, 20]})).unwrap()["removed"], json!(0));
|
||||
assert_eq!((steps(&s), path(&s, v1).anchor_count(), path(&s, extra).anchor_count()), (n, 2, 2));
|
||||
// The clipped line across is crossed by the second upright only: its stretch from the start goes.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
|
||||
assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_isolation_mode_only_the_isolated_objects_paths_are_trimmed() {
|
||||
let mut s = session();
|
||||
let (h, v1, v2) = across_and_uprights(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [v1.0, v2.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
// The line across is outside the group: a click or a rectangle on it leaves it whole.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(0));
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(0));
|
||||
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
|
||||
// It still crosses the uprights: a rectangle over the tops of both removes them down to it.
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [90, 40, 120, 20]})).unwrap()["removed"], json!(2));
|
||||
assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v2).0, 200.0, 100.0));
|
||||
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
|
||||
// An isolated path is trimmed itself.
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("object.isolate", &json!({"id": h.0})).unwrap();
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
|
||||
assert!(near(ends(&s, h).1, 100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_over_too_many_paths_to_check_is_refused() {
|
||||
let mut s = session();
|
||||
// 7,072 short lines apart from each other: each of them against every other is past the limit.
|
||||
s.edit("many", |d, _| {
|
||||
let layer = d.layers[0].id;
|
||||
for i in 0..7072 {
|
||||
let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0);
|
||||
let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(x, y), Point::new(x + 1.0, y)], false);
|
||||
let id = d.alloc_id();
|
||||
let top = d.node(layer).unwrap().children().unwrap().len();
|
||||
d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let n = steps(&s);
|
||||
let err = s.execute(TRIM, &json!({"rect": [-10, -10, 400, 300]})).unwrap_err().to_string();
|
||||
assert!(err.contains("too many paths"), "{err}");
|
||||
assert_eq!((steps(&s), stack(&s).len()), (n, 7072));
|
||||
// A rectangle over a few of them is checked.
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [-1, -1, 5, 2]})).unwrap()["removed"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_too_large_to_search_is_refused_only_when_touched() {
|
||||
let mut s = session();
|
||||
let (h, ..) = across_and_uprights(&mut s);
|
||||
// A border of 10,001 segments round all of them, more than a path to trim may have.
|
||||
let mut border: Vec<(f64, f64)> = (0..=10_001).map(|i| (-500.0 + i as f64 * 0.2, -500.0)).collect();
|
||||
border.push((-500.0, 1500.0));
|
||||
let b = line(&mut s, &border, true);
|
||||
// A rectangle and a line over the middle of the line across reach inside the border's box only.
|
||||
assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(1));
|
||||
assert!(near(ends(&s, h).1, 100.0, 100.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute(TRIM, &json!({"points": [[150, 80], [150, 120]]})).unwrap()["removed"], json!(1));
|
||||
// A rectangle on the border is refused, and changes nothing.
|
||||
let n = steps(&s);
|
||||
let err = s.execute(TRIM, &json!({"rect": [-510, 0, 20, 20]})).unwrap_err().to_string();
|
||||
assert!(err.contains("too many or too tangled"), "{err}");
|
||||
assert_eq!((steps(&s), path(&s, b).anchor_count()), (n, 10_003));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_nothing_crosses_goes_whole_open_or_closed() {
|
||||
let mut s = session();
|
||||
let open = line(&mut s, &[(0.0, 10.0), (50.0, 10.0), (50.0, 40.0)], false);
|
||||
let closed = line(&mut s, &[(100.0, 10.0), (150.0, 10.0), (150.0, 40.0)], true);
|
||||
let n = steps(&s);
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [25, 10]})).unwrap(), json!({"ids": [], "removed": 1}));
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [125, 10]})).unwrap(), json!({"ids": [], "removed": 1}));
|
||||
assert!(!exists(&s, open) && !exists(&s, closed));
|
||||
assert_eq!(steps(&s), n + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_ends_on_another_marks_a_place_and_paths_along_each_other_do_not() {
|
||||
let mut s = session();
|
||||
let h = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false);
|
||||
let stub = line(&mut s, &[(150.0, 100.0), (150.0, 160.0)], false);
|
||||
// The stub ends on the line: the line's stretch stops there, and the stub is one stretch.
|
||||
s.execute(TRIM, &json!({"at": [50, 100]})).unwrap();
|
||||
assert!(near(ends(&s, h).0, 150.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 130]})).unwrap()["removed"], json!(1));
|
||||
assert!(!exists(&s, stub));
|
||||
// A second line along the first doesn't mark it: the lower one goes whole.
|
||||
let along = line(&mut s, &[(200.0, 100.0), (260.0, 100.0)], false);
|
||||
s.execute(TRIM, &json!({"rect": [270, 90, 10, 20]})).unwrap();
|
||||
assert!(!exists(&s, h) && exists(&s, along));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_crossing_path_loses_its_loop() {
|
||||
let mut s = session();
|
||||
// A bow: its first and third segments cross at (50, 50).
|
||||
let bow = line(&mut s, &[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false);
|
||||
let r = s.execute(TRIM, &json!({"at": [100, 50]})).unwrap();
|
||||
assert_eq!(r["removed"], json!(1));
|
||||
let made = ids(&r);
|
||||
assert_eq!(made.len(), 2);
|
||||
assert_eq!(made[0], bow);
|
||||
assert!(near(ends(&s, made[0]).1, 50.0, 50.0) && near(ends(&s, made[1]).0, 50.0, 50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_members_are_trimmed_and_an_emptied_compound_path_goes() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false);
|
||||
let b = line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let members = |s: &Session| s.doc().unwrap().doc.node(compound).map(|n| n.children().unwrap().len());
|
||||
assert_eq!(members(&s), Some(2));
|
||||
// The members cross each other: a middle stretch splits one member in the compound path.
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [100, 75]})).unwrap()["removed"], json!(1));
|
||||
assert_eq!(members(&s), Some(2));
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [100, 125]})).unwrap()["removed"], json!(1));
|
||||
assert_eq!(members(&s), Some(1));
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [200, 100]})).unwrap()["removed"], json!(1));
|
||||
assert_eq!(members(&s), None, "the emptied compound path is removed");
|
||||
assert!(stack(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_stays_as_it_was_and_the_pieces_of_a_selected_path_are_selected() {
|
||||
let mut s = session();
|
||||
let (_, v1, _) = across_and_uprights(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [v1.0]})).unwrap();
|
||||
s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [v1], "the trimmed path was not selected");
|
||||
let mut s = session();
|
||||
let (h, ..) = across_and_uprights(&mut s);
|
||||
s.execute("select.set", &json!({"ids": [h.0]})).unwrap();
|
||||
let made = ids(&s.execute(TRIM, &json!({"at": [150, 100]})).unwrap());
|
||||
assert_eq!(made.len(), 2);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert!(made.iter().all(|id| sel.contains(*id)), "{:?}", sel.objects);
|
||||
// The second piece now ends on an upright: nothing crosses it, and it goes whole.
|
||||
s.execute("select.set", &json!({"ids": [made[1].0]})).unwrap();
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [250, 100]})).unwrap(), json!({"ids": [], "removed": 1}));
|
||||
assert!(s.doc().unwrap().selection.is_empty(), "a removed path leaves the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_paths_far_from_the_trimmed_one_cost_no_crossing_search() {
|
||||
let mut s = session();
|
||||
let (h, ..) = across_and_uprights(&mut s);
|
||||
// 3,000 short lines far below, each crossing its neighbor.
|
||||
s.edit("many", |d, _| {
|
||||
let layer = d.layers[0].id;
|
||||
for i in 0..3000 {
|
||||
let x = (i % 100) as f64 * 3.0;
|
||||
let y = 1000.0 + (i / 100) as f64 * 3.0;
|
||||
let sp = vectorcraft_geom::SubPath::polyline(
|
||||
&[Point::new(x, y), Point::new(x + 5.0, y + 5.0), Point::new(x + 5.0, y), Point::new(x, y + 5.0)],
|
||||
false,
|
||||
);
|
||||
let id = d.alloc_id();
|
||||
let top = d.node(layer).unwrap().children().unwrap().len();
|
||||
d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
|
||||
assert!(near(ends(&s, h).1, 100.0, 100.0));
|
||||
// A rectangle over all of them takes every stretch it touches.
|
||||
let r = s.execute(TRIM, &json!({"rect": [-10, 990, 400, 200]})).unwrap();
|
||||
assert!(r["removed"].as_u64().unwrap() >= 3000, "{}", r["removed"]);
|
||||
assert_eq!(stack(&s).len(), 4, "the line across in two pieces and the uprights");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_trims_on_release_as_one_step_and_escape_drops_a_drag() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
let mut s = session();
|
||||
let (h, v1, _) = across_and_uprights(&mut s);
|
||||
let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false);
|
||||
let view = crate::ViewInfo { smart_guides: false, ..crate::ViewInfo::default() };
|
||||
s.select_tool("trimAtCrossings", view).unwrap();
|
||||
assert_eq!(s.tool_id(), "trimAtCrossings");
|
||||
let n = steps(&s);
|
||||
// A drag over the middle of both lines across: nothing changes until the release.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 140.0, 90.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 130.0), view).unwrap();
|
||||
assert!(near(ends(&s, h).1, 300.0, 100.0));
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 130.0), view).unwrap();
|
||||
assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0));
|
||||
assert_eq!(steps(&s), n + 1, "one undo step for the drag");
|
||||
// Escape during a drag: the release removes nothing.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 90.0, 40.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), view).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), view).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert!(near(ends(&s, v1).0, 100.0, 50.0));
|
||||
// A click on the upright's top stretch removes it.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 70.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 70.0), view).unwrap();
|
||||
assert!(near(ends(&s, v1).0, 100.0, 100.0));
|
||||
assert_eq!(steps(&s), n + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_too_long_to_check_against_the_paths_is_refused() {
|
||||
let mut s = session();
|
||||
// A zigzag of 1,000 segments, and a line of 100,000 points inside its box that crosses none.
|
||||
let zigzag: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64 * 0.3, (i % 2) as f64 * 200.0)).collect();
|
||||
let z = line(&mut s, &zigzag, false);
|
||||
let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s));
|
||||
let points: Vec<Value> = (0..100_000).map(|i| json!([150.0 + (i % 2) as f64 * 0.01, 150.0])).collect();
|
||||
let err = s.execute(TRIM, &json!({ "points": points })).unwrap_err().to_string();
|
||||
assert!(err.contains("too long"), "{err}");
|
||||
assert_eq!(steps(&s), n);
|
||||
assert!(*s.doc().unwrap().doc == *before);
|
||||
// A short line there is checked, and removes nothing.
|
||||
assert_eq!(s.execute(TRIM, &json!({"points": [[150.0, 150.0], [150.01, 150.0]]})).unwrap()["removed"], json!(0));
|
||||
assert!(exists(&s, z));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused_and_change_nothing() {
|
||||
let mut s = session();
|
||||
across_and_uprights(&mut s);
|
||||
let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s));
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"at": null}),
|
||||
json!({"at": [150]}),
|
||||
json!({"at": [150, "y"]}),
|
||||
json!({"at": [150, 1e308]}),
|
||||
json!({"at": [150, 100], "tolerance": -1}),
|
||||
json!({"at": [150, 100], "tolerance": 101}),
|
||||
json!({"at": [150, 100], "tolerance": "3"}),
|
||||
json!({"at": [150, 100], "rect": [140, 90, 20, 40]}),
|
||||
json!({"rect": [140, 90, 20]}),
|
||||
json!({"rect": "all"}),
|
||||
json!({"rect": [0, 0, 1e9, 5]}),
|
||||
json!({"points": [[150, 80]]}),
|
||||
json!({"points": [[150, 80], [150]]}),
|
||||
json!({"points": "x"}),
|
||||
json!({"points": [[150, 80], [150, 130]], "at": [1, 2]}),
|
||||
] {
|
||||
assert!(s.execute(TRIM, &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(steps(&s), n);
|
||||
assert!(*s.doc().unwrap().doc == *before);
|
||||
}
|
||||
@@ -813,12 +813,18 @@ pub(super) fn capture_series_writes(
|
||||
/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series,
|
||||
/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed.
|
||||
pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool {
|
||||
let marks = series_children_in_paint_order(d, ids);
|
||||
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
|
||||
}
|
||||
|
||||
/// Each of `ids` directly in a series group, from the back to the front: a later mark's capture replaces an earlier
|
||||
/// one's, so the frontmost one sets what the graph stores.
|
||||
pub(crate) fn series_children_in_paint_order(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
// Only objects directly in a series group can be marks: other edits skip the search of the document for graphs.
|
||||
let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some());
|
||||
let mut marks: Vec<NodeId> = ids.iter().copied().filter(in_series).collect();
|
||||
// A later mark's capture replaces an earlier one's: take them in paint order.
|
||||
marks.sort_by_cached_key(|id| d.index_path(*id));
|
||||
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
|
||||
marks
|
||||
}
|
||||
|
||||
/// Store on the graph the paint that an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke
|
||||
@@ -1666,6 +1672,7 @@ fn copied_character_styles<'a>(source: &Node, targets: impl Iterator<Item = &'a
|
||||
local.baseline_shift /= scale.points;
|
||||
local.tcy_up_down /= scale.points;
|
||||
local.tcy_left_right /= scale.points;
|
||||
local.warichu_gap /= scale.points;
|
||||
local.h_scale /= scale.horizontal;
|
||||
// As with dimensional type commands, reject overflow and underflow before layout.
|
||||
if !copied_dimensions_fit(&effective, &local) {
|
||||
@@ -1684,6 +1691,7 @@ fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool {
|
||||
(from.baseline_shift, to.baseline_shift),
|
||||
(from.tcy_up_down, to.tcy_up_down),
|
||||
(from.tcy_left_right, to.tcy_left_right),
|
||||
(from.warichu_gap, to.warichu_gap),
|
||||
(from.h_scale, to.h_scale),
|
||||
]
|
||||
.iter()
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document: {…, units (English), unitId: the units' Preferences › Units id (points, millimeters, feetInches, …), rasterEffectsPpi}, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), fontDetails: [{font, match: exact|style|missingWithFallback|missing, family, style, file, embedding: allowed|editing|previewPrint|forbidden|null}] (each font of fonts, in order: how it was found, the face shown (null when none), that face's file name (null for a built-in face) and what its license allows), images, imageObjects: [{name, width, height, linked, file}] (the image objects in the order Linked Images and Embedded Images list them; file: what a linked image's row shows, null for an embedded one), swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient that color a part of it above opacity 0 (a stop at opacity 0 counts only where it mixes with a stop above opacity 0 over a stretch of some width), at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients, kinds}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else its kind's name, such as Linear gradient; kinds gives each one's kind, linear|radial|conic|diamond|freeform, when it is named by its kind, else null; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -85,17 +85,65 @@ pub struct Section {
|
||||
/// What a font's `fsType` lets a copy of it do, read as exports read it ([`Embedding`]): the
|
||||
/// most permissive usage bit wins, and bitmap-only fonts may not be embedded.
|
||||
pub fn embedding_label(fs_type: u16) -> &'static str {
|
||||
if Embedding::from_fs_type(fs_type) == Embedding::Forbidden {
|
||||
"embedding not allowed"
|
||||
} else if fs_type & 0x0008 != 0 {
|
||||
"embedding for editing"
|
||||
} else if fs_type & 0x0004 != 0 {
|
||||
"embedding for preview and print"
|
||||
} else {
|
||||
"embedding allowed"
|
||||
match embedding_id(fs_type) {
|
||||
"forbidden" => "embedding not allowed",
|
||||
"editing" => "embedding for editing",
|
||||
"previewPrint" => "embedding for preview and print",
|
||||
_ => "embedding allowed",
|
||||
}
|
||||
}
|
||||
|
||||
/// [`embedding_label`] as the `embedding` value of a `fontDetails` entry: `forbidden`, `editing`,
|
||||
/// `previewPrint` or `allowed`.
|
||||
pub fn embedding_id(fs_type: u16) -> &'static str {
|
||||
if Embedding::from_fs_type(fs_type) == Embedding::Forbidden {
|
||||
"forbidden"
|
||||
} else if fs_type & 0x0008 != 0 {
|
||||
"editing"
|
||||
} else if fs_type & 0x0004 != 0 {
|
||||
"previewPrint"
|
||||
} else {
|
||||
"allowed"
|
||||
}
|
||||
}
|
||||
|
||||
/// A font as Font Details lists it: its `fontDetails` entry and its row. The row gives the file
|
||||
/// the font is drawn from (`built in` for a face with no file) and whether its license lets it be
|
||||
/// embedded; for a style the family lacks, the face shown in its place; for a font that is not
|
||||
/// installed, the face shown instead, or `missing`. The entry holds the same facts as values
|
||||
/// (`match`: `exact`, `style`, `missingWithFallback` or `missing`), so the panel can show them in
|
||||
/// the interface language.
|
||||
fn font_detail(used: &super::fonts::UsedFont) -> (Value, (String, String)) {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let (family, style, version) = used;
|
||||
let label = super::fonts::font_label(used);
|
||||
// Found by any of its names (in the version the type names); a style the family lacks shows in
|
||||
// another.
|
||||
let resolved = db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
|
||||
let (entry, detail) = match resolved {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
|
||||
let file = f.path().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
let found = format!("{}; {}", file.as_deref().unwrap_or("built in"), embedding_label(f.fs_type()));
|
||||
let substituted = m == vectorcraft_text::FontMatch::Style;
|
||||
let entry = json!({
|
||||
"font": label,
|
||||
"match": if substituted { "style" } else { "exact" },
|
||||
"family": f.family,
|
||||
"style": f.style,
|
||||
"file": file,
|
||||
"embedding": embedding_id(f.fs_type()),
|
||||
});
|
||||
(entry, if substituted { format!("substituted: shown in {} {} ({found})", f.family, f.style) } else { found })
|
||||
}
|
||||
Some((f, _)) => (
|
||||
json!({ "font": label, "match": "missingWithFallback", "family": f.family, "style": f.style, "file": null, "embedding": null }),
|
||||
format!("missing: shown in {} {}", f.family, f.style),
|
||||
),
|
||||
None => (json!({ "font": label, "match": "missing", "family": null, "style": null, "file": null, "embedding": null }), "missing".into()),
|
||||
};
|
||||
(entry, (label, detail))
|
||||
}
|
||||
|
||||
/// An image object as Document Info lists it: (name, width, height, linked file).
|
||||
type ImageRow = (String, u32, u32, Option<String>);
|
||||
|
||||
@@ -131,14 +179,15 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S
|
||||
}
|
||||
|
||||
/// The Document Info categories of `d` (only `category` when given) from `info` (what
|
||||
/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the
|
||||
/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a
|
||||
/// pattern and with a gradient.
|
||||
/// `document.info` reports), its fonts and their Font Details rows, its image objects, the rows of
|
||||
/// the brushes its art uses, the rows of its Repeat objects and the rows of its objects filled or
|
||||
/// stroked with a spot color, with a pattern and with a gradient.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sections(
|
||||
d: &Document,
|
||||
info: &Value,
|
||||
fonts: &BTreeSet<super::fonts::UsedFont>,
|
||||
font_details: &[(String, String)],
|
||||
images: &[ImageRow],
|
||||
brushes: &[(String, String)],
|
||||
repeats: &[(String, String)],
|
||||
@@ -192,34 +241,7 @@ fn sections(
|
||||
"gradients" => gradient_objects.to_vec(),
|
||||
"symbols" => names("symbols"),
|
||||
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
|
||||
"fontDetails" => {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
fonts
|
||||
.iter()
|
||||
.map(|used| {
|
||||
let (family, style, version) = used;
|
||||
// Found by any of its names (in the version the type names); a style the
|
||||
// family lacks shows in another.
|
||||
let resolved =
|
||||
db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
|
||||
let detail = match resolved {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
|
||||
let file =
|
||||
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
|
||||
let found = format!("{file}; {}", embedding_label(f.fs_type()));
|
||||
if m == vectorcraft_text::FontMatch::Style {
|
||||
format!("substituted: shown in {} {} ({found})", f.family, f.style)
|
||||
} else {
|
||||
found
|
||||
}
|
||||
}
|
||||
Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style),
|
||||
None => "missing".into(),
|
||||
};
|
||||
(super::fonts::font_label(used), detail)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"fontDetails" => font_details.to_vec(),
|
||||
"linkedImages" => {
|
||||
images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect()
|
||||
}
|
||||
@@ -277,6 +299,39 @@ fn gradient_label(p: &Paint) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where [`gradient_label`]'s name for gradient paint `p` comes from: `swatch:<name>` for a
|
||||
/// gradient linked to a swatch, else `kind:<kind>` (`linear`, `radial`, `conic`, `diamond` or
|
||||
/// `freeform`). The two prefixes keep a swatch's name apart from a kind's name it reads like.
|
||||
fn gradient_source(p: &Paint) -> Option<String> {
|
||||
match p {
|
||||
Paint::Gradient(g) => Some(match g.swatch.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(swatch) => format!("swatch:{swatch}"),
|
||||
None => format!("kind:{}", g.gradient.kind.label().to_ascii_lowercase()),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// For each of `names` (an object's [`gradient_label`]s), the kind it names when only a kind
|
||||
/// gives that name: a name one of `sources` (the object's [`gradient_source`]s) gives as a kind's
|
||||
/// and none gives as a swatch's, else null. A swatch named like a kind keeps its name even where
|
||||
/// a gradient of that kind is listed under the same name.
|
||||
fn gradient_kinds(names: &[String], sources: &[String]) -> Vec<Value> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
if sources.iter().any(|s| s.strip_prefix("swatch:") == Some(name.as_str())) {
|
||||
return Value::Null;
|
||||
}
|
||||
sources
|
||||
.iter()
|
||||
.filter_map(|s| s.strip_prefix("kind:"))
|
||||
.find(|k| vectorcraft_color::GradientKind::parse(k).is_some_and(|kind| *name == format!("{} gradient", kind.label())))
|
||||
.map_or(Value::Null, |k| json!(k))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own
|
||||
/// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as
|
||||
/// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not
|
||||
@@ -289,10 +344,46 @@ fn spot_swatches(d: &Document) -> HashSet<&str> {
|
||||
first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect()
|
||||
}
|
||||
|
||||
/// Which of gradient `g`'s stops color some part of it that draws, by the stretches
|
||||
/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k`
|
||||
/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s,
|
||||
/// and both its stops count when that stretch has positive width and either stop is above opacity
|
||||
/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its
|
||||
/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop,
|
||||
/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width
|
||||
/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from
|
||||
/// its points, so each of its stops counts while any stop is above opacity 0.
|
||||
fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec<bool> {
|
||||
let stops = &g.stops;
|
||||
if g.kind == vectorcraft_color::GradientKind::Freeform {
|
||||
let any = stops.iter().any(|s| s.opacity > 0.0);
|
||||
return vec![any; stops.len()];
|
||||
}
|
||||
let mut drawn = vec![false; stops.len()];
|
||||
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
|
||||
*d = first.opacity > 0.0;
|
||||
}
|
||||
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
|
||||
*d |= last.opacity > 0.0;
|
||||
}
|
||||
// `f32::max` skips a NaN offset, which the sampler never picks either.
|
||||
let mut reach = f32::NEG_INFINITY;
|
||||
for k in 0..stops.len().saturating_sub(1) {
|
||||
reach = reach.max(stops[k].offset);
|
||||
let (a, b) = (&stops[k], &stops[k + 1]);
|
||||
if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) {
|
||||
drawn[k] = true;
|
||||
drawn[k + 1] = true;
|
||||
}
|
||||
}
|
||||
drawn
|
||||
}
|
||||
|
||||
/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is
|
||||
/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A
|
||||
/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws
|
||||
/// nothing, and a color that only looks like a spot color is not linked to one.
|
||||
/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the
|
||||
/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
|
||||
/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not
|
||||
/// linked to one.
|
||||
fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
|
||||
let mut out: Vec<String> = vec![];
|
||||
let mut add = |swatch: &Option<String>, tint: f32| {
|
||||
@@ -306,7 +397,10 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
|
||||
};
|
||||
match p {
|
||||
Paint::Solid { swatch, tint, .. } => add(swatch, *tint),
|
||||
Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)),
|
||||
Paint::Gradient(g) => {
|
||||
let drawn = drawn_stops(&g.gradient);
|
||||
g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
@@ -447,9 +541,11 @@ fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool
|
||||
/// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n`
|
||||
/// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of
|
||||
/// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with.
|
||||
fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec<String>; 3] {
|
||||
/// The fourth list gives where each gradient comes from: a swatch or a kind ([`gradient_source`]).
|
||||
fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec<String>; 4] {
|
||||
let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label));
|
||||
let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient];
|
||||
let gradient_from = one_label(gradient_source);
|
||||
let labels: [&PaintNames<'_>; 4] = [&spot, &pattern, &gradient, &gradient_from];
|
||||
let painted_runs = painted_runs(d, spots, n);
|
||||
let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label));
|
||||
// The characters' own paints: an inline graphic draws no outline and does not take its run's
|
||||
@@ -615,8 +711,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(k) = k {
|
||||
*counts.entry(k).or_default() += 1;
|
||||
}
|
||||
// The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw.
|
||||
let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() };
|
||||
// The spot colors, the patterns and the gradients this object paints, each once, and where each gradient comes from;
|
||||
// none when it does not draw.
|
||||
let [used_spots, used_patterns, used_gradients, used_sources] =
|
||||
if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() };
|
||||
// Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list.
|
||||
if !used_spots.is_empty() {
|
||||
let name = n.display_name();
|
||||
@@ -636,7 +734,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = n.display_name();
|
||||
gradients.extend(used_gradients.iter().cloned());
|
||||
gradient_rows.push((name.clone(), used_gradients.join(", ")));
|
||||
gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients }));
|
||||
let kinds = gradient_kinds(&used_gradients, &used_sources);
|
||||
gradient_objects.push(json!({ "id": n.id.0, "name": name, "gradients": used_gradients, "kinds": kinds }));
|
||||
*counts.entry("gradients").or_default() += 1;
|
||||
}
|
||||
if n.extras().mask.is_some() {
|
||||
@@ -671,6 +770,14 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style))));
|
||||
}
|
||||
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
|
||||
let (font_entries, font_rows): (Vec<Value>, Vec<(String, String)>) = fonts.iter().map(font_detail).unzip();
|
||||
// The image objects in walk order, as Linked Images and Embedded Images list them: `file` is a
|
||||
// linked image's row value (its path, or its file name alone when the request may not read it),
|
||||
// and null for an embedded one.
|
||||
let image_objects: Vec<Value> = image_rows
|
||||
.iter()
|
||||
.map(|(name, w, h, file)| json!({ "name": name, "width": w, "height": h, "linked": file.is_some(), "file": file }))
|
||||
.collect();
|
||||
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two
|
||||
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type
|
||||
// as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them.
|
||||
@@ -688,7 +795,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"objects": counts,
|
||||
"pathLength": path_length,
|
||||
"fonts": font_names,
|
||||
"fontDetails": font_entries,
|
||||
"images": images,
|
||||
"imageObjects": image_objects,
|
||||
"symbols": symbols,
|
||||
"spotColors": spot,
|
||||
"spotColorNames": spot,
|
||||
@@ -710,7 +819,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
// The document's name: its file's, else its title.
|
||||
out["document"]["name"] = json!(st.title());
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
|
||||
let sections = sections(d, &out, &fonts, &font_rows, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
|
||||
if text {
|
||||
return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) }));
|
||||
}
|
||||
@@ -724,6 +833,7 @@ fn document_summary(d: &Document) -> Value {
|
||||
"title": d.title,
|
||||
"colorMode": match d.color_mode { ColorMode::Rgb => "RGB", ColorMode::Cmyk => "CMYK" },
|
||||
"units": d.units.label(),
|
||||
"unitId": d.units.key(),
|
||||
"artboards": d.artboards.iter().map(|a| json!({ "name": a.name, "width": a.rect.width() / u, "height": a.rect.height() / u })).collect::<Vec<_>>(),
|
||||
"rasterEffectsPpi": d.raster_effects_ppi,
|
||||
})
|
||||
@@ -1250,11 +1360,11 @@ mod tests {
|
||||
assert_eq!(
|
||||
i["gradientObjects"],
|
||||
json!([
|
||||
{"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"]},
|
||||
{"id": b, "name": "<Rectangle>", "gradients": ["Radial gradient"]},
|
||||
{"id": f, "name": "<Rectangle>", "gradients": ["Freeform gradient"]},
|
||||
{"id": t, "name": "Type", "gradients": ["Ramp"]},
|
||||
{"id": bar, "name": "Bar", "gradients": ["Ramp"]},
|
||||
{"id": a, "name": "Both", "gradients": ["Ramp", "Linear gradient"], "kinds": [null, "linear"]},
|
||||
{"id": b, "name": "<Rectangle>", "gradients": ["Radial gradient"], "kinds": ["radial"]},
|
||||
{"id": f, "name": "<Rectangle>", "gradients": ["Freeform gradient"], "kinds": ["freeform"]},
|
||||
{"id": t, "name": "Type", "gradients": ["Ramp"], "kinds": [null]},
|
||||
{"id": bar, "name": "Bar", "gradients": ["Ramp"], "kinds": [null]},
|
||||
])
|
||||
);
|
||||
assert_eq!(i["gradientNames"], json!(["Freeform gradient", "Linear gradient", "Radial gradient", "Ramp"]));
|
||||
@@ -1493,6 +1603,95 @@ mod tests {
|
||||
assert_eq!(spots(&mut s), (vec![], None, None));
|
||||
}
|
||||
|
||||
/// Spot Color Objects leaves out a spot color linked only from gradient stops at an opacity of 0
|
||||
/// whose stretches all lead to other stops at an opacity of 0 or have no width, while another
|
||||
/// part of the gradient draws: an inner stop, an end stop and a stop at the offset of an opaque
|
||||
/// stop. A clear stop next to an opaque stop tints the stretch between them, so it stays listed.
|
||||
/// Type whose characters draw such a gradient follows the same rule, with and without Selection
|
||||
/// Only, and the report writes the same rows.
|
||||
#[test]
|
||||
fn spot_color_objects_skip_spot_stops_only_in_clear_stretches() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0});
|
||||
for (k, name) in ["Ink", "Near", "Far", "Edge", "Seam"].into_iter().enumerate() {
|
||||
s.execute("swatch.new", &json!({"name": name, "color": ink(0.2 * k as f64), "spot": true})).unwrap();
|
||||
}
|
||||
let stop = |offset: f64, swatch: Option<&str>, opacity: f64| match swatch {
|
||||
Some(sw) => json!({"offset": offset, "color": "#000", "swatch": sw, "opacity": opacity}),
|
||||
None => json!({"offset": offset, "color": "#fff", "opacity": opacity}),
|
||||
};
|
||||
let fill = |s: &mut Session, id: &Value, stops: Value| {
|
||||
s.execute("paint.setFill", &json!({"ids": [id], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
|
||||
};
|
||||
let square =
|
||||
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
// Near borders the opaque Ink stop; Far sits between clear stops; Edge ends the ramp after a
|
||||
// clear stop.
|
||||
let ramp = square(&mut s, 0.0);
|
||||
fill(
|
||||
&mut s,
|
||||
&ramp,
|
||||
json!([stop(0.0, Some("Ink"), 1.0), stop(0.4, Some("Near"), 0.0), stop(0.6, Some("Far"), 0.0), stop(1.0, Some("Edge"), 0.0)]),
|
||||
);
|
||||
s.execute("layer.setProps", &json!({"id": ramp, "name": "Ramp"})).unwrap();
|
||||
// Seam meets the opaque half only at one offset, where nothing between them has width.
|
||||
let seam = square(&mut s, 20.0);
|
||||
fill(&mut s, &seam, json!([stop(0.0, None, 0.0), stop(0.5, Some("Seam"), 0.0), stop(0.5, None, 1.0), stop(1.0, None, 1.0)]));
|
||||
let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
|
||||
fill(&mut s, &t, json!([stop(0.0, Some("Ink"), 1.0), stop(0.5, None, 0.0), stop(1.0, Some("Far"), 0.0)]));
|
||||
s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap();
|
||||
let check = |s: &mut Session, params: Value| {
|
||||
let i = s.execute("document.info", ¶ms).unwrap();
|
||||
let listed: Vec<(String, Value)> =
|
||||
i["spotObjects"].as_array().unwrap().iter().map(|o| (o["name"].as_str().unwrap().to_string(), o["spotColors"].clone())).collect();
|
||||
assert_eq!(listed, vec![("Ramp".to_string(), json!(["Ink", "Near"])), ("Type".to_string(), json!(["Ink"]))], "{params}");
|
||||
assert_eq!((i["spotColorNames"].clone(), i["objects"]["spots"].as_u64()), (json!(["Ink", "Near"]), Some(2)), "{params}");
|
||||
let rows = i["sections"].as_array().unwrap().iter().find(|c| c["id"] == "spotColors").unwrap()["rows"].clone();
|
||||
assert_eq!(rows, json!([["Ramp", "Ink, Near"], ["Type", "Ink"]]), "{params}");
|
||||
};
|
||||
check(&mut s, json!({}));
|
||||
s.execute("select.set", &json!({"ids": [ramp, seam, t]})).unwrap();
|
||||
check(&mut s, json!({"selectionOnly": true}));
|
||||
let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("\nSPOT COLOR OBJECTS\nRamp: Ink, Near\nType: Ink\n"), "{text}");
|
||||
assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}");
|
||||
}
|
||||
|
||||
/// A gradient's stops print where they color a stretch that draws, for stops in any order: the
|
||||
/// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an
|
||||
/// out-of-order stop's pair can have no width.
|
||||
#[test]
|
||||
fn drawn_stops_follow_the_sampled_stretches() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop};
|
||||
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
|
||||
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
|
||||
// In order: the clear stop next to the opaque one, not the one after it.
|
||||
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]);
|
||||
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
|
||||
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
|
||||
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]);
|
||||
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
|
||||
// pairs next to the stop at 0.3 have no width and it colors nothing.
|
||||
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
|
||||
assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]);
|
||||
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
|
||||
assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]);
|
||||
// The last stop colors the offsets above every stop's, even when it is out of order.
|
||||
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]);
|
||||
// The first stop colors the offsets up to its own.
|
||||
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]);
|
||||
// One stop, every stop clear, and no stops at all.
|
||||
assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]);
|
||||
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]);
|
||||
assert!(drawn_stops(&g(vec![])).is_empty());
|
||||
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
|
||||
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
|
||||
free.kind = GradientKind::Freeform;
|
||||
assert_eq!(drawn_stops(&free), vec![true, true, true]);
|
||||
}
|
||||
|
||||
/// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object,
|
||||
/// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of
|
||||
/// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no
|
||||
@@ -1672,6 +1871,112 @@ mod tests {
|
||||
assert_eq!(embedding_label(0x0003), "embedding not allowed");
|
||||
assert_eq!(embedding_label(0x0006), "embedding for preview and print");
|
||||
assert_eq!(embedding_label(0x000c), "embedding for editing");
|
||||
// `fontDetails` names each permission by id.
|
||||
assert_eq!(
|
||||
[0, 0x0002, 0x0104, 0x0008, 0x0200, 0x000c].map(embedding_id),
|
||||
["allowed", "forbidden", "previewPrint", "editing", "forbidden", "editing"]
|
||||
);
|
||||
}
|
||||
|
||||
/// `fontDetails` gives each Font Details row's facts as values, in the rows' order: how the
|
||||
/// font was found, the face shown, its file (null for a built-in face) and its embedding
|
||||
/// permission. The rows and the report stay in English.
|
||||
#[test]
|
||||
fn info_exposes_structured_font_details_without_changing_english_rows() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi"})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Heavy", "font": "Source Sans 3", "style": "Black Wide"})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Gone", "font": "Qx Absent Family"})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"category": "fontDetails"})).unwrap();
|
||||
let (entries, rows) = (i["fontDetails"].as_array().unwrap(), i["sections"][0]["rows"].as_array().unwrap());
|
||||
assert_eq!(entries.iter().map(|e| e["font"].clone()).collect::<Vec<_>>(), rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>());
|
||||
let at = |prefix: &str| entries.iter().position(|e| e["font"].as_str().unwrap().starts_with(prefix)).unwrap();
|
||||
let regular = at("Source Sans 3 Regular");
|
||||
assert_eq!(
|
||||
entries[regular],
|
||||
json!({"font": "Source Sans 3 Regular", "match": "exact", "family": "Source Sans 3", "style": "Regular", "file": null, "embedding": "allowed"})
|
||||
);
|
||||
assert_eq!(rows[regular][1], "built in; embedding allowed");
|
||||
let (wide, e) = (at("Source Sans 3 Black Wide"), &entries[at("Source Sans 3 Black Wide")]);
|
||||
assert_eq!((e["match"].as_str(), e["family"].as_str()), (Some("style"), Some("Source Sans 3")), "{e}");
|
||||
let file = e["file"].as_str().unwrap_or("built in");
|
||||
let fs_type = [("allowed", 0), ("forbidden", 0x0002), ("previewPrint", 0x0004), ("editing", 0x0008)]
|
||||
.into_iter()
|
||||
.find_map(|(id, bits)| (e["embedding"] == id).then_some(bits))
|
||||
.unwrap();
|
||||
let found = format!("{file}; {}", embedding_label(fs_type));
|
||||
assert_eq!(rows[wide][1], format!("substituted: shown in Source Sans 3 {} ({found})", e["style"].as_str().unwrap()));
|
||||
let (gone, e) = (at("Qx Absent Family"), &entries[at("Qx Absent Family")]);
|
||||
assert!(e["file"].is_null() && e["embedding"].is_null(), "{e}");
|
||||
match e["match"].as_str() {
|
||||
Some("missingWithFallback") => {
|
||||
assert_eq!(rows[gone][1], format!("missing: shown in {} {}", e["family"].as_str().unwrap(), e["style"].as_str().unwrap()))
|
||||
}
|
||||
Some("missing") => assert_eq!((rows[gone][1].as_str(), e["family"].is_null()), (Some("missing"), true)),
|
||||
m => panic!("{m:?}"),
|
||||
}
|
||||
let text = s.execute("document.info", &json!({"category": "fontDetails", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("Source Sans 3 Regular: built in; embedding allowed\n"), "{text}");
|
||||
}
|
||||
|
||||
/// `document.unitId` names the document's units as Preferences › Units does, beside the English
|
||||
/// `units`, and `imageObjects` lists the image objects in the order Embedded Images and Linked
|
||||
/// Images list them, with the file a linked one shows (null for an embedded one).
|
||||
#[test]
|
||||
fn info_exposes_ordered_images_and_unit_id() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.edit("Units", |d, _| {
|
||||
d.units = vectorcraft_doc::Unit::Millimeters;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for x in [0.0, 50.0] {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
||||
}
|
||||
let i = s.execute("document.info", &json!({})).unwrap();
|
||||
assert_eq!((i["document"]["units"].as_str(), i["document"]["unitId"].as_str()), (Some("Millimeters"), Some("millimeters")));
|
||||
let section = |id: &str| i["sections"].as_array().unwrap().iter().find(|s| s["id"] == id).unwrap()["rows"].as_array().unwrap().clone();
|
||||
assert!(section("document").iter().any(|r| *r == json!(["Units", "Millimeters"])));
|
||||
let embedded = section("embeddedImages");
|
||||
let objects = i["imageObjects"].as_array().unwrap();
|
||||
assert_eq!(objects.len(), 2, "{objects:?}");
|
||||
for (o, r) in objects.iter().zip(&embedded) {
|
||||
assert_eq!((o["linked"].clone(), o["file"].clone(), o["name"].clone()), (json!(false), Value::Null, r[0].clone()), "{o}");
|
||||
assert_eq!(r[1], format!("{} × {} px", o["width"], o["height"]));
|
||||
}
|
||||
assert!(section("linkedImages").is_empty());
|
||||
}
|
||||
|
||||
/// A `gradientObjects` entry's `kinds` gives the kind of each gradient listed by its kind
|
||||
/// rather than by a swatch, and null for a swatch's name, even one that reads like a kind and
|
||||
/// one an object's gradient of that kind shares; a type object's characters count as its own.
|
||||
#[test]
|
||||
fn gradient_kinds_tell_kinds_from_swatch_names() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let ramp = |kind: &str| json!({"kind": kind, "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]});
|
||||
s.execute("swatch.new", &json!({"name": "Radial gradient", "gradient": ramp("linear")})).unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [r], "swatch": "Radial gradient"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [r], "gradient": ramp("conic")})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [t], "gradient": ramp("diamond")})).unwrap();
|
||||
// The swatch's name and a radial gradient's kind name are listed once, as the name.
|
||||
let both = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [both], "swatch": "Radial gradient"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [both], "gradient": ramp("radial")})).unwrap();
|
||||
let i = s.execute("document.info", &json!({})).unwrap();
|
||||
let entry = |id: &Value| i["gradientObjects"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap().clone();
|
||||
assert_eq!(
|
||||
(entry(&r)["gradients"].clone(), entry(&r)["kinds"].clone()),
|
||||
(json!(["Radial gradient", "Conic gradient"]), json!([null, "conic"]))
|
||||
);
|
||||
assert_eq!((entry(&t)["gradients"].clone(), entry(&t)["kinds"].clone()), (json!(["Diamond gradient"]), json!(["diamond"])));
|
||||
assert_eq!((entry(&both)["gradients"].clone(), entry(&both)["kinds"].clone()), (json!(["Radial gradient"]), json!([null])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Smooth",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5)} smooth selected paths near the drag → {ids, before, after}",
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}",
|
||||
has_selection,
|
||||
smooth_region
|
||||
),
|
||||
|
||||
@@ -15,15 +15,27 @@ const SMOOTH_SPREAD: f64 = 1.0 / 3.0;
|
||||
|
||||
/// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of
|
||||
/// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the
|
||||
/// smoothing moved it, keeping a corner only where the path turns back on itself.
|
||||
fn refit_options(tol: f64, sigma: f64) -> SimplifyOptions {
|
||||
SimplifyOptions { tolerance: tol, corner_angle_deg: 179.0, straight_lines: false, smooth: sigma, smooth_moved_only: true }
|
||||
/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether
|
||||
/// the start and the end of the stretch refitted are ends of an open path, whose direction then
|
||||
/// follows the smoothed path within the reach rather than the first or last segment.
|
||||
fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions {
|
||||
SimplifyOptions {
|
||||
tolerance: tol,
|
||||
corner_angle_deg: 179.0,
|
||||
straight_lines: false,
|
||||
smooth: sigma,
|
||||
smooth_moved_only: true,
|
||||
free_start: free[0],
|
||||
free_end: free[1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth and refit the anchors of an open run `a[lo..=hi]` keeping its end points and outer handles.
|
||||
fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec<Anchor> {
|
||||
/// `free` says whether its first and last anchors are ends of an open path: the curve leaving or
|
||||
/// arriving at such an end can turn.
|
||||
fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<Anchor> {
|
||||
let piece = PathData::single(SubPath::new(anchors.to_vec(), false));
|
||||
let fitted = po::simplify_with(&piece, &refit_options(tol, sigma));
|
||||
let fitted = po::simplify_with(&piece, &refit_options(tol, sigma, free));
|
||||
let Some(mut out) = fitted.subpaths.into_iter().next().map(|s| s.anchors).filter(|a| a.len() >= 2) else { return anchors.to_vec() };
|
||||
let (f, l) = (anchors[0], anchors[anchors.len() - 1]);
|
||||
let last = out.len() - 1;
|
||||
@@ -228,7 +240,7 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
if sp.closed {
|
||||
let Some(k) = marked.iter().position(|m| !m) else {
|
||||
// Everything is in range: smooth the whole closed path, across its start too.
|
||||
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma));
|
||||
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
|
||||
return f.subpaths.into_iter().next();
|
||||
};
|
||||
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
|
||||
@@ -239,14 +251,14 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
open.anchors.push(open.anchors[0]);
|
||||
m.push(false);
|
||||
open.closed = false;
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma);
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false);
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
r0.h_in = l.h_in;
|
||||
}
|
||||
return Some(SubPath::new(res, true));
|
||||
}
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma), false))
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false))
|
||||
}
|
||||
|
||||
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
|
||||
@@ -278,8 +290,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez
|
||||
/// `anchors` with each run of `marked` ones smoothed by a Gaussian of spread `sigma` and refitted
|
||||
/// within `tol`, together with the stretch of the segments leading into the run that is in the
|
||||
/// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next
|
||||
/// to it stays as it was (#1512).
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Vec<Anchor> {
|
||||
/// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first
|
||||
/// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends,
|
||||
/// and those of the open run a closed path is cut into, keep their direction.
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec<Anchor> {
|
||||
let n = anchors.len();
|
||||
let mut runs = vec![];
|
||||
let mut i = 0;
|
||||
@@ -319,7 +333,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
|
||||
head = Some(Anchor { h_out: far.p1, ..*outer });
|
||||
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
|
||||
}
|
||||
let new = refit_run(&piece, tol, sigma);
|
||||
let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()];
|
||||
let new = refit_run(&piece, tol, sigma, free);
|
||||
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
|
||||
}
|
||||
out
|
||||
@@ -329,7 +344,7 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.smoothRegion";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_TOLERANCE, vectorcraft_tools::draw2::SMOOTH_MAX_TOLERANCE);
|
||||
let sigma = radius * SMOOTH_SPREAD;
|
||||
let st = s.doc()?;
|
||||
let mut plan = vec![];
|
||||
|
||||
@@ -133,7 +133,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "outlineText",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines",
|
||||
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, warichu, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -103,6 +103,70 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() {
|
||||
}
|
||||
}
|
||||
|
||||
/// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a
|
||||
/// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5
|
||||
/// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the
|
||||
/// art's bounds and the Summary's export, with one such warning per page. PDF/A files and `.ai`
|
||||
/// saves keep the warning without the option's name. Pages written in points (the option on, PDF
|
||||
/// 1.4, PDF/X, a page too narrow for a `UserUnit` above 1) and a page too long and narrow for
|
||||
/// 14,400 units have other warnings, which don't name it.
|
||||
#[test]
|
||||
fn a_page_with_a_user_unit_names_the_option_that_writes_it_in_points() {
|
||||
use super::pdf::LONG_PAGES_IN_POINTS;
|
||||
const SMALLER: &str = "so apps that ignore UserUnit show it that many times smaller";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 20_000, "height": 1_000})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 19_000, "height": 500})).unwrap();
|
||||
let warnings = |v: &Value| -> Vec<String> {
|
||||
v["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("UserUnit")).map(str::to_string).collect()
|
||||
};
|
||||
let named = |v: &Value| warnings(v).iter().any(|w| w.contains(LONG_PAGES_IN_POINTS));
|
||||
for p in [
|
||||
json!({"longPagesInPoints": false}),
|
||||
json!({"longPagesInPoints": false, "compatibility": "1.5"}),
|
||||
json!({"longPagesInPoints": false, "compatibility": "2.0"}),
|
||||
json!({"longPagesInPoints": false, "useArtboards": false}),
|
||||
] {
|
||||
let mut q = p.clone();
|
||||
q["includeDocument"] = json!(true);
|
||||
for v in [export(&mut s, p.clone()), s.execute("document.export", &as_export(&p)).unwrap(), s.execute("document.pdfSettings", &q).unwrap()] {
|
||||
let page: Vec<String> = warnings(&v).into_iter().filter(|w| w.starts_with("the page of artboard")).collect();
|
||||
assert_eq!(page.len(), 1, "{p}: {v}");
|
||||
assert!(page[0].ends_with(&format!("{SMALLER}{LONG_PAGES_IN_POINTS}")), "{p}: {page:?}");
|
||||
assert_eq!(warnings(&v).iter().filter(|w| w.contains("apps that ignore UserUnit")).count(), 1, "{p}: {v}");
|
||||
}
|
||||
}
|
||||
for p in [
|
||||
json!({"longPagesInPoints": true}),
|
||||
json!({"longPagesInPoints": false, "compatibility": "1.4"}),
|
||||
json!({"longPagesInPoints": false, "preset": "PDF/X-4:2010"}),
|
||||
] {
|
||||
assert!(!named(&export(&mut s, p.clone())), "{p}");
|
||||
}
|
||||
for p in
|
||||
[json!({"longPagesInPoints": false, "standard": "pdfA2b"}), json!({"longPagesInPoints": false, "standard": "pdfA2b", "compatibility": "1.4"})]
|
||||
{
|
||||
let v = export(&mut s, p.clone());
|
||||
assert!(warnings(&v).iter().any(|w| w.ends_with(SMALLER)) && !named(&v), "{p}: {v}");
|
||||
}
|
||||
let ai = s.execute("document.export", &json!({"format": "ai", "longPagesInPoints": false})).unwrap();
|
||||
assert!(warnings(&ai).iter().any(|w| w.ends_with(SMALLER)) && !named(&ai), "{ai}");
|
||||
// The art's bounds are within 14,400 pt.
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 500, "height": 500})).unwrap();
|
||||
assert!(!named(&export(&mut s, json!({"longPagesInPoints": false, "useArtboards": false}))));
|
||||
// A page 3 pt high keeps a UserUnit of 1, so it is written in points either way; a page
|
||||
// 85,040 × 17 pt gets a UserUnit above 1 that leaves its long side past 14,400 units.
|
||||
for (w, h) in [(20_000, 3), (85_040, 17)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": w, "height": h})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": w - 1_000, "height": h})).unwrap();
|
||||
let v = export(&mut s, json!({"longPagesInPoints": false}));
|
||||
assert!(!named(&v), "{w} × {h}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF of the art's bounds carries no Preserve Editing data, through `document.exportPdf`,
|
||||
/// `document.export` and the Summary's export in memory, and says so in a warning: reopened, its
|
||||
/// editing data would replace the document's artboards with the one around the art. A PDF of the
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
//! member with another order, and any other change to the list (items moved, or several added
|
||||
//! or removed at once), takes the model's whole list of fills and strokes.
|
||||
//!
|
||||
//! A path directly in a graph series that takes an edit from another member (a graph's paths are
|
||||
//! members only while the graph is isolated) also has what changed stored on its graph, so that
|
||||
//! every path of the series takes it when the graph is drawn again (docs/mcp.md).
|
||||
//!
|
||||
//! Members that are hidden or locked at the time are left as they are. An edit that would take any
|
||||
//! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that
|
||||
//! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag
|
||||
@@ -432,16 +436,24 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> {
|
||||
let [id] = st.selection.objects.as_slice() else {
|
||||
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
|
||||
};
|
||||
// A group's contents are checked when Global Edit starts: measuring them here would walk
|
||||
// every object in it each time the menu or the Properties panel asks. Other objects are
|
||||
// measured ([`supported`]), not reduced to their form: the Control bar asks every frame.
|
||||
match st.doc.node(*id) {
|
||||
Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()),
|
||||
Some(n) if supported(n) => Ok(()),
|
||||
_ => Err(UNSUPPORTED.into()),
|
||||
}
|
||||
// The object, a group's contents included, is measured ([`supported`]), not reduced to its
|
||||
// form, and the answer is kept until the document's revision moves: the Control bar asks
|
||||
// every frame, and a group's walk takes time with the anchors in it.
|
||||
let ok = match st.global_edit_support.get() {
|
||||
Some((rev, key, ok)) if rev == st.revision && key == *id => ok,
|
||||
_ => {
|
||||
let ok = st.doc.node(*id).is_some_and(supported);
|
||||
st.global_edit_support.set(Some((st.revision, *id, ok)));
|
||||
ok
|
||||
}
|
||||
};
|
||||
if ok { Ok(()) } else { Err(UNSUPPORTED.into()) }
|
||||
}
|
||||
|
||||
/// [`can_toggle`]'s answer for the one selected object: the revision and the object it was found
|
||||
/// for, and whether Global Edit supports it.
|
||||
pub type SupportCache = std::cell::Cell<Option<(u64, NodeId, bool)>>;
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.global_edit.is_some() {
|
||||
let st = s.doc_mut()?;
|
||||
@@ -778,9 +790,22 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se
|
||||
}
|
||||
super::object::keep_compound_aligned(d, *id, Scaling::default());
|
||||
}
|
||||
keep_series_changes(before, d, &edited);
|
||||
edited
|
||||
}
|
||||
|
||||
/// Store on their graphs what the members `edited`, and the objects in them, took on the paths directly in a graph
|
||||
/// series, for the graph's next redraw. The edited member is left to the edit's own command.
|
||||
fn keep_series_changes(before: &Document, d: &mut Document, edited: &[NodeId]) {
|
||||
let mut taken = vec![];
|
||||
for id in edited {
|
||||
if let Some(n) = d.node(*id) {
|
||||
n.walk(&mut |c: &Node| taken.push(c.id));
|
||||
}
|
||||
}
|
||||
super::appearance::capture_series_changes(before, d, &taken);
|
||||
}
|
||||
|
||||
/// The outlines of the members of the active document's Global Edit that are not selected, hold
|
||||
/// no selected object and sit inside no selected object, in their layer's colour: the canvas
|
||||
/// highlights them while Global Edit is on. While it is off, with one supported object selected,
|
||||
@@ -989,8 +1014,7 @@ mod tests {
|
||||
continue;
|
||||
}
|
||||
asked += 1;
|
||||
let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n);
|
||||
assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}");
|
||||
assert_eq!(can_toggle(&s).is_ok(), by_form(&n), "seed {seed}");
|
||||
match s.execute(C, &json!({})) {
|
||||
Ok(_) => {
|
||||
assert!(by_form(&n), "seed {seed}");
|
||||
|
||||
@@ -572,6 +572,124 @@ fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() {
|
||||
assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none()));
|
||||
}
|
||||
|
||||
/// Randomize Opacity on marks of one series stores the opacity of the frontmost of them on the graph in the same undo
|
||||
/// step, and every mark of the series has it when the graph is drawn again.
|
||||
#[test]
|
||||
fn a_random_opacity_on_series_marks_survives_regeneration() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [marks[1].0, marks[0].0], "min": 30, "max": 60, "seed": 7})).unwrap();
|
||||
let front = s.doc().unwrap().doc.node(marks[1]).unwrap().opacity;
|
||||
assert!(front < 1.0);
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(front));
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == front), "{:?}", transparency_of(&s, id, 0));
|
||||
assert!(transparency_of(&s, id, 1).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 1));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the opacities and what the graph stored");
|
||||
}
|
||||
|
||||
/// Sort Objects giving series marks new opacities stores the opacity of the frontmost of them on the graph, in place of
|
||||
/// the one another mark left there.
|
||||
#[test]
|
||||
fn an_opacity_sort_objects_gives_series_marks_survives_regeneration() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("object.setProps", &json!({"id": marks[2].0, "opacity": 30})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
|
||||
// From left to right the three columns get 30%, 65% and 100%: the frontmost, the rightmost, is fully opaque.
|
||||
let ids = [marks[0].0, marks[1].0, marks[2].0];
|
||||
s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap();
|
||||
let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect();
|
||||
assert_eq!(opacities, [0.3, 0.65, 1.0]);
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, None);
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3), "undo takes back the sort and what the graph stored");
|
||||
}
|
||||
|
||||
/// Sort Objects stores the opacity of the frontmost series mark whose opacity it changes: a mark in front that keeps its
|
||||
/// opacity leaves the stored value to the marks it changed.
|
||||
#[test]
|
||||
fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 60})).unwrap();
|
||||
s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 30})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3));
|
||||
// From left to right the columns trade 60%, 30% and 100% for 30%, 60% and 100%: the frontmost keeps its 100%.
|
||||
let ids = [marks[0].0, marks[1].0, marks[2].0];
|
||||
s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity"})).unwrap();
|
||||
let opacities: Vec<f32> = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect();
|
||||
assert_eq!(opacities, [0.3, 0.6, 1.0]);
|
||||
assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6));
|
||||
}
|
||||
|
||||
/// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark
|
||||
/// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them
|
||||
/// when the graph is drawn again.
|
||||
#[test]
|
||||
fn what_global_edit_gives_series_marks_survives_regeneration() {
|
||||
let (mut s, id, _, _) = column_marks();
|
||||
// Equal values: the first column of each series has the same shape.
|
||||
s.execute("graph.setData", &json!({"rows": [[3, 3], [5, 2]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let (first, other) = (series(&n, 0).children().unwrap()[0].id, series(&n, 1).children().unwrap()[0].id);
|
||||
s.execute("object.isolate", &json!({"id": id.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [first.0]})).unwrap();
|
||||
let r = s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}");
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.execute("transparency.set", &json!({"ids": [first.0], "opacity": 40, "blend": "multiply"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#e8573f"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [first.0], "color": "#1f4e9c"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [first.0], "weight": 5})).unwrap();
|
||||
let mark = s.doc().unwrap().doc.node(other).unwrap().clone();
|
||||
assert_eq!((mark.opacity, mark.blend), (0.4, BlendMode::Multiply), "Global Edit reaches the other series' mark");
|
||||
let kept = spec(&s, id).series_paints[1].clone();
|
||||
assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply)));
|
||||
assert_eq!(kept.fill, Some(mark.appearance.fill_paint()));
|
||||
assert_eq!((kept.stroke, kept.stroke_width), (Some(mark.appearance.stroke_paint()), Some(5.0)));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let normal = BlendMode::Normal;
|
||||
assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 1));
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == mark.appearance.fill_paint()));
|
||||
while s.doc().unwrap().history.undo.len() > steps {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(spec(&s, id).series_paints.iter().all(|p| *p == vectorcraft_doc::SeriesPaint::default()), "{:?}", spec(&s, id).series_paints);
|
||||
}
|
||||
|
||||
/// With a whole series group picked and Global Edit on, the other series groups of the same form take the edit of its
|
||||
/// marks, and the graph stores it for their series too.
|
||||
#[test]
|
||||
fn what_global_edit_gives_the_marks_of_another_series_group_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// Series without names draw no legend, so each series group holds only its columns, laid out like the other's.
|
||||
let csv = ",,\nQ1,3,3\nQ2,5,5";
|
||||
let made = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap();
|
||||
let id = NodeId(made["id"].as_u64().unwrap());
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let (first, other) = (series(&n, 0).clone(), series(&n, 1).id);
|
||||
s.execute("object.isolate", &json!({"id": id.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [first.id.0]})).unwrap();
|
||||
let r = s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
assert!(r["ids"].as_array().unwrap().contains(&json!(other.0)), "{r}");
|
||||
let marks: Vec<u64> = first.children().unwrap().iter().map(|m| m.id.0).collect();
|
||||
s.execute("paint.setFill", &json!({"ids": marks, "color": "#e8573f"})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let given = series(&n, 1).children().unwrap()[0].appearance.fill_paint();
|
||||
assert_eq!(given, series(&n, 0).children().unwrap()[0].appearance.fill_paint(), "Global Edit reaches the other group");
|
||||
assert_eq!(spec(&s, id).series_paints[1].fill, Some(given.clone()));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(series(&n, 1).children().unwrap().iter().all(|m| m.appearance.fill_paint() == given));
|
||||
}
|
||||
|
||||
/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of
|
||||
/// the series again; Normal forgets it.
|
||||
#[test]
|
||||
|
||||
@@ -994,20 +994,53 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left.
|
||||
fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
|
||||
/// Delete artboards `indices` (sorted, each once) of `d` with their guides; at least one must be
|
||||
/// left. The other artboards keep their order, no later artboard takes a deleted one's id, and
|
||||
/// `sel` forgets the deleted guides. One walk over the artboards and one over the ruler guides,
|
||||
/// however many go. Delete Artboard, Delete Empty Artboards, Cut Artboard and Artwork Cleanup's
|
||||
/// Empty Artboards.
|
||||
pub(crate) fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
|
||||
if indices.iter().any(|i| *i >= d.artboards.len()) {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
}
|
||||
if indices.len() >= d.artboards.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |g| g.artboard != Some(id));
|
||||
if indices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut gone: HashSet<u32> = HashSet::with_capacity(indices.len());
|
||||
let mut i = 0;
|
||||
d.artboards.retain(|a| {
|
||||
let keep = indices.binary_search(&i).is_err();
|
||||
i += 1;
|
||||
if !keep {
|
||||
gone.insert(a.id);
|
||||
}
|
||||
keep
|
||||
});
|
||||
count_artboard_pass();
|
||||
for &id in &gone {
|
||||
d.retire_artboard_id(id);
|
||||
}
|
||||
d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a)));
|
||||
count_artboard_pass();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many walks [`remove_artboards`] made over the artboards and the ruler guides on
|
||||
/// this thread.
|
||||
pub(crate) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: count one walk over the artboards or the ruler guides.
|
||||
fn count_artboard_pass() {
|
||||
#[cfg(test)]
|
||||
ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1);
|
||||
}
|
||||
|
||||
/// The artboards Delete Empty Artboards deletes: those with no artwork
|
||||
/// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one
|
||||
/// of them (a document keeps one, as Delete Artboards keeps artboard 1).
|
||||
|
||||
@@ -135,9 +135,7 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map_err(|_| bad("object.setLiveShape", format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
|
||||
})
|
||||
.transpose()?;
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
let relative_radii = p.get("relativeRadii").and_then(Value::as_bool);
|
||||
let sides = p.get("sides").and_then(Value::as_u64);
|
||||
// A number that must be `what` (None when absent).
|
||||
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
|
||||
match p.get(k) {
|
||||
@@ -151,6 +149,9 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
|
||||
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
|
||||
let radius = number("radius", |r| r >= 0.0, "a radius of 0 or more")?;
|
||||
// A polygon's side count: a whole number from 3 to 1000.
|
||||
let sides = number("sides", |n| n.fract() == 0.0 && (3.0..=1000.0).contains(&n), "a whole number from 3 to 1000")?.map(|n| n as u64);
|
||||
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
|
||||
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
|
||||
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,7 +8,9 @@
|
||||
//! (`…settings as file.new`), or, naming none, any key (the options of the format an export picks). Objects inside a value aren't
|
||||
//! described key by key, so only the top level is checked, except in the options of an export, a
|
||||
//! save or Export for Screens: those are checked against the options of the formats the command
|
||||
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]).
|
||||
//! can write, in `marks`, `bleed` and the other objects too ([`check_export_options`]); and in the
|
||||
//! objects of an array whose keys `MEMBER_KEYS` lists, such as the `items` of
|
||||
//! `object.setLiveShape`.
|
||||
//!
|
||||
//! [`Session::execute`] answers a key the description doesn't name with a parameter error
|
||||
//! ([`check_params`]), for every caller (the app, the control channel, MCP, the CLI) and for the
|
||||
@@ -269,6 +271,41 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
|
||||
)
|
||||
}
|
||||
|
||||
/// Commands whose params hold an array of objects with keys of their own: the command, the key of
|
||||
/// the array and the keys each of its objects takes.
|
||||
const MEMBER_KEYS: &[(&str, &str, &[&str])] = &[("object.setLiveShape", "items", &["id", "corners"])];
|
||||
|
||||
/// Each object in the arrays [`MEMBER_KEYS`] names for command `id` has only the keys it lists, or
|
||||
/// the parameter error naming the first other one with its place (`items[0].corner`), and the
|
||||
/// nearest accepted key or the accepted ones. A member that isn't an object is the command's to
|
||||
/// report.
|
||||
fn check_member_keys(id: &str, params: &Value) -> Result<()> {
|
||||
for (_, list, keys) in MEMBER_KEYS.iter().filter(|(c, ..)| *c == id) {
|
||||
let Some(members) = params.get(*list).and_then(Value::as_array) else { continue };
|
||||
for (i, member) in members.iter().enumerate() {
|
||||
let Value::Object(given) = member else { continue };
|
||||
let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { continue };
|
||||
let at = |k: &str| format!("{list}[{i}].{k}");
|
||||
let near = nearest(key, keys);
|
||||
let key = at(&shown_key(key));
|
||||
return Err(bad(
|
||||
id,
|
||||
match near {
|
||||
Some(near) => {
|
||||
let near = at(near);
|
||||
format!("unknown parameter `{key}`; did you mean `{near}`?")
|
||||
}
|
||||
None => {
|
||||
let keys = keys.iter().map(|k| at(k)).collect::<Vec<_>>().join(", ");
|
||||
format!("unknown parameter `{key}`; the command takes: {keys}")
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of the type
|
||||
/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`])
|
||||
@@ -526,7 +563,6 @@ pub const UNCHECKED: &[&str] = &[
|
||||
"object.rasterize",
|
||||
"object.repeat.options",
|
||||
"object.setBounds",
|
||||
"object.setLiveShape",
|
||||
"object.setOverprint",
|
||||
"object.setProps",
|
||||
"object.slice.divide",
|
||||
@@ -833,6 +869,7 @@ fn check_params_in(id: &str, params: &Value, in_batch: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
check_keys(id, spec.params, params)?;
|
||||
check_member_keys(id, params)?;
|
||||
if in_batch {
|
||||
crate::schemas::check(spec, params, false)?;
|
||||
}
|
||||
|
||||
@@ -314,6 +314,15 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
|
||||
let independent = switch_or(p, "independent", false, "path.setHandle")?;
|
||||
// A handle already there, on an anchor already of the kind it would get, changes nothing and
|
||||
// records no undo step.
|
||||
if let Some(a) = anchor_of(s, id, si, ai)? {
|
||||
let mut b = a;
|
||||
b.set_handle(out, pos, independent);
|
||||
if b == a {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
@@ -330,6 +339,12 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?;
|
||||
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
|
||||
// An anchor already at (x, y) changes nothing and records no undo step.
|
||||
if let Some(a) = anchor_of(s, id, si, ai)?
|
||||
&& a.transform(vectorcraft_geom::Affine::translate(pos - a.p)) == a
|
||||
{
|
||||
return ok();
|
||||
}
|
||||
s.edit("Move", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
@@ -345,6 +360,16 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Anchor `ai` of subpath `si` of path `id` in the active document, when `id` is a path that has it.
|
||||
/// Busy while a script runs, as [`Session::edit`] is.
|
||||
fn anchor_of(s: &Session, id: NodeId, si: usize, ai: usize) -> Result<Option<Anchor>> {
|
||||
if s.script_running() {
|
||||
return Err(EngineError::Busy);
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(d.node(id).and_then(|n| n.path_data()).and_then(|path| path.subpaths.get(si)).and_then(|sp| sp.anchors.get(ai)).copied())
|
||||
}
|
||||
|
||||
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?;
|
||||
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
|
||||
|
||||
@@ -803,35 +803,59 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Offset Path", |d, sel| {
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
let mut paths = false;
|
||||
// Each selected root, whether it is a group, and the style of each of its leaf paths; the
|
||||
// paths themselves go to `sources` in the same order.
|
||||
let (mut roots_found, mut sources) = (vec![], vec![]);
|
||||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut lv = vec![];
|
||||
leaves(&n, &mut lv);
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
let mut styles = vec![];
|
||||
each_leaf(n, &mut |l| {
|
||||
if !matches!(l.kind, NodeKind::Text(_))
|
||||
&& let Some((path, rule)) = node_path(l)
|
||||
{
|
||||
styles.push(Style::of(l));
|
||||
sources.push((path, rule, matches!(l.kind, NodeKind::Compound { .. })));
|
||||
}
|
||||
});
|
||||
roots_found.push((*id, matches!(n.kind, NodeKind::Group { .. }), styles));
|
||||
}
|
||||
let paths = !sources.is_empty();
|
||||
let offset = |(path, rule, compound): &(PathData, FillRule, bool)| {
|
||||
let normalized;
|
||||
let base = if *compound && path.is_closed() {
|
||||
normalized = po::normalize(path, *rule);
|
||||
&normalized
|
||||
} else {
|
||||
path
|
||||
};
|
||||
match po::try_offset_path(base, delta, join, miter) {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(po::PathOpsError::NonFinite) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
};
|
||||
// The offsets are worked out together; the nodes are made here, in selection order, so
|
||||
// the ids, the stacking and the first error are those of one path after another.
|
||||
let (offsets, _) = ordered_map(&sources, offset_threads(), offset, std::result::Result::is_err);
|
||||
let mut offsets = offsets.into_iter();
|
||||
let (mut out, mut above) = (vec![], vec![]);
|
||||
for (id, group, styles) in roots_found {
|
||||
let mut made = vec![];
|
||||
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
|
||||
let Some((path, rule)) = node_path(l) else { continue };
|
||||
paths = true;
|
||||
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
|
||||
let off = match po::try_offset_path(&base, delta, join, miter) {
|
||||
Ok(p) => p,
|
||||
Err(po::PathOpsError::NonFinite) => continue,
|
||||
Err(po::PathOpsError::TooComplex) => {
|
||||
for style in &styles {
|
||||
match offsets.next().flatten() {
|
||||
Some(Ok(Some(off))) if !off.is_empty() => made.push(shape_node_styled(d, off, style)),
|
||||
Some(Err(po::PathOpsError::TooComplex)) => {
|
||||
return Err(EngineError::Other("Offset Path: the path is too complex to offset".into()));
|
||||
}
|
||||
Err(e) => return Err(EngineError::Other(format!("Offset Path: {e}"))),
|
||||
};
|
||||
if off.is_empty() {
|
||||
continue;
|
||||
Some(Err(e)) => return Err(EngineError::Other(format!("Offset Path: {e}"))),
|
||||
_ => {}
|
||||
}
|
||||
made.push(shape_node(d, off, Some(l)));
|
||||
}
|
||||
if made.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let new = if made.len() == 1
|
||||
&& !matches!(n.kind, NodeKind::Group { .. })
|
||||
&& !group
|
||||
&& let Some(one) = made.pop()
|
||||
{
|
||||
one
|
||||
@@ -840,7 +864,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Node::group(gid, made.into_iter().map(Arc::new).collect())
|
||||
};
|
||||
out.push(new.id);
|
||||
above.push((*id, vec![new]));
|
||||
above.push((id, vec![new]));
|
||||
}
|
||||
if out.is_empty() && paths {
|
||||
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
|
||||
@@ -855,6 +879,86 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
/// The threads [`offset_path`] works on: the logical CPUs, or one on the web.
|
||||
fn offset_threads() -> usize {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
1
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
|
||||
}
|
||||
}
|
||||
|
||||
/// `f` of each of `items`, in their order. The items are split into runs whose sizes differ by
|
||||
/// at most one, as many as `threads` or the items allow and at least one. This thread works
|
||||
/// on the first run, and a thread of its own works on each other run (this thread does when that
|
||||
/// one can't start). Once `stop` accepts a result, the items after it are left out (`None`); every
|
||||
/// item before the first such result has its result. Returns the results and the number of
|
||||
/// threads that worked on them.
|
||||
pub(crate) fn ordered_map<T: Sync, R: Send>(
|
||||
items: &[T],
|
||||
threads: usize,
|
||||
f: impl Fn(&T) -> R + Sync,
|
||||
stop: impl Fn(&R) -> bool + Sync,
|
||||
) -> (Vec<Option<R>>, usize) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let mut out: Vec<Option<R>> = std::iter::repeat_with(|| None).take(items.len()).collect();
|
||||
let stopped = AtomicUsize::new(usize::MAX);
|
||||
let run = |from: usize, its: &[T], out: &mut [Option<R>]| {
|
||||
for (i, (item, slot)) in its.iter().zip(out).enumerate() {
|
||||
let at = from + i;
|
||||
if at > stopped.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let r = f(item);
|
||||
if stop(&r) {
|
||||
stopped.fetch_min(at, Ordering::Relaxed);
|
||||
}
|
||||
*slot = Some(r);
|
||||
}
|
||||
};
|
||||
let runs = threads.clamp(1, items.len().max(1));
|
||||
if runs == 1 {
|
||||
run(0, items, &mut out);
|
||||
return (out, 1);
|
||||
}
|
||||
// Run `k` holds the items from `start(k)` up to `start(k + 1)`.
|
||||
let start = |k: usize| k * items.len() / runs;
|
||||
let mut unstarted = vec![];
|
||||
let used = std::thread::scope(|s| {
|
||||
let mut rest = out.as_mut_slice();
|
||||
let mut first = None;
|
||||
let mut used = 1;
|
||||
for k in 0..runs {
|
||||
let (from, to) = (start(k), start(k + 1));
|
||||
let Some((o, tail)) = std::mem::take(&mut rest).split_at_mut_checked(to - from) else { break };
|
||||
rest = tail;
|
||||
let its = items.get(from..to).unwrap_or_default();
|
||||
if k == 0 {
|
||||
first = Some((o, its));
|
||||
continue;
|
||||
}
|
||||
let run = &run;
|
||||
match std::thread::Builder::new().spawn_scoped(s, move || run(from, its, o)) {
|
||||
Ok(_) => used += 1,
|
||||
Err(_) => unstarted.push(k),
|
||||
}
|
||||
}
|
||||
if let Some((o, its)) = first {
|
||||
run(0, its, o);
|
||||
}
|
||||
used
|
||||
});
|
||||
for (from, to) in unstarted.into_iter().map(|k| (start(k), start(k + 1))) {
|
||||
if let (Some(o), Some(its)) = (out.get_mut(from..to), items.get(from..to)) {
|
||||
run(from, its, o);
|
||||
}
|
||||
}
|
||||
(out, used)
|
||||
}
|
||||
|
||||
// ---------- Outline Stroke ----------
|
||||
|
||||
/// Can Outline Stroke turn `st` into filled art?
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.color.strokeToFill",
|
||||
"addon.color.swatchLegend",
|
||||
"addon.contour.set",
|
||||
"addon.cutting.banner",
|
||||
"addon.cutting.bridges",
|
||||
"addon.cutting.weed",
|
||||
"addon.image.applyColorMask",
|
||||
@@ -58,6 +59,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.pathtools.shape.set",
|
||||
"addon.pathtools.slideAnchor",
|
||||
"addon.pathtools.smooth",
|
||||
"addon.pathtools.trim",
|
||||
"addon.pen.append",
|
||||
"addon.pen.close",
|
||||
"addon.pen.closeJoin",
|
||||
|
||||
@@ -576,13 +576,25 @@ pub fn store(s: &Session) -> Option<Arc<dyn RecoveryStore>> {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let folder = Some(s.prefs.recovery_folder.trim()).filter(|f| !f.is_empty()).map(str::to_string).or_else(|| s.recovery.default_folder.clone());
|
||||
folder.map(|f| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>)
|
||||
folder(s).map(|(f, _)| Arc::new(FolderStore::new(f)) as Arc<dyn RecoveryStore>)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
}
|
||||
|
||||
/// The folder copies go to now, and whether the `recoveryFolder` preference names it (else it is
|
||||
/// the folder the app sets). `None` when they go to a store the host installed (browser storage)
|
||||
/// or nowhere.
|
||||
pub fn folder(s: &Session) -> Option<(String, bool)> {
|
||||
if s.recovery.store.is_some() || cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
match s.prefs.recovery_folder.trim() {
|
||||
"" => s.recovery.default_folder.clone().map(|f| (f, false)),
|
||||
set => Some((set.to_string(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_store(s: &Session) -> std::result::Result<(), String> {
|
||||
match store(s) {
|
||||
Some(_) => Ok(()),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -215,6 +215,11 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
("tateChuYoko", json!(v.tate_chu_yoko)),
|
||||
("tcyUpDown", json!(v.tcy_up_down)),
|
||||
("tcyLeftRight", json!(v.tcy_left_right)),
|
||||
("warichu", json!(v.warichu)),
|
||||
("warichuLines", json!(v.warichu_lines)),
|
||||
("warichuGap", json!(v.warichu_gap)),
|
||||
("warichuScale", json!(v.warichu_scale)),
|
||||
("warichuAlign", json!(v.warichu_align)),
|
||||
("akiLeft", json!(v.aki_left)),
|
||||
("akiRight", json!(v.aki_right)),
|
||||
("standardVerticalRoman", json!(v.standard_vertical_roman)),
|
||||
@@ -573,6 +578,19 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
|
||||
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
|
||||
}
|
||||
}
|
||||
let lines = vectorcraft_doc::text::WARICHU_LINES;
|
||||
if changed("warichuLines") && !lines.contains(&st.warichu_lines) {
|
||||
return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end())));
|
||||
}
|
||||
let scale = vectorcraft_doc::text::WARICHU_SCALE;
|
||||
for (key, value, lo, hi) in [
|
||||
("warichuGap", st.warichu_gap, 0.0, vectorcraft_doc::text::MAX_WARICHU_GAP),
|
||||
("warichuScale", st.warichu_scale, *scale.start(), *scale.end()),
|
||||
] {
|
||||
if changed(key) && !(lo..=hi).contains(&value) {
|
||||
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
|
||||
}
|
||||
}
|
||||
// A language of the list, stored as its tag (as text.setFormat takes it).
|
||||
if let Some(l) = &st.language
|
||||
&& kept.and_then(|k| k.get("language")) != m.get("language")
|
||||
@@ -1057,12 +1075,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
|
||||
|
||||
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
|
||||
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
|
||||
/// scales, leading, shifts, Insert Aki or stroke width are out of the range `charStyle.new` takes, whose
|
||||
/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
|
||||
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
|
||||
/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
|
||||
/// `src` of those skipped)
|
||||
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
|
||||
const RANGED: [&str; 18] = [
|
||||
const RANGED: [&str; 21] = [
|
||||
"size",
|
||||
"h_scale",
|
||||
"v_scale",
|
||||
@@ -1072,6 +1090,9 @@ fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
|
||||
"akiRight",
|
||||
"tcyUpDown",
|
||||
"tcyLeftRight",
|
||||
"warichuLines",
|
||||
"warichuGap",
|
||||
"warichuScale",
|
||||
"stroke_width",
|
||||
"left_indent",
|
||||
"right_indent",
|
||||
|
||||
@@ -824,8 +824,8 @@ fn load_all_styles_loads_both_kinds_in_one_step() {
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// A loaded character style whose size, scale, leading, shift or stroke width is out of the range
|
||||
/// `charStyle.new` takes is skipped.
|
||||
/// A loaded character style whose size, scale, leading, shift, warichu Line Gap or stroke width is
|
||||
/// out of the range `charStyle.new` takes is skipped.
|
||||
#[test]
|
||||
fn loaded_styles_out_of_range_are_skipped() {
|
||||
let (mut s, _) = session_with_text("Title");
|
||||
@@ -834,11 +834,12 @@ fn loaded_styles_out_of_range_are_skipped() {
|
||||
("Flat", json!({"h_scale": 0.5})),
|
||||
("Shifted", json!({"baseline_shift": 5000.0})),
|
||||
("Thick", json!({"stroke_width": -2.0})),
|
||||
("Gapped", json!({"warichuGap": 500.0})),
|
||||
("Fine", json!({"size": 12.0, "h_scale": 50.0})),
|
||||
];
|
||||
let data = styles_file(&file, &[]);
|
||||
let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
|
||||
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick"]}));
|
||||
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick", "Gapped"]}));
|
||||
assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Text Wrap"],
|
||||
None,
|
||||
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them → {ids}",
|
||||
"{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them; a negative offset (to -1000) lets the text that far into them → {ids}",
|
||||
has_selection,
|
||||
make
|
||||
),
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Wrap Options…",
|
||||
["Object", "Text Wrap"],
|
||||
None,
|
||||
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects; no options → the current {offset, invert}",
|
||||
"{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects (offset -1000 to 1000; a negative one lets text into the objects); no options → the current {offset, invert}",
|
||||
has_selection,
|
||||
options
|
||||
),
|
||||
@@ -186,6 +186,33 @@ mod tests {
|
||||
assert_eq!(lines(&s, t), plain);
|
||||
}
|
||||
|
||||
/// A negative offset from Text Wrap Options lets the lines beside a wrap object run that far
|
||||
/// into it, the options report it, and one undo puts back the offset before it.
|
||||
#[test]
|
||||
fn text_wrap_options_negative_offset_reflows_and_round_trips() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
|
||||
let t = s
|
||||
.execute("text.create", &json!({"x": 0, "y": 0, "text": "word ".repeat(200), "size": 12, "area": {"width": 400, "height": 400}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": -50, "y": 50, "width": 250, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("object.textWrap.make", &json!({})).unwrap();
|
||||
let beside = |s: &Session| lines(s, t).into_iter().filter(|l| l.0 > 1.0).map(|l| l.0).collect::<Vec<_>>();
|
||||
assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s));
|
||||
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -20})).unwrap()["offset"], json!(-20.0));
|
||||
assert!(!beside(&s).is_empty() && beside(&s).iter().all(|x| (x - 180.0).abs() < 1e-6), "{:?}", beside(&s));
|
||||
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(-20.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0));
|
||||
assert!(beside(&s).iter().all(|x| (x - 206.0).abs() < 1e-6), "{:?}", beside(&s));
|
||||
// An offset past -1000 is kept at -1000: the whole object lets text through.
|
||||
assert_eq!(s.execute("object.textWrap.options", &json!({"offset": -5000})).unwrap()["offset"], json!(-1000.0));
|
||||
assert!(lines(&s, t).iter().all(|l| l.0 < 1.0 && l.1 > 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_flows_on_both_sides_of_a_centred_object() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -68,11 +68,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn has_thread_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("no threaded text selected".into())
|
||||
}
|
||||
let selected: Ids = st.selection.objects.iter().copied().collect();
|
||||
if st.doc.text_threads.iter().flatten().any(|id| selected.contains(id)) { Ok(()) } else { Err("no threaded text selected".into()) }
|
||||
}
|
||||
|
||||
/// The text object `id` when it can be a frame of a thread: area type or type on a path.
|
||||
@@ -87,6 +84,43 @@ fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
|
||||
thread_local! {
|
||||
/// Tests: how many threads' stories [`reflow`] copied on this thread.
|
||||
static COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// Tests: how many frames [`leave_threads`] passed to [`continued_marks`] on this thread.
|
||||
static MARK_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// Tests: how many lookups and insertions [`Ids`] made on this thread.
|
||||
static ID_LOOKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A set of node ids, for the threaded-text commands to test whether a frame is selected, already
|
||||
/// listed or in a thread in the same time however many ids the set holds.
|
||||
#[derive(Clone, Default)]
|
||||
struct Ids(std::collections::HashSet<NodeId>);
|
||||
|
||||
impl Ids {
|
||||
fn contains(&self, id: &NodeId) -> bool {
|
||||
#[cfg(test)]
|
||||
ID_LOOKS.with(|c| c.set(c.get() + 1));
|
||||
self.0.contains(id)
|
||||
}
|
||||
|
||||
/// Add `id` to the set; whether it was not in it already.
|
||||
fn insert(&mut self, id: NodeId) -> bool {
|
||||
#[cfg(test)]
|
||||
ID_LOOKS.with(|c| c.set(c.get() + 1));
|
||||
self.0.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<NodeId> for Ids {
|
||||
fn from_iter<I: IntoIterator<Item = NodeId>>(ids: I) -> Self {
|
||||
Self(ids.into_iter().collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// [`vectorcraft_text::thread::continued`] for the frames of a thread that [`leave_threads`] marks.
|
||||
fn continued_marks(frames: &[&TextObject]) -> Vec<bool> {
|
||||
#[cfg(test)]
|
||||
MARK_VISITS.with(|c| c.set(c.get() + frames.len()));
|
||||
vectorcraft_text::thread::continued(frames)
|
||||
}
|
||||
|
||||
/// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing
|
||||
@@ -361,20 +395,74 @@ pub(crate) fn mark_continued(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] went through the document's
|
||||
/// threads on this thread.
|
||||
static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] have gone through the document's
|
||||
/// threads on this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn thread_scans() -> usize {
|
||||
THREAD_SCANS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a
|
||||
/// path; empty when `id` is in no thread or the thread has fewer than two such frames.
|
||||
pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
#[cfg(test)]
|
||||
THREAD_SCANS.with(|c| c.set(c.get() + 1));
|
||||
let Some(thread) = d.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] };
|
||||
story_in(d, thread, id)
|
||||
}
|
||||
|
||||
/// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it.
|
||||
pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec<NodeId> {
|
||||
let frames: Vec<NodeId> = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect();
|
||||
if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] }
|
||||
}
|
||||
|
||||
/// A document's text threads looked up by frame, built in one pass over them, for a command that
|
||||
/// asks [`ThreadIndex::threaded`] of each of many objects.
|
||||
pub(crate) struct ThreadIndex<'a> {
|
||||
doc: &'a Document,
|
||||
/// Each object the threads list, with the index of the first thread that lists it.
|
||||
first: std::collections::HashMap<NodeId, usize>,
|
||||
/// Each thread's count of entries that are area type or type on a path.
|
||||
live: Vec<usize>,
|
||||
}
|
||||
|
||||
impl<'a> ThreadIndex<'a> {
|
||||
pub(crate) fn of(doc: &'a Document) -> Self {
|
||||
#[cfg(test)]
|
||||
THREAD_SCANS.with(|c| c.set(c.get() + 1));
|
||||
let mut first = std::collections::HashMap::new();
|
||||
let mut live = Vec::with_capacity(doc.text_threads.len());
|
||||
for (i, thread) in doc.text_threads.iter().enumerate() {
|
||||
for id in thread {
|
||||
first.entry(*id).or_insert(i);
|
||||
}
|
||||
live.push(thread.iter().filter(|id| text_of(doc, **id).is_some()).count());
|
||||
}
|
||||
Self { doc, first, live }
|
||||
}
|
||||
|
||||
/// Whether `id` is a frame of a thread: [`story_of`] is not empty for it.
|
||||
pub(crate) fn threaded(&self, id: NodeId) -> bool {
|
||||
self.first.get(&id).and_then(|i| self.live.get(*i)).is_some_and(|n| *n >= 2) && text_of(self.doc, id).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
|
||||
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let given: Ids = ids.iter().copied().collect();
|
||||
let mut listed = given.clone();
|
||||
let mut out = ids.to_vec();
|
||||
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
|
||||
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| given.contains(id))) {
|
||||
for id in thread {
|
||||
if !out.contains(id) && text_of(d, *id).is_some() {
|
||||
if text_of(d, *id).is_some() && listed.insert(*id) {
|
||||
out.push(*id);
|
||||
}
|
||||
}
|
||||
@@ -399,13 +487,14 @@ pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> {
|
||||
/// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out
|
||||
/// its first line again, so its thread re-flows after the edit.
|
||||
pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
||||
let gone: Vec<(NodeId, &TextObject, Option<NodeId>)> =
|
||||
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect();
|
||||
// Each frame that left, with the type that takes its place.
|
||||
let gone: std::collections::HashMap<NodeId, (&TextObject, Option<NodeId>)> =
|
||||
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, (t, kept_type(d, *id)))).collect();
|
||||
if gone.is_empty() {
|
||||
return;
|
||||
}
|
||||
let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id);
|
||||
let mut stayed = vec![];
|
||||
let find = |id: &NodeId| gone.get(id);
|
||||
let mut changed = vec![];
|
||||
for i in 0..d.text_threads.len() {
|
||||
let thread = d.text_threads[i].clone();
|
||||
if !thread.iter().any(|id| find(id).is_some()) {
|
||||
@@ -414,28 +503,33 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
||||
let keep: Vec<NodeId> = thread
|
||||
.iter()
|
||||
.filter_map(|id| match find(id) {
|
||||
Some((_, _, kept)) => *kept,
|
||||
Some((_, kept)) => *kept,
|
||||
None => Some(*id),
|
||||
})
|
||||
.collect();
|
||||
if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
|
||||
if let (Some((last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
|
||||
take_overflow(d, *stay, last);
|
||||
}
|
||||
stayed.extend(keep.iter().copied());
|
||||
changed.push(keep.clone());
|
||||
d.text_threads[i] = keep;
|
||||
}
|
||||
d.text_threads.retain(|t| t.len() >= 2);
|
||||
let marks: Vec<(NodeId, bool)> = stayed
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| {
|
||||
let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
|
||||
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
|
||||
live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id)
|
||||
});
|
||||
(*id, on)
|
||||
})
|
||||
.collect();
|
||||
// One pass over each changed thread: a frame left alone continues nothing, and the area type and
|
||||
// type on a path of a thread that stays take their marks in thread order.
|
||||
let mut marks: Vec<(NodeId, bool)> = vec![];
|
||||
for keep in &changed {
|
||||
if keep.len() < 2 {
|
||||
marks.extend(keep.iter().map(|id| (*id, false)));
|
||||
continue;
|
||||
}
|
||||
let live: Vec<(NodeId, &TextObject)> = keep.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
|
||||
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
|
||||
let mut on: std::collections::HashMap<NodeId, bool> = std::collections::HashMap::new();
|
||||
for ((id, _), mark) in live.iter().zip(continued_marks(&frames)) {
|
||||
*on.entry(*id).or_default() |= mark;
|
||||
}
|
||||
marks.extend(keep.iter().map(|id| (*id, on.get(id).copied().unwrap_or(false))));
|
||||
}
|
||||
for (id, on) in marks {
|
||||
if text_of(d, id).is_some_and(|t| t.first_para_continues != on)
|
||||
&& let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
|
||||
@@ -450,21 +544,23 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
|
||||
/// thread with frames outside `art`, to the text it shows. When such a frame leaves its thread,
|
||||
/// [`leave_threads`] puts the text it has no room for after the text of the last frame that stays.
|
||||
pub(crate) fn keep_shown_text(d: &Document, art: &mut Node) {
|
||||
let mut inside = vec![];
|
||||
art.walk(&mut |n| inside.push(n.id));
|
||||
let last: Vec<NodeId> = d
|
||||
let mut inside = Ids::default();
|
||||
art.walk(&mut |n| {
|
||||
inside.insert(n.id);
|
||||
});
|
||||
let last: Ids = d
|
||||
.text_threads
|
||||
.iter()
|
||||
.filter(|t| t.iter().any(|id| !inside.contains(id)))
|
||||
.filter_map(|t| t.last().copied().filter(|id| inside.contains(id)))
|
||||
.collect();
|
||||
if !last.is_empty() {
|
||||
if !last.0.is_empty() {
|
||||
cut_to_shown(art, &last);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut the area type and type on a path `ids` in `n` to the text each one shows.
|
||||
fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
|
||||
fn cut_to_shown(n: &mut Node, ids: &Ids) {
|
||||
if ids.contains(&n.id)
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
&& !matches!(t.kind, TextKind::Point)
|
||||
@@ -567,21 +663,35 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
*d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f;
|
||||
}
|
||||
}
|
||||
// Existing threads containing any of these frames merge into the new one, keeping order.
|
||||
// Existing threads containing any of these frames merge into the new one, keeping order:
|
||||
// each frame takes in the first thread not yet taken in that lists it.
|
||||
let mut threads: Vec<Option<Vec<NodeId>>> = std::mem::take(&mut d.text_threads).into_iter().map(Some).collect();
|
||||
let mut listing: std::collections::HashMap<NodeId, Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, t) in threads.iter().enumerate() {
|
||||
for id in t.iter().flatten() {
|
||||
listing.entry(*id).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut order: Vec<NodeId> = vec![];
|
||||
let mut listed = Ids::default();
|
||||
for id in &ids {
|
||||
match d.text_threads.iter().position(|t| t.contains(id)) {
|
||||
Some(i) => {
|
||||
for x in d.text_threads.remove(i) {
|
||||
if !order.contains(&x) {
|
||||
let thread = listing.get(id).and_then(|at| at.iter().find_map(|i| threads[*i].take()));
|
||||
match thread {
|
||||
Some(thread) => {
|
||||
for x in thread {
|
||||
if listed.insert(x) {
|
||||
order.push(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
None if !order.contains(id) => order.push(*id),
|
||||
None => {}
|
||||
None => {
|
||||
if listed.insert(*id) {
|
||||
order.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.text_threads = threads.into_iter().flatten().collect();
|
||||
d.text_threads.push(order);
|
||||
// Force a re-flow of the new thread on this edit.
|
||||
touch(d, ids[0]);
|
||||
@@ -706,11 +816,12 @@ fn touch(d: &mut Document, id: NodeId) {
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let ids: Ids = selected_roots(s)?.into_iter().collect();
|
||||
s.edit("Release Threaded Text", |d, _| {
|
||||
// The released frames' text joins the rest of the story (they become empty).
|
||||
for thread in d.text_threads.clone() {
|
||||
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|i| ids.contains(i));
|
||||
for i in 0..d.text_threads.len() {
|
||||
let thread = d.text_threads[i].clone();
|
||||
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|id| ids.contains(id));
|
||||
if out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -750,7 +861,6 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
t.first_para_continues = false;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
|
||||
d.text_threads[i] = keep;
|
||||
}
|
||||
d.text_threads.retain(|t| t.len() >= 2);
|
||||
@@ -760,7 +870,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let ids: Ids = selected_roots(s)?.into_iter().collect();
|
||||
s.edit("Remove Threading", |d, _| {
|
||||
let (gone, kept): (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) =
|
||||
std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i)));
|
||||
|
||||
@@ -245,6 +245,28 @@ fn a_frame_left_alone_by_a_dropped_repeat_stops_continuing() {
|
||||
assert_eq!(t.plain_text(), "Tail");
|
||||
}
|
||||
|
||||
/// [`ThreadIndex::threaded`] answers as [`story_of`] does, by the first thread that lists an
|
||||
/// object, also for threads listed more than once, with point type, other art and missing ids.
|
||||
#[test]
|
||||
fn thread_index_matches_story_of() {
|
||||
let (mut s, a, b) = setup();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let c = s.execute("text.create", &json!({"x": 400, "y": 10, "text": "C", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||||
let p = s.execute("text.create", &json!({"x": 10, "y": 250, "text": "Point"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let [fa, fb, fc, fp, fr, fx] = [a, b, c, p, r, 9_999_999].map(NodeId);
|
||||
// a's first thread has one frame beside point type, c's has one beside a rectangle, and b's
|
||||
// lists a twice.
|
||||
d.text_threads = vec![vec![fp, fa], vec![fa, fb, fa], vec![fc, fr], vec![fc, fb], vec![fx, fb]];
|
||||
let index = ThreadIndex::of(&d);
|
||||
let got: Vec<(bool, bool)> = [fa, fb, fc, fp, fr, fx].iter().map(|id| (index.threaded(*id), !story_of(&d, *id).is_empty())).collect();
|
||||
let want: Vec<(bool, bool)> = [false, true, false, false, false, false].iter().map(|t| (*t, *t)).collect();
|
||||
assert_eq!(got, want);
|
||||
d.text_threads.clear();
|
||||
assert!(!ThreadIndex::of(&d).threaded(fb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_copy_only_the_stories_of_threads_whose_frames_changed() {
|
||||
let (mut s, a, b) = setup();
|
||||
@@ -1053,6 +1075,246 @@ fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether frame `id` of `d` is marked as continuing a paragraph.
|
||||
fn marked(d: &Document, id: NodeId) -> bool {
|
||||
text_of(d, id).unwrap().first_para_continues
|
||||
}
|
||||
|
||||
/// The marks of `thread`'s frames, each found from the frames before it: a frame continues a
|
||||
/// paragraph when the last of them with text does not end with a paragraph break. A frame left
|
||||
/// alone continues nothing.
|
||||
fn marks_by_scan(d: &Document, thread: &[NodeId]) -> Vec<bool> {
|
||||
(0..thread.len())
|
||||
.map(|k| {
|
||||
thread.len() >= 2
|
||||
&& thread[..k]
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|id| text_of(d, *id).unwrap().plain_text())
|
||||
.find(|t| !t.is_empty())
|
||||
.is_some_and(|t| !t.ends_with('\n'))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// When frames leave their threads (from the start, the middle or the end of a thread, outlined or
|
||||
/// with their type under a new id, leaving a thread with one frame), each frame that stays takes
|
||||
/// the mark found from the frames before it, and a thread that lost no frame keeps its marks.
|
||||
#[test]
|
||||
fn frames_that_stay_after_others_leave_their_threads_take_the_marks_of_their_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
let mut frame = |text: &str| {
|
||||
NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()))
|
||||
};
|
||||
let [a1, a2, a3, a4, a5] = ["One", "", "Two\n", "Three", "Four"].map(&mut frame);
|
||||
let [b1, b2] = ["Alpha", "Beta"].map(&mut frame);
|
||||
let [c1, c2] = ["x", "y"].map(&mut frame);
|
||||
let [d1, d2, d3, d4] = ["M", "N\n", "O", "P"].map(&mut frame);
|
||||
let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 20, "height": 20})).unwrap()));
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
d.text_threads = vec![vec![a1, a2, a3, a4, a5], vec![b1, b2], vec![c1, c2], vec![d1, d2, d3, d4]];
|
||||
// Every mark is the opposite of what it becomes, or (in the thread that loses no frame) wrong.
|
||||
for id in [a1, a2, a3, a4, a5, b1, b2, c1, d1, d2, d3, d4] {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.first_para_continues = true;
|
||||
}
|
||||
}
|
||||
let shown: Vec<(NodeId, String)> = d.text_threads.iter().flatten().map(|id| (*id, text_of(&d, *id).unwrap().plain_text())).collect();
|
||||
let was = frames(&d);
|
||||
// a1, b2 and d3 become a rectangle; a4 becomes a group holding its type under a new id.
|
||||
let outline = d.node(rect).unwrap().kind.clone();
|
||||
for id in [a1, b2, d3] {
|
||||
d.node_mut(id).unwrap().kind = outline.clone();
|
||||
}
|
||||
let mut kept = d.node(a4).unwrap().clone();
|
||||
kept.id = d.alloc_id();
|
||||
let new_a4 = kept.id;
|
||||
let layer = d.parent_of(a4);
|
||||
d.remove(a4).unwrap();
|
||||
d.insert(layer, usize::MAX, Node::group(a4, vec![std::sync::Arc::new(kept)])).unwrap();
|
||||
leave_threads(&mut d, &was);
|
||||
assert_eq!(d.text_threads, vec![vec![a2, a3, new_a4, a5], vec![c1, c2], vec![d1, d2, d4]]);
|
||||
for thread in [&[a2, a3, new_a4, a5][..], &[b1], &[d1, d2, d4]] {
|
||||
let want = marks_by_scan(&d, thread);
|
||||
assert_eq!(thread.iter().map(|id| marked(&d, *id)).collect::<Vec<_>>(), want, "{thread:?}");
|
||||
}
|
||||
assert_eq!([a2, a3, new_a4, a5, b1, d1, d2, d4].map(|id| marked(&d, id)), [false, false, false, true, false, false, true, false]);
|
||||
assert_eq!([c1, c2].map(|id| marked(&d, id)), [true, false], "a thread that lost no frame keeps its marks");
|
||||
assert!(!matches!(d.node(a4).unwrap().kind, NodeKind::Text(_)), "the group in the frame's place is not type");
|
||||
for (id, before) in shown {
|
||||
let now = if id == a4 { new_a4 } else { id };
|
||||
if let Some(t) = text_of(&d, now) {
|
||||
assert_eq!(t.plain_text(), before, "frame {id:?} keeps its text");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frames that stay in a long thread after one leaves are marked in one pass over the thread.
|
||||
#[test]
|
||||
fn a_long_thread_is_marked_in_one_pass_when_a_frame_leaves() {
|
||||
const FRAMES: usize = 256;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w0", "area": {"width": 150, "height": 60}})).unwrap()));
|
||||
let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()));
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let layer = d.parent_of(first);
|
||||
let model = d.node(first).unwrap().clone();
|
||||
let mut thread = vec![first];
|
||||
for k in 1..FRAMES {
|
||||
let mut n = model.clone();
|
||||
n.id = d.alloc_id();
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.runs.truncate(1);
|
||||
t.runs[0].text = format!("w{k}");
|
||||
t.first_para_continues = true;
|
||||
}
|
||||
thread.push(d.insert(layer, usize::MAX, n).unwrap());
|
||||
}
|
||||
d.text_threads = vec![thread.clone()];
|
||||
let was = frames(&d);
|
||||
let out = thread[FRAMES / 2];
|
||||
d.node_mut(out).unwrap().kind = d.node(rect).unwrap().kind.clone();
|
||||
MARK_VISITS.with(|c| c.set(0));
|
||||
leave_threads(&mut d, &was);
|
||||
let visits = MARK_VISITS.with(std::cell::Cell::get);
|
||||
let rest: Vec<NodeId> = thread.iter().copied().filter(|id| *id != out).collect();
|
||||
assert_eq!(d.text_threads, vec![rest.clone()]);
|
||||
assert!(visits <= FRAMES, "{visits} frames visited to mark a thread of {} frames", rest.len());
|
||||
assert_eq!(rest.iter().map(|id| marked(&d, *id)).collect::<Vec<_>>(), marks_by_scan(&d, &rest));
|
||||
}
|
||||
|
||||
/// `count` threads of `per` area type frames each, made in one edit, each frame holding a word.
|
||||
fn many_threads(count: usize, per: usize) -> (Session, Vec<Vec<NodeId>>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w", "area": {"width": 150, "height": 60}})).unwrap()));
|
||||
let threads = s
|
||||
.edit("Make", |d, _| {
|
||||
let layer = d.parent_of(first);
|
||||
let model = d.node(first).unwrap().clone();
|
||||
let mut threads = vec![];
|
||||
for _ in 0..count {
|
||||
let mut thread = vec![];
|
||||
for _ in 0..per {
|
||||
let mut n = model.clone();
|
||||
n.id = d.alloc_id();
|
||||
thread.push(d.insert(layer, usize::MAX, n).unwrap());
|
||||
}
|
||||
threads.push(thread);
|
||||
}
|
||||
d.text_threads = threads.clone();
|
||||
Ok(threads)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.text_threads, threads);
|
||||
(s, threads)
|
||||
}
|
||||
|
||||
/// Select the first frame of each of `threads`.
|
||||
fn select_firsts(s: &mut Session, threads: &[Vec<NodeId>]) {
|
||||
let ids: Vec<u64> = threads.iter().map(|t| t[0].0).collect();
|
||||
s.execute("select.set", &json!({ "ids": ids })).unwrap();
|
||||
}
|
||||
|
||||
/// Reset the count of [`Ids`] lookups, run `f`, and give its result with the lookups it made.
|
||||
fn looks<T>(f: impl FnOnce() -> T) -> (T, usize) {
|
||||
ID_LOOKS.with(|c| c.set(0));
|
||||
let out = f();
|
||||
(out, ID_LOOKS.with(std::cell::Cell::get))
|
||||
}
|
||||
|
||||
/// Whether Release Selection and Remove Threading are available is found in one pass over the
|
||||
/// threads' frames, however many frames are selected.
|
||||
#[test]
|
||||
fn thread_commands_are_offered_after_one_pass_over_the_threads() {
|
||||
let (mut s, made) = many_threads(300, 3);
|
||||
let (threads, loose) = made.split_at(150);
|
||||
s.edit("Unthread", |d, _| {
|
||||
d.text_threads = threads.to_vec();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// 450 frames out of any thread, then the last frame of the last thread.
|
||||
let mut ids: Vec<u64> = loose.iter().flatten().map(|id| id.0).collect();
|
||||
ids.push(threads[149][2].0);
|
||||
s.execute("select.set", &json!({ "ids": ids })).unwrap();
|
||||
let (offered, n) = looks(|| has_thread_selection(&s));
|
||||
assert!(offered.is_ok());
|
||||
assert!(n <= 2 * (451 + 450), "{n} id lookups");
|
||||
s.execute("select.set", &json!({ "ids": ids[..450] })).unwrap();
|
||||
assert!(has_thread_selection(&s).is_err(), "frames out of any thread are no threaded text");
|
||||
let rect = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
|
||||
assert!(has_thread_selection(&s).is_err(), "a rectangle is no threaded text");
|
||||
}
|
||||
|
||||
/// The frames of the selected frames' threads are listed with one lookup per frame.
|
||||
#[test]
|
||||
fn the_frames_threaded_with_a_selection_are_listed_in_one_pass() {
|
||||
let (s, threads) = many_threads(150, 3);
|
||||
let firsts: Vec<NodeId> = threads.iter().map(|t| t[0]).collect();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let (all, n) = looks(|| with_threads(d, &firsts));
|
||||
let mut want = firsts.clone();
|
||||
want.extend(threads.iter().flat_map(|t| t[1..].iter().copied()));
|
||||
assert_eq!(all, want);
|
||||
assert!(n <= 4 * (150 + 450), "{n} id lookups");
|
||||
}
|
||||
|
||||
/// The last frames of many threads copied out of them are found with a few lookups per frame and
|
||||
/// per object copied.
|
||||
#[test]
|
||||
fn the_last_frames_copied_out_of_many_threads_are_found_in_one_pass() {
|
||||
let (s, threads) = many_threads(150, 3);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let lasts: Vec<std::sync::Arc<Node>> = threads.iter().map(|t| std::sync::Arc::new(d.node(t[2]).unwrap().clone())).collect();
|
||||
let mut art = Node::group(NodeId(u64::MAX), lasts);
|
||||
let before = art.clone();
|
||||
let ((), n) = looks(|| keep_shown_text(d, &mut art));
|
||||
assert!(n <= 4 * (151 + 450), "{n} id lookups");
|
||||
assert_eq!(art, before, "each copied frame already shows all of its word");
|
||||
}
|
||||
|
||||
/// Threading one frame of each of many threads merges them in one pass, in the order of the
|
||||
/// selection.
|
||||
#[test]
|
||||
fn creating_a_thread_from_many_threads_merges_them_in_one_pass() {
|
||||
let (mut s, threads) = many_threads(150, 3);
|
||||
select_firsts(&mut s, &threads);
|
||||
let (made, n) = looks(|| s.execute("text.thread.create", &json!({})));
|
||||
made.unwrap();
|
||||
assert!(n <= 8 * (150 + 450), "{n} id lookups");
|
||||
assert_eq!(s.doc().unwrap().doc.text_threads, vec![threads.concat()]);
|
||||
}
|
||||
|
||||
/// Release Selection on one frame of each of many threads finds the selected frames with one
|
||||
/// lookup per frame.
|
||||
#[test]
|
||||
fn releasing_frames_of_many_threads_takes_one_pass() {
|
||||
let (mut s, threads) = many_threads(150, 3);
|
||||
select_firsts(&mut s, &threads);
|
||||
let (released, n) = looks(|| s.execute("text.thread.releaseSelection", &json!({})));
|
||||
released.unwrap();
|
||||
assert!(n <= 2 * 450, "{n} id lookups");
|
||||
let rest: Vec<Vec<NodeId>> = threads.iter().map(|t| t[1..].to_vec()).collect();
|
||||
assert_eq!(s.doc().unwrap().doc.text_threads, rest);
|
||||
}
|
||||
|
||||
/// Remove Threading on the last frame of each of many threads finds the selected frames with one
|
||||
/// lookup per frame.
|
||||
#[test]
|
||||
fn removing_the_threads_of_many_frames_takes_one_pass() {
|
||||
let (mut s, threads) = many_threads(150, 3);
|
||||
let lasts: Vec<u64> = threads.iter().map(|t| t[2].0).collect();
|
||||
s.execute("select.set", &json!({ "ids": lasts })).unwrap();
|
||||
let (removed, n) = looks(|| s.execute("text.thread.remove", &json!({})));
|
||||
removed.unwrap();
|
||||
assert!(n <= 2 * 450, "{n} id lookups");
|
||||
assert!(s.doc().unwrap().doc.text_threads.is_empty());
|
||||
}
|
||||
|
||||
/// The text that a thread's last frame has no room for is measured as the frame lays it out,
|
||||
/// without the first-line indent of the paragraph it continues: when the frame is outlined, the
|
||||
/// frame before it takes only that text.
|
||||
|
||||
@@ -1225,8 +1225,8 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|(id, t)| merged(&t.area, t.dynamic_text).map(|o| (id, o)))
|
||||
.collect::<Result<_>>()?;
|
||||
// Dynamic Text's Block is refused for threaded frames, as `type.dynamicText` refuses it.
|
||||
let frames: Vec<String> =
|
||||
planned.iter().filter(|(id, (_, block))| *block && !super::threads::story_of(d, *id).is_empty()).map(|(id, _)| id.0.to_string()).collect();
|
||||
let threads = super::threads::ThreadIndex::of(d);
|
||||
let frames: Vec<String> = planned.iter().filter(|(id, (_, block))| *block && threads.threaded(*id)).map(|(id, _)| id.0.to_string()).collect();
|
||||
if !frames.is_empty() {
|
||||
let frames = frames.join(", ");
|
||||
return Err(bad(C, format!("area type in a thread cannot use Dynamic Text: {frames}")));
|
||||
|
||||
@@ -1663,6 +1663,108 @@ fn tate_chu_yoko_settings_are_character_attributes_saved_only_when_set() {
|
||||
assert_eq!(offsets(&s), before);
|
||||
}
|
||||
|
||||
/// Warichu and Warichu Settings (Character panel menu, East Asian options): character attributes
|
||||
/// saved only when they differ from their defaults (off, 2 lines, no gap, 50%, Auto). Both
|
||||
/// commands set them in one undo step, a call that changes nothing records none, a bad value is
|
||||
/// refused naming the key before anything changes, the file and character styles keep them, and
|
||||
/// SVG and PDF write type with warichu as outlines.
|
||||
#[test]
|
||||
fn warichu_is_a_character_attribute_set_saved_and_exported() {
|
||||
use vectorcraft_doc::WarichuAlign;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ab cdef g"})).unwrap()["id"].as_u64().unwrap();
|
||||
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.runs.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let settings = |s: &Session| {
|
||||
runs(s)
|
||||
.iter()
|
||||
.map(|r| (r.text.clone(), r.style.warichu, r.style.warichu_lines, r.style.warichu_gap, r.style.warichu_scale, r.style.warichu_align))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
|
||||
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
|
||||
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
let text_ops = |pdf: &[u8]| {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
s.contains("Tj") || s.contains("TJ")
|
||||
};
|
||||
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
|
||||
|
||||
// text.setFormat with the values the characters already have changes nothing and records no
|
||||
// undo step.
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"warichu": false, "warichuLines": 2, "warichuAlign": "auto"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let p = json!({"id": id, "start": 3, "end": 7, "warichu": true, "warichuLines": 3, "warichuGap": 2.5, "warichuScale": 40, "warichuAlign": "justifyAll"});
|
||||
s.execute("text.setRangeStyle", &p).unwrap();
|
||||
let want = vec![
|
||||
("ab ".to_string(), false, 2, 0.0, 50.0, WarichuAlign::Auto),
|
||||
("cdef".to_string(), true, 3, 2.5, 40.0, WarichuAlign::JustifyAll),
|
||||
(" g".to_string(), false, 2, 0.0, 50.0, WarichuAlign::Auto),
|
||||
];
|
||||
assert_eq!(settings(&s), want);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let saved = serde_json::to_value(&runs(&s)[1].style).unwrap();
|
||||
assert_eq!(
|
||||
[&saved["warichu"], &saved["warichuLines"], &saved["warichuGap"], &saved["warichuScale"], &saved["warichuAlign"]],
|
||||
[&json!(true), &json!(3), &json!(2.5), &json!(40.0), &json!("justifyAll")]
|
||||
);
|
||||
// Saved and reopened as they were.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(
|
||||
t.runs.iter().map(|r| (r.style.warichu, r.style.warichu_lines, r.style.warichu_align)).collect::<Vec<_>>(),
|
||||
[(false, 2, WarichuAlign::Auto), (true, 3, WarichuAlign::JustifyAll), (false, 2, WarichuAlign::Auto)]
|
||||
);
|
||||
// A character style captured from the characters carries them.
|
||||
let attrs = super::super::textstyles::char_attrs(&runs(&s)[1].style);
|
||||
assert_eq!(
|
||||
(attrs["warichu"].clone(), attrs["warichuLines"].clone(), attrs["warichuAlign"].clone()),
|
||||
(json!(true), json!(3), json!("justifyAll"))
|
||||
);
|
||||
// Exports draw the stacked lines as outlines.
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert!(!svg.contains("<text"), "{svg}");
|
||||
let pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
assert!(!text_ops(&pdf));
|
||||
|
||||
// Undo takes the whole change back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(settings(&s).iter().all(|r| !r.1 && r.2 == 2));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
|
||||
// text.setFormat sets the selected objects' characters.
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"warichuScale": 70})).unwrap();
|
||||
assert!(settings(&s).iter().all(|r| r.4 == 70.0));
|
||||
|
||||
let before = settings(&s);
|
||||
for (cmd, p, want) in [
|
||||
("text.setFormat", json!({"warichuLines": 7}), "`warichuLines` must be a whole number from 1 to 6"),
|
||||
("text.setFormat", json!({"warichuLines": 2.5}), "`warichuLines` must be a whole number from 1 to 6"),
|
||||
("text.setFormat", json!({"warichuScale": 5}), "`warichuScale` must be from 10 to 100"),
|
||||
("text.setFormat", json!({"warichuGap": -1}), "`warichuGap` must be from 0 to 100"),
|
||||
("text.setRangeStyle", json!({"id": id, "warichuGap": "2"}), "`warichuGap` must be a number"),
|
||||
("text.setRangeStyle", json!({"id": id, "warichu": "yes"}), "`warichu` must be true or false"),
|
||||
(
|
||||
"text.setRangeStyle",
|
||||
json!({"id": id, "warichuAlign": "middle"}),
|
||||
"`warichuAlign` must be one of \"auto\", \"left\", \"center\", \"right\", \"justifyLeft\", \"justifyCenter\", \"justifyRight\", \"justifyAll\"",
|
||||
),
|
||||
] {
|
||||
let e = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}");
|
||||
}
|
||||
assert_eq!(settings(&s), before);
|
||||
}
|
||||
|
||||
/// Insert Aki Left and Right (Character panel, East Asian options): character attributes in
|
||||
/// thousandths of an em, 0 by default and saved only when not 0. Both commands set either side
|
||||
/// alone, keeping the other side of every run, in one undo step; a value that is not a number or
|
||||
|
||||
@@ -220,6 +220,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool {
|
||||
st.baseline_shift *= f;
|
||||
st.tcy_up_down *= f;
|
||||
st.tcy_left_right *= f;
|
||||
st.warichu_gap *= f;
|
||||
st.leading = Some((st.size + gap).max(min_leading));
|
||||
if let Some(art) = &mut piece.inline {
|
||||
art.baseline_shift *= f;
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use super::object::choice;
|
||||
use super::threads::ThreadIndex;
|
||||
use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
|
||||
@@ -556,7 +557,7 @@ fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
|
||||
let open = open.join(", ");
|
||||
return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}"));
|
||||
}
|
||||
let frames = threaded_ids(d, ids);
|
||||
let frames = threaded_ids(&ThreadIndex::of(d), ids);
|
||||
(!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}"))
|
||||
}
|
||||
|
||||
@@ -783,11 +784,6 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
options(s)
|
||||
}
|
||||
|
||||
/// Whether type `id` is a frame of a thread.
|
||||
fn threaded(d: &Document, id: NodeId) -> bool {
|
||||
!super::threads::story_of(d, id).is_empty()
|
||||
}
|
||||
|
||||
/// Type on a path or area type: the type Dynamic Text works on.
|
||||
fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> {
|
||||
path_text(n).or_else(|| area_text(n))
|
||||
@@ -820,17 +816,18 @@ fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> {
|
||||
/// path first; None when none is.
|
||||
fn threaded_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
|
||||
let (paths, areas): (Vec<NodeId>, Vec<NodeId>) = ids.iter().copied().partition(|id| path_text(d.node(*id)).is_some());
|
||||
let frames = threaded_ids(d, &paths);
|
||||
let threads = ThreadIndex::of(d);
|
||||
let frames = threaded_ids(&threads, &paths);
|
||||
if !frames.is_empty() {
|
||||
return Some(format!("type on a path in a thread cannot use Dynamic Text: {frames}"));
|
||||
}
|
||||
let frames = threaded_ids(d, &areas);
|
||||
let frames = threaded_ids(&threads, &areas);
|
||||
(!frames.is_empty()).then(|| format!("area type in a thread cannot use Dynamic Text: {frames}"))
|
||||
}
|
||||
|
||||
/// The ids among `ids` of frames of threads, as a list for a message; empty when there are none.
|
||||
fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
|
||||
ids.iter().filter(|id| threaded(d, **id)).map(|id| id.0.to_string()).collect::<Vec<_>>().join(", ")
|
||||
/// The ids among `ids` of frames of `threads`, as a list for a message; empty when there are none.
|
||||
fn threaded_ids(threads: &ThreadIndex, ids: &[NodeId]) -> String {
|
||||
ids.iter().filter(|id| threads.threaded(**id)).map(|id| id.0.to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on
|
||||
@@ -969,7 +966,7 @@ fn has_preset_type(s: &Session) -> std::result::Result<(), String> {
|
||||
if ids.is_empty() {
|
||||
return Err("select point type or type on a path".into());
|
||||
}
|
||||
let frames = threaded_ids(d, &ids);
|
||||
let frames = threaded_ids(&ThreadIndex::of(d), &ids);
|
||||
if !frames.is_empty() {
|
||||
return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}"));
|
||||
}
|
||||
@@ -1108,7 +1105,7 @@ fn dynamic_text_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select point type or type on a path"));
|
||||
}
|
||||
let frames = threaded_ids(d, &ids);
|
||||
let frames = threaded_ids(&ThreadIndex::of(d), &ids);
|
||||
if !frames.is_empty() {
|
||||
return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}")));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Free Distort",
|
||||
[],
|
||||
None,
|
||||
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
|
||||
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays; each warped object's bounding box (the objects inside groups and the art of linked masks too) turns as the warp turns the art at its center (object.resetBoundingBox squares it again); corners where the box already stands change nothing and add no undo step",
|
||||
has_doc,
|
||||
distort
|
||||
),
|
||||
@@ -109,11 +109,18 @@ impl Projective {
|
||||
|
||||
/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine
|
||||
/// approximation at their center. The art of a linked opacity mask is warped with the object it
|
||||
/// masks; an unlinked one stays where it is.
|
||||
fn warp_node(n: &mut Node, pr: &Projective) {
|
||||
/// masks; an unlinked one stays where it is. Each warped object's bounding-box angle follows the
|
||||
/// warp's affine approximation at that object's own center before the warp (as for the objects
|
||||
/// with no editable points); an object with no bounds keeps its angle, and a layer has none.
|
||||
/// Returns `n`'s geometric bounds before the warp ([`Node::geometric_bounds`]; none for a layer),
|
||||
/// gathered from its children's so that each subtree is measured once.
|
||||
fn warp_node(n: &mut Node, pr: &Projective) -> Option<Rect> {
|
||||
n.pin_gradients();
|
||||
match &mut n.kind {
|
||||
// The children before these are left out of a group's or compound path's bounds.
|
||||
let skip = n.children().map_or(0, Vec::len) - n.editing_children().len();
|
||||
let before = match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
let b = path.bounds();
|
||||
*live = None;
|
||||
for sp in &mut path.subpaths {
|
||||
for a in &mut sp.anchors {
|
||||
@@ -122,24 +129,41 @@ fn warp_node(n: &mut Node, pr: &Projective) {
|
||||
a.h_out = pr.apply(a.h_out);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
warp_node(Arc::make_mut(c), pr);
|
||||
}
|
||||
NodeKind::Layer { children, .. } => {
|
||||
warp_children(children, pr, |_| false);
|
||||
None
|
||||
}
|
||||
// A clip group is bounded by its clip path.
|
||||
NodeKind::Group { children, clip: true } => warp_children(children, pr, |i| i == 0),
|
||||
NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => warp_children(children, pr, |i| i >= skip),
|
||||
_ => {
|
||||
// No editable points: the affine approximation (which maps the gradients too).
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
let b = n.geometric_bounds();
|
||||
if let Some(b) = b {
|
||||
n.transform(pr.affine_near(b.center()), false);
|
||||
}
|
||||
return;
|
||||
return b;
|
||||
}
|
||||
};
|
||||
if let Some(b) = before {
|
||||
n.bbox_angle = vectorcraft_doc::orient::transformed_angle(n.bbox_angle, pr.affine_near(b.center()));
|
||||
}
|
||||
if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) {
|
||||
warp_node(Arc::make_mut(&mut m.art), pr);
|
||||
}
|
||||
n.appearance.warp_gradients(&|p| pr.affine_near(p));
|
||||
before
|
||||
}
|
||||
|
||||
/// Warps each of `children` and returns the union of the pre-warp bounds of those `counts` picks
|
||||
/// by index.
|
||||
fn warp_children(children: &mut [Arc<Node>], pr: &Projective, counts: impl Fn(usize) -> bool) -> Option<Rect> {
|
||||
children.iter_mut().enumerate().fold(None, |acc, (i, c)| {
|
||||
let b = warp_node(Arc::make_mut(c), pr);
|
||||
if counts(i) { vectorcraft_geom::union_opt(acc, b) } else { acc }
|
||||
})
|
||||
}
|
||||
|
||||
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -178,6 +202,11 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !r.width().is_finite() || !r.height().is_finite() || r.width().abs() < 1e-9 || r.height().abs() < 1e-9 {
|
||||
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
|
||||
}
|
||||
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
|
||||
// Corners that stay where they are change nothing: no edit and no undo step.
|
||||
if [tl, tr, br, bl] == src.corners() {
|
||||
return Ok(ids_json);
|
||||
}
|
||||
let pr = Projective::from_box(src, [tl, tr, br, bl]);
|
||||
s.edit("Free Distort", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -187,7 +216,7 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
Ok(ids_json)
|
||||
}
|
||||
|
||||
// ---------- eyedropper ----------
|
||||
|
||||
@@ -37,6 +37,9 @@ pub struct DocState {
|
||||
/// The objects Start Global Edit would take, highlighted before it starts
|
||||
/// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable.
|
||||
pub(crate) similar_outlines: cmd::globaledit::SimilarCache,
|
||||
/// Whether Start Global Edit supports the one selected object
|
||||
/// ([`cmd::globaledit::SupportCache`]), as of a revision. Not saved, not undoable.
|
||||
pub(crate) global_edit_support: cmd::globaledit::SupportCache,
|
||||
/// What leaving symbol editing mode goes back to ([`cmd::symboledit`]).
|
||||
pub(crate) symbol_edit_return: cmd::symboledit::Return,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -311,6 +314,7 @@ impl DocState {
|
||||
isolation: None,
|
||||
global_edit: None,
|
||||
similar_outlines: Default::default(),
|
||||
global_edit_support: Default::default(),
|
||||
symbol_edit_return: Default::default(),
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
|
||||
@@ -54,7 +54,26 @@ fn rect_in_range(r: vectorcraft_geom::Rect) -> bool {
|
||||
}
|
||||
|
||||
/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)?
|
||||
///
|
||||
/// A path or compound path whose anchors and handles are all finite and inside the range by a
|
||||
/// margin passes without being measured: its curves stay inside their control points. Any other
|
||||
/// object, and a path with a point outside that margin, is measured.
|
||||
pub(crate) fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range))
|
||||
ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).is_none_or(|n| points_in_range(n) || n.geometric_bounds().is_none_or(rect_in_range)))
|
||||
}
|
||||
|
||||
/// Coordinates at most this far from 0 keep a curve's measured bounds within [`MAX_COORD`]
|
||||
/// whatever the rounding of its extrema.
|
||||
const POINTS_LIMIT: f64 = MAX_COORD * (1.0 - 1e-9);
|
||||
|
||||
/// Whether every anchor and handle of path `n`, or of each path of compound path `n`, is finite and
|
||||
/// within [`POINTS_LIMIT`]. `false` for any other object.
|
||||
fn points_in_range(n: &vectorcraft_doc::Node) -> bool {
|
||||
let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= POINTS_LIMIT && p.y.abs() <= POINTS_LIMIT;
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)),
|
||||
NodeKind::Compound { .. } => n.editing_children().iter().all(|c| points_in_range(c)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
|
||||
mod actionset;
|
||||
pub(super) mod adjust;
|
||||
mod ai_open_path;
|
||||
mod anchor_edit_work;
|
||||
mod appearance;
|
||||
mod areafit;
|
||||
mod arrowheads;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! An edit of one anchor of a long selected path does not measure the path's segments
|
||||
//! ([`vectorcraft_geom::path::measured_segments`]) to check that it stays within the canvas, and
|
||||
//! an edit that leaves the anchor as it was records no undo step.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
|
||||
use vectorcraft_geom::path::measured_segments;
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, SubPath};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Most segments one anchor edit may measure, however many anchors the path has.
|
||||
const MOST_MEASURED: u64 = 16;
|
||||
|
||||
/// A session whose document holds one open zigzag path of `n` anchors, selected, with its anchor 5
|
||||
/// direct-selected.
|
||||
fn long_path(n: usize) -> (Session, NodeId) {
|
||||
let mut d = Document::new(2000.0, 1000.0);
|
||||
let anchors = (0..n).map(|i| Anchor::corner(Point::new(i as f64 * 0.1, if i % 2 == 0 { 100.0 } else { 110.0 }))).collect();
|
||||
let id = d.alloc_id();
|
||||
let node = Node::path(id, PathData::single(SubPath::new(anchors, false)), Appearance::default_art());
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
|
||||
ch.push(Arc::new(node));
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(d, None);
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.selection.set([id]);
|
||||
st.selection.anchors.insert(id, BTreeSet::from([(0, 5)]));
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn anchor(s: &Session, id: NodeId, ai: usize) -> Anchor {
|
||||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai]
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Runs `cmd` with `p` and returns how many segments it measured.
|
||||
fn measured(s: &mut Session, cmd: &str, p: Value) -> u64 {
|
||||
let before = measured_segments();
|
||||
s.execute(cmd, &p).unwrap();
|
||||
measured_segments() - before
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_anchor_edit_of_a_long_path_measures_few_segments() {
|
||||
for n in [100, 10_000] {
|
||||
let (mut s, id) = long_path(n);
|
||||
let a = anchor(&s, id, 5);
|
||||
let m = measured(&mut s, "path.moveAnchor", json!({"id": id.0, "anchor": 5, "x": 3, "y": 50}));
|
||||
assert!(m <= MOST_MEASURED, "path.moveAnchor on {n} anchors measured {m} segments");
|
||||
assert_eq!(anchor(&s, id, 5).p, Point::new(3.0, 50.0));
|
||||
let m = measured(&mut s, "path.setHandle", json!({"id": id.0, "anchor": 5, "which": "out", "x": 4, "y": 40}));
|
||||
assert!(m <= MOST_MEASURED, "path.setHandle on {n} anchors measured {m} segments");
|
||||
assert_eq!(anchor(&s, id, 5).h_out, Point::new(4.0, 40.0));
|
||||
let m = measured(&mut s, "path.moveAnchors", json!({"dx": 1, "dy": 0}));
|
||||
assert!(m <= MOST_MEASURED, "path.moveAnchors on {n} anchors measured {m} segments");
|
||||
let m = measured(&mut s, "object.nudge", json!({"dx": 1, "dy": 0}));
|
||||
assert!(m <= MOST_MEASURED, "object.nudge on {n} anchors measured {m} segments");
|
||||
assert_eq!(anchor(&s, id, 5).p, Point::new(5.0, 50.0));
|
||||
// Only anchor 5 moved, and undoing the four edits gives it back.
|
||||
assert_eq!(anchor(&s, id, 4), Anchor::corner(Point::new(4.0 * 0.1, 100.0)));
|
||||
assert_eq!(anchor(&s, id, 6), Anchor::corner(Point::new(6.0 * 0.1, 100.0)));
|
||||
for _ in 0..4 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(anchor(&s, id, 5), a);
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle past the canvas is measured: the edit stands while the curve stays within the canvas,
|
||||
/// and is refused once the curve leaves it.
|
||||
#[test]
|
||||
fn a_handle_past_the_canvas_is_measured() {
|
||||
let (mut s, id) = long_path(10_000);
|
||||
// The curve from anchor 5 reaches 4/9 of the way to its outgoing handle.
|
||||
let x = 2.0 * MAX_COORD;
|
||||
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": x, "y": 110})).unwrap();
|
||||
assert_eq!(anchor(&s, id, 5).h_out, Point::new(x, 110.0));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = undo_steps(&s);
|
||||
let x = 3.0 * MAX_COORD;
|
||||
assert!(s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": x, "y": 110})).is_err());
|
||||
assert!(s.doc().unwrap().doc == before, "the refused edit changed the document");
|
||||
assert_eq!(undo_steps(&s), undo);
|
||||
}
|
||||
|
||||
/// `path.moveAnchor` to where the anchor is and `path.setHandle` to where the handle is change
|
||||
/// nothing and record no undo step.
|
||||
#[test]
|
||||
fn an_anchor_edit_that_changes_nothing_records_no_undo_step() {
|
||||
let (mut s, id) = long_path(100);
|
||||
let a = anchor(&s, id, 5);
|
||||
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 5, "x": a.p.x, "y": a.p.y})).unwrap();
|
||||
assert_eq!(undo_steps(&s), 0, "path.moveAnchor recorded an undo step");
|
||||
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 5, "which": "out", "x": a.h_out.x, "y": a.h_out.y})).unwrap();
|
||||
assert_eq!(undo_steps(&s), 0, "path.setHandle recorded an undo step");
|
||||
assert_eq!(anchor(&s, id, 5), a);
|
||||
// An anchor that is not there is still refused.
|
||||
assert!(s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 100, "x": 0, "y": 0})).is_err());
|
||||
assert!(s.execute("path.setHandle", &json!({"id": id.0, "anchor": 100, "which": "out", "x": 0, "y": 0})).is_err());
|
||||
// A change still records one.
|
||||
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 5, "x": a.p.x + 1.0, "y": a.p.y})).unwrap();
|
||||
assert_eq!(undo_steps(&s), 1);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, OrientedBox};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -190,6 +190,227 @@ fn free_transform_distort_starts_from_the_turned_box() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the line through `a` and `b` crosses the line through `c` and `d`.
|
||||
fn cross(a: Point, b: Point, c: Point, d: Point) -> Point {
|
||||
let (r, q) = (b - a, d - c);
|
||||
let t = ((c - a).x * q.y - (c - a).y * q.x) / (r.x * q.y - r.y * q.x);
|
||||
a + r * t
|
||||
}
|
||||
|
||||
/// The counter-clockwise angle of the direction from `from` to `to`.
|
||||
fn direction(from: Point, to: Point) -> f64 {
|
||||
-(to.y - from.y).atan2(to.x - from.x).to_degrees()
|
||||
}
|
||||
|
||||
fn near_deg(a: f64, b: f64) -> bool {
|
||||
vectorcraft_geom::normalize_deg(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn json_points(pts: [Point; 4]) -> Value {
|
||||
json!(pts.map(|p| [p.x, p.y]))
|
||||
}
|
||||
|
||||
/// `pts` turned by `deg` (counter-clockwise) about `c`.
|
||||
fn turned(pts: [Point; 4], c: Point, deg: f64) -> [Point; 4] {
|
||||
let a = Affine::translate(c.to_vec2()) * Affine::rotate(-deg.to_radians()) * Affine::translate(-c.to_vec2());
|
||||
pts.map(|p| a * p)
|
||||
}
|
||||
|
||||
/// A perspective quad for the 100 × 100 box at the origin: its top-right corner raised 20 pt.
|
||||
const RAISED: [(f64, f64); 4] = [(0.0, 0.0), (100.0, -20.0), (100.0, 100.0), (0.0, 100.0)];
|
||||
|
||||
fn raised() -> [Point; 4] {
|
||||
RAISED.map(|(x, y)| Point::new(x, y))
|
||||
}
|
||||
|
||||
/// The angle a warp onto `quad` (from a box square to the page) turns an upright object's x axis
|
||||
/// to, where the object's center lands at `center`: every horizontal line of the box is drawn
|
||||
/// through the point where the lines of the quad's top and bottom sides cross.
|
||||
fn upright_angle_after(quad: [Point; 4], center: Point) -> f64 {
|
||||
direction(cross(quad[0], quad[1], quad[3], quad[2]), center)
|
||||
}
|
||||
|
||||
/// Where a warped rectangle's center lands: where the diagonals of its warped corners cross.
|
||||
fn warped_center(s: &Session, id: NodeId) -> Point {
|
||||
let p = anchors(s, id);
|
||||
assert_eq!(p.len(), 4, "{p:?}");
|
||||
cross(p[0], p[2], p[1], p[3])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_turns_an_upright_box_with_the_warp_at_its_center() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let quad = raised();
|
||||
s.execute("object.distort", &json!({ "corners": json_points(quad) })).unwrap();
|
||||
// The top tilts up to the right: a small counter-clockwise angle (atan 0.1 for this quad).
|
||||
let want = upright_angle_after(quad, cross(quad[0], quad[2], quad[1], quad[3]));
|
||||
assert!(near(want, 0.1_f64.atan().to_degrees()), "{want}");
|
||||
assert!(near(angle(&s, a), want), "{} vs {want}", angle(&s, a));
|
||||
// The Selection and Free Transform tools' box stands at it.
|
||||
assert!(near(selection_box(&s).angle, want), "{:?}", selection_box(&s));
|
||||
// Reset Bounding Box squares it to the page and leaves the art.
|
||||
let pts = anchors(&s, a);
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(angle(&s, a), 0.0);
|
||||
assert_eq!(anchors(&s, a), pts);
|
||||
|
||||
// A warp that only stretches a turned object along its own axes keeps its angle exactly.
|
||||
let mut s = session();
|
||||
let t = turned_rect(&mut s);
|
||||
let b = selection_box(&s);
|
||||
let wide = OrientedBox { angle: b.angle, rect: Rect::new(b.rect.x0, b.rect.y0, b.rect.x1 + 50.0, b.rect.y1) };
|
||||
let r = b.rect;
|
||||
s.execute("object.distort", &json!({"corners": json_points(wide.corners()), "from": [r.x0, r.y0, r.x1, r.y1], "angle": b.angle})).unwrap();
|
||||
assert_eq!(angle(&s, t), b.angle);
|
||||
assert!(near(selection_box(&s).rect.width(), 150.0), "{:?}", selection_box(&s));
|
||||
}
|
||||
|
||||
/// Every object's angle in `n`'s subtree, opacity mask art included, each with whether it is the
|
||||
/// art of an unlinked mask (or inside one).
|
||||
fn angles_with_links(n: &Node, unlinked: bool, out: &mut Vec<(f64, bool)>) {
|
||||
out.push((n.bbox_angle, unlinked));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
angles_with_links(c, unlinked, out);
|
||||
}
|
||||
if let Some(m) = &n.extras().mask {
|
||||
angles_with_links(&m.art, unlinked || !m.linked, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn group_angles(s: &Session, g: NodeId) -> Vec<(f64, bool)> {
|
||||
let mut out = vec![];
|
||||
angles_with_links(s.doc().unwrap().doc.node(g).unwrap(), false, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> NodeId {
|
||||
s.doc().unwrap().selection.objects[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_turns_each_member_and_linked_mask_by_its_own_warp() {
|
||||
let mut s = session();
|
||||
// A path turned 20° with a linked mask turned with it.
|
||||
let a = rect(&mut s, 100.0, 100.0, 60.0, 40.0);
|
||||
let am = rect(&mut s, 100.0, 100.0, 60.0, 40.0);
|
||||
sel(&mut s, &[a, am]);
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
// A path with an unlinked mask.
|
||||
let u = rect(&mut s, 200.0, 100.0, 40.0, 40.0);
|
||||
let um = rect(&mut s, 200.0, 100.0, 40.0, 40.0);
|
||||
sel(&mut s, &[u, um]);
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
|
||||
// A compound path turned 10°.
|
||||
let c1 = rect(&mut s, 100.0, 200.0, 40.0, 40.0);
|
||||
let c2 = rect(&mut s, 120.0, 220.0, 40.0, 40.0);
|
||||
sel(&mut s, &[c1, c2]);
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
let compound = selected(&s);
|
||||
s.execute("object.rotate", &json!({"angle": 10})).unwrap();
|
||||
// A clip group.
|
||||
let k1 = rect(&mut s, 250.0, 200.0, 50.0, 50.0);
|
||||
let k2 = rect(&mut s, 260.0, 210.0, 50.0, 50.0);
|
||||
sel(&mut s, &[k1, k2]);
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
let clip = selected(&s);
|
||||
sel(&mut s, &[a, u, compound, clip]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let before = group_angles(&s, g);
|
||||
assert!(before.len() >= 10 && before.iter().any(|(a, _)| near(*a, 20.0)) && before.iter().any(|(_, u)| *u), "{before:?}");
|
||||
// A warp that turns the group 30° about its center turns every object 30° more, except the
|
||||
// art of the unlinked mask, which stays.
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[g], false).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.distort", &json!({ "corners": json_points(turned(OrientedBox::aligned(b).corners(), b.center(), 30.0)) })).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
let after = group_angles(&s, g);
|
||||
assert_eq!(after.len(), before.len());
|
||||
for ((old, unlinked), (new, _)) in before.iter().zip(&after) {
|
||||
if *unlinked {
|
||||
assert_eq!(new, old, "{before:?} → {after:?}");
|
||||
} else {
|
||||
assert!(near_deg(*new, old + 30.0), "{before:?} → {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// Under a perspective warp each object follows the warp at its own center: two members of a
|
||||
// group, one above the other, tilt by different amounts, and the group by the warp at its own
|
||||
// center.
|
||||
let mut s = session();
|
||||
let p = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
let q = rect(&mut s, 0.0, 50.0, 100.0, 50.0);
|
||||
sel(&mut s, &[p, q]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let quad = raised();
|
||||
s.execute("object.distort", &json!({ "corners": json_points(quad) })).unwrap();
|
||||
let (want_p, want_q) = (upright_angle_after(quad, warped_center(&s, p)), upright_angle_after(quad, warped_center(&s, q)));
|
||||
assert!(near(angle(&s, p), want_p) && near(angle(&s, q), want_q), "{} {} vs {want_p} {want_q}", angle(&s, p), angle(&s, q));
|
||||
assert!(want_p > want_q + 1.0, "{want_p} {want_q}");
|
||||
let want_g = upright_angle_after(quad, cross(quad[0], quad[2], quad[1], quad[3]));
|
||||
assert!(near(angle(&s, g), want_g), "{} vs {want_g}", angle(&s, g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrored_and_collapsing_distorts_keep_valid_angles() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
// Corners swapped left for right mirror the art: the box stands at the mirrored angle.
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||||
let [tl, tr, br, bl] = OrientedBox::aligned(b).corners();
|
||||
s.execute("object.distort", &json!({ "corners": json_points([tr, tl, bl, br]) })).unwrap();
|
||||
assert!(near(angle(&s, a), -30.0), "{}", angle(&s, a));
|
||||
// A warp that collapses the art to a point leaves the angle as it was.
|
||||
let n = undo_len(&s);
|
||||
let c = b.center();
|
||||
match s.execute("object.distort", &json!({ "corners": json_points([c; 4]) })) {
|
||||
Ok(_) => assert!(near(angle(&s, a), -30.0), "{}", angle(&s, a)),
|
||||
Err(_) => {
|
||||
assert_eq!(undo_len(&s), n);
|
||||
assert!(near(angle(&s, a), -30.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_angles_undo_redo_and_reopen_together() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
let before = anchors(&s, a);
|
||||
let n = undo_len(&s);
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
s.execute("object.distort", &json!({ "corners": json_points(turned(OrientedBox::aligned(b).corners(), b.center(), 30.0)) })).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Free Distort"));
|
||||
assert!(near(angle(&s, a), 30.0), "{}", angle(&s, a));
|
||||
let warped = anchors(&s, a);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((anchors(&s, a), angle(&s, a)), (before, 0.0));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(anchors(&s, a), warped);
|
||||
assert!(near(angle(&s, a), 30.0));
|
||||
// A native file keeps the angle.
|
||||
let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
s.execute("document.open", &json!({"name": "warped.vectorcraft", "dataBase64": data})).unwrap();
|
||||
assert!(near(angle(&s, a), 30.0));
|
||||
sel(&mut s, &[a]);
|
||||
let b = selection_box(&s);
|
||||
assert!(near(b.angle, 30.0) && near(b.rect.width(), 100.0) && near(b.rect.height(), 100.0), "{b:?}");
|
||||
// The next handle drag scales along the turned box: the Right handle 50 pt further out.
|
||||
let right = b.to_doc() * Handle::Right.pos(b.rect);
|
||||
let to = b.to_doc() * (Handle::Right.pos(b.rect) + vectorcraft_geom::Vec2::new(50.0, 0.0));
|
||||
s.select_tool("selection", ViewInfo::default()).unwrap();
|
||||
drag_with(&mut s, &[right, to, to], Mods::default());
|
||||
let nb = selection_box(&s);
|
||||
assert!(near(nb.angle, 30.0) && near(nb.rect.width(), 150.0) && near(nb.rect.height(), 100.0), "{nb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_panel_sizes_follow_the_turned_box() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -3087,6 +3087,144 @@ fn live_paint_bucket_tool_fills_face() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
|
||||
}
|
||||
|
||||
/// `tool.setOption` refuses an invalid value for the Live Paint tools, naming its key, whether the
|
||||
/// tool is active or not and in the `key`/`value` and `values` forms: a switch that is not true or
|
||||
/// false, a color that is not an RGB color (an alpha part included), a width that is not a number
|
||||
/// and an option the tool does not have. Nothing is set, also from the valid values sent with it,
|
||||
/// and the saved settings stay as they were.
|
||||
#[test]
|
||||
fn live_paint_tool_options_refuse_invalid_values_and_change_nothing() {
|
||||
let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)];
|
||||
let widths = [json!("wide"), json!(null), json!(true)];
|
||||
let switches = [json!("true"), json!(null), json!(1)];
|
||||
for (tool, keys) in
|
||||
[("livePaintBucket", &["paintFills", "paintStrokes", "highlight"][..]), ("livePaintSelection", &["selectFills", "selectStrokes"][..])]
|
||||
{
|
||||
let mut bad: Vec<(&str, Value)> = keys.iter().flat_map(|k| switches.iter().map(move |v| (*k, v.clone()))).collect();
|
||||
bad.extend(colors.iter().map(|v| ("color", v.clone())));
|
||||
bad.extend(widths.iter().map(|v| ("width", v.clone())));
|
||||
bad.push(("colour", json!("#00ff00")));
|
||||
for active in [false, true] {
|
||||
let mut s = session();
|
||||
if active {
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
}
|
||||
let (options, prefs) = (s.tool_options_of(tool), s.prefs.clone());
|
||||
for (key, v) in &bad {
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("width".into(), json!(9));
|
||||
values.insert(key.to_string(), v.clone());
|
||||
for p in [json!({"tool": tool, "values": values}), json!({"tool": tool, "key": key, "value": v})] {
|
||||
let e = s.set_tool_option_cmd(&p).unwrap_err();
|
||||
assert!(e.contains(&format!("`{key}`")), "{tool} {p}: {e}");
|
||||
assert_eq!((s.tool_options_of(tool), &s.prefs), (options.clone(), &prefs), "{tool} {p}");
|
||||
}
|
||||
}
|
||||
// A `key` left without a `value` is an invalid value too.
|
||||
assert!(s.set_tool_option_cmd(&json!({"tool": tool, "key": "color"})).is_err());
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
assert_eq!(s.tool_options_of(tool), options, "{tool}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Live Paint tools' options take each color form they took before, and a width out of range
|
||||
/// is clamped to 1–20 rather than refused; the values are saved with the preferences.
|
||||
#[test]
|
||||
fn live_paint_tool_options_take_rgb_colors_and_clamp_the_width() {
|
||||
let mut s = session();
|
||||
for tool in ["livePaintBucket", "livePaintSelection"] {
|
||||
for (color, hex) in [("#00FF00", "#00ff00"), ("#0f0", "#00ff00"), ("0000ff", "#0000ff"), ("light blue", "#4f80ff"), ("white", "#ffffff")] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "key": "color", "value": color})).unwrap();
|
||||
assert_eq!(o["color"], hex, "{tool} {color}");
|
||||
}
|
||||
for (width, set) in [(100.0, 20.0), (0.0, 1.0), (-3.0, 1.0), (7.5, 7.5)] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"width": width}})).unwrap();
|
||||
assert_eq!(o["width"], set, "{tool} {width}");
|
||||
}
|
||||
assert_eq!(s.prefs.tool_settings[tool]["width"], 7.5);
|
||||
assert_eq!(s.prefs.tool_settings[tool]["color"], "#ffffff");
|
||||
}
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": "livePaintBucket", "values": {"paintFills": false, "highlight": false}})).unwrap();
|
||||
assert_eq!((&o["paintFills"], &o["highlight"]), (&json!(false), &json!(false)));
|
||||
}
|
||||
|
||||
/// Saved Live Paint settings that hold an invalid value still load: the tool keeps its default for
|
||||
/// that option and takes the valid ones.
|
||||
#[test]
|
||||
fn live_paint_tools_load_saved_settings_with_invalid_values() {
|
||||
let mut s = session();
|
||||
let saved = json!({"paintFills": "yes", "highlight": false, "width": "wide", "color": "nope", "paintStrokes": true});
|
||||
s.prefs.tool_settings.insert("livePaintBucket".into(), saved.as_object().unwrap().clone());
|
||||
s.select_tool("livePaintBucket", ViewInfo::default()).unwrap();
|
||||
let o = s.tool_options_of("livePaintBucket");
|
||||
assert_eq!(
|
||||
(&o["paintFills"], &o["paintStrokes"], &o["highlight"], &o["width"], &o["color"]),
|
||||
(&json!(true), &json!(true), &json!(false), &json!(4.0), &json!("#ff2a2a"))
|
||||
);
|
||||
}
|
||||
|
||||
/// `tool.setOption` refuses an invalid value for the Shape Builder, naming its key, whether the
|
||||
/// tool is active or not and in the `key`/`value` and `values` forms: a switch that is not true or
|
||||
/// false, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not
|
||||
/// a number, a `highlightColor` that is not an RGB color (an alpha part included) and an option the
|
||||
/// tool does not have. Nothing is set, also from the valid values sent with it, and the saved
|
||||
/// settings stay as they were.
|
||||
#[test]
|
||||
fn shape_builder_tool_options_refuse_invalid_values_and_change_nothing() {
|
||||
let tool = "shapeBuilder";
|
||||
let switches = ["openFilledAsClosed", "cursorSwatchPreview", "highlightFill", "gapDetection", "clickStrokeSplitsPath", "highlightStroke"];
|
||||
let mut bad: Vec<(&str, Value)> = switches.iter().flat_map(|k| [json!("true"), json!(null), json!(1)].map(move |v| (*k, v))).collect();
|
||||
bad.extend([json!("Swatches"), json!(true), json!(null), json!("")].map(|v| ("pickColorFrom", v)));
|
||||
bad.extend([json!("straight"), json!(1), json!(null)].map(|v| ("selection", v)));
|
||||
bad.extend([json!("wide"), json!(null), json!(true)].map(|v| ("gapLength", v)));
|
||||
let colors = [json!(3), json!("nope"), json!("#12345"), json!("#00ff0080"), json!("00ff0080"), json!("#0f08"), json!(null)];
|
||||
bad.extend(colors.map(|v| ("highlightColor", v)));
|
||||
bad.push(("color", json!("#00ff00")));
|
||||
for active in [false, true] {
|
||||
let mut s = session();
|
||||
if active {
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
}
|
||||
let (options, prefs) = (s.tool_options_of(tool), s.prefs.clone());
|
||||
for (key, v) in &bad {
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("gapLength".into(), json!(9));
|
||||
values.insert(key.to_string(), v.clone());
|
||||
for p in [json!({"tool": tool, "values": values}), json!({"tool": tool, "key": key, "value": v})] {
|
||||
let e = s.set_tool_option_cmd(&p).unwrap_err();
|
||||
assert!(e.contains(&format!("`{key}`")), "{p}: {e}");
|
||||
assert_eq!((s.tool_options_of(tool), &s.prefs), (options.clone(), &prefs), "{p}");
|
||||
}
|
||||
}
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
assert_eq!(s.tool_options_of(tool), options);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Shape Builder's options take each value they took before: the names of `pickColorFrom` and
|
||||
/// `selection`, each RGB color form, and a gap length out of range clamped rather than refused.
|
||||
#[test]
|
||||
fn shape_builder_tool_options_take_valid_values_and_clamp_the_gap() {
|
||||
let mut s = session();
|
||||
let tool = "shapeBuilder";
|
||||
for (color, hex) in [("#00FF00", "#00ff00"), ("#0f0", "#00ff00"), ("0000ff", "#0000ff"), ("light blue", "#4f80ff"), ("white", "#ffffff")] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "key": "highlightColor", "value": color})).unwrap();
|
||||
assert_eq!(o["highlightColor"], hex, "{color}");
|
||||
}
|
||||
for (gap, set) in [(1000.0, vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP), (-3.0, 0.0), (7.5, 7.5)] {
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"gapLength": gap}})).unwrap();
|
||||
assert_eq!(o["gapLength"], set, "{gap}");
|
||||
}
|
||||
let o = s
|
||||
.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "swatches", "selection": "straightLine", "gapDetection": true}}))
|
||||
.unwrap();
|
||||
assert_eq!((&o["pickColorFrom"], &o["selection"], &o["gapDetection"]), (&json!("swatches"), &json!("straightLine"), &json!(true)));
|
||||
let o = s.set_tool_option_cmd(&json!({"tool": tool, "values": {"pickColorFrom": "artwork", "selection": "freeform"}})).unwrap();
|
||||
assert_eq!((&o["pickColorFrom"], &o["selection"]), (&json!("artwork"), &json!("freeform")));
|
||||
assert_eq!(s.prefs.tool_settings[tool]["gapLength"], 7.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_sample_paints_next_face_without_changing_selection() {
|
||||
let mut s = session();
|
||||
@@ -4134,7 +4272,10 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
sorted.sort();
|
||||
assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}");
|
||||
let n = node(&s, g);
|
||||
assert_eq!(names(&n), order, "{method}");
|
||||
// Overlapping fills the ring whole and draws the field inside it above the ring: that path starts a second
|
||||
// group of the field's color, above the black one.
|
||||
let want: Vec<String> = if method == "overlapping" { order.iter().chain(order.first()).cloned().collect() } else { order.clone() };
|
||||
assert_eq!(names(&n), want, "{method}");
|
||||
// Each group holds its color's paths, and in turn they are the paths of the trace without
|
||||
// byColor, in their order.
|
||||
let groups = n.children().unwrap();
|
||||
@@ -4148,8 +4289,8 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
|
||||
// Release to Layers: a sublayer for each color, in the same order.
|
||||
let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap();
|
||||
let subs = ids(&r);
|
||||
assert_eq!(subs.len(), 3, "{method}");
|
||||
for (sub, name) in subs.iter().zip(&order) {
|
||||
assert_eq!(subs.len(), want.len(), "{method}");
|
||||
for (sub, name) in subs.iter().zip(&want) {
|
||||
let l = node(&s, *sub);
|
||||
assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}");
|
||||
assert_eq!(names(&l), std::slice::from_ref(name), "{method}");
|
||||
|
||||
@@ -1367,6 +1367,32 @@ fn smooth_tool_reduces_jagged_path() {
|
||||
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
|
||||
}
|
||||
|
||||
/// The anchors a zigzag `amplitude` points high keeps after one `path.smoothRegion` drag along it
|
||||
/// at `fidelity`, with the path, the drag, its radius and the Fidelity all scaled by `scale`: the
|
||||
/// same drag on screen zoomed in 1 / `scale` times.
|
||||
fn smoothed_zigzag_anchors(amplitude: f64, fidelity: f64, scale: f64) -> usize {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> =
|
||||
(0..=40).map(|i| json!({"x": (100.0 + i as f64 * 5.0) * scale, "y": (200.0 + if i % 2 == 0 { 0.0 } else { amplitude }) * scale})).collect();
|
||||
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let drag: Vec<_> = [(90.0, 200.0), (200.0, 200.0), (310.0, 200.0)].iter().map(|&(x, y)| json!([x * scale, y * scale])).collect();
|
||||
s.execute("path.smoothRegion", &json!({"points": drag, "radius": 12.0 * scale, "fidelity": fidelity * scale})).unwrap();
|
||||
path(&s, id).anchor_count()
|
||||
}
|
||||
|
||||
/// The Smooth tool sends its Fidelity (screen pixels) in points at Fidelity / zoom, so
|
||||
/// `path.smoothRegion` refits at the tolerance it is sent from the lowest Fidelity at the highest
|
||||
/// zoom (0.05 px at 64000 %) to the highest at the lowest zoom (100 px at 3.13 %): a drag smooths
|
||||
/// as much on screen at any zoom.
|
||||
#[test]
|
||||
fn smooth_region_refits_at_the_tolerance_sent_at_any_zoom() {
|
||||
for (amplitude, fidelity, scale) in [(1.5, 0.05, 1.0 / 640.0), (1.5, 0.05, 1.0 / 8.0), (5.0, 20.0, 1.0 / 0.0313), (5.0, 20.0, 4.0)] {
|
||||
let want = smoothed_zigzag_anchors(amplitude, fidelity, 1.0);
|
||||
assert_eq!(smoothed_zigzag_anchors(amplitude, fidelity, scale), want, "{amplitude} pt zigzag at {fidelity} px, zoom {}", 1.0 / scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag of the Smooth tool along a line jagged by up to 6 pt leaves no corner between a curve and
|
||||
/// a line: every anchor between the path's ends turns by at most 1°.
|
||||
#[test]
|
||||
@@ -1606,6 +1632,83 @@ fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() {
|
||||
assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt");
|
||||
}
|
||||
|
||||
/// A straight line along y = 200 from x = 100 to 400, one anchor every 5 pt, whose second and
|
||||
/// second-to-last anchors are 1.5 pt off the line: its first and last segments leave and arrive at
|
||||
/// 16.7° to it.
|
||||
fn smooth_jagged_ends(s: &mut Session) -> NodeId {
|
||||
let anchors: Vec<_> = (0..=60).map(|i| json!({"x": 100.0 + 5.0 * f64::from(i), "y": if i == 1 || i == 59 { 201.5 } else { 200.0 }})).collect();
|
||||
smooth_test_path(s, &anchors, false)
|
||||
}
|
||||
|
||||
/// The degrees between the direction `sp` leaves its first anchor along (`at_end` false), or arrives
|
||||
/// at its last anchor along, and the x axis.
|
||||
fn smooth_end_angle(sp: &vectorcraft_geom::SubPath, at_end: bool) -> f64 {
|
||||
let c = sp.segment(if at_end { sp.segment_count() - 1 } else { 0 });
|
||||
let d = if at_end {
|
||||
[c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9)
|
||||
} else {
|
||||
[c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9)
|
||||
};
|
||||
d.map_or(f64::NAN, |d| d.y.atan2(d.x).to_degrees().abs())
|
||||
}
|
||||
|
||||
/// The largest turn, in degrees, at any anchor of `sp` (other than its ends) whose x is in `xs`.
|
||||
fn smooth_largest_turn(sp: &vectorcraft_geom::SubPath, xs: std::ops::Range<f64>) -> f64 {
|
||||
let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord);
|
||||
(1..sp.segment_count())
|
||||
.filter(|&i| xs.contains(&sp.anchors[i].p.x))
|
||||
.map(|i| {
|
||||
let (prev, next) = (sp.segment(i - 1), sp.segment(i));
|
||||
let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0);
|
||||
let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0);
|
||||
arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// A Smooth drag over the whole of an open path turns the curve at each end to follow the smoothed
|
||||
/// path rather than the jag of its first or last segment, and leaves both end points where they were.
|
||||
#[test]
|
||||
fn smoothing_an_open_path_turns_its_jagged_ends_with_the_path() {
|
||||
let mut s = session();
|
||||
let id = smooth_jagged_ends(&mut s);
|
||||
let before = path(&s, id);
|
||||
assert!(smooth_end_angle(&before.subpaths[0], false) > 16.0 && smooth_end_angle(&before.subpaths[0], true) > 16.0);
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.0, 90.0, 410.0));
|
||||
let p = path(&s, id);
|
||||
assert_ne!(p, before, "the path is smoothed");
|
||||
let sp = &p.subpaths[0];
|
||||
let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
|
||||
assert!(first == Point::new(100.0, 200.0) && last == Point::new(400.0, 200.0), "the ends stay: {first:?} {last:?}");
|
||||
let (start, end) = (smooth_end_angle(sp, false), smooth_end_angle(sp, true));
|
||||
assert!(start <= 5.0, "the path leaves its start at {start}° to the line");
|
||||
assert!(end <= 5.0, "and arrives at its end at {end}°");
|
||||
let off = smooth_test_samples(&p).iter().map(|q| (q.y - 200.0).abs()).fold(0.0, f64::max);
|
||||
assert!(off <= 1.5, "the path stays within {off} pt of the line");
|
||||
}
|
||||
|
||||
/// A Smooth drag that reaches only the start of an open path turns the curve there, and the rest of
|
||||
/// the path, its far end with its jag included, stays as it was, joining the refitted part without a
|
||||
/// corner.
|
||||
#[test]
|
||||
fn a_smooth_drag_turns_only_the_end_it_reaches() {
|
||||
let mut s = session();
|
||||
let id = smooth_jagged_ends(&mut s);
|
||||
let before = path(&s, id);
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.0, 90.0, 200.0));
|
||||
let p = path(&s, id);
|
||||
assert_ne!(p, before, "the start is smoothed");
|
||||
let sp = &p.subpaths[0];
|
||||
assert_eq!(sp.anchors[0].p, Point::new(100.0, 200.0), "the start stays");
|
||||
let start = smooth_end_angle(sp, false);
|
||||
assert!(start <= 5.0, "the path leaves its start at {start}° to the line");
|
||||
let kept = |sp: &vectorcraft_geom::SubPath| sp.anchors.iter().filter(|a| a.p.x >= 215.0).copied().collect::<Vec<_>>();
|
||||
assert_eq!(kept(sp), kept(&before.subpaths[0]), "the anchors out of reach stay as they were");
|
||||
assert!(smooth_end_angle(sp, true) > 16.0, "the far end keeps its jag");
|
||||
let turn = smooth_largest_turn(sp, 101.0..300.0);
|
||||
assert!(turn <= 1.0, "the refitted part joins the rest with a {turn}° turn");
|
||||
}
|
||||
|
||||
/// `path.smoothRegion` along `drag` with the Smooth tool's reach and default Fidelity → the ids changed.
|
||||
fn smooth_pass_ids(s: &mut Session, drag: &[Point]) -> serde_json::Value {
|
||||
let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect();
|
||||
|
||||
@@ -456,3 +456,116 @@ fn first_text(s: &Session) -> NodeId {
|
||||
});
|
||||
found.unwrap()
|
||||
}
|
||||
|
||||
/// Whether command `id` is enabled for `s`'s selection, with the reason when it is not.
|
||||
fn enabled(s: &Session, id: &str) -> std::result::Result<(), String> {
|
||||
command_specs().iter().find(|c| c.id == id).map_or_else(|| Err(format!("no command {id}")), |c| (c.enabled)(s))
|
||||
}
|
||||
|
||||
/// What `f` returned, and how many times it went through the document's text threads to tell
|
||||
/// which objects are frames of one.
|
||||
fn scans<T>(f: impl FnOnce() -> T) -> (T, usize) {
|
||||
let before = crate::cmd::threads::thread_scans();
|
||||
let out = f();
|
||||
(out, crate::cmd::threads::thread_scans() - before)
|
||||
}
|
||||
|
||||
/// Threads `a` with a new 100 pt square frame at (`x`, `y`); the thread's frames.
|
||||
fn thread_with_frame(s: &mut Session, a: NodeId, x: f64, y: f64) -> Vec<u64> {
|
||||
let frame = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, frame]})).unwrap();
|
||||
let r = s.execute("text.thread.create", &json!({})).unwrap();
|
||||
r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// `ids` as a type command's refusal lists them.
|
||||
fn listed(ids: &[u64]) -> String {
|
||||
ids.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Dynamic Text, its presets, Center on Top and Bottom and Area Type Options look up the threads
|
||||
/// once for a whole selection, not once for each object, and refuse the same frames in the same
|
||||
/// order.
|
||||
#[test]
|
||||
fn type_commands_look_up_threads_once_for_the_whole_selection() {
|
||||
const N: usize = 24;
|
||||
let mut s = session();
|
||||
let area = |s: &mut Session, i: usize| {
|
||||
let x = 10.0 + 30.0 * i as f64;
|
||||
let r = s.execute("text.create", &json!({"x": x, "y": 10, "text": format!("Area {i}"), "area": {"width": 25, "height": 40}})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
};
|
||||
// Area type: every other one is the first frame of a thread of two.
|
||||
let (mut areas, mut threaded) = (vec![], vec![]);
|
||||
for i in 0..N {
|
||||
let a = area(&mut s, i);
|
||||
areas.push(a.0);
|
||||
if i % 2 == 0 {
|
||||
let frames = thread_with_frame(&mut s, a, 10.0 + 30.0 * i as f64, 500.0);
|
||||
assert_eq!(frames.len(), 2, "{frames:?}");
|
||||
areas.push(frames[1]);
|
||||
threaded.extend(frames);
|
||||
}
|
||||
}
|
||||
// The refusal lists the threaded frames in the order the objects are given.
|
||||
areas.reverse();
|
||||
let refused: Vec<u64> = areas.iter().copied().filter(|id| threaded.contains(id)).collect();
|
||||
let before = undo_len(&s);
|
||||
s.execute("select.set", &json!({ "ids": areas })).unwrap();
|
||||
let why = format!("area type in a thread cannot use Dynamic Text: {}", listed(&refused));
|
||||
let (e, n) = scans(|| enabled(&s, "type.dynamicText"));
|
||||
assert_eq!((e, n), (Err(why.clone()), 1));
|
||||
let (e, n) = scans(|| err(s.execute("type.dynamicText", &json!({"on": true, "ids": areas}))));
|
||||
assert!(e.ends_with(&why) && n == 1, "{n}: {e}");
|
||||
let (e, n) = scans(|| err(s.execute("text.areaOptions", &json!({"fit": "block", "ids": areas}))));
|
||||
assert!(e.ends_with(&why) && n == 1, "{n}: {e}");
|
||||
assert_eq!(undo_len(&s), before);
|
||||
|
||||
// Type on a path in a thread comes first, before the area type.
|
||||
let path = on_line(&mut s, "Threaded path");
|
||||
let path_frames = thread_with_frame(&mut s, path, 700.0, 500.0);
|
||||
let mut mixed = areas.clone();
|
||||
mixed.push(path.0);
|
||||
s.execute("select.set", &json!({ "ids": mixed })).unwrap();
|
||||
let why = format!("type on a path in a thread cannot use Dynamic Text: {}", path.0);
|
||||
let (e, n) = scans(|| enabled(&s, "type.dynamicText"));
|
||||
assert_eq!((e, n), (Err(why.clone()), 1));
|
||||
let (e, n) = scans(|| err(s.execute("type.dynamicText", &json!({"on": true, "ids": mixed}))));
|
||||
assert!(e.ends_with(&why) && n == 1, "{n}: {e}");
|
||||
|
||||
// Presets: point type beside type on a path in a thread.
|
||||
let mut points: Vec<u64> = (0..N)
|
||||
.map(|i| s.execute("text.create", &json!({"x": 10.0 + 30.0 * i as f64, "y": 300, "text": "P"})).unwrap()["id"].as_u64().unwrap())
|
||||
.collect();
|
||||
points.insert(N / 2, path.0);
|
||||
s.execute("select.set", &json!({ "ids": points })).unwrap();
|
||||
let (e, n) = scans(|| enabled(&s, "type.dynamicTextPreset"));
|
||||
assert_eq!((e, n), (Err(why.clone()), 1));
|
||||
let (e, n) = scans(|| err(s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": points}))));
|
||||
assert!(e.ends_with(&why) && n == 1, "{n}: {e}");
|
||||
|
||||
// Center on Top and Bottom: type on closed paths, some of it in threads.
|
||||
let mut circles = vec![];
|
||||
let mut in_threads = vec![];
|
||||
for i in 0..N {
|
||||
let (x, y) = (60.0 + 30.0 * i as f64, 200.0);
|
||||
let d = format!("M{} {y} A12 12 0 1 1 {} {y} A12 12 0 1 1 {} {y} Z", x - 12.0, x + 12.0, x - 12.0);
|
||||
let pid = s.execute("path.create", &json!({ "d": d })).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "O", "size": 6, "at": [x - 12.0, y]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
if i % 3 == 0 {
|
||||
thread_with_frame(&mut s, id, x, 650.0);
|
||||
in_threads.push(id.0);
|
||||
}
|
||||
circles.push(id.0);
|
||||
}
|
||||
s.execute("select.set", &json!({ "ids": circles })).unwrap();
|
||||
let why = format!("type on a path in a thread cannot be centered on its top or bottom: {}", listed(&in_threads));
|
||||
for c in ["type.pathCenter.top", "type.pathCenter.bottom"] {
|
||||
let (e, n) = scans(|| enabled(&s, c));
|
||||
assert_eq!((e, n), (Err(why.clone()), 1), "{c}");
|
||||
let (e, n) = scans(|| err(s.execute(c, &json!({ "ids": circles }))));
|
||||
assert!(e.ends_with(&why) && n == 1, "{c} {n}: {e}");
|
||||
}
|
||||
assert_eq!(path_frames.len(), 2);
|
||||
}
|
||||
|
||||
@@ -330,18 +330,103 @@ fn global_edit_needs_one_supported_object() {
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
// A group holding type is refused, though another such group exists.
|
||||
// A group holding type is not offered, though another such group exists.
|
||||
let group = |s: &mut Session, x: f64| {
|
||||
let p = rect(s, x, 100.0, 10.0, 10.0);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 150, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
group_of(s, &[p, t])
|
||||
};
|
||||
let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0));
|
||||
assert_not_offered(&mut s, g, "a group holding type");
|
||||
}
|
||||
|
||||
fn enabled(s: &Session, id: &str) -> bool {
|
||||
command_specs().iter().find(|c| c.id == id).is_some_and(|c| (c.enabled)(s).is_ok())
|
||||
}
|
||||
|
||||
/// With `id` selected, Start Global Edit is disabled, and running it changes nothing.
|
||||
fn assert_not_offered(s: &mut Session, id: NodeId, what: &str) {
|
||||
sel(s, &[id]);
|
||||
assert!(!enabled(s, "select.globalEdit"), "{what}");
|
||||
let r = s.execute("select.globalEdit", &json!({}));
|
||||
assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("symbol instances and groups of them")), "{what}: {r:?}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none(), "{what}");
|
||||
}
|
||||
|
||||
/// An embedded 4 × 4 image at `x`, in `parent` (the default layer when `None`) → its id. The
|
||||
/// selection stays as it is.
|
||||
fn image(s: &mut Session, x: f64, parent: Option<NodeId>) -> NodeId {
|
||||
s.edit("Place", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::translate((x, 0.0)) * Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(parent.or(d.default_layer()), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_enabled_check_follows_edits_to_the_selected_group() {
|
||||
let mut s = session();
|
||||
let (g, _) = card(&mut s, 0.0, 1.0);
|
||||
card(&mut s, 300.0, 1.0);
|
||||
sel(&mut s, &[g]);
|
||||
// The menu only checks that it is a group; starting refuses its contents.
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("symbol instances and groups of them"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
// The answer is kept between frames, and an edit of the selected group changes it.
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
image(&mut s, 0.0, Some(g));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
assert!(!enabled(&s, "select.globalEdit"), "an image put into the selected group");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
assert!(enabled(&s, "select.globalEdit"), "after Undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_global_edit_is_offered_only_for_a_group_of_supported_objects() {
|
||||
let mut s = session();
|
||||
// Groups holding an image, groups holding a group that holds type, and empty groups; two of
|
||||
// each, so that each would have a group like it.
|
||||
for x in [0.0, 300.0] {
|
||||
let p = rect(&mut s, x, 100.0, 10.0, 10.0);
|
||||
let i = image(&mut s, x, None);
|
||||
let g = group_of(&mut s, &[p, i]);
|
||||
assert_not_offered(&mut s, g, "a group holding an image");
|
||||
let (p, q) = (rect(&mut s, x, 200.0, 10.0, 10.0), rect(&mut s, x + 20.0, 200.0, 10.0, 10.0));
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": x, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let inner = group_of(&mut s, &[q, t]);
|
||||
let g = group_of(&mut s, &[p, inner]);
|
||||
assert_not_offered(&mut s, g, "a group holding a group that holds type");
|
||||
let empty = s
|
||||
.edit("Group", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
d.insert(d.default_layer(), 0, Node::group(id, vec![]))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
assert_not_offered(&mut s, empty, "an empty group");
|
||||
}
|
||||
// A group of supported objects is offered and starts; while on, Stop is offered whatever is
|
||||
// selected.
|
||||
let p = rect(&mut s, 600.0, 400.0, 10.0, 10.0);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 600, "y": 450, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let typed = group_of(&mut s, &[p, t]);
|
||||
let (g, _) = card(&mut s, 0.0, 1.0);
|
||||
let (copy, _) = card(&mut s, 300.0, 1.0);
|
||||
sel(&mut s, &[g]);
|
||||
assert!(enabled(&s, "select.globalEdit"));
|
||||
assert_eq!(start(&mut s, g, json!({})), [g, copy]);
|
||||
sel(&mut s, &[typed]);
|
||||
assert!(enabled(&s, "select.globalEdit"), "Stop");
|
||||
assert_eq!(s.execute("select.globalEdit", &json!({})).unwrap()["active"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -89,6 +89,43 @@ fn bad_corners_and_kinds_are_errors() {
|
||||
assert_eq!(corners(&s, id), ([0.0; 4], [CornerKind::Round; 4], 4), "nothing changed");
|
||||
}
|
||||
|
||||
/// A side count that isn't a whole number from 3 to 1000, a negative radius and an unknown key
|
||||
/// (at the top level or in one of `items`) are errors naming the key; the shapes and the undo
|
||||
/// history stay as they were. A radius of 0 and the ends of the side range still work.
|
||||
#[test]
|
||||
fn bad_sides_radii_and_keys_are_errors() {
|
||||
let (mut s, id) = session();
|
||||
let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 80, "sides": 6})).unwrap()["id"].as_u64().unwrap());
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(corners(s, id), st.doc.node(poly).unwrap().clone(), st.history.undo.len())
|
||||
};
|
||||
let before = state(&s);
|
||||
for (p, key) in [
|
||||
(json!({"ids": [poly.0], "sides": 2.5}), "`sides` must be a whole number from 3 to 1000, not 2.5"),
|
||||
(json!({"ids": [poly.0], "sides": -1}), "`sides` must be a whole number from 3 to 1000, not -1"),
|
||||
(json!({"ids": [poly.0], "sides": 2}), "`sides` must be a whole number from 3 to 1000, not 2"),
|
||||
(json!({"ids": [poly.0], "sides": 1001}), "`sides` must be a whole number from 3 to 1000, not 1001"),
|
||||
(json!({"ids": [id.0, poly.0], "radius": -4}), "`radius` must be a radius of 0 or more, not -4"),
|
||||
(json!({"items": [{"id": id.0, "corners": [0]}], "radius": -0.5}), "`radius` must be a radius of 0 or more, not -0.5"),
|
||||
(json!({"id": id.0, "path": [[0, 0]], "radius": 5}), "unknown parameter `path`"),
|
||||
(json!({"items": [{"id": id.0, "corner": [0]}], "radius": 5}), "unknown parameter `items[0].corner`; did you mean `items[0].corners`?"),
|
||||
] {
|
||||
match s.execute("object.setLiveShape", &p) {
|
||||
Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "object.setLiveShape" && msg.starts_with(key), "{p}: {msg}"),
|
||||
other => panic!("{p}: {other:?}"),
|
||||
}
|
||||
assert_eq!(state(&s), before, "{p}");
|
||||
}
|
||||
run(&mut s, json!({"ids": [poly.0], "sides": 3}));
|
||||
assert!(matches!(node_path(&s, poly).1, Some(LiveShape::Polygon { sides: 3, .. })));
|
||||
run(&mut s, json!({"ids": [poly.0], "sides": 1000.0}));
|
||||
assert!(matches!(node_path(&s, poly).1, Some(LiveShape::Polygon { sides: 1000, .. })));
|
||||
run(&mut s, json!({"id": id.0, "radius": 6}));
|
||||
run(&mut s, json!({"id": id.0, "radius": 0}));
|
||||
assert_eq!(corners(&s, id).0, [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selected_corners_round_alone_and_stay_selected() {
|
||||
let (mut s, id) = session();
|
||||
|
||||
@@ -534,6 +534,16 @@ fn the_document_info_report_has_every_category() {
|
||||
let i = run(&mut s, "document.info", json!({"category": "linkedImages"}));
|
||||
assert_eq!((i["sections"].as_array().unwrap().len(), i["sections"][0]["id"].as_str()), (1, Some("linkedImages")));
|
||||
assert_eq!(i["sections"][0]["rows"].as_array().unwrap().len(), 2);
|
||||
// `imageObjects` lists the linked images in the rows' order with the files they show, and the
|
||||
// embedded one with no file.
|
||||
let linked: Vec<Value> = i["imageObjects"].as_array().unwrap().iter().filter(|o| o["linked"] == true).cloned().collect();
|
||||
for (o, r) in linked.iter().zip(i["sections"][0]["rows"].as_array().unwrap()) {
|
||||
let shown = format!("{} × {} px — {}", o["width"], o["height"], o["file"].as_str().unwrap());
|
||||
assert_eq!((&o["name"], shown.as_str()), (&r[0], r[1].as_str().unwrap()));
|
||||
}
|
||||
assert_eq!(linked.len(), 2);
|
||||
let embedded: Vec<&Value> = i["imageObjects"].as_array().unwrap().iter().filter(|o| o["linked"] == false).collect();
|
||||
assert_eq!((embedded.len(), &embedded[0]["file"], &embedded[0]["width"]), (1, &Value::Null, &json!(600)));
|
||||
let all = run(&mut s, "document.info", json!({}));
|
||||
assert_eq!(all["sections"].as_array().unwrap().len(), 13);
|
||||
let i = run(&mut s, "document.info", json!({"category": "brushes"}));
|
||||
|
||||
@@ -1683,6 +1683,95 @@ fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() {
|
||||
assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3);
|
||||
}
|
||||
|
||||
/// `n` artboards in a row, each with a guide, and a guide on no artboard; every guide is selected,
|
||||
/// and the key guide is the one on artboard `key`.
|
||||
fn artboards_with_selected_guides(n: usize, key: usize) -> Session {
|
||||
let mut s = row_of_artboards(n);
|
||||
s.edit("selection fixture", |d, sel| {
|
||||
d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 75.0));
|
||||
sel.guides = d.ruler_guides().iter().filter_map(|g| g.id).collect();
|
||||
let on_key = d.artboards[key].id;
|
||||
sel.key_guide = d.ruler_guides().iter().find(|g| g.artboard == Some(on_key)).and_then(|g| g.id);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A document's artboards, ruler guides, selected guides and key guide.
|
||||
type BoardsAndGuides = (Vec<vectorcraft_doc::Artboard>, Vec<vectorcraft_doc::Guide>, Vec<NodeId>, Option<NodeId>);
|
||||
|
||||
/// The artboards, guides, selected guides and key guide after deleting artboards `indices` of the
|
||||
/// open document one at a time, the last first. A clone shares the document's retired artboard id,
|
||||
/// so the clone retires none.
|
||||
fn deleted_one_at_a_time(s: &Session, indices: &[usize]) -> BoardsAndGuides {
|
||||
let st = s.doc().unwrap();
|
||||
let (mut d, mut sel) = ((*st.doc).clone(), st.selection.clone());
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retain_guides(&mut sel, |g| g.artboard != Some(id));
|
||||
}
|
||||
let guides = d.ruler_guides();
|
||||
(d.artboards, guides, sel.guides, sel.key_guide)
|
||||
}
|
||||
|
||||
/// The same as [`deleted_one_at_a_time`] for the open document as it is.
|
||||
fn artboards_and_guides(s: &Session) -> BoardsAndGuides {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.artboards.clone(), st.doc.ruler_guides(), st.selection.guides.clone(), st.selection.key_guide)
|
||||
}
|
||||
|
||||
/// Delete Artboard with many artboards chosen walks the artboards once and the ruler guides once,
|
||||
/// and leaves the same artboards, guides and selected guides as deleting them one at a time, in
|
||||
/// one undo step.
|
||||
#[test]
|
||||
fn deleting_many_artboards_walks_the_artboards_and_guides_once() {
|
||||
use crate::cmd::layer::ARTBOARD_PASSES;
|
||||
let mut s = artboards_with_selected_guides(40, 7);
|
||||
let indices: Vec<usize> = (0..40).filter(|i| i % 2 == 1 || *i == 38).collect();
|
||||
let expected = deleted_one_at_a_time(&s, &indices);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
ARTBOARD_PASSES.set(0);
|
||||
s.execute("artboard.delete", &json!({ "indices": indices })).unwrap();
|
||||
assert_eq!(ARTBOARD_PASSES.get(), 2, "one walk over the artboards and one over the guides");
|
||||
let now = artboards_and_guides(&s);
|
||||
assert_eq!(now, expected);
|
||||
assert_eq!(now.0.len(), 19);
|
||||
assert_eq!(now.1.len(), 20, "the kept artboards' guides and the guide on no artboard stay");
|
||||
assert_eq!((now.2.len(), now.3), (20, None), "the deleted guides leave the selection, the key guide with them");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 40);
|
||||
assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 41);
|
||||
}
|
||||
|
||||
/// Delete Empty Artboards and Cut Artboard delete their artboards in the same single pass, with the
|
||||
/// same result as deleting them one at a time.
|
||||
#[test]
|
||||
fn delete_empty_and_cut_artboards_walk_the_artboards_and_guides_once() {
|
||||
use crate::cmd::layer::ARTBOARD_PASSES;
|
||||
let mut s = artboards_with_selected_guides(30, 0);
|
||||
box_at(&mut s, 4010.0, 20.0);
|
||||
let empty: Vec<usize> = (0..30).filter(|i| *i != 10).collect();
|
||||
let expected = deleted_one_at_a_time(&s, &empty);
|
||||
ARTBOARD_PASSES.set(0);
|
||||
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({ "deleted": empty }));
|
||||
assert_eq!(ARTBOARD_PASSES.get(), 2);
|
||||
assert_eq!(artboards_and_guides(&s), expected);
|
||||
assert_eq!(artboard_names(&s), ["Artboard 11"]);
|
||||
|
||||
let mut s = artboards_with_selected_guides(30, 4);
|
||||
let cut: Vec<usize> = (2..28).collect();
|
||||
let expected = deleted_one_at_a_time(&s, &cut);
|
||||
ARTBOARD_PASSES.set(0);
|
||||
s.execute("artboard.cut", &json!({ "indices": cut, "art": false })).unwrap();
|
||||
assert_eq!(ARTBOARD_PASSES.get(), 2);
|
||||
assert_eq!(artboards_and_guides(&s), expected);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 4);
|
||||
assert!(s.execute("artboard.cut", &json!({ "indices": [0, 1, 2, 3], "art": false })).is_err(), "a document keeps one artboard");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 4);
|
||||
}
|
||||
|
||||
/// What counts as art: hidden and locked objects, an object straddling two artboards and one
|
||||
/// that only touches an artboard's edge; a group counts by its members, not by its box.
|
||||
#[test]
|
||||
|
||||
@@ -400,6 +400,43 @@ fn stroke_set_refuses_an_unknown_key() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.setLiveShape` names every key it reads, and each of its `items` takes `id` and
|
||||
/// `corners` alone: another key, at the top level, in an item or in a batch step, is refused
|
||||
/// instead of being ignored.
|
||||
#[test]
|
||||
fn set_live_shape_refuses_an_unknown_key() {
|
||||
const ID: &str = "object.setLiveShape";
|
||||
assert!(!UNCHECKED.contains(&ID));
|
||||
let e = refusal(ID, json!({"id": 3, "path": [[0, 0], [1, 1]], "radius": 5}));
|
||||
assert!(e.starts_with("unknown parameter `path`; the command takes: ") && e.contains("relativeRadii"), "{e}");
|
||||
let e = refusal(ID, json!({"radiuss": 5}));
|
||||
assert_eq!(e, "unknown parameter `radiuss`; did you mean `radius`?");
|
||||
let e = refusal(ID, json!({"items": [{"id": 3, "corners": [0]}, {"id": 4, "corner": [0]}], "radius": 5}));
|
||||
assert_eq!(e, "unknown parameter `items[1].corner`; did you mean `items[1].corners`?");
|
||||
let e = refusal(ID, json!({"items": [{"id": 3, "radius": 5}]}));
|
||||
assert_eq!(e, "unknown parameter `items[0].radius`; the command takes: items[0].id, items[0].corners");
|
||||
let step = |params| json!({"commands": [{"command": "select.all", "params": {}}, {"command": ID, "params": params}]});
|
||||
for (bad, key) in [(json!({"path": "x"}), "path"), (json!({"items": [{"id": 3, "kind": "chamfer"}]}), "items[0].kind")] {
|
||||
match check_params("command.batch", &step(bad.clone())) {
|
||||
Err(EngineError::BadParams { cmd, msg }) => {
|
||||
assert!(cmd == ID && msg.starts_with(&format!("unknown parameter `{key}`")), "{bad}: {cmd} {msg}")
|
||||
}
|
||||
other => panic!("{bad}: {other:?}"),
|
||||
}
|
||||
}
|
||||
for ok in [
|
||||
json!({"items": [{"id": 3, "corners": [0, 2]}, {"id": 4}], "radius": 5}),
|
||||
json!({"corners": [0], "kind": "chamfer", "radius": 2, "relativeRadii": false}),
|
||||
json!({"id": 3, "radius": 5}),
|
||||
json!({"ids": [3], "sides": 6, "polygonAngle": 15, "polygonRadius": 20, "sideLength": null, "makeSidesEqual": true}),
|
||||
json!({"pieStart": 30, "pieEnd": 90, "invertPie": true}),
|
||||
json!({"items": null}),
|
||||
] {
|
||||
assert!(check_params(ID, &ok).is_ok(), "{ok}");
|
||||
assert!(check_params("command.batch", &step(ok.clone())).is_ok(), "{ok}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such,
|
||||
/// naming its key, not as a missing id; an id left out is still a missing one.
|
||||
#[test]
|
||||
|
||||
@@ -441,6 +441,98 @@ fn offset_open_path_whose_ends_meet_grows_the_shape() {
|
||||
assert_eq!(path_of(&s, r[0]).0.subpaths.len(), 1);
|
||||
}
|
||||
|
||||
/// The ids of the objects in the first layer, back to front.
|
||||
fn first_layer(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect()
|
||||
}
|
||||
|
||||
/// Offset Path of several objects at once: each result goes directly above its object and takes
|
||||
/// the next ids in the objects' order, a group gives a group of its paths' offsets with their
|
||||
/// styles, a compound path gives a compound path, an inset that leaves nothing and type give
|
||||
/// nothing, and one undo gives the objects back.
|
||||
#[test]
|
||||
fn offset_path_of_several_objects_keeps_their_order() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||||
set_fill(&mut s, b, Color::rgb(1.0, 0.0, 0.0));
|
||||
set_fill(&mut s, c, Color::rgb(0.0, 0.0, 1.0));
|
||||
s.execute("select.set", &json!({"ids": [b.0, c.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let tiny = rect(&mut s, 100.0, 0.0, 4.0, 4.0);
|
||||
let outer = rect(&mut s, 200.0, 0.0, 40.0, 40.0);
|
||||
let inner = rect(&mut s, 210.0, 10.0, 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
let cp = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 300, "y": 10, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = first_layer(&s);
|
||||
assert_eq!(before, [a, g, tiny, cp, t]);
|
||||
let first = s.doc().unwrap().doc.peek_next_id();
|
||||
s.execute("select.set", &json!({"ids": [a.0, g.0, tiny.0, cp.0, t.0]})).unwrap();
|
||||
let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": -3, "joins": "miter"})).unwrap());
|
||||
// a's offset, then b's and c's and their group, then the compound path and its two subpaths.
|
||||
assert_eq!(r, [NodeId(first), NodeId(first + 3), NodeId(first + 4)]);
|
||||
assert_eq!(first_layer(&s), [a, r[0], g, r[1], tiny, cp, r[2], t]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, r);
|
||||
assert!(close(bounds(&s, r[0]), Rect::new(3.0, 3.0, 17.0, 17.0), 1e-2), "{:?}", bounds(&s, r[0]));
|
||||
let group = node(&s, r[1]);
|
||||
assert!(matches!(group.kind, NodeKind::Group { .. }), "{:?}", group.kind);
|
||||
let kids = group.children().unwrap();
|
||||
assert_eq!(kids.iter().map(|k| k.id).collect::<Vec<_>>(), [NodeId(first + 1), NodeId(first + 2)]);
|
||||
assert_eq!(kids[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert_eq!(kids[1].appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||
let rings = node(&s, r[2]);
|
||||
assert!(matches!(rings.kind, NodeKind::Compound { .. }), "{:?}", rings.kind);
|
||||
assert!(close(bounds(&s, r[2]), Rect::new(203.0, 3.0, 237.0, 37.0), 1e-2), "{:?}", bounds(&s, r[2]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first_layer(&s), before);
|
||||
}
|
||||
|
||||
/// Offset Path works out the offsets of several paths on more than one thread, and gives the
|
||||
/// results in the paths' order.
|
||||
#[test]
|
||||
fn offset_path_works_on_several_threads_in_order() {
|
||||
let seen = std::sync::Mutex::new(vec![]);
|
||||
let items: Vec<usize> = (0..8).collect();
|
||||
let work = |i: &usize| {
|
||||
if let Ok(mut seen) = seen.lock() {
|
||||
seen.push(std::thread::current().id());
|
||||
}
|
||||
i * 2
|
||||
};
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items, 4, work, |_| false);
|
||||
assert_eq!(out, (0..8).map(|i| Some(i * 2)).collect::<Vec<_>>());
|
||||
assert_eq!(used, 4);
|
||||
let threads: std::collections::HashSet<_> = seen.into_inner().unwrap().into_iter().collect();
|
||||
assert_eq!(threads.len(), 4);
|
||||
// A count just past the threads still uses every thread.
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items[..5], 4, |i| i * 2, |_| false);
|
||||
assert_eq!(out, (0..5).map(|i| Some(i * 2)).collect::<Vec<_>>());
|
||||
assert_eq!(used, 4);
|
||||
// One item, or one thread, is worked on here.
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items[..1], 4, |i| i * 2, |_| false);
|
||||
assert_eq!((out, used), (vec![Some(0)], 1));
|
||||
let (out, used) = crate::cmd::pathops::ordered_map(&items, 1, |i| i * 2, |_| false);
|
||||
assert_eq!(used, 1);
|
||||
assert_eq!(out.len(), 8);
|
||||
}
|
||||
|
||||
/// Once a result stops the work (an offset that fails), the paths after it are left out, and every
|
||||
/// path before it has its result, so the first failure in the paths' order is the one reported.
|
||||
#[test]
|
||||
fn offset_path_stops_after_the_first_failure() {
|
||||
let items: Vec<usize> = (0..8).collect();
|
||||
let (out, _) = crate::cmd::pathops::ordered_map(&items, 1, |i| *i, |r| *r == 2 || *r == 5);
|
||||
assert_eq!(out, [Some(0), Some(1), Some(2), None, None, None, None, None]);
|
||||
for threads in [2, 4, 8] {
|
||||
let (out, _) = crate::cmd::pathops::ordered_map(&items, threads, |i| *i, |r| *r == 2 || *r == 5);
|
||||
assert_eq!(out[..3], [Some(0), Some(1), Some(2)], "{threads} threads");
|
||||
let first = out.iter().position(|r| r.is_some_and(|r| r == 2 || r == 5));
|
||||
assert_eq!(first, Some(2), "{threads} threads");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_of_line() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -164,7 +164,13 @@ fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() {
|
||||
let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap();
|
||||
let bytes = pdf(&v);
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit 1.969"));
|
||||
assert!(warned(&v, "it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller"), "{v}");
|
||||
assert!(
|
||||
warned(
|
||||
&v,
|
||||
"it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller: turn on Write pages longer than 200 in at full size in points"
|
||||
),
|
||||
"{v}"
|
||||
);
|
||||
reopened(&bytes);
|
||||
for bad in [json!("yes"), json!(1)] {
|
||||
assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}");
|
||||
|
||||
@@ -396,6 +396,23 @@ fn an_app_that_leaves_has_its_copies_offered_at_once() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder copies go to: the `recoveryFolder` preference, trimmed, when it names one, else the
|
||||
/// folder the app sets; none with a store the host installed. The web build has no folders.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn the_folder_in_use_is_the_preference_or_the_apps() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(cmd::recovery::folder(&s), None);
|
||||
s.recovery.set_default_folder(Some("/app/recovery".into()));
|
||||
assert_eq!(cmd::recovery::folder(&s), Some(("/app/recovery".into(), false)));
|
||||
s.prefs.recovery_folder = " \t ".into();
|
||||
assert_eq!(cmd::recovery::folder(&s), Some(("/app/recovery".into(), false)));
|
||||
s.prefs.recovery_folder = " /chosen/copies ".into();
|
||||
assert_eq!(cmd::recovery::folder(&s), Some(("/chosen/copies".into(), true)));
|
||||
s.recovery.set_store(Arc::new(MemoryStore::default()));
|
||||
assert_eq!(cmd::recovery::folder(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_areas_are_locked_by_running_apps() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id()));
|
||||
|
||||
@@ -571,6 +571,25 @@ fn distort_command_warps_the_linked_mask_of_a_path_in_a_group() {
|
||||
assert_eq!(a.extras().mask.as_ref().unwrap().art.path_data(), a.path_data(), "and so is its mask");
|
||||
}
|
||||
|
||||
/// Corners where the box already stands change nothing: no undo step, the document as it was. A
|
||||
/// corner moved by a hundredth of a point is a warp.
|
||||
#[test]
|
||||
fn unchanged_distort_records_no_history() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let n = undo_len(&s);
|
||||
let at = |x1: f64| json!([[b.x0, b.y0], [x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]]);
|
||||
assert_eq!(s.execute("object.distort", &json!({"corners": at(b.x1)})).unwrap()["ids"], json!([a.0]));
|
||||
assert_eq!(undo_len(&s), n);
|
||||
assert!(s.doc().unwrap().doc == doc, "the document is unchanged");
|
||||
s.execute("object.distort", &json!({"corners": at(b.x1 + 0.01)})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert!(s.doc().unwrap().doc != doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_perspective_midpoint_is_projective() {
|
||||
// Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real
|
||||
@@ -1239,7 +1258,7 @@ fn distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle() {
|
||||
}
|
||||
s.execute("object.distort", &json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": 90, "ids": [a.0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
assert_eq!(undo_len(&s), history, "corners where the box stands change nothing");
|
||||
// Without `from`, the box is the objects' bounds in the turned frame: the same box here.
|
||||
s.execute("object.distort", &json!({"corners": corners, "angle": 90, "ids": [a.0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
|
||||
|
||||
@@ -1311,14 +1311,12 @@ impl Session {
|
||||
{
|
||||
return Err(format!("unknown tool `{t}`"));
|
||||
}
|
||||
// The active tool's merge being computed is applied with the options it was started with.
|
||||
if tool.is_none_or(|t| t == self.tool.id()) {
|
||||
self.finish_tool_edit_later().map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if let Some(k) = p.get("key").and_then(Value::as_str) {
|
||||
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
// Every value is checked before any is set, so an invalid one changes nothing.
|
||||
vectorcraft_tools::builder::check_builder_options(tool.unwrap_or(self.tool.id()), &values)?;
|
||||
// The Place queue is read later, by a click outside any automation scope: its files must
|
||||
// be readable now, under the roots of whoever sets it.
|
||||
if let Some(entries) = values.get("queue").and_then(|q| q.get("entries")).and_then(Value::as_array) {
|
||||
@@ -1336,6 +1334,10 @@ impl Session {
|
||||
return Err("Unknown dimension mode".into());
|
||||
}
|
||||
}
|
||||
// The active tool's merge being computed is applied with the options it was started with.
|
||||
if tool.is_none_or(|t| t == self.tool.id()) {
|
||||
self.finish_tool_edit_later().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(self.set_tool_options(tool, &values))
|
||||
}
|
||||
|
||||
|
||||
@@ -849,6 +849,14 @@ fn structured_junk() {
|
||||
("addon.pathtools.movePointsTogether", json!({"enabled": true})),
|
||||
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})),
|
||||
("addon.pathtools.movePointsTogether", json!({"enabled": false})),
|
||||
// Path Precision Tools: Trim at Crossings.
|
||||
("addon.pathtools.trim", json!({"at": [1e308, -1e308], "tolerance": 1e308})),
|
||||
("addon.pathtools.trim", json!({"at": ["x", null], "rect": [0, 0, f64::NAN, 1], "points": "all"})),
|
||||
("addon.pathtools.trim", json!({"rect": [-1e7, -1e7, 2e7, 2e7]})),
|
||||
("addon.pathtools.trim", json!({"rect": [0, 0, -1e308, 1e308]})),
|
||||
("addon.pathtools.trim", json!({"points": [[0, 0], [1e7, 1e7], [-1e7, 5], [0, 0]]})),
|
||||
("addon.pathtools.trim", json!({"points": [[0, 0], [1, "y"], null]})),
|
||||
("addon.pathtools.trim", json!({"at": [50, 50], "tolerance": 100})),
|
||||
// Arrange Tools add-on.
|
||||
("addon.arrange.grid", json!({"columns": u64::MAX, "gapX": 1e6, "gapY": -1e6, "centerOnArtboard": true})),
|
||||
("addon.arrange.grid", json!({"layout": "row", "gapX": f64::MAX, "columns": -1})),
|
||||
@@ -901,6 +909,11 @@ fn structured_junk() {
|
||||
("addon.cutting.weed", json!({"spacing": 1e-300, "box": "no", "direction": ["both"], "swatch": 5})),
|
||||
("addon.cutting.weed", json!({"spacing": "1 mm", "box": false, "direction": "vertical", "swatch": "no such swatch"})),
|
||||
("addon.cutting.weed", json!({"count": u64::MAX, "margin": "x", "gap": null, "weight": 0})),
|
||||
("addon.cutting.banner", json!({"hem": 1e5, "grommetInset": 1e5, "spacing": 1e-300, "diameter": 1000})),
|
||||
("addon.cutting.banner", json!({"hem": -1, "spacing": f64::MAX, "weight": 1e308, "top": "no", "allArtboards": 1})),
|
||||
("addon.cutting.banner", json!({"hem": 0, "grommetInset": 0, "spacing": 0.01, "allArtboards": true})),
|
||||
("addon.cutting.banner", json!({"spacing": "1 mm", "grommets": false, "swatch": 5, "left": null})),
|
||||
("addon.cutting.banner", json!({"top": false, "right": false, "bottom": false, "left": false, "allArtboards": false})),
|
||||
// Type Tools add-on.
|
||||
("addon.type.textTweaker", json!({"attribute": "size", "from": 1296, "to": 0.1, "shape": "random", "seed": u64::MAX})),
|
||||
("addon.type.textTweaker", json!({"attribute": "tracking", "from": f64::MAX, "to": -1e308, "shape": "spike"})),
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
|
||||
//! the version numbers of the newest files users sent ([`private_data`]).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -385,9 +386,15 @@ fn splice(text: &[u8], payloads: &[Vec<u8>]) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]),
|
||||
/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are
|
||||
/// written as); `None` when nothing changes.
|
||||
/// in its symbols' art, its patterns' tile art (which instances and pattern fills are written as)
|
||||
/// and its opacity masks' art too ([`baked_masks`]); `None` when nothing changes.
|
||||
fn baked(doc: &Document) -> Option<Document> {
|
||||
let art = baked_art(doc);
|
||||
baked_masks(art.as_ref().unwrap_or(doc)).or(art)
|
||||
}
|
||||
|
||||
/// [`baked`] for the layers, symbols and patterns, leaving opacity masks' art as it is.
|
||||
fn baked_art(doc: &Document) -> Option<Document> {
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let d = baked.as_ref().unwrap_or(doc);
|
||||
let needs = vectorcraft_effects::needs_bake;
|
||||
@@ -416,6 +423,100 @@ fn baked(doc: &Document) -> Option<Document> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `doc` with the art of the opacity masks in its layers', symbols' and patterns' art baked as
|
||||
/// [`baked_art`] bakes the layers. Each round bakes the art of the outermost masks whose art needs
|
||||
/// it, and the next round the masks inside that art, at most [`writer::MAX_NEST`] rounds (the
|
||||
/// writer leaves masks nested deeper out). Baked art keeps its id, by which the writer finds the
|
||||
/// art as it was before baking ([`writer::styled_objects`]). `None` when no mask's art needs baking.
|
||||
fn baked_masks(doc: &Document) -> Option<Document> {
|
||||
let mut out: Option<Document> = None;
|
||||
// The masks' art baked so far, by id: it isn't baked again.
|
||||
let mut done = HashSet::new();
|
||||
for _ in 0..writer::MAX_NEST {
|
||||
let d = out.as_ref().unwrap_or(doc);
|
||||
let roots = || d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.patterns.iter().flat_map(|p| p.art.iter()));
|
||||
let mut arts = vec![];
|
||||
for n in roots() {
|
||||
mask_arts(n, &done, &mut arts);
|
||||
}
|
||||
if arts.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Each art baked as a layer of a copy of the document, as [`baked_art`] bakes symbols.
|
||||
let mut copy = d.clone();
|
||||
copy.layers = arts.iter().map(|a| Arc::new(Node::group(vectorcraft_doc::NodeId(0), vec![a.clone()]))).collect();
|
||||
let Some(mut next) = vectorcraft_effects::bake_document(©) else { break };
|
||||
let layers = std::mem::replace(&mut next.layers, d.layers.clone());
|
||||
let mut baked = layers.iter().map(|l| l.children().and_then(|c| c.first().cloned()));
|
||||
for l in next.layers.iter_mut() {
|
||||
if let Some(n) = with_baked_masks(l, &done, &mut baked) {
|
||||
*l = n;
|
||||
}
|
||||
}
|
||||
for s in next.symbols.iter_mut() {
|
||||
if let Some(n) = with_baked_masks(&s.art, &done, &mut baked) {
|
||||
s.art = n;
|
||||
}
|
||||
}
|
||||
for p in next.patterns.iter_mut() {
|
||||
for a in p.art.iter_mut() {
|
||||
if let Some(n) = with_baked_masks(a, &done, &mut baked) {
|
||||
*a = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
done.extend(arts.iter().map(|a| a.id));
|
||||
out = Some(next);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does [`baked_masks`] bake the art of mask `m` in this round?
|
||||
fn bakes_mask_art(m: &vectorcraft_doc::OpacityMask, done: &HashSet<vectorcraft_doc::NodeId>) -> bool {
|
||||
!done.contains(&m.art.id) && vectorcraft_effects::needs_bake(&m.art)
|
||||
}
|
||||
|
||||
/// The art of the opacity masks in `n` that [`baked_masks`] bakes in this round, in the order
|
||||
/// [`with_baked_masks`] puts them back: a mask's art before the object's members, and the masks
|
||||
/// inside art that isn't baked.
|
||||
fn mask_arts(n: &Node, done: &HashSet<vectorcraft_doc::NodeId>, out: &mut Vec<Arc<Node>>) {
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
if bakes_mask_art(m, done) {
|
||||
out.push(m.art.clone());
|
||||
} else {
|
||||
mask_arts(&m.art, done, out);
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
mask_arts(c, done, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with the art of its opacity masks taken from `baked` in the order [`mask_arts`] lists it;
|
||||
/// `None` when nothing in it changes.
|
||||
fn with_baked_masks(n: &Arc<Node>, done: &HashSet<vectorcraft_doc::NodeId>, baked: &mut dyn Iterator<Item = Option<Arc<Node>>>) -> Option<Arc<Node>> {
|
||||
let art =
|
||||
n.extras().mask.as_ref().and_then(|m| if bakes_mask_art(m, done) { baked.next().flatten() } else { with_baked_masks(&m.art, done, baked) });
|
||||
let members: Vec<Option<Arc<Node>>> = n.children().map(|c| c.iter().map(|c| with_baked_masks(c, done, baked)).collect()).unwrap_or_default();
|
||||
if art.is_none() && members.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
let mut m = (**n).clone();
|
||||
if let Some(a) = art
|
||||
&& let Some(mask) = m.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
{
|
||||
mask.art = a;
|
||||
}
|
||||
if let Some(children) = m.children_mut() {
|
||||
for (c, new) in children.iter_mut().zip(members) {
|
||||
if let Some(new) = new {
|
||||
*c = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Arc::new(m))
|
||||
}
|
||||
|
||||
/// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with
|
||||
/// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers
|
||||
/// that go with that form; and what it couldn't hold.
|
||||
|
||||
@@ -6538,6 +6538,132 @@ fn effects_in_a_styled_objects_mask_are_warned_about() {
|
||||
assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n"));
|
||||
}
|
||||
|
||||
/// A 200 × 100 document of an image whose opacity mask doesn't clip, of the art `art` makes.
|
||||
fn masked_image(art: impl FnOnce(&mut Document) -> Node) -> Document {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None);
|
||||
let art = art(&mut doc);
|
||||
im.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into());
|
||||
doc.insert(Some(layer), usize::MAX, im).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// The opacity mask art of the first image in `doc` that has one.
|
||||
fn image_mask_art(doc: &Document) -> Arc<Node> {
|
||||
let mut art = None;
|
||||
doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Image(_))
|
||||
&& art.is_none()
|
||||
&& let Some(m) = n.extras().mask.as_ref()
|
||||
{
|
||||
art = Some(m.art.clone());
|
||||
}
|
||||
});
|
||||
art.expect("an image with an opacity mask")
|
||||
}
|
||||
|
||||
/// How many of the artboard's pixels `a` and `b` draw differently.
|
||||
fn pixels_apart(a: &Document, b: &Document) -> usize {
|
||||
let (a, b) = (vectorcraft_testkit::raster::render_artboard(a), vectorcraft_testkit::raster::render_artboard(b));
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
(0..a.height).flat_map(|y| (0..a.width).map(move |x| (x, y))).filter(|&(x, y)| a.pixel(x, y) != b.pixel(x, y)).count()
|
||||
}
|
||||
|
||||
/// An opacity mask's art keeps its live effects as an art style: an image masked by a black path
|
||||
/// with Offset Path (hiding the image under it) reads back with the effect live on the mask's path,
|
||||
/// alone or as a member of a group of two, and draws as it did.
|
||||
#[test]
|
||||
fn a_masked_image_with_live_offset_path_round_trips() {
|
||||
let offset = fx("path.offsetPath", serde_json::json!({"offset": 8.0, "joins": "miter", "miterLimit": 4.0}));
|
||||
let black = solid(Color::rgb(0.0, 0.0, 0.0));
|
||||
for members in [1, 2] {
|
||||
let made = |effects: Vec<vectorcraft_doc::Effect>| {
|
||||
masked_image(|doc| {
|
||||
let grown = styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), black.clone(), Paint::None, effects);
|
||||
if members == 1 {
|
||||
return grown;
|
||||
}
|
||||
let plain = square(doc, Rect::new(130.0, 30.0, 170.0, 70.0), Color::rgb(0.0, 0.0, 0.0));
|
||||
group(doc, vec![grown, plain], false)
|
||||
})
|
||||
};
|
||||
let doc = made(vec![offset.clone()]);
|
||||
assert!(pixels_apart(&doc, &made(vec![])) > 0, "{members}: the effect draws nothing");
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{members}: {:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let art = image_mask_art(&back);
|
||||
let grown = if members == 1 {
|
||||
art.clone()
|
||||
} else {
|
||||
assert!(art.appearance.effects.is_empty(), "the group took the effect: {:?}", art.appearance.effects);
|
||||
let children = art.children().expect("a group of the mask's art");
|
||||
assert_eq!(children.len(), 2);
|
||||
assert!(children[1].appearance.effects.is_empty());
|
||||
children[0].clone()
|
||||
};
|
||||
assert!(matches!(grown.kind, NodeKind::Path { .. }), "{members}: {:?}", grown.kind);
|
||||
let [e] = grown.appearance.effects.as_slice() else { panic!("{members}: {:?}", grown.appearance.effects) };
|
||||
assert_eq!(e.id, "path.offsetPath");
|
||||
assert_eq!(e.params.get("offset").and_then(serde_json::Value::as_f64), Some(8.0), "{:?}", e.params);
|
||||
assert_eq!(pixels_apart(&doc, &back), 0, "{members}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The art of a mask keeps Offset Path live when its root is hidden (the canvas draws it all the
|
||||
/// same) and when it is the art of a mask inside another mask's art (that clips).
|
||||
#[test]
|
||||
fn hidden_and_nested_mask_art_keep_live_offset_path() {
|
||||
let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0}));
|
||||
let grown = |doc: &mut Document, effects, gray: f32| {
|
||||
styled_rect(doc, "Grown", Rect::new(60.0, 30.0, 110.0, 70.0), solid(Color::rgb(gray, gray, gray)), Paint::None, effects)
|
||||
};
|
||||
let hidden = |effects| {
|
||||
masked_image(|doc| {
|
||||
let mut art = grown(doc, effects, 0.0);
|
||||
art.visible = false;
|
||||
art
|
||||
})
|
||||
};
|
||||
let nested = |effects| {
|
||||
masked_image(|doc| {
|
||||
let mut outer = square(doc, Rect::new(20.0, 15.0, 180.0, 85.0), Color::rgb(0.0, 0.0, 0.0));
|
||||
let inner = grown(doc, effects, 1.0);
|
||||
outer.extras_mut().mask = Some(OpacityMask { art: Arc::new(inner), clip: true, invert: false, disabled: false, linked: true }.into());
|
||||
outer
|
||||
})
|
||||
};
|
||||
for (case, made) in [("hidden", &hidden as &dyn Fn(Vec<vectorcraft_doc::Effect>) -> Document), ("nested", &nested)] {
|
||||
let doc = made(vec![offset.clone()]);
|
||||
assert!(pixels_apart(&doc, &made(vec![])) > 0, "{case}: the effect draws nothing");
|
||||
let back = read_back(&doc);
|
||||
let mut art = image_mask_art(&back);
|
||||
if case == "nested" {
|
||||
art = art.extras().mask.as_ref().expect("the mask inside the mask's art").art.clone();
|
||||
}
|
||||
let ids: Vec<&str> = art.appearance.effects.iter().map(|e| e.id.as_str()).collect();
|
||||
assert_eq!(ids, ["path.offsetPath"], "{case}");
|
||||
assert_eq!(pixels_apart(&doc, &back), 0, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A geometry effect on an opacity mask's art that no art style holds (Roughen) is applied to the
|
||||
/// art written, so the mask reads back drawing what it drew, but for pixels on its edge that the
|
||||
/// written points' rounding moves.
|
||||
#[test]
|
||||
fn a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws() {
|
||||
let rough = fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"}));
|
||||
let black = solid(Color::rgb(0.0, 0.0, 0.0));
|
||||
let rect = |doc: &mut Document, effects| styled_rect(doc, "Rough", Rect::new(60.0, 30.0, 140.0, 70.0), black.clone(), Paint::None, effects);
|
||||
let doc = masked_image(|doc| rect(doc, vec![rough]));
|
||||
assert!(pixels_apart(&doc, &masked_image(|doc| rect(doc, vec![]))) > 0, "the effect draws nothing");
|
||||
let back = read_back(&doc);
|
||||
assert!(image_mask_art(&back).appearance.effects.is_empty());
|
||||
let apart = pixels_apart(&doc, &back);
|
||||
assert!(apart <= 4, "{apart} pixels differ");
|
||||
}
|
||||
|
||||
/// On a large canvas no art style is written (the reader scales the styles of a large canvas):
|
||||
/// objects with effects are written as their vector art with their geometry effects applied, with a
|
||||
/// warning.
|
||||
@@ -7486,6 +7612,13 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
};
|
||||
let frame = |doc: &mut Document, ap: Appearance| {
|
||||
let outer = Node::path(doc.alloc_id(), square(), Appearance::default());
|
||||
let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(30.0, 20.0, 70.0, 50.0)), Appearance::default());
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::NonZero });
|
||||
n.appearance = ap.into();
|
||||
n
|
||||
};
|
||||
// A group of a filled square and `second`.
|
||||
let group = |second: fn(&mut Document) -> Node| {
|
||||
move |doc: &mut Document, ap: Appearance| {
|
||||
@@ -7510,7 +7643,12 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
|
||||
for (what, doc, kept) in [
|
||||
("a twisted curve", twisted(45.0, filled(), path(oval())), true),
|
||||
("a curve twisted a quarter turn back", twisted(-90.0, filled(), path(oval())), true),
|
||||
("a square twisted three quarters of a turn", twisted(270.0, filled(), path(square())), true),
|
||||
("a square twisted half a turn", twisted(180.0, filled(), path(square())), true),
|
||||
("a square twisted half a turn back", twisted(-180.0, filled(), path(square())), true),
|
||||
("a square twisted three quarters of a turn", twisted(270.0, filled(), path(square())), false),
|
||||
("a square twisted three quarters of a turn back", twisted(-270.0, filled(), path(square())), false),
|
||||
("a straight compound path twisted half a turn", twisted(180.0, filled(), frame), true),
|
||||
("a straight compound path twisted a full turn", twisted(360.0, filled(), frame), false),
|
||||
("a curve twisted past a quarter turn", twisted(120.0, filled(), path(oval())), false),
|
||||
("a stroked curve", twisted(45.0, Appearance::basic(black_fill(), red.clone(), 2.0), path(oval())), false),
|
||||
("a curved compound path", twisted(-60.0, filled(), ring), true),
|
||||
@@ -7625,8 +7763,8 @@ fn a_style_that_isnt_written_defines_no_gradient() {
|
||||
assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}");
|
||||
}
|
||||
|
||||
/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One
|
||||
/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is
|
||||
/// An object in a plain group, a sublayer, a clipping group by a path or an opacity mask keeps its
|
||||
/// style there. One in a symbol's art or in art cut to a clipping object other than a path is
|
||||
/// written as its drawn look, with a warning.
|
||||
#[test]
|
||||
fn where_a_styled_object_keeps_its_style() {
|
||||
@@ -7683,8 +7821,17 @@ fn where_a_styled_object_keeps_its_style() {
|
||||
n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into());
|
||||
put(&mut doc, n);
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
assert!(!shown(&out.program).contains("(Anon 1) XW"));
|
||||
assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let mut art = None;
|
||||
back.walk(|n| {
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
art = Some(m.art.clone());
|
||||
}
|
||||
});
|
||||
let art = art.expect("the opacity mask");
|
||||
assert_eq!(art.name.as_deref(), Some("Inside"));
|
||||
assert_eq!(art.appearance.effects.len(), 1);
|
||||
// In art cut to a clipping object other than a path: type.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let t = vectorcraft_doc::TextObject::point(
|
||||
|
||||
@@ -47,7 +47,7 @@ pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES};
|
||||
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
|
||||
/// out). Groups and layers need no limit of their own: a document is never deeper than
|
||||
/// [`vectorcraft_doc::MAX_DEPTH`].
|
||||
const MAX_NEST: u32 = 8;
|
||||
pub(super) const MAX_NEST: u32 = 8;
|
||||
|
||||
pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps";
|
||||
pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps";
|
||||
@@ -200,6 +200,9 @@ pub(super) struct Writer<'a> {
|
||||
/// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the
|
||||
/// art being written is in.
|
||||
hidden: u32,
|
||||
/// The `hidden` and `nest` values at which the objects of the opacity mask's art being written
|
||||
/// are written: objects there may be written with their art styles.
|
||||
mask_level: Option<(u32, u32)>,
|
||||
/// The image just written was written as a placed file.
|
||||
placed: bool,
|
||||
/// The type just written was written as type, not as its outlines.
|
||||
@@ -289,6 +292,7 @@ impl<'a> Writer<'a> {
|
||||
canvas_origin: origin,
|
||||
payloads: vec![],
|
||||
hidden: 0,
|
||||
mask_level: None,
|
||||
placed: false,
|
||||
typed: false,
|
||||
bound: vec![],
|
||||
@@ -613,10 +617,10 @@ impl<'a> Writer<'a> {
|
||||
if self.in_symbol {
|
||||
self.wrote_fixed |= symbols::redrawn(n);
|
||||
}
|
||||
// On the layers, outside art cut to a clipping object: with its art style, when it has one.
|
||||
// On the layers or in an opacity mask's art, outside art cut to a clipping object: with its
|
||||
// art style, when it has one.
|
||||
if !self.in_look
|
||||
&& self.hidden == 0
|
||||
&& self.nest == 0
|
||||
&& ((self.hidden == 0 && self.nest == 0) || self.mask_level == Some((self.hidden, self.nest)))
|
||||
&& self.clip.is_none()
|
||||
&& !self.in_symbol
|
||||
&& let Some(orig) = self.styled.get(&n.id).cloned()
|
||||
@@ -843,21 +847,27 @@ impl<'a> Writer<'a> {
|
||||
};
|
||||
// A plain group of two or more objects, or of none, is the group the art is read into. A group
|
||||
// of one object is written as a group, because the reader makes one shown object the art.
|
||||
// A group that may be written with its art style is written as one object.
|
||||
let plain = matches!(art.kind, NodeKind::Group { clip: false, .. })
|
||||
&& Xy::of(art) == Xy::PLAIN
|
||||
&& art.visible
|
||||
&& !art.locked
|
||||
&& art.name.as_deref().is_none_or(str::is_empty)
|
||||
&& art.extras().mask.is_none();
|
||||
&& art.extras().mask.is_none()
|
||||
&& !self.styled.contains_key(&art.id);
|
||||
let members = match art.children() {
|
||||
Some(c) if plain && c.len() != 1 => c.as_slice(),
|
||||
_ => std::slice::from_ref(art),
|
||||
};
|
||||
self.nest += 1;
|
||||
self.hidden += 1;
|
||||
// The art's objects (not those inside its symbols, live objects or pattern tiles, which are
|
||||
// deeper) may be written with their art styles.
|
||||
let level = self.mask_level.replace((self.hidden, self.nest));
|
||||
for c in members {
|
||||
self.node(c);
|
||||
}
|
||||
self.mask_level = level;
|
||||
self.hidden -= 1;
|
||||
self.nest -= 1;
|
||||
let lines = std::mem::replace(&mut self.body, outer);
|
||||
|
||||
@@ -39,8 +39,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize {
|
||||
doc.layers.iter().map(|l| count(l)).sum::<usize>() + doc.symbols.iter().map(|s| count(&s.art)).sum::<usize>()
|
||||
}
|
||||
|
||||
/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]),
|
||||
/// by id: paths and compound paths with points and groups, with live effects, linked to a graphic
|
||||
/// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art
|
||||
/// style ([`Writer::styled_object`]), by id (a mask's art root shown, as [`Writer::mask`] writes
|
||||
/// it): paths and compound paths with points and groups, with live effects, linked to a graphic
|
||||
/// style or (paths and compound paths) with a brush on a stroke, and embedded images with one Warp
|
||||
/// ([`warped_image`]), without Opacity & Mask Define
|
||||
/// Knockout Shape (the reader takes an object's transparency
|
||||
@@ -52,6 +53,13 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap<NodeId, A
|
||||
if candidate(n) {
|
||||
out.insert(n.id, n.clone());
|
||||
}
|
||||
if let Some(m) = n.extras().mask.as_ref() {
|
||||
let mut art = m.art.clone();
|
||||
if !art.visible {
|
||||
Arc::make_mut(&mut art).visible = true;
|
||||
}
|
||||
walk(std::slice::from_ref(&art), out);
|
||||
}
|
||||
// A group's members are written with a style of their own only when the group is written
|
||||
// without one. Baking a container with effects of its own changes its members, which
|
||||
// keep their ids: they are written as baking left them.
|
||||
|
||||
@@ -208,6 +208,7 @@ pub(in crate::ai_editing) fn holds(n: &Node, t: &TextObject) -> bool {
|
||||
shape_ok
|
||||
&& t.wrap.is_empty()
|
||||
&& t.ruby.is_empty()
|
||||
&& !t.stacks_warichu()
|
||||
&& t.runs.iter().any(|r| r.text.chars().any(|c| !c.is_whitespace()))
|
||||
&& runs_held(t)
|
||||
&& plain(n)
|
||||
@@ -964,6 +965,7 @@ impl TextDoc {
|
||||
&& t.wrap.is_empty()
|
||||
&& plain(n)
|
||||
&& t.ruby.is_empty()
|
||||
&& !t.stacks_warichu()
|
||||
&& runs_held(t)
|
||||
&& (story_scale(t) / scale - 1.0).abs() <= 1e-9
|
||||
&& within_reader(t, scale)
|
||||
|
||||
@@ -1107,11 +1107,17 @@ fn no_guides(n: &Node, depth: usize) -> bool {
|
||||
/// Twist keeps the file's drawn look.
|
||||
const MAX_TWIST: f64 = 90.0;
|
||||
|
||||
/// The largest Twist angle, in degrees either way, read as a live effect on a path or compound path
|
||||
/// with straight segments: VectorCraft's own limit. One with a larger Twist keeps the file's drawn
|
||||
/// look.
|
||||
const MAX_STRAIGHT_TWIST: f64 = 180.0;
|
||||
|
||||
/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything
|
||||
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only when nothing is
|
||||
/// stroked: on a path or compound path with straight segments at any angle, or up to [`MAX_TWIST`]
|
||||
/// degrees either way on another object with no guide in it. Transform that scales, turns or reflects
|
||||
/// is read only when nothing is stroked (its reference point lies on the bounds without strokes).
|
||||
/// stroked and its angle is a number: up to [`MAX_STRAIGHT_TWIST`] degrees either way on a path or
|
||||
/// compound path with straight segments, or up to [`MAX_TWIST`] degrees either way on another object
|
||||
/// with no guide in it. Transform that scales, turns or reflects is read only when nothing is stroked
|
||||
/// (its reference point lies on the bounds without strokes).
|
||||
fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
use crate::import::ai::style::StyleItem;
|
||||
let stroked = st.items.iter().any(|i| matches!(i, StyleItem::Stroke { paint, .. } if !paint.is_none()))
|
||||
@@ -1121,7 +1127,8 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
|
||||
&& object.children().into_iter().flatten().all(|c| moves_alike(c, 1))
|
||||
&& st.effects.iter().all(|e| match e.id.as_str() {
|
||||
"distort.twist" => {
|
||||
!stroked && (straight(object) || (no_guides(object, 0) && e.params["angle"].as_f64().is_some_and(|a| a.abs() <= MAX_TWIST)))
|
||||
let within = |limit: f64| e.params["angle"].as_f64().is_some_and(|a| a.abs() <= limit);
|
||||
!stroked && if straight(object) { within(MAX_STRAIGHT_TWIST) } else { no_guides(object, 0) && within(MAX_TWIST) }
|
||||
}
|
||||
"distort.transform" => {
|
||||
let p = &e.params;
|
||||
|
||||
@@ -7159,7 +7159,6 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
|
||||
let object = |hidden: &str| {
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
|
||||
};
|
||||
let square = object(&format!("{}f\n", rect_path(20.0, 20.0, 60.0, 50.0)));
|
||||
let oval = "20 35 m\n20 45 60 45 60 35 c\n60 25 20 25 20 35 c\n";
|
||||
let curved = object(&format!("{oval}f\n"));
|
||||
let ring = object(&format!("*u\n{oval}f\n30 35 m\n30 40 50 40 50 35 c\n50 30 30 30 30 35 c\nf\n*U\n"));
|
||||
@@ -7184,7 +7183,6 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
|
||||
("a twisted group", read(&twist(90.0, None), &distort_group()), 2),
|
||||
("a group with a hidden member", read(&twist(45.0, None), &hidden_member), 2),
|
||||
("a curve twisted a quarter turn back", read(&twist(-90.0, BLUE_FILL), &curved), 0),
|
||||
("a square twisted three quarters of a turn, as before", read(&twist(270.0, BLUE_FILL), &square), 0),
|
||||
("a straight compound path with a guide, as before", read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(20.0, 20.0, 60.0, 50.0))), 2),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
@@ -7213,6 +7211,45 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twist_is_read_on_straight_paths_and_compound_paths_up_to_half_a_turn() {
|
||||
// GUARDS: a path or compound path with straight segments was read with a live Twist at any angle.
|
||||
// The object, `hidden` painted with its style, under its drawn look.
|
||||
let object = |hidden: &str| {
|
||||
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
|
||||
};
|
||||
let square = object(&format!("{}f\n", rect_path(20.0, 20.0, 60.0, 50.0)));
|
||||
let frame = object(&format!("*u\n{}f\n{}f\n*U\n", rect_path(20.0, 20.0, 60.0, 50.0), rect_path(30.0, 30.0, 50.0, 40.0)));
|
||||
let twist = |angle: f64| distort_styles("Adobe Twirl", &format!("{angle} /Real (angle) ,\n"), BLUE_FILL);
|
||||
let read = |angle: f64, art_: &str| read_ok(&format!("{}{}", twist(angle), layer(art_)));
|
||||
for (what, r, members, angle) in [
|
||||
("a square twisted half a turn", read(180.0, &square), 0, 180.0),
|
||||
("a square twisted half a turn back", read(-180.0, &square), 0, -180.0),
|
||||
("a straight compound path twisted half a turn", read(180.0, &frame), 2, 180.0),
|
||||
] {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert_eq!(a[0].children().map_or(0, |c| c.len()), members, "{what}: the object, not its look: {a:#?}");
|
||||
let [e] = a[0].appearance.effects.as_slice() else { panic!("{what}: {:?}", a[0].appearance) };
|
||||
assert_eq!((e.id.as_str(), e.params["angle"].as_f64()), ("distort.twist", Some(angle)), "{what}");
|
||||
}
|
||||
for (what, r) in [
|
||||
("a square twisted just past half a turn", read(181.0, &square)),
|
||||
("a square twisted just past half a turn back", read(-181.0, &square)),
|
||||
("a square twisted three quarters of a turn", read(270.0, &square)),
|
||||
("a square twisted three quarters of a turn back", read(-270.0, &square)),
|
||||
("a square twisted two turns", read(720.0, &square)),
|
||||
("a square twisted ten turns", read(3600.0, &square)),
|
||||
("a straight compound path twisted a full turn", read(360.0, &frame)),
|
||||
] {
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1, "{what}: {a:#?}");
|
||||
assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
|
||||
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
|
||||
|
||||
@@ -98,13 +98,14 @@ pub(super) fn rotated(t: &TextObject) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Can 8-bit text hold type `t`: horizontal, without ruby (which a text object can't hold), its
|
||||
/// runs plain characters (no inline graphics) that 8-bit text can hold, filled and stroked with
|
||||
/// colours?
|
||||
/// Can 8-bit text hold type `t`: horizontal, without ruby or warichu (which a text object can't
|
||||
/// hold), its runs plain characters (no inline graphics) that 8-bit text can hold, filled and
|
||||
/// stroked with colours?
|
||||
pub(super) fn holds_as_text(t: &TextObject) -> bool {
|
||||
let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None);
|
||||
!t.vertical
|
||||
&& t.ruby.is_empty()
|
||||
&& !t.stacks_warichu()
|
||||
&& t.runs.iter().any(|r| !r.text.is_empty())
|
||||
&& t.runs.iter().all(|r| {
|
||||
r.inline.is_none()
|
||||
|
||||
@@ -248,6 +248,16 @@ fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Anchor>,
|
||||
d.deserialize_seq(V)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// [`measured_segments`] of this thread.
|
||||
static MEASURED_SEGMENTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many segments [`PathData::bounds`] has measured on this thread.
|
||||
pub fn measured_segments() -> u64 {
|
||||
MEASURED_SEGMENTS.get()
|
||||
}
|
||||
|
||||
/// An open or closed run of anchors.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SubPath {
|
||||
@@ -568,6 +578,7 @@ impl PathData {
|
||||
}
|
||||
let mut bbox: Option<Rect> = None;
|
||||
for sp in &self.subpaths {
|
||||
MEASURED_SEGMENTS.set(MEASURED_SEGMENTS.get().saturating_add(sp.segment_count() as u64));
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
// `to_bezpath_into` writes a line where neither handle is out, else a cubic.
|
||||
|
||||
@@ -511,7 +511,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
obj(
|
||||
json!({
|
||||
"ids": {"type": "array", "items": {"type": "integer"}},
|
||||
"offset": num("Gap between text and object (default 6 pt)"),
|
||||
"offset": num("Gap between text and object in pt, -1000 to 1000 (default 6 pt); a negative gap lets text that far into the object"),
|
||||
"invert": {"type": "boolean"},
|
||||
"release": {"type": "boolean"},
|
||||
}),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,9 +2,9 @@
|
||||
//! rule, equalise handles, add evenly spaced anchors, close), what its live path effects add to them
|
||||
//! ([`add_anchors`], [`smooth_anchors`], [`split_at_anchors`]), the readouts the Measure & Inspect
|
||||
//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]), the places
|
||||
//! where paths cross ([`crossings`]), anchors slid along their path ([`slide`]), touching edges
|
||||
//! merged ([`shared_edge`]), paths divided into dashes ([`divide_dashes`]) and the Parametric Shape
|
||||
//! Tool's shapes ([`shapes`]).
|
||||
//! where paths cross ([`crossings`]), the stretches between them ([`spans`]), anchors slid along
|
||||
//! their path ([`slide`]), touching edges merged ([`shared_edge`]), paths divided into dashes
|
||||
//! ([`divide_dashes`]) and the Parametric Shape Tool's shapes ([`shapes`]).
|
||||
|
||||
mod crossings;
|
||||
mod dashes;
|
||||
@@ -14,19 +14,21 @@ mod shared_edge;
|
||||
mod slide;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod trim;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Vec2};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at};
|
||||
pub use crossings::{MAX_CROSSED_BY_SEGMENTS, MAX_CROSSING_SEGMENTS, Place, Work, crossings, crossings_on, crossings_on_within, insert_at};
|
||||
pub use dashes::{DashSpec, Piece, divide_dashes};
|
||||
pub use extend::{ExtendMode, extend_length_to, extend_open};
|
||||
pub use shared_edge::{
|
||||
COINCIDENCE, EndSide, Follow, MAX_COINCIDENCE, MergePlan, MergePreview, Stretch, apply_merge, ends_at, merge_preview, stretches_at,
|
||||
};
|
||||
pub use slide::{MAX_SLIDE, slide_anchor, slide_stops, slide_t_at};
|
||||
pub use trim::{Span, slice, span_at, spans, without_spans};
|
||||
|
||||
use crate::fit::{end_tangent, start_tangent};
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Where paths cross: [`crossings`] finds the places, [`insert_at`] puts anchors there (which
|
||||
//! Split / Add Points at Crossings then keeps or cuts at).
|
||||
//! Split / Add Points at Crossings then keeps or cuts at), and [`crossings_on`] finds the places on
|
||||
//! one path (which Trim at Crossings removes stretches between).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
|
||||
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point, Rect};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
/// Most segments [`crossings`] compares at once.
|
||||
@@ -85,16 +86,82 @@ fn segment_crossings(a: &CubicBez, b: &CubicBez, budget: &mut usize) -> Option<V
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// Do segments `i` and `j` of `sp` meet at an anchor they share?
|
||||
fn adjacent(sp: &SubPath, i: usize, j: usize) -> bool {
|
||||
let n = sp.segment_count();
|
||||
i.abs_diff(j) == 1 || (sp.closed && n > 1 && i.abs_diff(j) == n - 1)
|
||||
/// Where segment `c` crosses itself, as its two parameters in order: a cubic loops over itself at
|
||||
/// most once. `None` when it doesn't, or only at a cusp or an end.
|
||||
fn self_crossing(c: &CubicBez) -> Option<(f64, f64)> {
|
||||
// In powers of t, c(t) = a t³ + b t² + k t + p0. Two parameters s and t meet where
|
||||
// a (s² + st + t²) + b (s + t) + k = 0: with u = s + t and v = st, crossing that with `a` gives u,
|
||||
// and dotting it with `a` gives v; s and t are the roots of z² - u z + v.
|
||||
let (p0, p1, p2, p3) = (c.p0.to_vec2(), c.p1.to_vec2(), c.p2.to_vec2(), c.p3.to_vec2());
|
||||
let a = p3 - p2 * 3.0 + p1 * 3.0 - p0;
|
||||
let b = (p2 - p1 * 2.0 + p0) * 3.0;
|
||||
let k = (p1 - p0) * 3.0;
|
||||
let (ab, aa) = (a.cross(b), a.dot(a));
|
||||
// A curve with no loop can leave these near zero, however long and thin a loop is: the checks
|
||||
// at the end drop what that gives.
|
||||
if ab == 0.0 || aa == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let u = -a.cross(k) / ab;
|
||||
let v = u * u + (b * u + k).dot(a) / aa;
|
||||
let disc = u * u - 4.0 * v;
|
||||
if !disc.is_finite() || disc <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let (s, t) = ((u - disc.sqrt()) / 2.0, (u + disc.sqrt()) / 2.0);
|
||||
let inside = |x: f64| END < x && x < 1.0 - END;
|
||||
// The two places are one point, and the curve passes it in two directions (a straight curve
|
||||
// that doubles back on itself meets itself along its length, not at a crossing).
|
||||
let (ds, dt) = (c.deriv().eval(s).to_vec2(), c.deriv().eval(t).to_vec2());
|
||||
let across = ds.cross(dt).abs() > 1e-15 * ds.hypot() * dt.hypot();
|
||||
(inside(s) && inside(t) && across && c.eval(s).distance(c.eval(t)) < SAME).then_some((s, t))
|
||||
}
|
||||
|
||||
/// Where `paths` cross one another (and, with `self_crossings`, where each crosses itself): for
|
||||
/// each path, the places on it, in order. Segments that meet at an anchor they share don't count as
|
||||
/// crossing there. `None` past [`MAX_CROSSING_SEGMENTS`] segments or when the curves are too tangled
|
||||
/// to sort out, or when a coordinate isn't finite.
|
||||
/// For each segment of `sp`, and one past the last, the first segment at or after it that has a
|
||||
/// length (the segment count when none has): segments with none lie at one point, between anchors
|
||||
/// that coincide.
|
||||
fn with_length_from(sp: &SubPath) -> Vec<usize> {
|
||||
let n = sp.segment_count();
|
||||
let mut out = vec![n; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
let c = sp.segment(i);
|
||||
let next = if control_box(&c).size().to_vec2().hypot() < SAME { out.get(i + 1).copied().unwrap_or(n) } else { i };
|
||||
if let Some(o) = out.get_mut(i) {
|
||||
*o = next;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The anchors segments `i` and `j` of `sp` share (`with_length` from [`with_length_from`]): none,
|
||||
/// one, or both ends for the two segments of a closed subpath of two. Segments with no length between
|
||||
/// them leave them meeting at that point.
|
||||
fn shared_anchors(sp: &SubPath, with_length: &[usize], i: usize, j: usize) -> Vec<Point> {
|
||||
let (n, lo, hi) = (sp.segment_count(), i.min(j), i.max(j));
|
||||
let from = |k: usize| with_length.get(k).copied().unwrap_or(n);
|
||||
let mut out = vec![];
|
||||
if hi > lo && from(lo + 1) >= hi {
|
||||
out.extend([sp.segment(lo).p3, sp.segment(hi).p0]);
|
||||
}
|
||||
// Round the first anchor of a closed subpath.
|
||||
if sp.closed && n > 1 && hi > lo && from(hi + 1) >= n && from(0) >= lo {
|
||||
out.extend([sp.segment(hi).p3, sp.segment(lo).p0]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where segments `a` and `b` cross, without the places at the anchors in `shared`: two segments that
|
||||
/// meet at an anchor don't cross there, but they can cross again elsewhere.
|
||||
fn crossings_apart(a: &CubicBez, b: &CubicBez, shared: &[Point], budget: &mut usize) -> Option<Vec<(f64, f64)>> {
|
||||
let mut found = segment_crossings(a, b, budget)?;
|
||||
found.retain(|(ta, _)| !shared.iter().any(|p| a.eval(*ta).distance(*p) < SAME));
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// Where `paths` cross one another (and, with `self_crossings`, where each crosses itself, a segment
|
||||
/// that loops over itself included): for each path, the places on it, in order. Segments that meet
|
||||
/// at an anchor they share don't count as crossing there. `None` past [`MAX_CROSSING_SEGMENTS`]
|
||||
/// segments or when the curves are too tangled to sort out, or when a coordinate isn't finite.
|
||||
pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option<Vec<Vec<Place>>> {
|
||||
let mut segs: Vec<(usize, usize, usize, CubicBez, Rect)> = vec![];
|
||||
for (k, path) in paths.iter().enumerate() {
|
||||
@@ -111,19 +178,22 @@ pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option<Vec<Vec<Pl
|
||||
}
|
||||
}
|
||||
}
|
||||
let with_length: Vec<Vec<Vec<usize>>> = paths.iter().map(|p| p.subpaths.iter().map(with_length_from).collect()).collect();
|
||||
let mut out: Vec<Vec<Place>> = vec![vec![]; paths.len()];
|
||||
let mut budget = MAX_STEPS;
|
||||
for (i, (ka, sa, ia, ca, ra)) in segs.iter().enumerate() {
|
||||
if self_crossings && let Some((s, t)) = self_crossing(ca) {
|
||||
out.get_mut(*ka)?.extend([(*sa, *ia, s), (*sa, *ia, t)]);
|
||||
}
|
||||
for (kb, sb, ib, cb, rb) in segs.iter().skip(i + 1) {
|
||||
if ka == kb
|
||||
&& (!self_crossings || (sa == sb && paths.get(*ka).and_then(|p| p.subpaths.get(*sa)).is_some_and(|sp| adjacent(sp, *ia, *ib))))
|
||||
{
|
||||
if (ka == kb && !self_crossings) || !ra.inflate(SAME, SAME).overlaps(*rb) {
|
||||
continue;
|
||||
}
|
||||
if !ra.inflate(SAME, SAME).overlaps(*rb) {
|
||||
continue;
|
||||
}
|
||||
for (ta, tb) in segment_crossings(ca, cb, &mut budget)? {
|
||||
let shared = match (paths.get(*ka).and_then(|p| p.subpaths.get(*sa)), with_length.get(*ka).and_then(|w| w.get(*sa))) {
|
||||
(Some(sp), Some(w)) if ka == kb && sa == sb => shared_anchors(sp, w, *ia, *ib),
|
||||
_ => vec![],
|
||||
};
|
||||
for (ta, tb) in crossings_apart(ca, cb, &shared, &mut budget)? {
|
||||
out.get_mut(*ka)?.push((*sa, *ia, ta));
|
||||
out.get_mut(*kb)?.push((*sb, *ib, tb));
|
||||
}
|
||||
@@ -136,6 +206,101 @@ pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option<Vec<Vec<Pl
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Most segments of other paths [`crossings_on`] compares one path with.
|
||||
pub const MAX_CROSSED_BY_SEGMENTS: usize = 200_000;
|
||||
/// Most pairs of segments [`crossings_on`] looks at.
|
||||
const MAX_PAIRS: usize = 60_000_000;
|
||||
|
||||
/// The work [`crossings_on_within`] may still do: pairs of segments to look at and subdivision steps.
|
||||
/// One value shared by several calls bounds them all together.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Work {
|
||||
pairs: usize,
|
||||
steps: usize,
|
||||
}
|
||||
|
||||
impl Default for Work {
|
||||
/// The work one [`crossings_on`] call may do.
|
||||
fn default() -> Self {
|
||||
Self { pairs: MAX_PAIRS, steps: MAX_STEPS }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `others` cross `path`, and where `path` crosses itself (a segment that loops over itself
|
||||
/// included): the places on `path`, in order, within the work of [`Work::default`].
|
||||
pub fn crossings_on(path: &PathData, others: &[&PathData]) -> Option<Vec<Place>> {
|
||||
crossings_on_within(path, others, &mut Work::default())
|
||||
}
|
||||
|
||||
/// [`crossings_on`], drawing on `work`. Only the segments of `others` whose control boxes meet the
|
||||
/// box of `path` are compared with it, so the paths far from it cost one box test per segment.
|
||||
/// Segments of `path` that meet at an anchor they share don't count as crossing there, and a
|
||||
/// segment of `others` with a coordinate that isn't finite is left out. `None` when `path` has more
|
||||
/// than [`MAX_CROSSING_SEGMENTS`] segments or a coordinate that isn't finite, when more than
|
||||
/// [`MAX_CROSSED_BY_SEGMENTS`] segments of `others` lie in its box, when the pairs of segments to
|
||||
/// look at are more than `work` has left, or when the curves are too tangled to sort out in the
|
||||
/// steps it has left.
|
||||
pub fn crossings_on_within(path: &PathData, others: &[&PathData], work: &mut Work) -> Option<Vec<Place>> {
|
||||
let mut own: Vec<(usize, usize, CubicBez, Rect)> = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
for s in 0..sp.segment_count() {
|
||||
let c = sp.segment(s);
|
||||
if ![c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
own.push((si, s, c, control_box(&c)));
|
||||
if own.len() > MAX_CROSSING_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(reach) = own.iter().map(|o| o.3).reduce(|a, b| a.union(b)).map(|r| r.inflate(SAME, SAME)) else { return Some(vec![]) };
|
||||
let mut near: Vec<(CubicBez, Rect)> = vec![];
|
||||
for sp in others.iter().flat_map(|p| &p.subpaths) {
|
||||
for s in 0..sp.segment_count() {
|
||||
let c = sp.segment(s);
|
||||
let r = control_box(&c);
|
||||
if [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()) && reach.overlaps(r) {
|
||||
near.push((c, r));
|
||||
if near.len() > MAX_CROSSED_BY_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
work.pairs = work.pairs.checked_sub(own.len().saturating_mul(near.len() + own.len() / 2))?;
|
||||
let with_length: Vec<Vec<usize>> = path.subpaths.iter().map(with_length_from).collect();
|
||||
let mut out: Vec<Place> = vec![];
|
||||
let budget = &mut work.steps;
|
||||
for (i, (sa, ia, ca, ra)) in own.iter().enumerate() {
|
||||
let ra = ra.inflate(SAME, SAME);
|
||||
if let Some((s, t)) = self_crossing(ca) {
|
||||
out.extend([(*sa, *ia, s), (*sa, *ia, t)]);
|
||||
}
|
||||
for (cb, rb) in &near {
|
||||
if ra.overlaps(*rb) {
|
||||
out.extend(segment_crossings(ca, cb, budget)?.into_iter().map(|(ta, _)| (*sa, *ia, ta)));
|
||||
}
|
||||
}
|
||||
for (sb, ib, cb, rb) in own.iter().skip(i + 1) {
|
||||
if !ra.overlaps(*rb) {
|
||||
continue;
|
||||
}
|
||||
let shared = match (path.subpaths.get(*sa), with_length.get(*sa)) {
|
||||
(Some(sp), Some(w)) if sa == sb => shared_anchors(sp, w, *ia, *ib),
|
||||
_ => vec![],
|
||||
};
|
||||
for (ta, tb) in crossings_apart(ca, cb, &shared, budget)? {
|
||||
out.push((*sa, *ia, ta));
|
||||
out.push((*sb, *ib, tb));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|x, y| (x.0, x.1).cmp(&(y.0, y.1)).then(x.2.total_cmp(&y.2)));
|
||||
out.dedup_by(|x, y| (x.0, x.1) == (y.0, y.1) && (x.2 - y.2).abs() < END);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `sp` with an anchor at each of `places` (segment, t), the shape unchanged → (the subpath, the
|
||||
/// indexes of the anchors at those places: the new ones, and existing ones where a place falls on
|
||||
/// an anchor). Places off the subpath are left out.
|
||||
|
||||
@@ -434,3 +434,235 @@ fn splitting_reads_an_anchors_kind_from_its_handles() {
|
||||
assert_eq!(split_at_anchors(&sq, true, false).len(), 3);
|
||||
assert!(split_at_anchors(&sq, true, false).iter().all(|p| !p.closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossings_on_finds_one_paths_places_among_many() {
|
||||
// A horizontal line crossed by two uprights, with a self-crossing bow far away.
|
||||
let across = PathData::single(line(&[(0.0, 50.0), (300.0, 50.0)], false));
|
||||
let up1 = PathData::single(line(&[(100.0, 0.0), (100.0, 100.0)], false));
|
||||
let up2 = PathData::single(line(&[(200.0, 0.0), (200.0, 100.0)], false));
|
||||
let far = PathData::single(line(&[(1000.0, 0.0), (1100.0, 100.0), (1100.0, 0.0), (1000.0, 100.0)], false));
|
||||
let places = crossings_on(&across, &[&up1, &up2, &far]).unwrap();
|
||||
assert_eq!(places.len(), 2, "{places:?}");
|
||||
let x = |p: &Place| across.subpaths[p.0].segment(p.1).eval(p.2).x;
|
||||
assert!(near(x(&places[0]), 100.0, 1e-6) && near(x(&places[1]), 200.0, 1e-6));
|
||||
// A path's crossings with itself count, once on each of the two segments.
|
||||
let own = crossings_on(&far, &[&across]).unwrap();
|
||||
assert_eq!(own.iter().map(|p| p.1).collect::<Vec<_>>(), [0, 2], "{own:?}");
|
||||
// A path that ends on another marks the other there, and its own end.
|
||||
let stub = PathData::single(line(&[(150.0, 50.0), (150.0, 90.0)], false));
|
||||
assert_eq!(crossings_on(&across, &[&stub]).unwrap().len(), 1);
|
||||
assert!(near(crossings_on(&stub, &[&across]).unwrap()[0].2, 0.0, 1e-6));
|
||||
// Lines lying along each other don't cross, and a path with a coordinate that isn't finite is refused.
|
||||
let along = PathData::single(line(&[(50.0, 50.0), (250.0, 50.0)], false));
|
||||
assert!(crossings_on(&across, &[&along]).unwrap().is_empty());
|
||||
let broken = PathData::single(line(&[(0.0, f64::NAN), (10.0, 10.0)], false));
|
||||
assert!(crossings_on(&broken, &[&across]).is_none());
|
||||
assert_eq!(crossings_on(&across, &[&broken, &up1]).unwrap().len(), 1, "a broken path beside it is left out");
|
||||
assert!(crossings_on(&PathData::default(), &[&across]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_that_share_an_anchor_still_cross_elsewhere() {
|
||||
// A straight segment, then a curve from its end that swings back across it near x = 17.4.
|
||||
let hook = SubPath::new(
|
||||
vec![Anchor::corner(pt(0.0, 0.0)), Anchor { h_out: pt(-50.0, 50.0), ..Anchor::corner(pt(100.0, 0.0)) }, Anchor::corner(pt(50.0, -50.0))],
|
||||
false,
|
||||
);
|
||||
let path = PathData::single(hook.clone());
|
||||
let places = crossings_on(&path, &[]).unwrap();
|
||||
assert_eq!(places.iter().map(|p| p.1).collect::<Vec<_>>(), [0, 1], "{places:?}");
|
||||
let x = hook.segment(0).eval(places[0].2).x;
|
||||
assert!(near(x, 17.4, 0.01), "{x}");
|
||||
assert!(hook.segment(1).eval(places[1].2).distance(pt(x, 0.0)) < 1e-3);
|
||||
// Split at Crossings finds it too, and neither counts the anchor the segments share.
|
||||
assert_eq!(crossings(&[&path], true).unwrap()[0], places);
|
||||
// The two segments of a closed subpath share both ends: a lens has no crossing.
|
||||
let lens = SubPath::new(
|
||||
vec![
|
||||
Anchor { h_out: pt(30.0, -40.0), h_in: pt(30.0, 40.0), ..Anchor::corner(pt(0.0, 0.0)) },
|
||||
Anchor { h_in: pt(70.0, -40.0), h_out: pt(70.0, 40.0), ..Anchor::corner(pt(100.0, 0.0)) },
|
||||
],
|
||||
true,
|
||||
);
|
||||
assert!(crossings_on(&PathData::single(lens.clone()), &[]).unwrap().is_empty());
|
||||
assert!(crossings(&[&PathData::single(lens)], true).unwrap()[0].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_with_no_length_between_them_still_share_their_anchor() {
|
||||
// An open path with its middle anchor doubled, and a closed one whose last anchor sits on its
|
||||
// first: the segments either side of the one with no length meet there and don't cross.
|
||||
let doubled = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false));
|
||||
let ring = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)], true));
|
||||
for p in [&doubled, &ring] {
|
||||
assert!(crossings_on(p, &[]).unwrap().is_empty(), "{:?}", crossings_on(p, &[]));
|
||||
assert!(crossings(&[p], true).unwrap()[0].is_empty(), "{:?}", crossings(&[p], true));
|
||||
}
|
||||
// A segment that crosses them elsewhere still does.
|
||||
let bow = PathData::single(line(&[(0.0, 0.0), (100.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false));
|
||||
assert_eq!(crossings_on(&bow, &[]).unwrap().iter().map(|p| p.1).collect::<Vec<_>>(), [0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_work_budget_bounds_several_calls_together() {
|
||||
// 1,000 segments in a straight row: 500,000 pairs a call, so 120 calls spend 60,000,000.
|
||||
let row: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64, 0.0)).collect();
|
||||
let path = PathData::single(line(&row, false));
|
||||
let mut work = Work::default();
|
||||
let calls = (0..200).take_while(|_| crossings_on_within(&path, &[], &mut work).is_some()).count();
|
||||
assert_eq!(calls, 120);
|
||||
assert!(crossings_on(&path, &[]).is_some(), "a call of its own has its own budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_that_loops_over_itself_crosses_itself() {
|
||||
// One cubic that meets itself at t = 0.25 and t = 0.75.
|
||||
let looped = PathData::single(SubPath::new(
|
||||
vec![Anchor { h_out: pt(13.0 / 9.0, 1.0), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(0.0, 1.0), ..Anchor::corner(pt(1.0, 0.0)) }],
|
||||
false,
|
||||
));
|
||||
let places = crossings_on(&looped, &[]).unwrap();
|
||||
assert_eq!(places.len(), 2, "{places:?}");
|
||||
assert!(near(places[0].2, 0.25, 1e-9) && near(places[1].2, 0.75, 1e-9), "{places:?}");
|
||||
assert_eq!(crossings(&[&looped], true).unwrap()[0], places);
|
||||
assert!(crossings(&[&looped], false).unwrap()[0].is_empty());
|
||||
// Its two visits to that point divide it into three stretches, and the loop can go alone.
|
||||
let sp = &looped.subpaths[0];
|
||||
let three = spans(sp, &places.iter().map(|p| (p.1, p.2)).collect::<Vec<_>>());
|
||||
assert_eq!(three.len(), 3, "{three:?}");
|
||||
let left = without_spans(sp, &three, &[false, true, false]).unwrap();
|
||||
assert_eq!(left.len(), 2);
|
||||
let x = sp.segment(0).eval(0.25);
|
||||
assert_eq!((left[0].anchors[0].p, left[1].anchors.last().unwrap().p), (pt(0.0, 0.0), pt(1.0, 0.0)));
|
||||
assert!(left[0].anchors.last().unwrap().p.distance(x) < 1e-3 && left[1].anchors[0].p.distance(x) < 1e-3);
|
||||
// The same loop squashed to a 10⁻¹² of its height.
|
||||
let flat = PathData::single(SubPath::new(
|
||||
vec![
|
||||
Anchor { h_out: pt(13.0 / 9.0, 1e-12), ..Anchor::corner(pt(0.0, 0.0)) },
|
||||
Anchor { h_in: pt(0.0, 1e-12), ..Anchor::corner(pt(1.0, 0.0)) },
|
||||
],
|
||||
false,
|
||||
));
|
||||
assert_eq!(crossings_on(&flat, &[]).unwrap().iter().map(|p| (p.2 * 4.0).round()).collect::<Vec<_>>(), [1.0, 3.0]);
|
||||
// An arch, an S and a cusp don't cross themselves.
|
||||
for (h1, h2) in [((0.0, 1.0), (1.0, 1.0)), ((0.3, 1.0), (0.7, -1.0)), ((1.0, 1.0), (0.0, 1.0))] {
|
||||
let c = PathData::single(SubPath::new(
|
||||
vec![Anchor { h_out: pt(h1.0, h1.1), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(h2.0, h2.1), ..Anchor::corner(pt(1.0, 0.0)) }],
|
||||
false,
|
||||
));
|
||||
assert!(crossings_on(&c, &[]).unwrap().is_empty(), "{h1:?} {h2:?}");
|
||||
}
|
||||
// Nor does a straight curve that doubles back along itself, at a slant.
|
||||
let back = PathData::single(SubPath::new(
|
||||
vec![Anchor { h_out: pt(1.6, 1.2), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(-0.8, -0.6), ..Anchor::corner(pt(0.8, 0.6)) }],
|
||||
false,
|
||||
));
|
||||
assert!(crossings_on(&back, &[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_divide_a_subpath_at_its_places() {
|
||||
let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
|
||||
assert_eq!(spans(&open, &[]), [(0.0, 2.0)]);
|
||||
assert_eq!(spans(&open, &[(1, 0.5), (0, 0.25)]), [(0.0, 0.25), (0.25, 1.5), (1.5, 2.0)]);
|
||||
// Places at an open end, off the subpath, or twice at one point, add no stretch.
|
||||
assert_eq!(spans(&open, &[(0, 0.0), (1, 1.0), (7, 0.5), (0, 0.5), (0, 0.5 + 1e-9), (0, f64::NAN)]), [(0.0, 0.5), (0.5, 2.0)]);
|
||||
// A place at an anchor is the same from either segment.
|
||||
assert_eq!(spans(&open, &[(0, 1.0), (1, 0.0)]), [(0.0, 1.0), (1.0, 2.0)]);
|
||||
let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
assert_eq!(spans(&ring, &[]), [(0.0, 4.0)]);
|
||||
assert_eq!(spans(&ring, &[(1, 0.5)]), [(1.5, 5.5)], "one place: from it round to it again");
|
||||
assert_eq!(spans(&ring, &[(3, 0.5), (0, 0.5)]), [(0.5, 3.5), (3.5, 4.5)]);
|
||||
// The closing segment's end is the first anchor.
|
||||
assert_eq!(spans(&ring, &[(3, 1.0), (0, 0.0), (2, 0.0)]), [(0.0, 2.0), (2.0, 4.0)]);
|
||||
assert!(spans(&SubPath::new(vec![Anchor::corner(pt(1.0, 1.0))], false), &[(0, 0.5)]).is_empty());
|
||||
// Which stretch a place is on: the one that starts there, the last one at an open end.
|
||||
let three = spans(&open, &[(1, 0.5), (0, 0.25)]);
|
||||
assert_eq!(span_at(&open, &three, 0, 0.1), Some(0));
|
||||
assert_eq!(span_at(&open, &three, 0, 0.25), Some(1));
|
||||
assert_eq!(span_at(&open, &three, 1, 1.0), Some(2));
|
||||
assert_eq!(span_at(&open, &three, 0, f64::NAN), None);
|
||||
let two = spans(&ring, &[(3, 0.5), (0, 0.5)]);
|
||||
assert_eq!((span_at(&ring, &two, 0, 0.25), span_at(&ring, &two, 3, 0.75), span_at(&ring, &two, 2, 0.0)), (Some(1), Some(1), Some(0)));
|
||||
// The closing segment's end is the first anchor: on the stretch that starts there, or runs through it.
|
||||
let at_first = spans(&ring, &[(0, 0.0), (2, 0.0)]);
|
||||
assert_eq!((span_at(&ring, &at_first, 3, 1.0), span_at(&ring, &at_first, 0, 0.0)), (Some(0), Some(0)));
|
||||
assert_eq!((span_at(&ring, &two, 3, 1.0), span_at(&ring, &spans(&ring, &[]), 3, 1.0)), (Some(1), Some(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subpath_without_some_stretches_keeps_the_rest_as_it_was() {
|
||||
// An open line of three segments, crossed twice on its middle one: the middle stretch goes.
|
||||
let open = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0)], false);
|
||||
let three = spans(&open, &[(1, 0.25), (1, 0.75)]);
|
||||
let left = without_spans(&open, &three, &[false, true, false]).unwrap();
|
||||
let xs = |sp: &SubPath| sp.anchors.iter().map(|a| a.p.x).collect::<Vec<_>>();
|
||||
// A straight segment's parameter doesn't run evenly along it: the cuts are where it puts them.
|
||||
let (q1, q3) = (open.segment(1).eval(0.25).x, open.segment(1).eval(0.75).x);
|
||||
assert!(100.0 < q1 && q1 < q3 && q3 < 200.0);
|
||||
assert_eq!(left.iter().map(xs).collect::<Vec<_>>(), [vec![0.0, 100.0, q1], vec![q3, 200.0, 300.0]]);
|
||||
assert!(left.iter().all(|p| !p.closed && p.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p)), "straight pieces have no handles");
|
||||
// Nothing marked: unchanged. All marked: nothing left. An end stretch: one piece.
|
||||
assert!(without_spans(&open, &three, &[false, false, false]).is_none());
|
||||
assert!(without_spans(&open, &three, &[]).is_none());
|
||||
assert_eq!(without_spans(&open, &three, &[true, true, true]), Some(vec![]));
|
||||
assert_eq!(without_spans(&open, &three, &[true, false, false]).unwrap().iter().map(xs).collect::<Vec<_>>(), [vec![q1, 200.0, 300.0]]);
|
||||
// Stretches left side by side stay one piece with no anchor where they meet.
|
||||
let four = spans(&open, &[(0, 0.5), (1, 0.5), (2, 0.5)]);
|
||||
assert_eq!(
|
||||
without_spans(&open, &four, &[false, false, false, true]).unwrap().iter().map(xs).collect::<Vec<_>>(),
|
||||
[vec![0.0, 100.0, 200.0, 250.0]]
|
||||
);
|
||||
// A closed square that loses a stretch opens, and its first anchor stays inside the piece.
|
||||
let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
let two = spans(&ring, &[(1, 0.5), (2, 0.5)]);
|
||||
let open_ring = without_spans(&ring, &two, &[true, false]).unwrap();
|
||||
assert_eq!(open_ring.len(), 1);
|
||||
let pts: Vec<Point> = open_ring[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts, [pt(50.0, 100.0), pt(0.0, 100.0), pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 50.0)]);
|
||||
assert!(!open_ring[0].closed);
|
||||
// The same when the removed stretch is the one through the first anchor.
|
||||
let through = without_spans(&ring, &two, &[false, true]).unwrap();
|
||||
assert_eq!(through[0].anchors.iter().map(|a| a.p).collect::<Vec<_>>(), [pt(100.0, 50.0), pt(100.0, 100.0), pt(50.0, 100.0)]);
|
||||
// Three places: the two stretches left on both sides of the first anchor are one piece through it.
|
||||
let three = spans(&ring, &[(0, 0.5), (1, 0.5), (2, 0.5)]);
|
||||
assert_eq!(three, [(0.5, 1.5), (1.5, 2.5), (2.5, 4.5)]);
|
||||
let around = without_spans(&ring, &three, &[false, true, false]).unwrap();
|
||||
assert_eq!(around.len(), 1, "{around:?}");
|
||||
let pts: Vec<Point> = around[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts, [pt(50.0, 100.0), pt(0.0, 100.0), pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 50.0)]);
|
||||
// A closed subpath with one place loses all of itself.
|
||||
assert_eq!(without_spans(&ring, &spans(&ring, &[(0, 0.5)]), &[true]), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slice_of_a_curve_keeps_its_shape_and_the_anchors_it_runs_through() {
|
||||
let mut circle = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
for i in 0..4 {
|
||||
auto_smooth(&mut circle, i, SMOOTH_RATIO);
|
||||
}
|
||||
let piece = slice(&circle, (0.5, 2.5));
|
||||
assert_eq!(piece.anchors.len(), 4);
|
||||
assert!(!piece.closed);
|
||||
// Its ends are corners with the handle off the piece retracted; the anchors between stay smooth, and
|
||||
// the whole segment between them keeps its handles.
|
||||
assert_eq!((piece.anchors[0].kind, piece.anchors[3].kind), (AnchorKind::Corner, AnchorKind::Corner));
|
||||
assert_eq!((piece.anchors[0].h_in, piece.anchors[3].h_out), (piece.anchors[0].p, piece.anchors[3].p));
|
||||
assert_eq!((piece.anchors[1].p, piece.anchors[2].p), (circle.anchors[1].p, circle.anchors[2].p));
|
||||
assert_eq!((piece.anchors[1].h_out, piece.anchors[2].h_in), (circle.anchors[1].h_out, circle.anchors[2].h_in));
|
||||
assert_eq!((piece.anchors[1].kind, piece.anchors[2].kind), (AnchorKind::Smooth, AnchorKind::Smooth));
|
||||
// Every point of the piece is on the circle's outline.
|
||||
for i in 0..piece.segment_count() {
|
||||
for k in 0..=8 {
|
||||
let p = piece.segment(i).eval(k as f64 / 8.0);
|
||||
let d = PathData::single(circle.clone()).nearest(p).unwrap().4;
|
||||
assert!(d < 1e-6, "{p:?} is {d} off");
|
||||
}
|
||||
}
|
||||
assert!(near(length(&piece), length(&circle) / 2.0, 1e-3));
|
||||
// No length, backwards or not finite: nothing.
|
||||
assert!(slice(&circle, (1.0, 1.0)).anchors.is_empty());
|
||||
assert!(slice(&circle, (2.0, 1.0)).anchors.is_empty());
|
||||
assert!(slice(&circle, (f64::NAN, 1.0)).anchors.is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! The stretches of a subpath between the places it is crossed ([`spans`]), the stretch a place
|
||||
//! lies on ([`span_at`]), a stretch as a subpath of its own ([`slice`]) and the subpath with some
|
||||
//! of its stretches removed ([`without_spans`]).
|
||||
|
||||
use kurbo::{ParamCurve, Point};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
|
||||
|
||||
/// A parameter this close to a segment's end is its anchor.
|
||||
const END: f64 = 1e-6;
|
||||
/// Places closer than this (pt) are one.
|
||||
const SAME: f64 = 1e-3;
|
||||
|
||||
/// A stretch of a subpath: where it starts and ends, each a segment's index plus the parameter on
|
||||
/// that segment. On a closed subpath the end is past the segment count where the stretch runs
|
||||
/// through the first anchor.
|
||||
pub type Span = (f64, f64);
|
||||
|
||||
/// The point at position `u` (a segment's index plus the parameter on it) of `sp`, which has
|
||||
/// segments. A closed subpath's positions wrap round.
|
||||
fn point_at(sp: &SubPath, u: f64) -> Point {
|
||||
let n = sp.segment_count();
|
||||
let u = if sp.closed { u.rem_euclid(n as f64) } else { u.clamp(0.0, n as f64) };
|
||||
let seg = (u.floor() as usize).min(n.saturating_sub(1));
|
||||
sp.segment(seg).eval(u - seg as f64)
|
||||
}
|
||||
|
||||
/// The stretches `places` (segment, t) divide `sp` into, in the subpath's order. An open subpath
|
||||
/// starts and ends a stretch at its ends; a closed one with no place is one stretch, the whole of
|
||||
/// it, and so is a closed one with one place, from that place round to it again. Places the subpath
|
||||
/// runs between without moving 0.001 pt away are one (a loop's two visits to a point are two), so
|
||||
/// are a place and an open end, and places off the subpath are left out.
|
||||
pub fn spans(sp: &SubPath, places: &[(usize, f64)]) -> Vec<Span> {
|
||||
let n = sp.segment_count();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let nf = n as f64;
|
||||
let mut at: Vec<f64> = places
|
||||
.iter()
|
||||
.filter(|(seg, t)| *seg < n && t.is_finite())
|
||||
.map(|&(seg, t)| {
|
||||
let t = if t <= END {
|
||||
0.0
|
||||
} else if t >= 1.0 - END {
|
||||
1.0
|
||||
} else {
|
||||
t
|
||||
};
|
||||
let u = seg as f64 + t;
|
||||
if sp.closed && u >= nf { u - nf } else { u }
|
||||
})
|
||||
.collect();
|
||||
at.sort_by(f64::total_cmp);
|
||||
// Two positions are one place when the subpath stays at the same point between them; a loop
|
||||
// that comes back to a point visits it twice.
|
||||
let same = |a: f64, b: f64| {
|
||||
let p = point_at(sp, a);
|
||||
(b - a).abs() < 1.0 && [0.25, 0.5, 0.75, 1.0].iter().all(|f| point_at(sp, a + (b - a) * f).distance(p) < SAME)
|
||||
};
|
||||
at.dedup_by(|later, earlier| same(*earlier, *later));
|
||||
if !sp.closed {
|
||||
at.retain(|u| !same(0.0, *u) && !same(*u, nf));
|
||||
let bounds: Vec<f64> = std::iter::once(0.0).chain(at).chain([nf]).collect();
|
||||
return bounds.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect();
|
||||
}
|
||||
// The last place and the first meet round the first anchor.
|
||||
while let (Some(&first), Some(&last)) = (at.first(), at.last())
|
||||
&& at.len() > 1
|
||||
&& same(last - nf, first)
|
||||
{
|
||||
at.pop();
|
||||
}
|
||||
let Some(&first) = at.first() else { return vec![(0.0, nf)] };
|
||||
at.push(first + nf);
|
||||
at.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
|
||||
}
|
||||
|
||||
/// Which of `stretches` of `sp` the place (`seg`, `t`) lies on. A place where two stretches meet
|
||||
/// is on the one that starts there.
|
||||
pub fn span_at(sp: &SubPath, stretches: &[Span], seg: usize, t: f64) -> Option<usize> {
|
||||
let nf = sp.segment_count() as f64;
|
||||
if !t.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let u = (seg as f64 + t.clamp(0.0, 1.0)).min(nf);
|
||||
// On a closed subpath the closing segment's end is the first anchor.
|
||||
let u = if sp.closed && u >= nf { u - nf } else { u };
|
||||
let on = |&(from, to): &Span| (from <= u && u < to) || (sp.closed && from <= u + nf && u + nf < to);
|
||||
stretches.iter().position(on).or_else(|| (!sp.closed && u >= nf).then(|| stretches.len().checked_sub(1)).flatten())
|
||||
}
|
||||
|
||||
/// The stretch `(from, to)` of `sp` as an open subpath of its own: the anchors it runs through keep
|
||||
/// their kind and handles, and its ends are corners. Empty when the stretch has no length.
|
||||
pub fn slice(sp: &SubPath, (from, to): Span) -> SubPath {
|
||||
let n = sp.segment_count();
|
||||
let mut anchors: Vec<Anchor> = vec![];
|
||||
if n == 0 || !(from.is_finite() && to.is_finite()) || from < 0.0 || to <= from {
|
||||
return SubPath::new(anchors, false);
|
||||
}
|
||||
let to = to.min(from + n as f64);
|
||||
let mut i = from.floor() as usize;
|
||||
while (i as f64) < to {
|
||||
let (t0, t1) = ((from - i as f64).max(0.0), (to - i as f64).min(1.0));
|
||||
let seg = i % n;
|
||||
i += 1;
|
||||
if t1 <= t0 {
|
||||
continue;
|
||||
}
|
||||
let whole = sp.segment(seg);
|
||||
let c = if t0 <= 0.0 && t1 >= 1.0 { whole } else { whole.subsegment(t0..t1) };
|
||||
let (h1, h2) = if sp.segment_is_line(seg) { (c.p0, c.p3) } else { (c.p1, c.p2) };
|
||||
match anchors.last_mut() {
|
||||
Some(a) => a.h_out = h1,
|
||||
None => anchors.push(Anchor { p: c.p0, h_in: c.p0, h_out: h1, kind: AnchorKind::Corner }),
|
||||
}
|
||||
// An anchor the stretch runs through keeps its kind.
|
||||
let through = t1 >= 1.0 && (i as f64) < to;
|
||||
let kind = if through { sp.anchors.get((seg + 1) % sp.anchors.len()).map_or(AnchorKind::Corner, |a| a.kind) } else { AnchorKind::Corner };
|
||||
anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind });
|
||||
}
|
||||
SubPath::new(anchors, false)
|
||||
}
|
||||
|
||||
/// `sp` without the stretches of `stretches` that `remove` marks: the pieces left, each an open
|
||||
/// subpath, in the subpath's order. Stretches left side by side stay one piece, so no anchor is
|
||||
/// added where a stretch that stays meets another. `None` when no stretch is marked, and no piece
|
||||
/// when all are.
|
||||
pub fn without_spans(sp: &SubPath, stretches: &[Span], remove: &[bool]) -> Option<Vec<SubPath>> {
|
||||
let n = stretches.len();
|
||||
let gone = |i: usize| remove.get(i).copied().unwrap_or(false);
|
||||
let first_gone = (0..n).find(|&i| gone(i))?;
|
||||
let nf = sp.segment_count() as f64;
|
||||
// A closed subpath is walked from its first removed stretch, so the stretches left on both
|
||||
// sides of its first anchor are one piece.
|
||||
let start = if sp.closed { first_gone } else { 0 };
|
||||
let mut out = vec![];
|
||||
let mut run: Option<Span> = None;
|
||||
for k in 0..n {
|
||||
let i = (start + k) % n;
|
||||
let Some(&(from, to)) = stretches.get(i).filter(|_| !gone(i)) else {
|
||||
out.extend(run.take().map(|r| slice(sp, r)));
|
||||
continue;
|
||||
};
|
||||
run = Some(match run {
|
||||
// Past the first anchor of a closed subpath the positions start again.
|
||||
Some((a, b)) => (a, if to < b { to + nf } else { to }),
|
||||
None => (from, to),
|
||||
});
|
||||
}
|
||||
out.extend(run.map(|r| slice(sp, r)));
|
||||
out.retain(|p| p.anchors.len() >= 2);
|
||||
Some(out)
|
||||
}
|
||||
+109
-7
@@ -28,11 +28,26 @@ pub struct SimplifyOptions {
|
||||
/// keeps its shape. The limit is the same along the whole stretch, so where smoothing moves some of
|
||||
/// its points far, all of it is refitted within up to `tolerance`, as without this option.
|
||||
pub smooth_moved_only: bool,
|
||||
/// With [`smooth`](Self::smooth), the curve at an open subpath's first anchor leaves it along the
|
||||
/// direction of the smoothed path over the 3 spreads next to it rather than along its first
|
||||
/// segment. The anchor stays where it is. A closed subpath has no such end, and [`simplify_part`]
|
||||
/// ignores this.
|
||||
pub free_start: bool,
|
||||
/// [`free_start`](Self::free_start) for an open subpath's last anchor.
|
||||
pub free_end: bool,
|
||||
}
|
||||
|
||||
impl Default for SimplifyOptions {
|
||||
fn default() -> Self {
|
||||
Self { tolerance: 0.5, corner_angle_deg: 30.0, straight_lines: false, smooth: 0.0, smooth_moved_only: false }
|
||||
Self {
|
||||
tolerance: 0.5,
|
||||
corner_angle_deg: 30.0,
|
||||
straight_lines: false,
|
||||
smooth: 0.0,
|
||||
smooth_moved_only: false,
|
||||
free_start: false,
|
||||
free_end: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +122,8 @@ fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
|
||||
if n == 0 || sp.closed && n < 2 {
|
||||
return Some(sp.clone());
|
||||
}
|
||||
let closed_opts = SimplifyOptions { free_start: false, free_end: false, ..*opts };
|
||||
let opts = if sp.closed { &closed_opts } else { opts };
|
||||
if sp.closed {
|
||||
// Start the loop at its first corner, so no fit runs across one.
|
||||
let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0);
|
||||
@@ -282,6 +299,12 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
cuts.insert(0, 0);
|
||||
cuts.push(run.len());
|
||||
let mut result: Vec<Seg> = Vec::new();
|
||||
// Whether `part` starts where `run` does and whether it ends where `run` does, for the free ends.
|
||||
let free = |part: &[(&Seg, usize)]| {
|
||||
let starts = part.first().zip(run.first()).is_some_and(|((s, _), r)| std::ptr::eq(*s, r));
|
||||
let ends = part.last().zip(run.last()).is_some_and(|((s, _), r)| std::ptr::eq(*s, r));
|
||||
[opts.free_start && starts, opts.free_end && ends]
|
||||
};
|
||||
for w in cuts.windows(2) {
|
||||
let piece = &run[w[0]..w[1]];
|
||||
let mut part: Vec<(&Seg, usize)> = Vec::new();
|
||||
@@ -290,7 +313,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
let steps = sample_steps(&s.c, tol);
|
||||
if steps <= budget.min(MAX_SEGMENT_SAMPLES) && result.len() < MAX_RUN_RESULT {
|
||||
if part_samples + steps > MAX_FIT_SAMPLES {
|
||||
refit_part(&part, opts, tol, None, &mut result);
|
||||
refit_part(&part, opts, tol, None, free(&part), &mut result);
|
||||
part.clear();
|
||||
part_samples = 0;
|
||||
}
|
||||
@@ -299,7 +322,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
part.push((s, steps));
|
||||
} else {
|
||||
part_samples = 0;
|
||||
refit_part(&part, opts, tol, None, &mut result);
|
||||
refit_part(&part, opts, tol, None, free(&part), &mut result);
|
||||
part.clear();
|
||||
if opts.straight_lines {
|
||||
let room = MAX_RUN_RESULT.saturating_sub(result.len());
|
||||
@@ -313,7 +336,7 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
}
|
||||
}
|
||||
}
|
||||
refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), &mut result);
|
||||
refit_part(&part, opts, tol, ends.filter(|_| part.len() == run.len()), free(&part), &mut result);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -321,14 +344,18 @@ pub(crate) fn simplify_run_within(run: &[Seg], opts: &SimplifyOptions, mut budge
|
||||
/// Adds the refit of `part`, consecutive segments with no corner between them and the steps to
|
||||
/// sample each one with, to `result`. `ends`, given only when `part` is a whole closed loop, is the
|
||||
/// direction it leaves and arrives along; when the loop is smoothed, the smoothing runs across its
|
||||
/// start and the direction comes from the smoothed samples there.
|
||||
fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option<Vec2>, result: &mut Vec<Seg>) {
|
||||
/// start and the direction comes from the smoothed samples there. `free` says whether the start and
|
||||
/// the end of `part` are free ends of an open subpath: when it is smoothed, the refit leaves such a
|
||||
/// start and arrives at such an end along [`free_end_direction`], or along its first or last segment
|
||||
/// when that gives none or turns from the segment by 90° or more.
|
||||
fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends: Option<Vec2>, free: [bool; 2], result: &mut Vec<Seg>) {
|
||||
let (Some((first, _)), Some((last, _))) = (part.first(), part.last()) else { return };
|
||||
let mut pts = Vec::new();
|
||||
for (k, (s, steps)) in part.iter().enumerate() {
|
||||
sample(&s.c, *steps, &mut pts, k == 0);
|
||||
}
|
||||
let mut tol = tol;
|
||||
let (mut t0, mut t1) = (start_tangent(&first.c), end_tangent(&last.c));
|
||||
if opts.smooth > 0.0 {
|
||||
let smoothed = if ends.is_some() { smooth_loop_points(&pts, opts.smooth) } else { smooth_points(&pts, opts.smooth) };
|
||||
if ends.is_some()
|
||||
@@ -336,6 +363,18 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends
|
||||
{
|
||||
ends = unit(next - prev).or(ends);
|
||||
}
|
||||
if ends.is_none() {
|
||||
if free[0]
|
||||
&& let Some(d) = free_end_direction(&smoothed, opts.smooth, false).filter(|d| d.dot(t0) > 0.0)
|
||||
{
|
||||
t0 = d;
|
||||
}
|
||||
if free[1]
|
||||
&& let Some(d) = free_end_direction(&smoothed, opts.smooth, true).filter(|d| d.dot(t1) > 0.0)
|
||||
{
|
||||
t1 = d;
|
||||
}
|
||||
}
|
||||
if opts.smooth_moved_only {
|
||||
tol = tol.min((2.0 * smoothing_moved(&pts, &smoothed)).max(tol * SMOOTH_MOVED_FLOOR));
|
||||
}
|
||||
@@ -351,7 +390,7 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, mut ends
|
||||
// to 1.5% of `tol`: 3/4 of how far its handles may be from the line. The fitted curves join
|
||||
// smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. The last
|
||||
// curve of a loop fitted across its start leaves along the loop's first segment.
|
||||
let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d));
|
||||
let (t0, t1) = ends.map_or((t0, t1), |d| (d, d));
|
||||
let fitted = fit_cubics(&pts, t0, t1, tol * 0.98);
|
||||
let from = result.len();
|
||||
let mut arrive = t0;
|
||||
@@ -451,6 +490,66 @@ fn smoothing_moved(pts: &[Point], smoothed: &[Point]) -> f64 {
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// How far along the path, in spreads of the smoothing, [`free_end_direction`] looks from the end:
|
||||
/// as far as the Gaussian reaches. VectorCraft's own value.
|
||||
const FREE_END_SPREADS: f64 = 3.0;
|
||||
|
||||
/// [`free_end_direction`] gives no direction when the path near the end spreads out from it more
|
||||
/// evenly than this: the ratio of the least to the most squared distance from a line through the end,
|
||||
/// over all such lines. VectorCraft's own value.
|
||||
const FREE_END_MAX_SPREAD: f64 = 1.0 / 3.0;
|
||||
|
||||
/// The direction the samples `pts` of an open run leave their first point along, or with `at_end`
|
||||
/// arrive at their last point along: the direction of the line through that point that the path
|
||||
/// within [`FREE_END_SPREADS`] spreads `sigma` of it along its length, and at most half its length,
|
||||
/// lies closest to by the least sum of squared distances, pointing along the path. `None` when the
|
||||
/// path there has no length or is not finite, when its mean point is within a tenth of its length
|
||||
/// there of that point (it doubles back), or when it spreads from that point more evenly than
|
||||
/// [`FREE_END_MAX_SPREAD`].
|
||||
fn free_end_direction(pts: &[Point], sigma: f64, at_end: bool) -> Option<Vec2> {
|
||||
let n = pts.len();
|
||||
let total: f64 = pts.windows(2).map(|w| w[0].distance(w[1])).sum();
|
||||
let reach = (FREE_END_SPREADS * sigma).min(total / 2.0);
|
||||
if !(reach.is_finite() && reach > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let at = |i: usize| pts.get(if at_end { n - 1 - i } else { i }).copied();
|
||||
let origin = at(0)?;
|
||||
// The integrals along the path of v vᵀ and of v, for v from the end to each point.
|
||||
let (mut sxx, mut sxy, mut syy, mut sum, mut along) = (0.0, 0.0, 0.0, Vec2::ZERO, 0.0);
|
||||
let mut a = Vec2::ZERO;
|
||||
for i in 1..n {
|
||||
let Some(p) = at(i) else { break };
|
||||
let mut b = p - origin;
|
||||
let mut len = (b - a).hypot();
|
||||
if along + len > reach {
|
||||
b = a.lerp(b, (reach - along) / len);
|
||||
len = reach - along;
|
||||
}
|
||||
sxx += len * (a.x * a.x + a.x * b.x + b.x * b.x) / 3.0;
|
||||
syy += len * (a.y * a.y + a.y * b.y + b.y * b.y) / 3.0;
|
||||
sxy += len * (2.0 * a.x * a.y + a.x * b.y + b.x * a.y + 2.0 * b.x * b.y) / 6.0;
|
||||
sum += (a + b) * (len / 2.0);
|
||||
along += len;
|
||||
a = b;
|
||||
if along >= reach {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (trace, apart) = (sxx + syy, (sxx - syy).hypot(2.0 * sxy));
|
||||
// The two principal values are (trace ± apart) / 2.
|
||||
if !(trace.is_finite() && apart.is_finite() && trace > 0.0) || trace - apart > FREE_END_MAX_SPREAD * (trace + apart) {
|
||||
return None;
|
||||
}
|
||||
let mean = sum / along;
|
||||
if !mean.hypot().is_finite() || mean.hypot() < 0.1 * along {
|
||||
return None;
|
||||
}
|
||||
let d = Vec2::from_angle(0.5 * (2.0 * sxy).atan2(sxx - syy));
|
||||
let d = if d.dot(mean) >= 0.0 { d } else { -d };
|
||||
unit(if at_end { -d } else { d })
|
||||
}
|
||||
|
||||
/// How many groups of samples a spread holds at most in [`smooth_points`]. Weighing them in groups
|
||||
/// leaves each smoothed point within a thousandth of a spread of the mean of its samples weighed one
|
||||
/// by one.
|
||||
@@ -597,6 +696,7 @@ pub struct SimplifiedPart {
|
||||
/// `segments` is simplified whole, as [`simplify_with`] does. A run whose refit would have more
|
||||
/// segments than it had is left as it is (except with Convert to Straight Lines).
|
||||
pub fn simplify_part(path: &PathData, segments: &BTreeSet<(usize, usize)>, opts: &SimplifyOptions) -> SimplifiedPart {
|
||||
let opts = &SimplifyOptions { free_start: false, free_end: false, ..*opts };
|
||||
let (mut runs, mut anchors) = (BTreeSet::new(), BTreeMap::new());
|
||||
let mut subpaths = Vec::with_capacity(path.subpaths.len());
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
@@ -784,6 +884,8 @@ pub fn smooth_options(path: &PathData, level: f64) -> SimplifyOptions {
|
||||
straight_lines: false,
|
||||
smooth: size * 0.05 * level / 100.0,
|
||||
smooth_moved_only: false,
|
||||
free_start: false,
|
||||
free_end: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -488,6 +488,61 @@ fn simplify_keeps_the_join_at_the_start_of_a_smoothed_loop_smooth() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The degrees between the x axis and the direction `sp` leaves its first anchor along.
|
||||
fn start_angle(sp: &SubPath) -> f64 {
|
||||
let c = sp.segment(0);
|
||||
let d = [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
|
||||
d.y.atan2(d.x).to_degrees()
|
||||
}
|
||||
|
||||
/// With `free_start` and `free_end`, a smoothed open subpath leaves its first anchor and arrives at
|
||||
/// its last one along the smoothed path, not along a jagged first or last segment, and its end points
|
||||
/// stay. Only a flagged end turns, and a closed subpath, Object › Path › Smooth's selected segments
|
||||
/// (`simplify_part`) and a refit without smoothing give the same result with the flags as without.
|
||||
#[test]
|
||||
fn free_ends_turn_only_open_smoothed_ends() {
|
||||
let pts: Vec<Point> = (0..=40).map(|i| Point::new(5.0 * f64::from(i), if i == 1 || i == 39 { 1.5 } else { 0.0 })).collect();
|
||||
let path = PathData::single(SubPath::polyline(&pts, false));
|
||||
let base = SimplifyOptions { tolerance: 2.5, corner_angle_deg: 179.0, smooth: 4.0, smooth_moved_only: true, ..Default::default() };
|
||||
let both = SimplifyOptions { free_start: true, free_end: true, ..base };
|
||||
let s = simplify_with(&path, &both);
|
||||
let sp = &s.subpaths[0];
|
||||
assert_eq!((sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p), (pts[0], pts[40]), "{s:?}");
|
||||
let mut back = sp.clone();
|
||||
back.reverse();
|
||||
assert!(start_angle(sp).abs() <= 5.0 && (start_angle(&back).abs() - 180.0).abs() <= 5.0, "{s:?}");
|
||||
let start_only = simplify_with(&path, &SimplifyOptions { free_start: true, ..base });
|
||||
let plain = simplify_with(&path, &base);
|
||||
assert!(start_angle(&start_only.subpaths[0]).abs() <= 5.0, "{start_only:?}");
|
||||
assert!(start_angle(&plain.subpaths[0]) > 16.0, "{plain:?}");
|
||||
let (mut a, mut b) = (start_only.subpaths[0].clone(), plain.subpaths[0].clone());
|
||||
a.reverse();
|
||||
b.reverse();
|
||||
assert!((start_angle(&a) - start_angle(&b)).abs() <= 1e-9, "the last anchor keeps its direction: {start_only:?}");
|
||||
// Flags that must have no effect.
|
||||
let ring = PathData::single(SubPath::polyline(&pts[..39], true));
|
||||
assert_eq!(simplify_with(&ring, &both), simplify_with(&ring, &base));
|
||||
let unsmoothed = SimplifyOptions { smooth: 0.0, ..base };
|
||||
assert_eq!(simplify_with(&path, &SimplifyOptions { free_start: true, free_end: true, ..unsmoothed }), simplify_with(&path, &unsmoothed));
|
||||
let picked: std::collections::BTreeSet<(usize, usize)> = (0..20).map(|i| (0, i)).collect();
|
||||
assert_eq!(simplify_part(&path, &picked, &both).path, simplify_part(&path, &picked, &base).path);
|
||||
}
|
||||
|
||||
/// A free end whose smoothed path turns back by more than 90° from its first segment keeps that
|
||||
/// segment's direction: a path that leaves its start along x for 2 pt, then turns by 170°.
|
||||
#[test]
|
||||
fn a_free_end_that_turns_back_keeps_its_first_direction() {
|
||||
let back = kurbo::Vec2::from_angle(170f64.to_radians());
|
||||
let mut pts = vec![Point::ORIGIN, Point::new(2.0, 0.0)];
|
||||
pts.extend((1..=10).map(|i| Point::new(2.0, 0.0) + back * (3.0 * f64::from(i))));
|
||||
let path = PathData::single(SubPath::polyline(&pts, false));
|
||||
let opts =
|
||||
SimplifyOptions { tolerance: 2.5, corner_angle_deg: 179.0, smooth: 4.0, smooth_moved_only: true, free_start: true, ..Default::default() };
|
||||
let s = simplify_with(&path, &opts);
|
||||
let angle = start_angle(&s.subpaths[0]);
|
||||
assert!(angle.abs() <= 1.0, "the path leaves its start at {angle}°: {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_straight_lines_option() {
|
||||
let c = dense_circle(50.0, 200);
|
||||
|
||||
@@ -60,6 +60,9 @@ pub(crate) struct Exporter<'a> {
|
||||
images: HashMap<String, Option<Image>>,
|
||||
/// `Some` while opacity mask art is written: whether an image in it was left out.
|
||||
image_left_out: Option<bool>,
|
||||
/// While the pieces of a mesh-warped image are written: the size its pixels are resampled for
|
||||
/// ([`Exporter::warp_image_size`]).
|
||||
warp_size: Option<images::SharedSize>,
|
||||
/// The brush library, parsed when the first brushed stroke is written.
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
/// Whether the group being written is a knockout group (what its neutral children inherit).
|
||||
|
||||
@@ -31,6 +31,7 @@ impl<'a> Exporter<'a> {
|
||||
warnings: vec![],
|
||||
images: HashMap::new(),
|
||||
image_left_out: None,
|
||||
warp_size: None,
|
||||
brushes: None,
|
||||
knockout: doc.page_knockout,
|
||||
lab_spots: vec![],
|
||||
|
||||
+101
-10
@@ -1,5 +1,5 @@
|
||||
use super::exporter::{UNDECODED, UNWRITABLE_IMAGE, q, writable, xf};
|
||||
use super::{Arc, BlendMode, Color, Exporter, Image, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
|
||||
use super::{Arc, BlendMode, Color, Exporter, Image, Node, NodeKind, Paint, Rect, Size, Surface, cmyk, krilla};
|
||||
use krilla::color::device_n::{Colorant, DeviceNSpace, Process};
|
||||
use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace};
|
||||
|
||||
@@ -59,13 +59,17 @@ impl Exporter<'_> {
|
||||
/// the Output settings write them. The coverage is resampled as the Compression settings ask
|
||||
/// ([`crate::images::coverage_sized`]). `None` when the paint isn't a solid color, the image is
|
||||
/// missing or left out, its pixels can't be decoded, or its color channel exceeds
|
||||
/// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; image rejections are cached like [`Self::load_image`].
|
||||
/// [`vectorcraft_doc::MAX_RASTER_ALLOC`]; cached by key and paint (and size when images are
|
||||
/// resampled), image rejections too.
|
||||
/// With `interpolate` (and the export's own setting), viewers may smooth it.
|
||||
pub(super) fn colorized_image(&mut self, im: &vectorcraft_doc::ImageObject, paint: &Paint, interpolate: bool) -> Option<Image> {
|
||||
let Paint::Solid { color, swatch, tint } = paint else { return None };
|
||||
let size = placed_size(im);
|
||||
let size = self.image_size(im);
|
||||
let interpolate = interpolate && self.interpolate;
|
||||
let mut cache = format!("{}\u{0}{paint:?}\u{0}{:.3}x{:.3}", im.key, size.0, size.1);
|
||||
let mut cache = format!("{}\u{0}{paint:?}", im.key);
|
||||
if self.downsampled() {
|
||||
cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1));
|
||||
}
|
||||
if !interpolate && self.interpolate {
|
||||
cache.push_str("\u{0}pixelated");
|
||||
}
|
||||
@@ -124,9 +128,8 @@ impl Exporter<'_> {
|
||||
fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64), interpolate: bool) -> Option<Image> {
|
||||
let key = im.key.as_str();
|
||||
let c = self.compression;
|
||||
let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None);
|
||||
let interpolate = interpolate && self.interpolate;
|
||||
let mut cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
|
||||
let mut cache = if self.downsampled() { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() };
|
||||
if !interpolate && self.interpolate {
|
||||
cache.push_str("\u{0}pixelated");
|
||||
}
|
||||
@@ -173,9 +176,12 @@ impl Exporter<'_> {
|
||||
if m.invert || !plain || (mi.xf, mi.width, mi.height) != (im.xf, im.width, im.height) {
|
||||
return None;
|
||||
}
|
||||
let size = placed_size(im);
|
||||
let size = self.image_size(im);
|
||||
let interpolate = interpolate && self.interpolate;
|
||||
let mut cache = format!("{}\u{0}{}\u{0}{}\u{0}{:.3}x{:.3}", im.key, mi.key, m.clip, size.0, size.1);
|
||||
let mut cache = format!("{}\u{0}{}\u{0}{}", im.key, mi.key, m.clip);
|
||||
if self.downsampled() {
|
||||
cache.push_str(&format!("\u{0}{:.3}x{:.3}", size.0, size.1));
|
||||
}
|
||||
if !interpolate && self.interpolate {
|
||||
cache.push_str("\u{0}pixelated");
|
||||
}
|
||||
@@ -206,7 +212,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(img) = img.or_else(|| self.load_image(im, placed_size(im), !pixelated)) else {
|
||||
let Some(img) = img.or_else(|| self.load_image(im, self.image_size(im), !pixelated)) else {
|
||||
self.image_left_out = self.image_left_out.map(|_| true);
|
||||
return;
|
||||
};
|
||||
@@ -221,6 +227,56 @@ impl Exporter<'_> {
|
||||
s.draw_image(img, size);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Whether the Compression settings resample any kind of image.
|
||||
fn downsampled(&self) -> bool {
|
||||
let c = self.compression;
|
||||
[c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None)
|
||||
}
|
||||
|
||||
/// The size image `im` is resampled for: [`Self::warp_image_size`]'s while the pieces of a
|
||||
/// mesh-warped image of the same pixels are written, else the size it is placed at.
|
||||
fn image_size(&self, im: &vectorcraft_doc::ImageObject) -> (f64, f64) {
|
||||
match &self.warp_size {
|
||||
Some(w) if (w.key.as_str(), w.width, w.height) == (im.key.as_str(), im.width, im.height) => w.size,
|
||||
_ => placed_size(im),
|
||||
}
|
||||
}
|
||||
|
||||
/// When `children` are the pieces of a mesh-warped image ([`Node::warp_piece_image`]) and the
|
||||
/// Compression settings resample images: the image they show, and the widest and highest any
|
||||
/// piece places it, which every piece's image is resampled for, so that the pieces paint one
|
||||
/// image. `None` for other children, pieces of different images, or a size that isn't finite
|
||||
/// and positive.
|
||||
pub(super) fn warp_image_size(&self, children: &[Arc<Node>]) -> Option<SharedSize> {
|
||||
if !self.downsampled() {
|
||||
return None;
|
||||
}
|
||||
let mut shared: Option<SharedSize> = None;
|
||||
for c in children {
|
||||
let n = c.warp_piece_image()?;
|
||||
let NodeKind::Image(im) = &n.kind else { return None };
|
||||
let im = self.doc.shown_image(n, im);
|
||||
let (w, h) = placed_size(&im);
|
||||
match &mut shared {
|
||||
Some(s) if (s.key.as_str(), s.width, s.height) == (im.key.as_str(), im.width, im.height) => {
|
||||
s.size = (s.size.0.max(w), s.size.1.max(h));
|
||||
}
|
||||
Some(_) => return None,
|
||||
None => shared = Some(SharedSize { key: im.key.clone(), width: im.width, height: im.height, size: (w, h) }),
|
||||
}
|
||||
}
|
||||
shared.filter(|s| s.size.0.is_finite() && s.size.1.is_finite() && s.size.0 > 0.0 && s.size.1 > 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The size the pixels of image `key` (`width` × `height` pixels) are resampled for while the
|
||||
/// pieces of a mesh-warped image are written ([`Exporter::warp_image_size`]).
|
||||
pub(super) struct SharedSize {
|
||||
key: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
size: (f64, f64),
|
||||
}
|
||||
|
||||
/// The size image `im` is placed at: its pixel grid's sides through its transform.
|
||||
@@ -320,13 +376,48 @@ mod tests {
|
||||
let image =
|
||||
ImageObject { key: key.into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
|
||||
assert!(exporter.colorized_image(&image, &paint, true).is_none(), "{key}");
|
||||
let cache = format!("{key}\u{0}{paint:?}\u{0}1.000x1.000");
|
||||
let cache = format!("{key}\u{0}{paint:?}");
|
||||
assert!(matches!(exporter.images.get(&cache), Some(None)), "rejection for {key} was not cached");
|
||||
assert!(exporter.colorized_image(&image, &paint, true).is_none(), "second call for {key}");
|
||||
}
|
||||
assert_eq!(exporter.images.len(), 3);
|
||||
}
|
||||
|
||||
/// Without downsampling, a colorized image and a CMYK image with a grid mask are made once for
|
||||
/// every size they are placed at.
|
||||
#[test]
|
||||
fn colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling() {
|
||||
let gray = |pixels: Vec<u8>| {
|
||||
let mut png = vec![];
|
||||
image::GrayImage::from_raw(2, 2, pixels).unwrap().write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
ImageBlob::png(png)
|
||||
};
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
doc.images.insert("pic".into(), gray(vec![0, 255, 128, 64]));
|
||||
doc.images.insert("alpha".into(), gray(vec![255, 128, 0, 64]));
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(2, 2, [0u8, 102, 255, 0].repeat(4)).unwrap();
|
||||
doc.images.insert("inks".into(), ImageBlob::cmyk_tiff(&inks).unwrap());
|
||||
let settings = crate::PdfSettings::default();
|
||||
let mut exporter = Exporter::new(&doc, &settings);
|
||||
let paint = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let image = |key: &str, scale: f64| ImageObject {
|
||||
key: key.into(),
|
||||
width: 2,
|
||||
height: 2,
|
||||
xf: Affine::scale(scale),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
for scale in [10.0, 20.0] {
|
||||
assert!(exporter.colorized_image(&image("pic", scale), &paint, true).is_some(), "colorized at {scale}");
|
||||
let art = vectorcraft_doc::Node::new(doc.layers[0].id, vectorcraft_doc::NodeKind::Image(Box::new(image("alpha", scale))));
|
||||
let mask = vectorcraft_doc::OpacityMask::new(art, true);
|
||||
assert!(exporter.masked_image(&image("inks", scale), &mask, true).is_some(), "masked at {scale}");
|
||||
}
|
||||
assert_eq!(exporter.images.len(), 2, "{:?}", exporter.images.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tinted_color_rejects_oversized_and_overflowing_buffers() {
|
||||
let max = usize::try_from(vectorcraft_doc::MAX_RASTER_ALLOC).unwrap_or(usize::MAX);
|
||||
|
||||
@@ -100,6 +100,17 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::measured_children`] of a plain group or layer. When they are the pieces of a
|
||||
/// mesh-warped image, every piece, on the page or not, takes part in the one size its pixels
|
||||
/// are resampled for ([`Self::warp_image_size`]).
|
||||
fn group_children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect, measured: &[Measured]) {
|
||||
let outer = self.warp_image_size(children).map(|w| self.warp_size.replace(w));
|
||||
self.measured_children(s, children, page, measured);
|
||||
if let Some(outer) = outer {
|
||||
self.warp_size = outer;
|
||||
}
|
||||
}
|
||||
|
||||
/// A luminosity mask that is 1 − the knockout shape of `c`, whose box is in `measured`: white,
|
||||
/// then black through an alpha mask of `c` (at full object opacity without its own mask, unless
|
||||
/// those define its shape).
|
||||
@@ -268,7 +279,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||||
self.measured_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
|
||||
self.group_children(s, children, page, measured.map_or(&[], |m| m.members.as_slice()));
|
||||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// The region every output clips to; nothing to clip by hides the clipped art. The
|
||||
|
||||
@@ -38,8 +38,9 @@ impl Exporter<'_> {
|
||||
// stroke overprints.
|
||||
let text_space = xf(t.xf);
|
||||
// Ruby can't be real text in one run (it isn't characters of the base text): the type's
|
||||
// characters go out as outlines where ruby is present, ruby included.
|
||||
let plain = (!self.outline_text && t.ruby.is_empty()).then(|| t.plain_text());
|
||||
// characters go out as outlines where ruby is present, ruby included. Warichu's stacked
|
||||
// lines go out as outlines too.
|
||||
let plain = (!self.outline_text && t.ruby.is_empty() && !t.stacks_warichu()).then(|| t.plain_text());
|
||||
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
|
||||
for (i, run) in t.runs.iter().enumerate() {
|
||||
let mut bp = BezPath::new();
|
||||
|
||||
@@ -133,6 +133,39 @@ fn colorized_images_are_downsampled() {
|
||||
assert!(text.contains("/Width 150") && !text.contains("/Width 600"), "resampled to 150 ppi");
|
||||
}
|
||||
|
||||
/// The pieces of a colorized image under a mesh warp, placed at different widths, paint one
|
||||
/// colorized image and its one soft mask, with downsampling off and on.
|
||||
#[test]
|
||||
fn a_colorized_warped_image_is_written_once_for_all_its_pieces() {
|
||||
for compression in [serde_json::json!({}), serde_json::json!({"gray": {"downsample": "bicubic", "ppi": 150}})] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let gray = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([((x + y) % 200) as u8]));
|
||||
let mut png = vec![];
|
||||
gray.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("pic".into(), ImageBlob::png(png));
|
||||
let im = ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 600,
|
||||
height: 600,
|
||||
xf: Affine::scale(72.0 / 600.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
|
||||
n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))));
|
||||
// 0.5 to 1.5 times as wide from left to right.
|
||||
let stretch = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x * (0.5 + p.x / 144.0), p.y);
|
||||
let warped = vectorcraft_doc::live::mesh_warp_image(&n, &im, &stretch, 4, false, false);
|
||||
d.insert(d.default_layer(), 0, warped).unwrap();
|
||||
let settings: PdfSettings = serde_json::from_value(serde_json::json!({ "compression": compression })).unwrap();
|
||||
let out = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
let found = images(&out);
|
||||
assert_eq!(found.len(), 2, "{compression}: the colorized image and its soft mask, not {found:?}");
|
||||
assert!(found.contains(&("DeviceRGB".into(), vec![], true)), "{compression}: {found:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Printed separations keep a colorized image's pixels on every plate and separate its color:
|
||||
/// a spot-colorized image prints on its ink's plate only. An uncolorized image of the same pixels
|
||||
/// is separated as before.
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use craft_pdf::syntax::object::{Name, Object};
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -72,6 +73,55 @@ fn images_above_the_threshold_are_resampled_to_the_target_resolution() {
|
||||
assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi");
|
||||
}
|
||||
|
||||
/// The width and height of each image XObject in `pdf`.
|
||||
fn image_sizes(pdf: &[u8]) -> Vec<(u32, u32)> {
|
||||
let file = craft_pdf::syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap();
|
||||
file.objects()
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Object::Stream(s) if s.dict().get::<Name<'_>>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") => {
|
||||
Some((s.dict().get::<u32>(b"Width").unwrap_or(0), s.dict().get::<u32>(b"Height").unwrap_or(0)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A mesh warp that stretches the image across more toward its right side cuts it into pieces
|
||||
/// placed at different widths. With downsampling on, every piece paints one image, resampled for
|
||||
/// the widest piece; another placement of the image keeps its own resampling.
|
||||
#[test]
|
||||
fn a_warped_image_is_written_once_for_all_its_pieces() {
|
||||
let compression = json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}});
|
||||
for masked in [false, true] {
|
||||
let mut d = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let n = d.node(id).unwrap().clone();
|
||||
let NodeKind::Image(im) = &n.kind else { panic!("the image") };
|
||||
// 0.5 to 1.5 times as wide from left to right, within the page.
|
||||
let stretch = |p: Point| Point::new(p.x * (0.5 + p.x / 144.0), p.y);
|
||||
*d.node_mut(id).unwrap() = vectorcraft_doc::live::mesh_warp_image(&n, im, &stretch, 4, false, masked);
|
||||
let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap();
|
||||
settings.compression.compress_text = false;
|
||||
let bytes = export_with_report(&d, &PdfOptions { settings: settings.clone(), ..Default::default() }).unwrap().bytes;
|
||||
let sizes = image_sizes(&bytes);
|
||||
assert_eq!(sizes.len(), 1, "masked {masked}: one image for 32 pieces, not {sizes:?}");
|
||||
// One inch high (150 pixels at 150 ppi), and wider than one inch at its widest.
|
||||
let (w, h) = sizes[0];
|
||||
assert!(h == 150 && w > 150 && w < 600, "masked {masked}: {w} × {h}");
|
||||
assert!(String::from_utf8_lossy(&bytes).matches(" Do").count() >= 32, "masked {masked}: every piece paints the image");
|
||||
|
||||
// The image placed again, two inches wide and high, is resampled for that placement.
|
||||
let plain = Node::new(d.alloc_id(), NodeKind::Image(Box::new(ImageObject { xf: Affine::scale(144.0 / 600.0), ..(**im).clone() })));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 1, plain).unwrap();
|
||||
let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
|
||||
let mut sizes = image_sizes(&bytes);
|
||||
sizes.sort_unstable();
|
||||
assert_eq!(sizes, [(w, h), (300, 300)], "masked {masked}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_zip_and_automatic_compression() {
|
||||
let png = doc(encoded(colour(), ImageFormat::Png), "image/png");
|
||||
|
||||
@@ -210,6 +210,60 @@ fn a_documents_own_composite_font_draws_without_being_registered() {
|
||||
assert!(db.composite_font(NAME).is_none(), "given back after each render");
|
||||
}
|
||||
|
||||
/// A placed document draws its type in its own definition of a composite font that the document
|
||||
/// placing it defines differently.
|
||||
#[test]
|
||||
fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() {
|
||||
use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts, ImageBlob, LinkInfo, PlacedDocument};
|
||||
let _adding = adding_fonts();
|
||||
const NAME: &str = "CF Placed Twice";
|
||||
let definition = |roman: &str| {
|
||||
let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular"));
|
||||
parts.roman = CompositePart::new(roman, "Regular");
|
||||
CompositeFont { name: NAME.into(), parts }
|
||||
};
|
||||
let typed = |family: &str| {
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style))));
|
||||
let l = d.layers[0].id;
|
||||
let id = d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
};
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
// A page placing `d` (drawn exactly), defining `fonts`.
|
||||
let page = |d: &Document, id: NodeId, key: &str, fonts: Vec<CompositeFont>| {
|
||||
let blob = ImageBlob::new("application/json", key.as_bytes().to_vec());
|
||||
let p = PlacedDocument {
|
||||
link: LinkInfo::new("/art/type.vectorcraft"),
|
||||
key: key.into(),
|
||||
bounding: false,
|
||||
page_box: Default::default(),
|
||||
width: 300.0,
|
||||
height: 150.0,
|
||||
xf: Affine::IDENTITY,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let art = d.node(id).unwrap().clone();
|
||||
vectorcraft_doc::placed_document::prime(&p, blob.bytes.clone(), (d.clone(), art, Rect::new(0.0, 0.0, 300.0, 150.0)));
|
||||
let mut page = Document::new(300.0, 150.0);
|
||||
page.composite_fonts.extend(fonts);
|
||||
page.images.insert(key.into(), blob);
|
||||
let n = Node::new(page.alloc_id(), NodeKind::PlacedDocument(Box::new(p)));
|
||||
let l = page.layers[0].id;
|
||||
page.insert(Some(l), 0, n).unwrap();
|
||||
Renderer::new().render(&page, 300, 150, Affine::IDENTITY, &opts).pixels
|
||||
};
|
||||
let (mut placed, id) = typed(NAME);
|
||||
placed.composite_fonts.push(definition("JetBrains Mono"));
|
||||
let (plain, plain_id) = typed("JetBrains Mono");
|
||||
let (inter, inter_id) = typed("Inter");
|
||||
let own = page(&placed, id, "fontchange-placed-twice", vec![definition("Inter")]);
|
||||
assert!(own == page(&plain, plain_id, "fontchange-placed-twice-plain", vec![]), "drawn in the placed document's definition");
|
||||
assert!(own != page(&inter, inter_id, "fontchange-placed-twice-inter", vec![]), "not in the page's");
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font(NAME).is_none());
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
/// available: the glyph geometry kept for it is laid out again.
|
||||
#[test]
|
||||
|
||||
@@ -82,7 +82,8 @@ impl Writer<'_> {
|
||||
let heights = vectorcraft_text::FontDb::global().font_heights_resolved(&resolved);
|
||||
let t = &*heights;
|
||||
let inline = t.runs.iter().any(|r| r.inline.is_some());
|
||||
// Ruby is written as outlines with its base text: a reader's <text> can't place it. So are
|
||||
// Ruby is written as outlines with its base text: a reader's <text> can't place it. So is
|
||||
// type with warichu, whose stacked lines a reader's <text> can't place either. So are
|
||||
// small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869),
|
||||
// and a glyph chosen for a character, which a reader's <text> draws with the default glyph. So
|
||||
// is type in one of the document's composite fonts, which a reader can't find by its name.
|
||||
@@ -92,8 +93,12 @@ impl Writer<'_> {
|
||||
|| r.style.glyph_alternate.is_some()
|
||||
|| vectorcraft_doc::CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some()
|
||||
}) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty()));
|
||||
let outlined =
|
||||
self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable;
|
||||
let outlined = self.opts.outline_text
|
||||
|| t.vertical
|
||||
|| !t.ruby.is_empty()
|
||||
|| t.stacks_warichu()
|
||||
|| (inline && matches!(t.kind, TextKind::OnPath { .. }))
|
||||
|| unwritable;
|
||||
let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) };
|
||||
let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none());
|
||||
// Live type with an inside or outside character stroke writes that stroke apart from the
|
||||
|
||||
@@ -20,7 +20,7 @@ use text::TextSlots;
|
||||
|
||||
pub use depth::MAX_ELEMENT_DEPTH;
|
||||
pub(crate) use depth::xml_too_deep;
|
||||
pub use files::MAX_NESTED_SVG_IMAGES;
|
||||
pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT};
|
||||
pub(crate) use fx::BLEND;
|
||||
|
||||
/// Most objects an imported SVG may expand to: a guard against `<use>` and nested-image
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user