Merge remote-tracking branch 'upstream/main' into fix/remaining-type-envelopes

# Conflicts:
#	docs/file-format-parity.md
#	docs/target-app-parity.md
This commit is contained in:
jmverges
2026-11-12 14:14:56 +00:00
301 changed files with 19153 additions and 1275 deletions
+1
View File
@@ -267,6 +267,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/flip-vertical-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/folder-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/frame.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/graduation-cap.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Learn panel, Explore tutorials button |
| `assets/icons/grid-3x3.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/group.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/hand.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
+2
View File
@@ -122,6 +122,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 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-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.
@@ -139,6 +140,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-10-31: Export for Screens works as the reference app's dialog: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File (the chosen artboards as one PDF's pages, `pdfs: "single"`) or Multiple Files, iOS and Android rows, format presets in a ☰ menu, and a count of the files the export writes (#4895). About 55 community PRs and fixes landed the same day: Find Font, Replace Font, Outline Stroke and Expand leave hidden and locked art alone (with a warning); DXF export writes LINE and ELLIPSE entities and outlines only the strokes a CAD line can't draw; Blob Brush Angle and Roundness; Shift-constrained Perspective Selection; Grid Repeat Options Preview; a gradient mesh from a file budgeted at about a million quads; width-profile strokes keep their shape zoomed out; `.ai`/EPS import fidelity (area type, envelopes, transparency in dictionaries, character styles); PDF import finds hyphenated and CMap-suffixed font names; Windows renders follow the process's core affinity; Enter or Escape in a floating panel's field no longer reaches the canvas (#4803); Properties shows Blend Options for any selected blend (#4817); Artwork Cleanup finds doubled type (#4835) and joins stacked lines into one object (Join Point Text, #4826). Headline numbers unchanged.
- 2026-10-31: Arrange Tools, a built-in add-on (Object › Plug-ins › Arrange Tools): Arrange in Grid lays the selected objects out in rows and columns, a row or a column with gaps, from the selection's top-left or centred on the artboard; Swap Objects swaps two objects' places (and, with Swap Objects and Sizes, their sizes); Replace with Top Object puts a copy of the topmost selected object in place of each of the others, kept, stretched or fitted in proportion; Set Size of Each gives every selected object an exact width and/or height about a reference point; Zoom to Selection fits the view to the selection. Each is one undo step with a previewed dialog where it has options. A VectorCraft addition: headline numbers unchanged.
- 2026-11-11: Window › Swatch Libraries › Gradients › Organic Fades: fifteen soft fades (ten linear, five radial) through three to five unevenly spaced stops with off-center midpoints, mixed with the Perceptual method, that thin out to transparent at the end of a linear one and at the edge of a radial one; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-11-11: Window › Swatch Libraries › Gradients › Holographic: seven foils (opaque linear gradients of six narrow pastel and pearl bands with off-center midpoints) and seven glows (radial gradients from a bright center out to a clear edge), mixed with the Perceptual method; they also list in the Swatches panel's library menu, `swatch.library.list` and the Gradient panel's Presets.
- 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`).
- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
- 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn.
+1
View File
@@ -651,6 +651,7 @@ mod portable {
impl Answer {
/// The answer to `request` when it couldn't be read: `why`, or no files.
#[cfg(any(test, target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn failed(request: Request, why: String) -> Self {
match request {
Request::Text => Self::Text(Err(why)),
+1 -1
View File
@@ -378,7 +378,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
// Load Actions… refuses a file over its size limit without reading all of it.
read_head: Some(Box::new(read_file_head)),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
exists: Some(std::sync::Arc::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-graduation-cap"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z" />
<path d="M22 10v6" />
<path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5" />
</svg>

After

Width:  |  Height:  |  Size: 497 B

+1 -1
View File
@@ -3,7 +3,7 @@ name = "vectorcraft-cad"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader"
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader and writer"
[lints]
workspace = true
+7
View File
@@ -16,11 +16,18 @@
//! and the art of every page goes on one artboard; each comes back as a warning. Printer escape
//! sequences and PJL lines around the instructions are skipped.
//!
//! [`export`] writes the visible art over a region as an HP-GL or HP-GL/2 plot, and
//! [`export_regions`] one plot per region with one pen numbering: see [`write`].
//!
//! - `lex`: the bytes as instructions, numbers, labels and PE data.
//! - `plot`: the instructions played on the pen, as shapes.
//! - `write`: the writer, paths to pen moves.
mod lex;
mod plot;
pub mod write;
pub use write::{HpglOptions, HpglOutput, Origin, Version, export, export_regions};
use std::sync::Arc;
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
//! Polylines cut by a region: each segment is split where it crosses the region's edges, and the
//! pieces whose middle lies inside are kept, joined where they meet.
use vectorcraft_geom::{FillRule, Point, Rect};
/// A clipping region: closed rings (each closes implicitly) and the rule that decides inside.
pub(super) struct Region {
rings: Vec<Vec<Point>>,
rule: FillRule,
bounds: Rect,
}
impl Region {
/// The region of `rings` under `rule`; `None` when it has no ring of three points or more.
pub fn new(rings: Vec<Vec<Point>>, rule: FillRule) -> Option<Self> {
let rings: Vec<Vec<Point>> = rings.into_iter().filter(|r| r.len() >= 3).collect();
let bounds = rings.iter().flatten().fold(None, |b: Option<Rect>, p| Some(b.map_or(Rect::from_points(*p, *p), |b| b.union_pt(*p))))?;
Some(Self { rings, rule, bounds })
}
/// The edges this region has, which [`Region::cut`] tests each segment against.
fn edges(&self) -> u64 {
self.rings.iter().map(|r| r.len() as u64).sum()
}
/// Does `p` lie on one of the region's edges (within a millionth of a point)?
fn on_edge(&self, p: Point) -> bool {
self.rings.iter().any(|ring| {
ring.iter().enumerate().any(|(i, &a)| {
let b = ring[(i + 1) % ring.len()];
let ab = b - a;
let len2 = ab.hypot2();
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
(a + ab * t).distance(p) <= 1e-6
})
})
}
/// Is `p` inside (non-zero winding or an odd number of crossings), or on an edge?
fn contains(&self, p: Point) -> bool {
if self.on_edge(p) {
return true;
}
let mut winding = 0i64;
for ring in &self.rings {
for (i, &a) in ring.iter().enumerate() {
let b = ring[(i + 1) % ring.len()];
if a.y <= p.y {
if b.y > p.y && cross(a, b, p) > 0.0 {
winding += 1;
}
} else if b.y <= p.y && cross(a, b, p) < 0.0 {
winding -= 1;
}
}
}
match self.rule {
FillRule::NonZero => winding != 0,
FillRule::EvenOdd => winding % 2 != 0,
}
}
/// Where segment `a`–`b` crosses this region's edges, as fractions of its length strictly
/// between 0 and 1, sorted.
fn crossings(&self, a: Point, b: Point) -> Vec<f64> {
let mut ts = vec![];
for ring in &self.rings {
for (i, &c) in ring.iter().enumerate() {
let d = ring[(i + 1) % ring.len()];
if let Some(t) = intersect(a, b, c, d)
&& t > 1e-12
&& t < 1.0 - 1e-12
{
ts.push(t);
}
}
}
ts.sort_by(f64::total_cmp);
ts.dedup_by(|x, y| (*x - *y).abs() <= 1e-12);
ts
}
/// The parts of polyline `line` inside the region, each a polyline of two points or more.
/// Every test of a point or a segment against an edge takes one from `budget`; `None` when
/// the budget runs out first.
pub fn cut(&self, line: &[Point], budget: &mut u64) -> Option<Vec<Vec<Point>>> {
let edges = self.edges();
let mut out: Vec<Vec<Point>> = vec![];
let mut cur: Vec<Point> = vec![];
for w in line.windows(2) {
let (a, b) = (w[0], w[1]);
// A segment wholly off the region's box lies outside it.
let near = overlaps(Rect::from_points(a, b), self.bounds);
let ts = if near {
spend(budget, edges)?;
self.crossings(a, b)
} else {
vec![]
};
let mut from = 0.0;
for to in ts.into_iter().chain([1.0]) {
let (p, q) = (a.lerp(b, from), a.lerp(b, to));
// Each piece between crossings is tested on its own: its edge and winding tests.
let inside = if near {
spend(budget, edges.saturating_mul(2))?;
self.contains(p.midpoint(q))
} else {
false
};
if inside {
if cur.last().is_none_or(|l| l.distance(p) > 1e-9) {
flush(&mut out, &mut cur);
cur.push(p);
}
cur.push(q);
} else {
flush(&mut out, &mut cur);
}
from = to;
}
}
flush(&mut out, &mut cur);
// A closed line that leaves the region and comes back: its last piece ends where its
// first starts, at the line's start, and the two are one piece.
if line.len() > 2
&& line.first() == line.last()
&& out.len() >= 2
&& let (Some(end), Some(start)) = (out.last().and_then(|l| l.last()), out.first().and_then(|l| l.first()))
&& end.distance(*start) <= 1e-9
{
let first = out.remove(0);
if let Some(last) = out.last_mut() {
last.extend(first.into_iter().skip(1));
}
}
Some(out)
}
}
/// Take `n` from `budget`; `None` when it holds less.
fn spend(budget: &mut u64, n: u64) -> Option<()> {
*budget = budget.checked_sub(n)?;
Some(())
}
fn flush(out: &mut Vec<Vec<Point>>, cur: &mut Vec<Point>) {
let line = std::mem::take(cur);
if line.len() >= 2 {
out.push(line);
}
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// Which side of line `a`–`b` point `p` lies on (positive: left).
fn cross(a: Point, b: Point, p: Point) -> f64 {
(b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y)
}
/// Where segment `a`–`b` crosses segment `c`–`d`, as a fraction of `a`–`b`; `None` when they
/// are parallel or don't meet.
fn intersect(a: Point, b: Point, c: Point, d: Point) -> Option<f64> {
let (r, s) = (b - a, d - c);
let den = r.x * s.y - r.y * s.x;
if den.abs() <= f64::EPSILON * (r.hypot() * s.hypot()).max(f64::MIN_POSITIVE) {
return None;
}
let ac = c - a;
let t = (ac.x * s.y - ac.y * s.x) / den;
let u = (ac.x * r.y - ac.y * r.x) / den;
((0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u)).then_some(t)
}
#[cfg(test)]
mod tests {
use super::*;
fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<Point> {
vec![Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)]
}
/// `line` cut by `r` with no limit on the work.
fn cut(r: &Region, line: &[Point]) -> Vec<Vec<Point>> {
let mut unlimited = u64::MAX;
r.cut(line, &mut unlimited).unwrap()
}
#[test]
fn a_line_across_a_square_keeps_its_middle() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
let pieces = cut(&r, &[Point::new(-5.0, 5.0), Point::new(15.0, 5.0)]);
assert_eq!(pieces, vec![vec![Point::new(0.0, 5.0), Point::new(10.0, 5.0)]]);
// Wholly outside, nothing; wholly inside, the line as it is.
assert!(cut(&r, &[Point::new(20.0, 0.0), Point::new(30.0, 0.0)]).is_empty());
let inside = vec![Point::new(1.0, 1.0), Point::new(9.0, 1.0), Point::new(9.0, 9.0)];
assert_eq!(cut(&r, &inside), vec![inside]);
}
/// Each piece between crossings costs a pass over the edges: a line through the teeth of a
/// comb spends far more than its two points times the edges.
#[test]
fn every_piece_between_crossings_is_paid_for() {
let mut ring = vec![];
for i in 0..100 {
let x = f64::from(i) * 4.0;
ring.extend([Point::new(x, 0.0), Point::new(x + 2.0, 0.0), Point::new(x + 2.0, 10.0), Point::new(x + 4.0, 10.0)]);
}
ring.extend([Point::new(400.0, 20.0), Point::new(0.0, 20.0)]);
let r = Region::new(vec![ring], FillRule::NonZero).unwrap();
let line = [Point::new(-1.0, 5.0), Point::new(401.0, 5.0)];
// 402 edges, 200 crossings, 201 pieces: 402 + 201 × 2 × 402 tests.
let need = 402 + 201 * 2 * 402;
let mut budget = need;
assert_eq!(r.cut(&line, &mut budget).map(|p| p.len()), Some(100));
assert_eq!(budget, 0);
let mut short = need - 1;
assert!(r.cut(&line, &mut short).is_none());
// A segment off the region's box costs nothing.
assert_eq!(r.cut(&[Point::new(0.0, 30.0), Point::new(9.0, 30.0)], &mut 0), Some(vec![]));
}
/// A line along any of the region's edges is inside it.
#[test]
fn a_line_on_an_edge_is_kept() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
for line in [
[Point::new(0.0, 0.0), Point::new(10.0, 0.0)],
[Point::new(10.0, 0.0), Point::new(10.0, 10.0)],
[Point::new(10.0, 10.0), Point::new(0.0, 10.0)],
[Point::new(0.0, 10.0), Point::new(0.0, 0.0)],
] {
assert_eq!(cut(&r, &line), vec![line.to_vec()], "{line:?}");
}
}
#[test]
fn a_line_leaving_and_coming_back_is_two_pieces() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0)];
assert_eq!(cut(&r, &line), vec![vec![Point::new(5.0, 5.0), Point::new(10.0, 5.0)], vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0)]]);
}
/// A closed line that starts inside, leaves and comes back is one piece: the part after it
/// comes back runs on into the part before it left.
#[test]
fn a_closed_line_cut_once_stays_one_piece() {
let r = Region::new(vec![square(0.0, 0.0, 10.0, 10.0)], FillRule::NonZero).unwrap();
let line = [Point::new(5.0, 5.0), Point::new(15.0, 5.0), Point::new(15.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0)];
assert_eq!(cut(&r, &line), vec![vec![Point::new(10.0, 8.0), Point::new(5.0, 8.0), Point::new(5.0, 5.0), Point::new(10.0, 5.0)]]);
}
#[test]
fn the_fill_rule_decides_a_hole() {
let rings = vec![square(0.0, 0.0, 10.0, 10.0), square(3.0, 3.0, 7.0, 7.0)];
let line = [Point::new(-1.0, 5.0), Point::new(11.0, 5.0)];
// Both rings wind the same way: even-odd cuts a hole, non-zero doesn't.
let even_odd = cut(&Region::new(rings.clone(), FillRule::EvenOdd).unwrap(), &line);
assert_eq!(even_odd, vec![vec![Point::new(0.0, 5.0), Point::new(3.0, 5.0)], vec![Point::new(7.0, 5.0), Point::new(10.0, 5.0)]]);
let non_zero = cut(&Region::new(rings, FillRule::NonZero).unwrap(), &line);
assert_eq!(non_zero.len(), 1);
assert!(Region::new(vec![vec![Point::ZERO, Point::new(1.0, 1.0)]], FillRule::NonZero).is_none());
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Which of a pen's paths lie inside which of its closed paths, in plotter units, for drawing the
//! inner paths first.
/// The box of `p`: `[x0, y0, x1, y1]`.
pub(super) fn bounds(p: &[(i64, i64)]) -> [i64; 4] {
p.iter().fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |b, q| [b[0].min(q.0), b[1].min(q.1), b[2].max(q.0), b[3].max(q.1)])
}
/// For each of `paths` (`boxes` are their [`bounds`]), the closed paths it lies inside: none of
/// its segments crosses the closed path's line, each part of it between the points where it meets
/// that line lies inside the closed path (by the even-odd rule) or on the line, and at least one
/// part lies inside. A path touching the line of the one around it is inside it, a path crossing
/// it is not, and copies of one outline are not inside each other. A path is tested only against
/// closed paths larger than it (by the area of their box, then the area they enclose, then paint
/// order), so no two paths are each inside the other, and only against the closed paths in the
/// [`Grid`] cell of its box's first corner. `None` once `work` is spent: a grid cell filled and a
/// box compared cost one each, and each segment tested costs the closed path's points for its
/// crossings and again for each part of it.
pub(super) fn outer(paths: &[Vec<(i64, i64)>], boxes: &[[i64; 4]], work: &mut u64) -> Option<Vec<Vec<usize>>> {
let size: Vec<(i128, i128)> = paths
.iter()
.zip(boxes)
.map(|(p, b)| {
let box_area = (i128::from(b[2]) - i128::from(b[0])) * (i128::from(b[3]) - i128::from(b[1]));
(box_area, if closed(p) { twice_area(p) } else { 0 })
})
.collect();
let rings: Vec<usize> = (0..paths.len()).filter(|&i| paths.get(i).is_some_and(|p| closed(p))).collect();
let mut out = vec![vec![]; paths.len()];
if rings.is_empty() {
return Some(out);
}
let grid = Grid::new(&rings, boxes, work)?;
for (a, (p, ab)) in paths.iter().zip(boxes).enumerate() {
for &r in grid.at(ab[0], ab[1]) {
spend(work, 1)?;
let (Some(rb), Some(ring)) = (boxes.get(r), paths.get(r)) else { continue };
let smaller = (size.get(a), a) < (size.get(r), r);
if !smaller || rb[0] > ab[0] || rb[1] > ab[1] || rb[2] < ab[2] || rb[3] < ab[3] {
continue;
}
if inside(p, ring, work)?
&& let Some(o) = out.get_mut(a)
{
o.push(r);
}
}
}
Some(out)
}
/// The most cells along each side of a [`Grid`].
const MAX_GRID_SIDE: usize = 512;
/// The most cells of a [`Grid`] a closed path is listed in; one whose box covers more is listed
/// once, among the wide ones. A closed path has 4 points or more, so the grid lists no more
/// entries than the pen has points.
const MAX_CELLS_PER_PATH: usize = 4;
/// A grid over the boxes of a pen's closed paths, about one cell for each, up to
/// [`MAX_GRID_SIDE`] a side: each cell lists, in paint order, the closed paths whose box covers
/// it, and the closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells are listed
/// apart. A closed path whose box holds a point is among the wide ones or in that point's cell.
struct Grid {
/// The first corner of the closed paths' boxes together.
x0: i128,
y0: i128,
/// Their width and height, plus one.
w: i128,
h: i128,
/// Cells along each side.
side: usize,
/// The cells row by row.
cells: Vec<Vec<usize>>,
/// The closed paths whose box covers more than [`MAX_CELLS_PER_PATH`] cells.
wide: Vec<usize>,
}
impl Grid {
/// The grid of the closed paths `rings` (indices into `boxes`). `None` once `work` is spent.
fn new(rings: &[usize], boxes: &[[i64; 4]], work: &mut u64) -> Option<Self> {
let all = rings
.iter()
.filter_map(|&r| boxes.get(r))
.fold([i64::MAX, i64::MAX, i64::MIN, i64::MIN], |e, b| [e[0].min(b[0]), e[1].min(b[1]), e[2].max(b[2]), e[3].max(b[3])]);
let side = ((rings.len() as f64).sqrt().ceil() as usize).clamp(1, MAX_GRID_SIDE);
let (x0, y0) = (i128::from(all[0]), i128::from(all[1]));
let mut grid = Self {
x0,
y0,
w: (i128::from(all[2]) - x0 + 1).max(1),
h: (i128::from(all[3]) - y0 + 1).max(1),
side,
cells: vec![vec![]; side * side],
wide: vec![],
};
for &r in rings {
let Some(b) = boxes.get(r) else { continue };
let (Some((cx0, cy0)), Some((cx1, cy1))) = (grid.cell(b[0], b[1]), grid.cell(b[2], b[3])) else { continue };
let covered = (cx1 - cx0 + 1) * (cy1 - cy0 + 1);
if covered > MAX_CELLS_PER_PATH {
spend(work, 1)?;
grid.wide.push(r);
continue;
}
spend(work, covered as u64)?;
for cy in cy0..=cy1 {
for cell in grid.cells.get_mut(cy * side + cx0..=cy * side + cx1).into_iter().flatten() {
cell.push(r);
}
}
}
Some(grid)
}
/// The column and row of the cell holding `(x, y)`, `None` outside the grid.
fn cell(&self, x: i64, y: i64) -> Option<(usize, usize)> {
let (dx, dy) = (i128::from(x) - self.x0, i128::from(y) - self.y0);
if dx < 0 || dy < 0 || dx >= self.w || dy >= self.h {
return None;
}
let side = self.side as i128;
Some(((dx * side / self.w) as usize, (dy * side / self.h) as usize))
}
/// The wide closed paths, then those listed in the cell holding `(x, y)`.
fn at(&self, x: i64, y: i64) -> impl Iterator<Item = &usize> {
let cell = self.cell(x, y).and_then(|(cx, cy)| self.cells.get(cy * self.side + cx)).map_or(&[][..], Vec::as_slice);
self.wide.iter().chain(cell)
}
}
/// Takes `n` from `work`; `None`, with `work` at 0, when it has less.
fn spend(work: &mut u64, n: u64) -> Option<()> {
let left = work.checked_sub(n);
*work = left.unwrap_or(0);
left.map(|_| ())
}
/// Does `p` start and end at one point, with three other points or more?
fn closed(p: &[(i64, i64)]) -> bool {
p.len() > 3 && p.first() == p.last()
}
/// Twice the area the closed path `p` encloses.
fn twice_area(p: &[(i64, i64)]) -> i128 {
let sum: i128 = p
.windows(2)
.map(|w| match w {
[a, b] => i128::from(a.0) * i128::from(b.1) - i128::from(b.0) * i128::from(a.1),
_ => 0,
})
.sum();
sum.abs()
}
/// Does `p` lie inside the closed path `ring`, as [`outer`] decides? `None` once `work` is spent.
fn inside(p: &[(i64, i64)], ring: &[(i64, i64)], work: &mut u64) -> Option<bool> {
let mut any = false;
for w in p.windows(2) {
let [s, e] = w else { continue };
spend(work, ring.len() as u64)?;
// Where the segment meets the line without crossing it: its ends and the ring's points on
// it. Between two of these the segment is all inside or all outside.
let mut stops = vec![*s, *e];
for edge in ring.windows(2) {
let [a, b] = edge else { continue };
if crosses((*s, *e), (*a, *b)) {
return Some(false);
}
if a != s && a != e && on((*s, *e), *a) {
stops.push(*a);
}
}
let along = |q: &(i64, i64)| {
(i128::from(q.0) - i128::from(s.0)) * (i128::from(e.0) - i128::from(s.0))
+ (i128::from(q.1) - i128::from(s.1)) * (i128::from(e.1) - i128::from(s.1))
};
stops.sort_by_key(along);
stops.dedup();
for part in stops.windows(2) {
let [m, n] = part else { continue };
spend(work, ring.len() as u64)?;
// The part's middle, doubled so it is whole.
match locate((i128::from(m.0) + i128::from(n.0), i128::from(m.1) + i128::from(n.1)), ring) {
Some(false) => return Some(false),
Some(true) => any = true,
None => {}
}
}
}
Some(any)
}
/// Which side of the line through `a` and `b` `c` is on: 1 left, -1 right, 0 on it.
fn side(a: (i64, i64), b: (i64, i64), c: (i64, i64)) -> i128 {
let d = (i128::from(b.0) - i128::from(a.0)) * (i128::from(c.1) - i128::from(a.1))
- (i128::from(b.1) - i128::from(a.1)) * (i128::from(c.0) - i128::from(a.0));
d.signum()
}
/// Do segments `u` and `v` cross at a point inside both, each passing from one side of the other
/// to the other side?
fn crosses(u: ((i64, i64), (i64, i64)), v: ((i64, i64), (i64, i64))) -> bool {
side(u.0, u.1, v.0) * side(u.0, u.1, v.1) < 0 && side(v.0, v.1, u.0) * side(v.0, v.1, u.1) < 0
}
/// Does `q` lie on segment `u`?
fn on(u: ((i64, i64), (i64, i64)), q: (i64, i64)) -> bool {
let ((x0, y0), (x1, y1)) = u;
side(u.0, u.1, q) == 0 && q.0 >= x0.min(x1) && q.0 <= x0.max(x1) && q.1 >= y0.min(y1) && q.1 <= y0.max(y1)
}
/// Where the doubled point `q` lies against the closed path `ring`: `Some(true)` inside by the
/// even-odd rule, `Some(false)` outside, `None` on its line.
fn locate(q: (i128, i128), ring: &[(i64, i64)]) -> Option<bool> {
let mut inside = false;
for w in ring.windows(2) {
let [a, b] = w else { continue };
let (a, b) = ((2 * i128::from(a.0), 2 * i128::from(a.1)), (2 * i128::from(b.0), 2 * i128::from(b.1)));
// Which side of the segment's line `q` is on, 0 on it.
let side = (b.0 - a.0) * (q.1 - a.1) - (b.1 - a.1) * (q.0 - a.0);
if side == 0 && q.0 >= a.0.min(b.0) && q.0 <= a.0.max(b.0) && q.1 >= a.1.min(b.1) && q.1 <= a.1.max(b.1) {
return None;
}
// A segment crossing the level of `q` to its right.
if (a.1 > q.1) != (b.1 > q.1) && (side > 0) == (b.1 > a.1) {
inside = !inside;
}
}
Some(inside)
}
#[cfg(test)]
mod tests {
use super::*;
fn square(x0: i64, y0: i64, x1: i64, y1: i64) -> Vec<(i64, i64)> {
vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
}
fn nest(paths: &[Vec<(i64, i64)>]) -> Option<Vec<Vec<usize>>> {
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let mut work = u64::MAX;
outer(paths, &boxes, &mut work)
}
#[test]
fn points_inside_outside_and_on_the_line() {
let ring = square(0, 0, 10, 10);
assert_eq!(locate((10, 10), &ring), Some(true));
assert_eq!(locate((30, 10), &ring), Some(false));
assert_eq!(locate((10, -2), &ring), Some(false));
assert_eq!(locate((20, 10), &ring), None);
assert_eq!(locate((0, 0), &ring), None);
}
#[test]
fn a_path_inside_another_lies_inside_it_and_not_the_other_way() {
let paths = [square(0, 0, 100, 100), square(10, 10, 20, 20), vec![(30, 30), (40, 40)]];
assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0]]));
}
#[test]
fn touching_counts_as_inside_and_crossing_does_not() {
// A square with a corner on the outline's left edge, a triangle whose corners lie on the
// outline's edges, and a square that crosses the outline.
let paths = [square(0, 0, 100, 100), square(0, 40, 20, 60), vec![(0, 50), (50, 0), (100, 50), (0, 50)], square(90, 90, 120, 95)];
assert_eq!(nest(&paths), Some(vec![vec![], vec![0], vec![0], vec![]]));
}
/// Every point of a copy lies on the other's line, and none inside it.
#[test]
fn copies_of_one_outline_do_not_lie_inside_each_other() {
let paths = [square(0, 0, 10, 10), square(0, 0, 10, 10), vec![(10, 10), (10, 0), (0, 0), (0, 10), (10, 10)]];
assert_eq!(nest(&paths), Some(vec![vec![], vec![], vec![]]));
}
/// A line whose ends and middle lie inside an outline but which crosses a slot cut into it.
#[test]
fn a_line_across_a_slot_is_not_inside() {
let slotted = vec![(0, 0), (100, 0), (100, 100), (14, 100), (14, 40), (12, 40), (12, 100), (0, 100), (0, 0)];
assert_eq!(nest(&[slotted, vec![(10, 50), (40, 50)]]), Some(vec![vec![], vec![]]));
}
/// A line that leaves an outline and comes back through two of its corners, crossing none of
/// its sides, and one that runs along the bottom of a slot.
#[test]
fn a_line_through_corners_is_inside_only_where_it_stays_inside() {
let v_slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (15, 40), (10, 50), (10, 100), (0, 100), (0, 0)];
assert_eq!(nest(&[v_slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![]]));
let slot = vec![(0, 0), (100, 0), (100, 100), (20, 100), (20, 50), (10, 50), (10, 100), (0, 100), (0, 0)];
assert_eq!(nest(&[slot, vec![(5, 50), (40, 50)]]), Some(vec![vec![], vec![0]]));
}
/// An outline that crosses itself encloses no area by its signed sum, yet a line in one of its
/// lobes lies inside it.
#[test]
fn a_line_inside_a_lobe_of_a_crossed_outline_lies_inside_it() {
let bow = vec![(0, 0), (100, 100), (0, 100), (100, 0), (0, 0)];
assert_eq!(twice_area(&bow), 0);
assert_eq!(nest(&[bow, vec![(40, 80), (60, 80)]]), Some(vec![vec![], vec![0]]));
}
#[test]
fn a_ring_inside_a_ring_inside_a_ring_lies_inside_both() {
let paths = [square(20, 20, 80, 80), square(0, 0, 100, 100), square(40, 40, 60, 60)];
assert_eq!(nest(&paths), Some(vec![vec![1], vec![], vec![0, 1]]));
}
/// 10,000 parts in rows, each with a hole: each hole is tested against the few closed paths
/// near it, not against every part.
#[test]
fn parts_in_rows_are_tested_only_against_the_closed_paths_near_them() {
let mut paths = vec![];
for row in 0..100 {
for col in 0..100 {
let (x, y) = (col * 30, row * 30);
paths.push(square(x, y, x + 20, y + 20));
paths.push(square(x + 5, y + 5, x + 15, y + 15));
}
}
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let mut work = 5_000_000;
let found = outer(&paths, &boxes, &mut work);
let want: Vec<Vec<usize>> = (0..paths.len()).map(|i| if i % 2 == 1 { vec![i - 1] } else { vec![] }).collect();
assert_eq!(found, Some(want));
}
/// 400 closed paths that each cover most of the grid are listed once each, not in every cell
/// they cover.
#[test]
fn closed_paths_covering_many_cells_are_listed_once() {
let mut paths: Vec<Vec<(i64, i64)>> = (0..400).map(|i| square(i, i, 1000 + i, 1000 + i)).collect();
paths.extend((0..400).map(|i| {
let (x, y) = ((i % 20) * 50, (i / 20) * 50);
square(x, y, x + 10, y + 10)
}));
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let rings: Vec<usize> = (0..paths.len()).collect();
let mut work = u64::MAX;
let Some(grid) = Grid::new(&rings, &boxes, &mut work) else { panic!("the budget ran out") };
assert_eq!(grid.wide, (0..400).collect::<Vec<_>>());
assert!(grid.cells.iter().map(Vec::len).sum::<usize>() <= 400 * MAX_CELLS_PER_PATH);
}
#[test]
fn finding_what_lies_inside_stops_at_its_budget() {
let paths: Vec<Vec<(i64, i64)>> = (0..50).map(|i| square(-i, -i, 100 + i, 100 + i)).collect();
let boxes: Vec<[i64; 4]> = paths.iter().map(|p| bounds(p)).collect();
let mut work = 1_000;
assert_eq!(outer(&paths, &boxes, &mut work), None);
assert_eq!(work, 0);
let mut work = u64::MAX;
let found = outer(&paths, &boxes, &mut work);
assert_eq!(found.map(|o| o.iter().map(Vec::len).sum::<usize>()), Some(50 * 49 / 2));
}
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,5 +1,5 @@
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
//! HP-GL/2 plot file reader.
//! HP-GL/2 plot file reader and writer.
//!
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
@@ -12,13 +12,13 @@
//! clipping) is approximated or left out, and each such loss comes back as a warning.
//!
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`].
//! [`hpgl::import`] reads an HP-GL plotter file, and [`hpgl::export`] writes one: see [`hpgl`].
//!
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
//! - `writer`: group codes, handles and the file's sections, tables and objects.
//! - `scene`: the document walk that turns objects into entities.
//! - `import`: the reader, entities to art, blocks to symbols.
//! - `hpgl`: the HP-GL reader, the pen's moves to paths.
//! - `hpgl`: the HP-GL reader, the pen's moves to paths, and the writer, paths to pen moves.
mod aci;
pub mod hpgl;
+112 -1
View File
@@ -73,6 +73,7 @@ pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[
lib!("sky-gradients", "Sky Gradients", sky_gradients),
lib!("neutral-gradients", "Neutral Gradients", neutral_gradients),
lib!("organic-fades", "Organic Fades", organic_fades),
lib!("holographic", "Holographic", holographic),
lib!("duotone-gradients", "Duotone Gradients", duotone_gradients),
lib!("radial-glows", "Radial Glows", radial_glows),
];
@@ -531,6 +532,62 @@ fn organic_fades() -> (Vec<Swatch>, Vec<SwatchGroup>) {
(s, vec![])
}
/// The stops of a [`holographic`] look: offset, opacity and midpoint toward the next stop.
type Bands = [(f32, f32, f32)];
/// Foil: six opaque linear bands, a narrow bright one among them, with off-center midpoints.
const FOIL: &Bands = &[(0.0, 1.0, 0.37), (0.13, 1.0, 0.64), (0.34, 1.0, 0.28), (0.48, 1.0, 0.68), (0.71, 1.0, 0.42), (1.0, 1.0, 0.5)];
/// Glow: five radial rings from a bright center out to a clear edge.
const GLOW: &Bands = &[(0.0, 0.95, 0.4), (0.12, 1.0, 0.58), (0.31, 0.72, 0.34), (0.58, 0.3, 0.62), (1.0, 0.0, 0.5)];
/// A [`holographic`] look: name, kind, stops and the OKLCH color of each stop in order.
type Look = (&'static str, GradientKind, &'static Bands, Anchors);
/// Iridescent looks mixed perceptually: linear foils of narrow pastel and pearl bands, and radial
/// glows that fade out to a clear edge. Each color is one stop of [`FOIL`] or [`GLOW`] in order.
fn holographic() -> (Vec<Swatch>, Vec<SwatchGroup>) {
use GradientKind::{Linear, Radial};
// Pale pearl, pastel and deep-violet OKLCH `(l, c, h°)` anchors.
const PEARL: (f64, f64, f64) = (0.97, 0.01, 90.0);
const SILVER: (f64, f64, f64) = (0.86, 0.015, 255.0);
const BLUSH: (f64, f64, f64) = (0.82, 0.055, 340.0);
const VIOLET: (f64, f64, f64) = (0.72, 0.1, 310.0);
const BLUE: (f64, f64, f64) = (0.73, 0.09, 255.0);
const CYAN: (f64, f64, f64) = (0.79, 0.09, 210.0);
const MINT: (f64, f64, f64) = (0.82, 0.08, 165.0);
const LIME: (f64, f64, f64) = (0.86, 0.085, 125.0);
const GOLD: (f64, f64, f64) = (0.89, 0.08, 85.0);
const AMBER: (f64, f64, f64) = (0.82, 0.1, 55.0);
const CORAL: (f64, f64, f64) = (0.78, 0.09, 25.0);
const DEEP_VIOLET: (f64, f64, f64) = (0.42, 0.04, 275.0);
let looks: [Look; 14] = [
("Prism Veil", Linear, FOIL, &[SILVER, BLUSH, PEARL, CYAN, VIOLET, SILVER]),
("Opal Ribbon", Linear, FOIL, &[PEARL, MINT, BLUSH, PEARL, BLUE, SILVER]),
("Pearl Flash", Linear, FOIL, &[SILVER, VIOLET, PEARL, GOLD, CYAN, PEARL]),
("Aurora Foil", Linear, FOIL, &[BLUE, MINT, PEARL, BLUSH, CYAN, SILVER]),
("Iris Sheen", Linear, FOIL, &[BLUSH, VIOLET, PEARL, CYAN, MINT, SILVER]),
("Frost Spectrum", Linear, FOIL, &[CYAN, BLUE, PEARL, MINT, BLUSH, SILVER]),
("Silver Tide", Linear, FOIL, &[SILVER, CYAN, PEARL, VIOLET, BLUE, SILVER]),
("Rose Quartz Gleam", Radial, GLOW, &[PEARL, BLUSH, CORAL, VIOLET, BLUSH]),
("Moonstone Shift", Radial, GLOW, &[PEARL, CYAN, MINT, BLUE, SILVER]),
("Violet Edge", Radial, GLOW, &[PEARL, VIOLET, BLUSH, BLUE, DEEP_VIOLET]),
("Mint Halo", Radial, GLOW, &[PEARL, MINT, CYAN, LIME, MINT]),
("Amber Corona", Radial, GLOW, &[PEARL, GOLD, AMBER, CORAL, AMBER]),
("Polar Glint", Radial, GLOW, &[PEARL, CYAN, BLUE, VIOLET, BLUE]),
("Pastel Orbit", Radial, GLOW, &[PEARL, BLUSH, MINT, CYAN, VIOLET]),
];
let s = looks
.iter()
.map(|&(name, kind, bands, colors)| {
let stops: Vec<(f32, Color, f32, f32)> =
bands.iter().zip(colors).map(|(&(at, opacity, mid), &(l, c, h))| (at, oklch(l, c, h), opacity, mid)).collect();
gradient_with(name, kind, GradientMethod::Perceptual, &stops)
})
.collect();
(s, vec![])
}
/// The sequential scales of [`perceptual`]: name and anchors of rising lightness.
const SEQUENTIAL: [(&str, Anchors); 5] = [
("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]),
@@ -708,9 +765,63 @@ mod tests {
}
}
#[test]
fn holographic_holds_seven_opaque_foils_and_seven_glows_to_a_clear_edge() {
let lib = builtin_library("holographic").unwrap();
let order: Vec<&str> = GRADIENT_LIBRARIES.iter().map(|b| b.name).collect();
assert_eq!(order.iter().position(|n| *n == "Holographic"), Some(4), "after Organic Fades: {order:?}");
assert!(lib.groups.is_empty());
assert_eq!(lib.len(), 14);
// No name another built-in gradient has, in any case.
let mut others = std::collections::HashSet::new();
for other in builtins().filter(|b| b.id != "holographic").map(|b| builtin_library(b.id).unwrap()) {
others.extend(other.iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| s.name.to_lowercase()));
}
assert!(lib.iter().all(|s| !others.contains(&s.name.to_lowercase())), "a name another library has");
let (mut foils, mut glows) = (0, 0);
for s in lib.iter() {
let Paint::Gradient(g) = &s.paint else { panic!("{} is a gradient", s.name) };
let (g, n) = (&g.gradient, &s.name);
assert_eq!(g.method, GradientMethod::Perceptual, "{n}");
let bands = match &g.kind {
GradientKind::Linear => {
foils += 1;
FOIL
}
GradientKind::Radial => {
glows += 1;
GLOW
}
kind => panic!("{n}: {kind:?}"),
};
// Every stop takes its band's offset, opacity and midpoint, and none is dropped.
let shape: Vec<(f32, f32, f32)> = g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect();
assert_eq!(shape, bands, "{n}");
assert!(g.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{n}");
}
assert_eq!((foils, glows), (7, 7));
// Foils are opaque; a glow is clear at the edge.
assert!(FOIL.iter().all(|&(_, opacity, _)| opacity == 1.0));
assert_eq!(GLOW.last().map(|b| b.1), Some(0.0));
// Every segment's midpoint is off center and within the Gradient panel's 13 to 87%.
for bands in [FOIL, GLOW] {
assert!(bands[..bands.len() - 1].iter().all(|&(_, _, m)| (m - 0.5).abs() > 0.02 && (0.13..=0.87).contains(&m)));
assert!(bands.windows(2).all(|w| w[0].0 < w[1].0) && bands[0].0 == 0.0 && bands[bands.len() - 1].0 == 1.0);
}
// Sampled colors: Prism Veil's bright band is the pearl anchor, and Violet Edge ends in deep violet.
let stop = |name: &str, i: usize| match &lib.swatch(name).unwrap().paint {
Paint::Gradient(g) => g.gradient.stops[i].color,
_ => panic!("{name} is a gradient"),
};
assert_eq!(stop("Prism Veil", 2), oklch(0.97, 0.01, 90.0));
assert_eq!(stop("Violet Edge", 4), oklch(0.42, 0.04, 275.0));
let [l, ..] = to_oklab(&stop("Violet Edge", 4));
assert!(l < 0.5 && to_oklab(&stop("Prism Veil", 2))[0] > 0.95, "{l}");
}
#[test]
fn the_other_libraries_keep_classic_mixing_and_centered_midpoints() {
for b in builtins().filter(|b| b.id != "organic-fades") {
for b in builtins().filter(|b| !["organic-fades", "holographic"].contains(&b.id)) {
for s in builtin_library(b.id).unwrap().iter() {
if let Paint::Gradient(g) = &s.paint {
assert_eq!(g.gradient.method, GradientMethod::Classic, "{}", s.name);
+67 -8
View File
@@ -1051,7 +1051,8 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option<Vec<usize>> {
/// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and
/// its handles) moved onto the key's centre, so moving a key moves its end of the spine;
/// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread
/// evenly along it by arc length).
/// evenly along it by arc length) while the keys sit there; once one is moved off, pinned as
/// above at the anchors where they were spread.
///
/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]).
pub fn blend_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> {
@@ -1077,7 +1078,24 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio
return Some((PathData::single(SubPath::polyline(&centers, false)), Some((0..keys.len()).collect())));
};
let mut first = stored.subpaths.first()?.clone();
let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) };
let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else {
let mut path = PathData::single(first);
// A key moved off where Replace Spine spread it takes its point of the spine along, as
// on a pinned spine; otherwise the steps would put it back on the spine (#6809).
if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) {
return Some((path, None));
}
let Some(mut ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) };
let mut first = path.subpaths.into_iter().next()?;
open_at_start(&mut first, &mut ka);
for (k, &ai) in keys.iter().zip(&ka) {
if let Some(a) = first.anchors.get_mut(ai) {
let d = center_of(k) - a.p;
a.translate(d);
}
}
return Some((PathData::single(first), Some(ka)));
};
for (k, &ai) in keys.iter().zip(&ka) {
let c = center_of(k);
if let Some(a) = first.anchors.get_mut(ai) {
@@ -1091,21 +1109,62 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio
/// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its
/// keys sit (by arc length), so editing it keeps them in place.
pub fn pin_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Vec<usize>)> {
let (mut path, anchors) = blend_spine(keys, spec)?;
if let Some(a) = anchors {
return Some((path, a));
// A spine from Replace Spine is pinned as stored, even with a key moved off it (where
// [`blend_spine`] moves that key's point along): the layout is the same.
if keys.len() >= 2
&& let Some(first) = stored_spine(spec).and_then(|s| s.subpaths.first())
&& pinned(spec, keys.len(), first.anchors.len()).is_none()
{
let mut path = PathData::single(first.clone());
let mut out = spread_anchors(&mut path, keys.len())?;
// A closed spine's end keys get points of their own, so that once pinned each still
// moves only its own end.
let mut sp = path.subpaths.into_iter().next()?;
open_at_start(&mut sp, &mut out);
return Some((PathData::single(sp), out));
}
let (path, anchors) = blend_spine(keys, spec)?;
Some((path, anchors?))
}
/// 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)?
fn keys_spread_along(keys: &[Arc<Node>], spine: &Spine) -> bool {
let k = keys.len();
let total = Spine::new(&path)?.length();
keys.iter().enumerate().all(|(i, key)| {
let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 };
center_of(key).distance(spine.at(f).0) < 0.01
})
}
/// Open closed spine `sp` at its first anchor when the last key sits there with the first (as
/// [`spread_anchors`] puts them): a copy of that anchor at the end takes the closing segment and
/// the last key, so the two end keys move their ends of the spine apart.
fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) {
let Some(&start) = sp.anchors.first() else { return };
if !sp.closed || ka.len() < 2 {
return;
}
if let Some(last) = ka.last_mut().filter(|a| **a == 0) {
*last = sp.anchors.len();
sp.anchors.push(start);
sp.closed = false;
}
}
/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit
/// on, splitting segments where none is.
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);
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(&mut path, s)?);
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);
Some((path, out))
Some(out)
}
/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a
+2 -2
View File
@@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
envelope,
NodeKind::Envelope {
content,
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: false,
options: Default::default(),
@@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() {
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: true,
options: Default::default(),
+1 -1
View File
@@ -267,7 +267,7 @@ pub struct Document {
/// they were copied from instead of the current layer.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub paste_remembers_layers: bool,
/// File → File Info (the title is [`Document::title`]).
/// File → File Info (its title is [`Document::info_title`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub metadata: DocMetadata,
/// Effect → Document Raster Effects Settings besides the resolution
+197 -3
View File
@@ -60,12 +60,12 @@ pub use envelope::{
pub(crate) use expand::max_outset;
pub use expand::{
Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with,
expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live,
expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live,
};
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version};
#[cfg(test)]
mod tests {
@@ -132,6 +132,200 @@ mod tests {
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
/// Each warp style's Bézier net, at points worked out by a separate implementation of the same
/// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) →
/// the point in the box (origin at its top left, y down).
#[test]
fn warp_nets_bend_each_style_as_its_own_patch() {
#[rustfmt::skip]
let samples = [
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045),
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443),
("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666),
("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371),
("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712),
("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703),
("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585),
("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631),
("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44),
("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792),
("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352),
("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48),
("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472),
("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68),
("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68),
("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224),
("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769),
];
for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples {
let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") };
let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v);
assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})");
}
}
/// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half
/// circle through the bottom corners about the bottom's middle, and the top edge the one 100
/// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle.
#[test]
fn warp_net_arc_edges_are_concentric_circles() {
let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false);
let center = Point::new(100.0, 100.0);
for i in 0..=20 {
let u = f64::from(i) / 20.0;
for (v, r) in [(0.0, 200.0), (1.0, 100.0)] {
let d = net.at(u, v).distance(center);
assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}");
// Its ends and middle lie on the circle.
if i % 10 == 0 {
assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}");
}
}
}
// Bend 0 leaves the box as it is.
for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] {
let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false);
assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}");
}
}
/// The horizontal distortion scales each column of the net about its middle (narrowing the left
/// side for a positive value), then the vertical one each row (narrowing the top).
#[test]
fn warp_net_distortion_scales_the_columns_then_the_rows() {
let corners = |dh: f64, dv: f64| {
let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false);
[net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)]
};
let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9);
assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0));
assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5));
// Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and
// it then scales by 1.4 about its middle, (100, 0).
assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4));
}
/// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number
/// counts as 0, and out of range as its limit; a size that isn't a number as 0.
#[test]
fn warp_net_takes_any_numbers() {
for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] {
for (b, dh, w, h) in
[(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)]
{
for vertical in [false, true] {
let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6);
assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}");
}
}
}
let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false);
assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false));
}
/// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only
/// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's
/// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number,
/// gives finite points inside the box and past it, and the unit square's points scaled up where
/// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite
/// width and a height of 1e-300 or 100 every y is the unit square's times the height, also
/// where x goes past the largest finite number.
#[test]
fn warp_net_bends_a_huge_box_without_overflowing() {
let w = 1e308;
let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false);
let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false);
for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] {
let (p, q) = (flag.at(u, v), small.at(u, v));
assert!(p.is_finite(), "({u}, {v}): {p:?}");
assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}");
assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}");
}
let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false);
assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300));
let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false);
for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] {
let p = fisheye.at(u, v);
assert!(p.is_finite(), "({u}, {v}): {p:?}");
}
let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false);
let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE);
let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1));
assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}");
// The bottom of a box with a subnormal height stays exactly at that height: the net is
// built on the box halved, and the height's lowest bit but one is set.
let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2);
let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0);
assert_eq!(sub.y, low, "{sub:?}");
for h in [1e-300, 100.0] {
let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false);
for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] {
let (p, q) = (tall.at(u, v), unit.at(u, v));
assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}");
}
}
}
/// Content with no width or no height takes the middle of a warp envelope's patch along that
/// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight.
#[test]
fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() {
let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let arc = warp("arc");
let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0));
for y in [0.0, 30.0, 100.0] {
let p = map(Point::new(100.0, y));
assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}");
}
let bulge = warp("bulge");
let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0));
for x in [0.0, 70.0, 200.0] {
let p = map(Point::new(x, 50.0));
assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}");
}
}
/// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written
/// as none, so files keep their bytes, and the nets' version is written.
#[test]
fn warp_envelopes_write_their_map_version_only_when_it_has_one() {
let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap();
assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}");
assert!(!serde_json::to_string(&old).unwrap().contains("version"));
let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let text = serde_json::to_string(&new).unwrap();
assert!(text.contains(r#""version":1"#), "{text}");
assert_eq!(serde_json::from_str::<EnvelopeKind>(&text).unwrap(), new);
}
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
@@ -537,7 +731,7 @@ mod tests {
#[test]
fn an_envelope_maps_from_its_box_with_its_margins() {
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = [Arc::new(rect)];
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
// Top, bottom, left and right.
+8
View File
@@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
use vectorcraft_geom::Homography;
fn is_zero(n: &u32) -> bool {
*n == 0
}
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -19,6 +23,10 @@ pub enum EnvelopeKind {
v: f64,
#[serde(default = "yes")]
horizontal: bool,
/// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net,
/// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point).
#[serde(default, skip_serializing_if = "is_zero")]
version: u32,
},
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
Mesh {
+34 -13
View File
@@ -178,29 +178,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
a.is_finite().then_some(a)
}
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
/// seams show.
/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`]
/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's
/// options say).
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
// pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image
// is left out.
if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) {
// An image an inner envelope already cut into pieces is cut again here, so the pieces count
// against [`MAX_GENERATED`]. Past it, the image is left out.
if !Generating::charge(mesh_warp_cost(k)) {
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![], clip: false };
return g;
}
mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency)
}
/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two
/// triangles a cell, three objects each.
pub fn mesh_warp_cost(cells: usize) -> usize {
let k = cells.max(1);
k.saturating_mul(k).saturating_mul(6)
}
/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into
/// `cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the
/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past
/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the
/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name,
/// transparency, opacity mask and appearance.
pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node {
let k = cells.max(1);
// A grayscale image's fill color colorizes each piece; the group of pieces takes none.
let colorize = n.extras().colorize.clone();
let (pw, ph) = (im.width as f64, im.height as f64);
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
let mut pieces = Vec::with_capacity(2 * k * k);
for j in 0..k {
for i in 0..k {
let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
let d = s.map(|p| (w.f)(im.xf * p));
let d = s.map(|p| f(im.xf * p));
for t in [[0, 1, 2], [0, 2, 3]] {
let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v]));
let Some(xf) = affine_between(st, dt) else { continue };
@@ -216,8 +233,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
.collect();
let shape = PathData::single(SubPath::polyline(&tri, true));
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
image.extras_mut().pixelated = !w.options.anti_alias;
if w.options.preserve_shape == PreserveShape::Transparency {
image.extras_mut().pixelated = pixelated;
image.extras_mut().colorize = colorize.clone();
if masked {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
@@ -234,6 +252,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
}
let mut g = n.clone();
g.kind = NodeKind::Group { children: pieces, clip: false };
if let Some(e) = g.extras_if_any() {
e.colorize = None;
}
g
}
+11 -2
View File
@@ -1,5 +1,5 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point};
use super::{
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
one,
@@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64)
pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn Fn(f64, f64) -> Point + 'a> {
let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height());
match kind {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => {
let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc);
let b = bend.clamp(-100.0, 100.0) / 100.0;
let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0);
if uses_net(*version) {
let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal);
// Content with no width or no height takes the middle of the patch along that side.
let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0);
return Box::new(move |u: f64, v: f64| {
let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v });
frame * (src.origin() + net.at(u, v).to_vec2())
});
}
let c = src.center();
let (hw, hh) = (src.width() / 2.0, src.height() / 2.0);
let horizontal = *horizontal;
+331
View File
@@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6
(x2, y2)
}
// =====================================================================================
// Warp nets: each style as one Bézier patch over its box
// =====================================================================================
/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With
/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep
/// their look; new and imported warps are given this one.
pub const WARP_NET: u32 = 1;
/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws
/// with the newest map it has.
pub fn uses_net(version: u32) -> bool {
version >= WARP_NET
}
/// The map version `v` names when it is one this build draws: a whole number from 0 to
/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`).
pub fn warp_version(v: &serde_json::Value) -> Option<u32> {
let n = v.as_f64()?;
(n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32)
}
/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row
/// from the top, in the box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug)]
struct Net {
rows: usize,
cols: usize,
p: [[Point; 4]; 4],
}
impl Net {
/// The undistorted box `w` × `h` as a `rows` × `cols` net.
fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self {
let mut p = [[Point::ZERO; 4]; 4];
for (r, row) in p.iter_mut().enumerate().take(rows) {
for (c, q) in row.iter_mut().enumerate().take(cols) {
*q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64));
}
}
Self { rows, cols, p }
}
/// Upside down within a box `h` high: the rows in reverse order, each point mirrored.
fn mirrored(self, h: f64) -> Self {
let mut out = self;
for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) {
for (q, f) in to.iter_mut().zip(from).take(self.cols) {
*q = Point::new(f.x, h - f.y);
}
}
out
}
/// Rows and columns exchanged, and x with y: a net made on the box turned on its side.
fn transposed(self) -> Self {
let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] };
for (r, row) in self.p[..self.rows].iter().enumerate() {
for (c, q) in row[..self.cols].iter().enumerate() {
out.p[c][r] = Point::new(q.y, q.x);
}
}
out
}
/// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from
/// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1).
fn distort_columns(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for c in 0..cols {
let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0);
let mid = half_way(self.p[0][c], self.p[rows - 1][c]);
for row in &mut self.p[..rows] {
row[c] = mid + (row[c] - mid) * s;
}
}
}
/// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0
/// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1).
fn distort_rows(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for (r, row) in self.p[..rows].iter_mut().enumerate() {
let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0);
let mid = half_way(row[0], row[cols - 1]);
for q in &mut row[..cols] {
*q = mid + (*q - mid) * s;
}
}
}
/// The same patch as a bicubic net (degree elevation, which leaves the surface as it is).
fn bicubic(self) -> [[Point; 4]; 4] {
let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect();
let mut out = [[Point::ZERO; 4]; 4];
for c in 0..4 {
let col: Vec<Point> = rows.iter().map(|row| row[c]).collect();
for (to, q) in out.iter_mut().zip(cubic_line(&col)) {
to[c] = q;
}
}
out
}
}
/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one.
fn half_way(a: Point, b: Point) -> Point {
Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y)
}
/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form.
fn cubic_line(p: &[Point]) -> [Point; 4] {
match *p {
[a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b],
[a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c],
[a, b, c, d] => [a, b, c, d],
_ => [Point::ZERO; 4],
}
}
/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord`
/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along
/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is
/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord.
fn arc_handles(chord: f64, b: f64) -> (f64, f64) {
let th = b.abs().min(1.0) * PI;
let len = chord / (3.0 * (th / 4.0).cos().powi(2));
(len * (th / 2.0).cos(), len * (th / 2.0).sin())
}
/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive
/// `b` when `up`, down when not, the other way for a negative one.
fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] {
let (ax, ay) = arc_handles(w, b);
let dy = if (b > 0.0) == up { -ay } else { ay };
[Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)]
}
/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top
/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly
/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean
/// out by θ/2.
fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] {
let th = b.abs().min(1.0) * PI;
let (s, c) = (th / 2.0).sin_cos();
let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c)));
let (ax, ay) = arc_handles(tr.x - tl.x, b);
let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr];
let bottom = arc_row(w, h, b.abs(), true);
let mut out = [[Point::ZERO; 4]; 4];
for (r, row) in out.iter_mut().enumerate() {
for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) {
*q = t.lerp(u, r as f64 / 3.0);
}
}
out
}
/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion.
/// `vertical`: the net of the box turned on its side, where Rise moves the other half.
fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net {
use WarpStyle::*;
let two = |top: [Point; 4], bottom: [Point; 4]| {
let mut p = [[Point::ZERO; 4]; 4];
(p[0], p[1]) = (top, bottom);
Net { rows: 2, cols: 4, p }
};
let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y));
match style {
Arc => {
let rows = arc_rows(w, h, b);
let net = two(rows[0], rows[3]);
if b < 0.0 { net.mirrored(h) } else { net }
}
ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)),
ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)),
Arch => {
let top = arc_row(w, 0.0, b, true);
two(top, top.map(|q| Point::new(q.x, q.y + h)))
}
Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)),
ShellLower | ShellUpper => {
let a = arc_rows(w, h, b);
let mut net = Net::flat(w, h, 4, 4);
if b >= 0.0 {
// The outer arc and the ends of the row inside it, upside down for Shell Lower.
net.p[0] = a[0];
(net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]);
if style == ShellLower { net.mirrored(h) } else { net }
} else {
// The inner arc and the ends of the row outside it, upside down for Shell Upper.
net.p[3] = a[3];
(net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]);
if style == ShellUpper { net.mirrored(h) } else { net }
}
}
Flag | Fish | Rise => {
let mut net = Net::flat(w, h, 2, 4);
let d = 2.0 * b * h;
match style {
Flag => {
for row in &mut net.p[..2] {
(row[1].y, row[2].y) = (row[1].y + d, row[2].y - d);
}
}
Fish => {
(net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d);
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
}
_ => {
for row in &mut net.p[..2] {
if vertical {
(row[2].y, row[3].y) = (row[2].y - d, row[3].y - d);
} else {
(row[0].y, row[1].y) = (row[0].y + d, row[1].y + d);
}
}
}
}
net
}
Wave => {
let mut net = Net::flat(w, h, 3, 4);
let d = 2.0 * b * h;
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
net
}
Fisheye => {
let mut net = Net::flat(w, h, 4, 4);
let c = Point::new(w / 2.0, h / 2.0);
for row in &mut net.p[1..3] {
for q in &mut row[1..3] {
*q = c + (*q - c) * (1.0 + 4.0 * b);
}
}
net
}
Inflate | Squeeze => {
let mut net = Net::flat(w, h, 3, 3);
let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 };
net.p[0][1].y -= b * h / 2.0;
net.p[2][1].y += b * h / 2.0;
net.p[1][0].x -= side;
net.p[1][2].x += side;
net
}
Twist => {
// The four inner points move round: clockwise for a positive bend.
let mut net = Net::flat(w, h, 4, 4);
let (dx, dy) = (b.abs() * w, b.abs() * h);
if b >= 0.0 {
net.p[1][1].x += dx;
net.p[1][2].y += dy;
net.p[2][2].x -= dx;
net.p[2][1].y -= dy;
} else {
net.p[1][1].y += dy;
net.p[1][2].x -= dx;
net.p[2][2].y -= dy;
net.p[2][1].x += dx;
}
net
}
}
}
/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns
/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the
/// box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WarpNet {
/// The control points over the box divided by `scale`.
net: [[Point; 4]; 4],
/// 1, or a power of two when the net over the box itself does not fit in finite numbers.
scale: f64,
w: f64,
h: f64,
}
impl WarpNet {
/// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each
/// -1..1, clamped); `vertical` bends along the box's height.
pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self {
let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 };
let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 };
let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h));
let build = |w: f64, h: f64| {
let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) };
net.distort_columns(dh);
net.distort_rows(dv);
net.bicubic()
};
// A box near the largest finite size can overflow the net. The net is then built on the box
// divided by the smallest power of two that fits (2 to 4096), an exact division for every
// side above the subnormal range, and points are multiplied back as they are read.
let mut out = Self { net: build(w, h), scale: 1.0, w, h };
while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) {
let scale = out.scale * 2.0;
out = Self { net: build(w / scale, h / scale), scale, w, h };
}
out
}
/// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A
/// coordinate the patch would send past the largest finite number is the box's own, held to
/// the finite range.
pub fn at(&self, u: f64, v: f64) -> Point {
let p = self.at_scaled(u, v);
let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) };
let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) };
Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h))
}
/// [`Self::at`] divided by the net's scale.
fn at_scaled(&self, u: f64, v: f64) -> Point {
let bern = |t: f64| {
let s = 1.0 - t;
[s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t]
};
let (bu, bv) = (bern(u), bern(v));
let (mut x, mut y) = (0.0, 0.0);
for (row, wv) in self.net.iter().zip(bv) {
for (q, wu) in row.iter().zip(bu) {
x += wv * wu * q.x;
y += wv * wu * q.y;
}
}
Point::new(x, y)
}
}
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
+41 -2
View File
@@ -1,11 +1,15 @@
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
//! before File Info existed load unchanged.
//! document title is [`Document::title`], the name it shows in its tab and offers Save As when it
//! has no file; a title the file it was opened from gave itself is [`DocMetadata::title`], and
//! [`Document::info_title`] is the title File Info shows and exports write. Every field is
//! defaulted, so documents saved before File Info existed load unchanged.
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
use crate::Document;
/// File Info → Copyright Status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -44,6 +48,13 @@ pub const MAX_RATING: u8 = 5;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocMetadata {
/// The title the file this document was opened from gives itself (an SVG's root `<title>`, a
/// PDF's XMP `dc:title` or Info `Title`), which File Info shows and exports write in place of
/// [`Document::title`], the document's display name. `None`: the file gave none, so the
/// display name is the title. Left out of a saved document when unset, so other documents
/// save as they did.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub author: String,
/// The author's position or job title.
pub author_title: String,
@@ -83,6 +94,16 @@ impl DocMetadata {
}
}
impl Document {
/// The title File Info shows and the files an export writes carry (PDF `Title` and XMP
/// `dc:title`, SVG `<title>`, PNG `Title`): the title the file this document was opened from
/// gave itself ([`DocMetadata::title`]), else [`Document::title`]. The display name (the tab, the
/// default file name) is always [`Document::title`].
pub fn info_title(&self) -> &str {
self.metadata.title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(&self.title)
}
}
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
@@ -191,6 +212,24 @@ mod tests {
assert!(DocMetadata::default().png_text("").is_empty());
}
#[test]
fn the_info_title_is_the_files_own_title_else_the_display_name() {
let mut d = Document::new(10.0, 10.0);
d.title = "box.svg".into();
assert_eq!(d.info_title(), "box.svg");
d.metadata.title = Some("Access paths".into());
assert_eq!((d.title.as_str(), d.info_title()), ("box.svg", "Access paths"));
d.metadata.title = Some(" ".into());
assert_eq!(d.info_title(), "box.svg");
// Saved only when set: other documents' metadata is as before, and the field comes back.
d.metadata.title = None;
assert!(!serde_json::to_string(&d.metadata).unwrap().contains("title"));
d.metadata.title = Some("Access paths".into());
let json = serde_json::to_string(&d.metadata).unwrap();
assert_eq!(serde_json::from_str::<DocMetadata>(&json).unwrap().title.as_deref(), Some("Access paths"));
assert_eq!(serde_json::from_str::<DocMetadata>("{}").unwrap().title, None);
}
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
+26 -2
View File
@@ -194,8 +194,14 @@ fn round(h: &str, width: f64) -> StrokeLayer {
StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) }
}
/// Live effect `id` with `params` (the others keep the effect's defaults).
fn fx(id: &str, params: Value) -> Effect {
/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its
/// style's Bézier net, as a new Warp does.
fn fx(id: &str, mut params: Value) -> Effect {
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::live::WARP_NET));
}
Effect { id: id.into(), params, visible: true }
}
@@ -657,6 +663,24 @@ mod tests {
assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn");
}
/// The built-in styles' Warps draw with the map a new Warp takes.
#[test]
fn builtin_warp_styles_take_the_nets_map() {
let mut warps = 0;
for b in STYLE_LIBRARIES {
let lib = builtin_style_library(b.id).unwrap();
for g in &lib.styles {
let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect();
effects.extend(g.appearance.items.iter().flat_map(|i| i.effects()));
for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) {
assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id);
warps += 1;
}
}
}
assert!(warps >= 3, "{warps}");
}
#[test]
fn painted_and_sketched_follows_textures_with_one_effect_per_style() {
let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect();
+1 -1
View File
@@ -52,7 +52,7 @@ pub struct TextObject {
/// the object, so the runs (and their styles) stay the text itself.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ruby: Vec<RubySpan>,
#[serde(default)]
#[serde(default = "ParaStyle::omitted")]
pub para: ParaStyle,
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
+7
View File
@@ -28,6 +28,12 @@ pub struct CharStyle {
/// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kerning: Option<f64>,
/// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by
/// itself: the first character of the text, of a paragraph, or after a forced line break. The
/// Type tool's caret before such a character kerns with it (#5111), since there is no character
/// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect.
#[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")]
pub kerning_before: f64,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
@@ -411,6 +417,7 @@ impl Default for CharStyle {
leading: None,
tracking: 0.0,
kerning: None,
kerning_before: 0.0,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
+20
View File
@@ -244,9 +244,21 @@ pub struct ParaStyle {
/// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with
/// kashidas before they widen their word spaces.
pub kashida: Kashida,
/// Korean Line Break Anywhere (Paragraph panel menu): a line may break between any two Hangul
/// syllables. Off, a Korean word (the characters between spaces, when any of them is Hangul)
/// breaks only at its spaces and punctuation, or where it is wider than the line. New type and
/// imported text have it off; paragraphs saved before it existed open with it on, the line
/// breaks they were laid out with.
pub korean_line_break_anywhere: bool,
}
impl ParaStyle {
/// The attributes a text object saved without `para` reads with: the defaults, with Korean
/// Line Break Anywhere on, as a saved paragraph without that field reads.
pub(crate) fn omitted() -> Self {
Self { korean_line_break_anywhere: true, ..Self::default() }
}
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
@@ -330,6 +342,10 @@ struct ParaStyleFile {
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
/// Written only when off: a paragraph saved without it was laid out breaking between Hangul
/// syllables.
#[serde(default = "crate::yes", skip_serializing_if = "crate::skip::is_true")]
korean_line_break_anywhere: bool,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -361,6 +377,7 @@ impl From<ParaStyle> for ParaStyleFile {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
..
} = p;
Self {
@@ -386,6 +403,7 @@ impl From<ParaStyle> for ParaStyleFile {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
}
}
}
@@ -415,6 +433,7 @@ impl From<ParaStyleFile> for ParaStyle {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
..
} = f;
Self {
@@ -439,6 +458,7 @@ impl From<ParaStyleFile> for ParaStyle {
kinsoku_type,
roman_hanging,
kashida,
korean_line_break_anywhere,
}
}
}
+1
View File
@@ -365,6 +365,7 @@ impl ParaList {
position: CharPosition::Normal,
tracking: 0.0,
kerning: None,
kerning_before: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
underline: false,
+23
View File
@@ -184,6 +184,29 @@ fn justification_options_read_from_a_file_stay_in_range() {
assert_eq!(same, d);
}
/// Korean Line Break Anywhere is off for a new paragraph and saved only when off; a paragraph
/// saved before it existed, or a text object saved without paragraph attributes, reads with it on,
/// and both values read back as saved.
#[test]
fn korean_line_break_anywhere_is_saved_only_when_off() {
let off = ParaStyle::default();
assert!(!off.korean_line_break_anywhere);
let saved = serde_json::to_value(&off).unwrap();
assert_eq!(saved["korean_line_break_anywhere"], false);
assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), off);
let on = ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() };
let saved = serde_json::to_value(&on).unwrap();
assert!(saved.get("korean_line_break_anywhere").is_none());
assert_eq!(serde_json::from_value::<ParaStyle>(saved).unwrap(), on);
let old: ParaStyle = serde_json::from_str(r#"{"justify":"Left"}"#).unwrap();
assert!(old.korean_line_break_anywhere);
let mut saved = serde_json::to_value(TextObject::point(Point::ZERO, "가나", CharStyle::default())).unwrap();
assert_eq!(saved["para"]["korean_line_break_anywhere"], false);
saved.as_object_mut().unwrap().remove("para");
let old: TextObject = serde_json::from_value(saved).unwrap();
assert_eq!(old.para, ParaStyle { korean_line_break_anywhere: true, ..ParaStyle::default() });
}
/// Numbered lists: each number style writes its numbers and rolls over (letters after Z,
/// roman numerals in subtractive forms, zero-padded numbers past their digits), the marker adds
/// a period, and the count runs through list paragraphs, skips empty and bulleted ones and
+20 -2
View File
@@ -1,4 +1,7 @@
use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
use super::{
CLASSIC_EXTRUDE, CLASSIC_REVOLVE, CLASSIC_ROTATE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value,
json, trap,
};
/// Catalogue entry for one effect.
#[derive(Clone, Debug, PartialEq)]
@@ -81,6 +84,7 @@ fn lengths_of(id: &str) -> Lengths {
"threeD.revolve" => always(&["offset"]),
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
"threeD.classicExtrude" => always(&["depth", "bevelHeight"]),
"threeD.classicRevolve" => always(&["offset"]),
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
@@ -112,7 +116,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
@@ -207,6 +211,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.",
json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}),
),
g(
CLASSIC_REVOLVE,
"Revolve (Classic)…",
&["Effect", "3D and Materials", "3D (Classic)"],
"{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.",
json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}),
),
g(
CLASSIC_ROTATE,
"Rotate (Classic)…",
&["Effect", "3D and Materials", "3D (Classic)"],
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}),
),
g(
"convertToShape.rectangle",
"Rectangle…",
+8 -6
View File
@@ -83,18 +83,20 @@ pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in};
pub use revolve::{
CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
};
pub use solids::{
CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in,
three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at,
validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES,
classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in,
three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in,
validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
pub use trap::TRAP;
pub use vectorcraft_three_d::Mesh;
pub use warp::{WarpStyle, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version};
mod catalog;
mod geometry;
+25 -2
View File
@@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's
/// map `version` given as a whole float is stored as an integer.
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
@@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value {
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
upgrade_scribble(id, &mut m);
upgrade_warp_version(id, &mut m);
Value::Object(m)
}
other => other.clone(),
@@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
}
}
/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer.
fn upgrade_warp_version(id: &str, m: &mut Map<String, Value>) {
if !id.starts_with("warp.") {
return;
}
if let Some(v) = m.get_mut("version")
&& !v.is_u64()
&& let Some(n) = crate::warp::warp_version(v)
{
*v = json!(n);
}
}
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
if id != "stylize.scribble" {
return;
@@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
let mut params = merged_params(id, params);
// A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry
// no version, so warps saved without one keep the earlier map.
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::warp::WARP_NET));
}
Some(Effect { id: id.to_string(), params, visible: true })
}
/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see
+280 -16
View File
@@ -4,19 +4,22 @@
//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp
//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur,
//! feather) stay on the result for the renderer and exporters. Type without
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
//! them ([`image_rows`]).
use std::collections::HashSet;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
@@ -548,7 +551,14 @@ fn as_art_opts(
let mut art = match &n.kind {
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
let image = Node::new(n.id, n.kind.clone());
let mut image = Node::new(n.id, n.kind.clone());
// Its pixels draw as the image's do: colorized by a grayscale image's fill color, and
// smoothed or not.
{
let mut e = image.extras_mut();
e.colorize = n.extras().colorize.clone();
e.pixelated = n.extras().pixelated;
}
let clipped = || {
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
@@ -632,9 +642,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images
/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them
/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
@@ -705,8 +715,9 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
let max = budget.at_least(used);
// Read before the effects run: a path's own copies don't make it art of several objects.
let copy_whole = copies_whole(art);
let mut warped = WarpedImages::new();
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let Some(bounds) = warped.bounds(art) else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
@@ -739,10 +750,21 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
}
let mut groups = vec![];
if follows {
group_marks(art, &mut vec![], &HashSet::new(), &mut groups);
group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups);
// The images earlier Warps bend are measured by their pieces only when a group's
// gradient needs the bounds.
if !groups.is_empty()
&& let Some(images) = warped.when_cut(art)
{
groups.clear();
group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups);
}
}
// The ids of the paths that kept their shape (past the budget).
// The ids of the paths that kept their shape (past the budget), and their places in the
// order [`for_each_leaf`] visits the paths.
let mut kept_ids = HashSet::new();
let mut kept_leaves = HashSet::new();
let mut leaf_at = 0usize;
let transform = (e.id == "distort.transform").then(|| {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
let options = crate::distort::TransformOptions::of(&params);
@@ -757,6 +779,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
continue;
}
for_each_leaf(art, &mut |leaf| {
let at = leaf_at;
leaf_at += 1;
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
let before = Budget::of(&path);
@@ -789,6 +813,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
kept_ids.insert(leaf.id);
kept_leaves.insert(at);
return;
};
used = used.minus(before).plus(Budget::of(&out));
@@ -804,6 +829,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
if boxes {
rebase_leaves(art, &mut leaves.into_iter());
}
// Warp bends images too: each one's pixels follow the warp's map over the same box. The
// images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image
// whose clipping frame kept its shape (past the budget) keeps its pixels as they are.
if let Some(style) = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id)
&& crate::warp::bends(&e.id, &params)
{
let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, &params));
warped.reserve(art);
mark_warped_images(art, &f, &kept_leaves, false, &mut 0, &mut warped);
}
if let Some((m, copies, options, _)) = transform
&& options.objects
{
@@ -816,14 +851,16 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
// follow their bounds under Free Distort and Warp (they stay when no path changed them).
// Each stays when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
let images = if groups.is_empty() { None } else { warped.when_cut(art) };
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1;
if !groups.is_empty() {
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, &params, bounds, from, to, seed));
}
if follows && !held {
whole.push(crate::Carry::of(&e.id, &params, bounds, Some(bounds), art.geometric_bounds(), seed));
whole.push(crate::Carry::of(&e.id, &params, bounds, Some(bounds), warped.bounds(art), seed));
}
}
cut_warped_images(art, &warped);
whole
}
@@ -1445,17 +1482,25 @@ type GroupMark = (Vec<usize>, Option<Rect>, bool);
/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or
/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`.
fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) {
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images`
/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]).
fn group_marks(
n: &Node,
at: &mut Vec<usize>,
kept: &HashSet<NodeId>,
images: Option<&WarpedImages>,
out: &mut Vec<GroupMark>,
) -> (Option<Rect>, bool) {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)),
NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false),
NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
let skip = children.len().saturating_sub(n.editing_children().len());
let (mut bounds, mut held) = (None, false);
for (i, c) in children.iter().enumerate() {
at.push(i);
let (b, h) = group_marks(c, at, kept, out);
let (b, h) = group_marks(c, at, kept, images, out);
if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) {
out.push((at.clone(), b, h));
}
@@ -1590,6 +1635,225 @@ pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, ma
copy_placed(n, m, copies, &mut room);
}
/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number).
pub(crate) const WARP_IMAGE_CELLS: usize = 10;
/// A Warp effect's point map, shared by the images it bends.
pub(crate) type WarpMap = Rc<dyn Fn(Point) -> Point>;
/// One image a Warp effect bent: the image's own key, and each warp it took with the placement
/// the image had when it took it, in order.
type WarpedImage = (String, Vec<(WarpMap, Affine)>);
/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's
/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform
/// effect makes keeps. The images are cut into pieces once, after the last effect
/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece,
/// and a later Warp or Transform moves the one set of pieces.
pub(crate) struct WarpedImages {
/// What this table's keys start with: no image key in the art starts with it
/// ([`WarpedImages::reserve`]).
prefix: String,
/// The prefix was checked against the art's image keys.
reserved: bool,
images: Vec<WarpedImage>,
}
impl WarpedImages {
pub(crate) fn new() -> Self {
static TABLES: AtomicUsize = AtomicUsize::new(0);
Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] }
}
/// Before the first image of `art` is marked: lengthen the prefix until no image key in `art`
/// starts with it, so no key from the document is read as one of this table's.
pub(crate) fn reserve(&mut self, art: &Node) {
if self.reserved {
return;
}
self.reserved = true;
let mut keys = vec![];
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
keys.push(im.key.clone());
}
});
while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) {
self.prefix.push(':');
}
}
/// What this table's keys start with.
#[cfg(test)]
pub(crate) fn prefix(&self) -> &str {
&self.prefix
}
/// The image that `key`, a key of this table, stands for.
fn get(&self, key: &str) -> Option<&WarpedImage> {
self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::<usize>().ok()?)
}
/// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's
/// new key.
fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String {
let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![]));
warps.push((f.clone(), xf));
self.images.push((own, warps));
format!("{}{}", self.prefix, self.images.len() - 1)
}
/// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that
/// this table lists?
fn fits(&self, n: &Node, max: usize) -> bool {
let mut count = 0usize;
n.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind
&& self.get(&im.key).is_some()
{
count += 1;
}
});
vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count())
}
/// The geometric bounds of `art` as it draws after the effects so far: each image this table
/// lists measured by the pieces the warps and moves it took would cut it into, when the art
/// has room for them ([`cut_warped_images`]).
pub(crate) fn bounds(&self, art: &Node) -> Option<Rect> {
if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) {
return art.geometric_bounds();
}
// The pieces the images would be cut into now: each group of pieces is shown or hidden as
// its image is, and hidden objects are left out of a layer's bounds.
let mut probe = art.clone();
restore_warped_images(&mut probe, self, true);
probe.geometric_bounds()
}
/// The geometric bounds of image `n`: when this table lists it, measured by the pieces the
/// warps and moves it took would cut it into.
fn image_bounds(&self, n: &Node) -> Option<Rect> {
let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() };
if self.get(&im.key).is_none() {
return n.geometric_bounds();
}
let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]);
restore_warped_images(&mut probe, self, true);
probe.geometric_bounds()
}
/// This table when the art `art` has room for the pieces of the images it lists, so that a
/// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise.
fn when_cut(&self, art: &Node) -> Option<&Self> {
(!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self)
}
}
/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers
/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of
/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the
/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take
/// the warp.
pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet<usize>, held: bool, at: &mut usize, warped: &mut WarpedImages) {
let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return };
let mut held = held;
// A clip group's first member is its clipping art: when a path in it kept its shape, it and
// the members it clips keep theirs.
if *clip && let Some(first) = children.first() {
let start = *at;
held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p));
}
for c in children.iter_mut() {
match &c.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1,
NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped),
NodeKind::Image(_) if !held => {
if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind {
im.key = warped.add(&im.key, im.xf, f);
}
}
_ => {}
}
}
}
/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of
/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image
/// is, smoothed or not) through the warps it took and the moves between and after them
/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces
/// would pass it, no image is cut. An image whose placement has no inverse is not cut either.
fn cut_warped_images(n: &mut Node, warped: &WarpedImages) {
cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS);
}
/// [`cut_warped_images`] with the art keeping to `max` nodes.
pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) {
if warped.images.is_empty() {
return;
}
let cut = warped.fits(n, max);
restore_warped_images(n, warped, cut);
}
/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut
/// into pieces.
fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
let NodeKind::Image(im) = &c.kind else {
if c.children().is_some() {
restore_warped_images(Arc::make_mut(c), warped, cut);
}
continue;
};
let Some((key, warps)) = warped.get(&im.key) else { continue };
let mut image = (**c).clone();
let NodeKind::Image(own) = &mut image.kind else { continue };
own.key = key.clone();
// The pieces start from the image as it was placed when it took its first warp.
let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() });
let map = warped_map(warps, own.xf);
*c = Arc::new(match (first, map) {
(Some(first), Some(map)) if cut => {
let pixelated = image.extras().pixelated;
vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false)
}
_ => image,
});
}
}
/// The map that takes a point of an image placed by the first of `warps`' placements to where the
/// warps and the moves between them take it, for an image now placed by `placed`: each warp in
/// turn, then the move from the placement the image had when it took that warp to the one it had
/// at the next, or to `placed` after the last. `None` when a placement has no inverse.
fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option<Box<dyn Fn(Point) -> Point>> {
let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len());
for (i, (f, at)) in warps.iter().enumerate() {
let det = at.determinant();
if !det.is_finite() || det == 0.0 {
return None;
}
let next = warps.get(i + 1).map_or(placed, |(_, at)| *at);
let m = next * at.inverse();
if !m.is_finite() {
return None;
}
steps.push((f.clone(), m));
}
Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q))))
}
/// How many paths [`for_each_leaf`] visits in `n`.
fn leaf_count(n: &Node) -> usize {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1,
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(),
_ => 0,
}
}
/// An object that a Transform effect places through its transform rather than reshapes: an image
/// (pixel space to document) or a symbol instance (the symbol's art to document).
fn is_placed(n: &Node) -> bool {
+96 -36
View File
@@ -1,4 +1,5 @@
//! Revolve as live, shaded vector art, shared by the canvas and every exporter.
//! Revolve and Revolve (Classic) as live, shaded vector art, shared by the canvas and every
//! exporter.
use std::sync::Arc;
use serde_json::Value;
@@ -13,6 +14,14 @@ use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
pub const REVOLVE: &str = "threeD.revolve";
mod classic;
pub use classic::CLASSIC_REVOLVE;
/// Is `id` Revolve or Revolve (Classic), the effects this module draws?
pub fn is_revolve(id: &str) -> bool {
id == REVOLVE || id == CLASSIC_REVOLVE
}
/// Most bytes the copies of the object's paint in the faces may take together. Each face and the
/// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of
/// thousands of faces took gigabytes; past this the source path is drawn.
@@ -55,7 +64,7 @@ pub(crate) fn paint_copies_add(used: &mut usize, copies: usize, paint: &Paint) -
}
fn visible(fx: &[Effect]) -> bool {
fx.iter().any(|e| e.visible && e.id == REVOLVE)
fx.iter().any(|e| e.visible && is_revolve(&e.id))
}
pub fn has_revolve(n: &Node) -> bool {
@@ -88,6 +97,17 @@ pub fn revolve_options(p: &Value) -> Revolve {
}
}
/// The options Revolve (`id` [`REVOLVE`]) or Revolve (Classic) ([`CLASSIC_REVOLVE`]) with
/// parameters `p` sweeps its surface with.
fn options_of(id: &str, p: &Value) -> Revolve {
if id == CLASSIC_REVOLVE { classic::options(p) } else { revolve_options(p) }
}
/// The options effect `fx`, a Revolve or a Revolve (Classic), sweeps its surface with.
fn fx_options(fx: &Effect) -> Revolve {
options_of(&fx.id, &merged_params(&fx.id, &fx.params))
}
/// Validate before a command mutates the document. Loading external files is still bounded
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
@@ -97,11 +117,16 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
/// [`validate_revolve`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// revolve the art they show ([`resolve_for`]), cut for Revolve whatever 3D effect `n` carries now.
pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Result<(), String> {
validate_revolve_params(p)?;
validate_in(doc, n, REVOLVE, p)
}
/// [`validate_revolve_in`] for Revolve or Revolve (Classic), `id`.
pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_params_of(id, p)?;
let n = &*resolve_for(doc, n, "Revolve", false)?;
let effects = &n.appearance.effects;
// The profile is measured before the object's one Revolve, hidden or shown.
let before = effects.iter().position(|e| e.id == REVOLVE).and_then(|i| effects.get(..i)).unwrap_or(effects);
let before = effects.iter().position(|e| is_revolve(&e.id)).and_then(|i| effects.get(..i)).unwrap_or(effects);
if is_container(n) {
const EMPTY: &str = "Revolve requires a group holding a shape with at least two anchors";
let mut shapes = members(n, "Revolve")?;
@@ -110,7 +135,7 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
}
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or(EMPTY)?;
let options = shared_options(&shapes, revolve_options(p))?;
let options = shared_options(&shapes, options_of(id, p))?;
let mut copies = 0;
shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?;
let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0));
@@ -123,8 +148,8 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
let path = geometry(&path, before, &GeomContext::of(n));
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let paints = solid_paints(n);
check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?;
check_shadow(p, [&path], b, revolve_options(p))
check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?;
check_shadow(p, [&path], b, options_of(id, p))
}
/// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast
@@ -138,14 +163,21 @@ fn check_shadow<'a>(p: &Value, paths: impl IntoIterator<Item = &'a PathData>, b:
}
/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of
/// appearance item `target` of `n`. The surfaces of every fill and stroke that carries a Revolve,
/// `target`'s included, share one budget of paint copies, as in [`art`]. A visible object-level
/// Revolve draws in place of those surfaces.
/// appearance item `target` of `n` ([`validate_item_of`]).
#[cfg(test)]
pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: usize) -> Result<(), String> {
validate_item_of(n, REVOLVE, p, target, index)
}
/// [`validate_revolve`] for a Revolve or Revolve (Classic), `id`, with parameters `p` at index
/// `index` of the effects of appearance item `target` of `n`. The surfaces of every fill and stroke
/// that carries one, `target`'s included, share one budget of paint copies, as in [`art`]. A visible
/// object-level Revolve draws in place of those surfaces.
pub(crate) fn validate_item_of(n: &Node, id: &str, p: &Value, target: usize, index: usize) -> Result<(), String> {
if is_container(n) || visible(&n.appearance.effects) || target >= n.appearance.items.len() {
return validate_revolve(n, p);
return validate_in(None, n, id, p);
}
validate_revolve_params(p)?;
validate_params_of(id, p)?;
const EMPTY: &str = "Revolve requires a shape with at least two anchors";
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
@@ -156,13 +188,13 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u
let mut copies = 0;
for (i, item) in n.appearance.items.iter().enumerate() {
let effects = item.effects();
let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) };
let params = match at.and_then(|at| effects.get(at)) {
_ if i == target => p.clone(),
Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params),
let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && is_revolve(&e.id)) };
let (kind, params) = match at.and_then(|at| effects.get(at)) {
_ if i == target => (id, p.clone()),
Some(fx) if crate::paints(item) => (fx.id.as_str(), merged_params(&fx.id, &fx.params)),
_ => continue,
};
let options = revolve_options(&params);
let options = options_of(kind, &params);
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
let b = path.bounds().ok_or(EMPTY)?;
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
@@ -217,6 +249,16 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
Ok(())
}
/// [`validate_revolve_params`] for Revolve or Revolve (Classic), `id`; Revolve (Classic) also takes
/// only its own parameters, each in its range.
pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> {
validate_revolve_params(p)?;
if id == CLASSIC_REVOLVE {
classic::validate(p)?;
}
Ok(())
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
@@ -226,7 +268,7 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when
/// the surface it is cast from is past the limits.
fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result<Option<Node>, String> {
let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) };
let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(None) };
let (_, opacity, blend) = paint_of(item);
if opacity <= 0.0 {
return Ok(None);
@@ -242,7 +284,7 @@ fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b:
/// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the
/// limits.
fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result<Vec<Arc<Node>>, String> {
let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) };
let Some(settings) = crate::solids::shadow::options(&merged_params(&fx.id, &fx.params)) else { return Ok(vec![]) };
let mut caster = vectorcraft_three_d::ShadowCaster::new();
let mut cast = vec![];
for s in shapes {
@@ -259,7 +301,8 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// the bytes of the paint copies of every surface of the object.
/// the bytes of the paint copies of every surface of the object. Revolve (Classic) draws every face
/// with its classic surface, as Extrude & Bevel (Classic) does.
#[allow(clippy::too_many_arguments)]
fn surface(
n: &Node,
@@ -275,6 +318,16 @@ fn surface(
) -> Option<(Vec<f64>, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
if fx.id == CLASSIC_REVOLVE {
let facets = vectorcraft_three_d::revolve_facets(path, b, options).ok()?;
*copies = copies.saturating_add(paint_copies(facets.len(), &paint));
if *copies > MAX_PAINT_COPIES {
return None;
}
let depths = facets.iter().map(|f| f.depth).collect();
let p = merged_params(&fx.id, &fx.params);
return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
}
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
*copies = copies.saturating_add(paint_copies(faces.len(), &paint));
@@ -371,8 +424,7 @@ fn surface(
fn object_surface(n: &Node, path: &PathData, fx: &Effect, item: &AppearanceItem, expand: bool, copies: &mut usize) -> Option<Node> {
let b = path.bounds()?;
let (paint, opacity, blend) = paint_of(item);
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
surface(n, path, b, options, fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
surface(n, path, b, fx_options(fx), fx, paint, opacity, blend, expand, copies).map(|(_, art)| art)
}
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
@@ -429,10 +481,10 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> {
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let (paint, opacity, _) = paint_of(item);
let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let mesh = vectorcraft_three_d::revolve_mesh(path, rule, b, fx_options(fx))?;
Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) })
};
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
let before = n.appearance.effects.get(..index).unwrap_or_default();
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let path = geometry(&path, before, &ctx);
@@ -445,7 +497,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE)
if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id))
&& let Some(fx) = item.effects().get(index)
{
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
@@ -461,13 +513,13 @@ fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::
.appearance
.effects
.iter()
.position(|e| e.visible && e.id == REVOLVE)
.position(|e| e.visible && is_revolve(&e.id))
.ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?;
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let mut shapes = members(n, "Revolve")?;
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let options = shared_options(&shapes, fx_options(fx))?;
let mut out = vec![];
for s in &shapes {
let Some(item) = surface_item(&s.items) else { continue };
@@ -491,7 +543,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
let mut children = vec![];
let mut copies = 0;
let mut remaining = n.appearance.effects.clone();
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
let fx = n.appearance.effects.get(index)?;
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
// An object's surface uses its top visible fill, falling back to its stroke.
@@ -500,7 +552,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let surface = object_surface(n, &path, fx, item, expand, &mut copies)?;
if let Some(b) = path.bounds()
&& let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
&& let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx)).ok()?
{
children.push(Arc::new(shadow));
}
@@ -511,7 +563,7 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
.effects
.iter()
.enumerate()
.filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index))
.filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index))
.map(|(_, e)| e.clone())
.collect();
} else {
@@ -520,14 +572,14 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) {
let fx = item.effects().get(index)?;
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?;
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect();
if let Some(b) = p.bounds()
&& let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
&& let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, fx_options(fx)).ok()?
{
shadow.appearance.effects = art.appearance.effects.clone();
shadows.push(Arc::new(shadow));
@@ -666,12 +718,12 @@ fn overlaps(a: Rect, b: Rect) -> bool {
/// each in its own paint. Every member's faces are drawn in one back-to-front order. `None` when
/// the effect is only on the group's own fills or strokes or when its members can't be revolved.
fn group_art(n: &Node, expand: bool) -> Option<Node> {
let index = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE)?;
let index = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id))?;
let fx = n.appearance.effects.get(index)?;
let mut shapes = members(n, "").ok()?;
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
let b = union_bounds(&shapes)?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
let options = shared_options(&shapes, fx_options(fx)).ok()?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut copies = 0;
let mut surfaces = vec![];
@@ -690,6 +742,10 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
return None;
}
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
// A classic surface whose gradient has transparent stops carries an opacity mask: its faces go
// in runs, as a transparent member's do, and each run carries the mask.
let masks: Vec<Option<Box<vectorcraft_doc::OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect();
let opaque = opaque && masks.iter().all(Option::is_none);
let children = if !plain {
scoped(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect(), 0)
} else {
@@ -731,7 +787,8 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
runs.push(Run { member: None, scopes, bounds: Rect::ZERO, faces: vec![face], cover: vec![], opacity, blend });
continue;
};
if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() {
let masked = masks.get(member).is_some_and(Option::is_some);
if opacity >= 1.0 && blend == Default::default() && scopes.is_empty() && !masked {
runs.push(Run { member: None, scopes, bounds: cover.bounds, faces: vec![face], cover: vec![cover], opacity, blend });
continue;
}
@@ -767,6 +824,9 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
let mut group = Node::group(n.id, r.faces);
group.opacity = r.opacity;
group.blend = r.blend;
if let Some(mask) = r.member.and_then(|m| masks.get(m)).cloned().flatten() {
group.extras_mut().mask = Some(mask);
}
Some((r.scopes, Arc::new(group)))
});
scoped(n.id, runs.collect(), 0)
+220
View File
@@ -0,0 +1,220 @@
//! Revolve (Classic): the surface Revolve sweeps from a path, drawn with the classic surfaces of
//! Extrude & Bevel (Classic) (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend
//! steps and shading color.
use serde_json::Value;
use vectorcraft_three_d::Revolve;
pub const CLASSIC_REVOLVE: &str = "threeD.classicRevolve";
/// The numeric parameters: (key, lowest, highest, whole numbers only).
const RANGES: [(&str, f64, f64, bool); 13] = [
("angle", 0.0, 360.0, false),
("offset", 0.0, 1000.0, false),
("rotationX", -180.0, 180.0, false),
("rotationY", -180.0, 180.0, false),
("rotationZ", -180.0, 180.0, false),
("perspectiveAngle", 0.0, 160.0, false),
("lightRotation", -180.0, 180.0, false),
("lightHeight", 0.0, 90.0, false),
("lightIntensity", 0.0, 100.0, false),
("ambient", 0.0, 100.0, false),
("highlightIntensity", 0.0, 100.0, false),
("highlightSize", 0.0, 100.0, false),
("blendSteps", 1.0, 256.0, true),
];
const FLAGS: [&str; 2] = ["cap", "lightBehind"];
/// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge,
/// rotation and perspective, built unshaded, since the classic surfaces light its faces.
pub(crate) fn options(p: &Value) -> Revolve {
Revolve { shade: false, smooth_shading: false, lights: None, light_color: [1.0; 3], ..super::revolve_options(p) }
}
/// Check Revolve (Classic)'s parameters `p` (its defaults filled in) beyond the checks it shares
/// with Revolve: every key is one of its parameters, numbers are in range, flags are true or false,
/// and the surface and shading color are named.
pub(crate) fn validate(p: &Value) -> Result<(), String> {
crate::solids::classic::validate_classic(CLASSIC_REVOLVE, p, &RANGES, &FLAGS)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId};
use vectorcraft_geom::{BezPath, PathData};
use super::*;
const BLUE: Color = Color::Rgb { r: 0.2, g: 0.4, b: 0.8 };
/// Half a vase's profile, open, filled blue.
fn profile() -> Node {
let mut b = BezPath::new();
b.move_to((100.0, 40.0));
b.line_to((130.0, 60.0));
b.line_to((130.0, 140.0));
b.line_to((100.0, 160.0));
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0))
}
fn revolved(params: Value) -> Node {
let mut n = profile();
n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &params).unwrap());
crate::revolve_art(&n).unwrap()
}
/// The faces of the surface of `art`, back to front.
fn faces(art: &Node) -> Vec<&Node> {
art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect()
}
fn fill(face: &Node) -> Option<Color> {
face.appearance.items.iter().find_map(|i| match i {
AppearanceItem::Fill(f) => f.paint.color(),
AppearanceItem::Stroke(_) => None,
})
}
fn facets(params: &Value) -> usize {
let n = profile();
let path = n.path_data().unwrap();
let p = crate::merged_params(CLASSIC_REVOLVE, params);
vectorcraft_three_d::revolve_facets(path, path.bounds().unwrap(), options(&p)).unwrap().len()
}
#[test]
fn plastic_shades_every_face_of_the_swept_surface_in_the_fill() {
let art = revolved(json!({}));
let drawn = faces(&art);
assert_eq!(drawn.len(), facets(&json!({})), "one face for each facet of the surface");
let colors: Vec<[f32; 3]> = drawn.iter().map(|f| fill(f).unwrap().to_rgb()).collect();
// Lit from one side, the faces take more than one tone, and none is another hue: a face is
// the blue darkened toward black, lightened toward white, or both.
let distinct = colors.iter().filter(|c| colors.iter().any(|d| (c[0] - d[0]).abs() > 0.02)).count();
assert!(distinct > 0, "{colors:?}");
// Diffuse has no highlight, so every face is the blue darkened toward black.
let art = revolved(json!({"surface": "diffuse"}));
for face in faces(&art) {
let [r, g, b] = fill(face).unwrap().to_rgb();
assert!(b <= 0.8 + 1e-6 && (g * 2.0 - b).abs() < 1e-4 && (r * 4.0 - b).abs() < 1e-4, "{r} {g} {b}");
}
}
#[test]
fn wireframe_strokes_the_edges_of_every_face_and_no_shading_keeps_the_paint() {
let art = revolved(json!({"surface": "wireframe"}));
let faces_drawn = faces(&art);
assert_eq!(faces_drawn.len(), facets(&json!({"surface": "wireframe"})));
for face in faces_drawn {
match face.appearance.items.as_slice() {
[AppearanceItem::Stroke(s)] => assert_eq!((s.paint.color(), s.width), (Some(BLUE), 0.5)),
items => panic!("a wireframe face is one stroke: {items:?}"),
}
}
for face in faces(&revolved(json!({"surface": "none"}))) {
assert_eq!(fill(face), Some(BLUE));
}
}
#[test]
fn angle_offset_edge_and_cap_shape_the_surface() {
let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap();
// Seen from the front, the full turn is twice the profile's width across, about its left edge.
assert!((full.width() - 60.0).abs() < 2.0, "{full:?}");
let ring = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "offset": 20})).visual_bounds().unwrap();
assert!((ring.width() - 100.0).abs() < 2.0, "{ring:?}");
// About its right edge the axis is at x 130, so the surface reaches x 160.
let right = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "edge": "right"})).visual_bounds().unwrap();
assert!((right.x1 - 160.0).abs() < 2.0, "{right:?}");
assert!(facets(&json!({"angle": 90})) < facets(&json!({})));
// The profile's ends lie on the axis, so they close nothing until an offset moves them off it.
assert!(facets(&json!({"cap": false, "offset": 10})) < facets(&json!({"offset": 10})), "hollow leaves out the end caps");
}
#[test]
fn a_group_revolves_its_members_about_one_shared_axis() {
let mut b = BezPath::new();
b.move_to((150.0, 60.0));
b.line_to((170.0, 60.0));
b.line_to((170.0, 140.0));
let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(profile()), std::sync::Arc::new(outer)]);
g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).unwrap());
let art = crate::revolve_art(&g).unwrap();
// The axis is at the left edge of both members together, x 100, so the outer one reaches 70
// out on either side of it.
let bounds = art.visual_bounds().unwrap();
assert!((bounds.x0 - 30.0).abs() < 2.0 && (bounds.x1 - 170.0).abs() < 2.0, "{bounds:?}");
let mut colors = vec![];
art.walk(&mut |n| colors.extend(fill(n).map(|c| c.to_rgb())));
assert!(colors.iter().any(|c| c[2] > c[0]) && colors.iter().any(|c| c[0] == c[2]), "both members' paints are drawn");
}
/// A group member in a gradient with transparent stops keeps the shared back-to-front order of
/// every member's faces: its faces go in runs that carry its opacity mask, and the other
/// member's faces are drawn between them where they are in front of some and behind others.
#[test]
fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let art = |end_opacity: f32| {
let mut end = GradientStop::new(1.0, Color::BLACK);
end.opacity = end_opacity;
let gradient =
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end]))));
let mut b = BezPath::new();
b.move_to((150.0, 60.0));
b.line_to((170.0, 60.0));
b.line_to((170.0, 140.0));
let outer = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0));
let mut g = Node::group(NodeId(3), vec![std::sync::Arc::new(outer), std::sync::Arc::new(profile())]);
g.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &json!({})).unwrap());
crate::revolve_art(&g).unwrap()
};
// The drawing order: `M` for a group with an opacity mask, `F` for a face outside one.
fn visit(n: &Node, order: &mut String) {
if n.extras().mask.is_some() {
order.push('M');
return;
}
if n.path_data().is_some() {
order.push('F');
}
for c in n.children().into_iter().flatten() {
visit(c, order);
}
}
let order = |art: &Node| {
let mut order = String::new();
visit(art, &mut order);
order
};
assert!(!order(&art(1.0)).contains('M'), "an opaque gradient needs no mask");
let masked = order(&art(0.5));
let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap());
assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "faces between the member's runs: {masked}");
}
#[test]
fn bad_parameters_are_refused() {
let check = |p: Value| crate::validate_three_d(&profile(), CLASSIC_REVOLVE, &crate::merged_params(CLASSIC_REVOLVE, &p));
assert_eq!(check(json!({})), Ok(()));
for (p, error) in [
(json!({"segments": 32}), "unknown parameter `segments`"),
(json!({"angle": 400}), "`angle` must be between 0 and 360"),
(json!({"offset": 1001}), "`offset` must be between 0 and 1000"),
(json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"),
(json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"),
(json!({"lightBehind": 1}), "`lightBehind` must be true or false"),
(json!({"surface": "glossy"}), "`surface` must be one of"),
(json!({"shadingColor": "white"}), "`shadingColor` must be one of"),
(json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"edge": "middle"}), "Revolve edge must be left or right"),
(json!({"cap": "yes"}), "Revolve cap must be a boolean"),
] {
let e = check(p.clone()).unwrap_err();
assert!(e.contains(error), "{p}: {e}");
}
}
}
+31 -16
View File
@@ -1,4 +1,4 @@
//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art,
//! Extrude & Bevel, Inflate, Rotate, Extrude & Bevel (Classic) and Rotate (Classic) as live, shaded vector art,
//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
use std::sync::Arc;
@@ -14,8 +14,8 @@ use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
mod classic;
pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading};
pub(crate) mod classic;
pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLORS, classic_extrude_options, classic_shading};
mod source;
pub(crate) use source::{resolve, resolve_for};
@@ -26,11 +26,22 @@ pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE];
pub const SOLIDS: [&str; 5] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE, CLASSIC_ROTATE];
/// Is `id` one of the 3D and Materials effects (Revolve included)?
/// Is `id` one of the 3D and Materials effects (Revolve and Revolve (Classic) included)?
pub fn is_three_d(id: &str) -> bool {
id == crate::REVOLVE || SOLIDS.contains(&id)
crate::revolve::is_revolve(id) || SOLIDS.contains(&id)
}
/// Is `id` one of the 3D (Classic) effects: Extrude & Bevel (Classic), Revolve (Classic) or Rotate
/// (Classic)?
pub fn is_classic_three_d(id: &str) -> bool {
id == CLASSIC_EXTRUDE || id == crate::CLASSIC_REVOLVE || id == CLASSIC_ROTATE
}
/// Does 3D effect `id` turn the art as a flat plane: Rotate or Rotate (Classic)?
fn is_plane(id: &str) -> bool {
id == ROTATE || id == CLASSIC_ROTATE
}
/// The 3D and Materials panel's object types and the effect that draws each: (type, effect id).
@@ -193,6 +204,9 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
if id == CLASSIC_EXTRUDE {
classic::validate(p)?;
}
if id == CLASSIC_ROTATE {
classic::validate_rotate(p)?;
}
crate::lighting::validate(p)?;
shadow::validate(p)?;
if let Some(v) = p.get("bevel")
@@ -225,7 +239,7 @@ pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value)
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> {
validate_solid_params(id, p)?;
let label = label_of(id);
let n = &*source::resolve_for(doc, n, label, id == ROTATE)?;
let n = &*source::resolve_for(doc, n, label, is_plane(id))?;
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
@@ -417,7 +431,7 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]).
pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) }
if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) }
}
/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of
@@ -436,7 +450,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va
Some(_) if is_container(n) || source::resolves(n) => {
Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into())
}
Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index),
Some((item, index)) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(n, id, p, item, index),
Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)),
None => validate_three_d_in(doc, n, id, p),
}
@@ -446,7 +460,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va
/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
/// not need to fit.
pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) }
if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) }
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
@@ -708,12 +722,12 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurface
/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on
/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the
/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow
/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past
/// off, for Extrude & Bevel (Classic) and Rotate (Classic) or with no shapes. A stroke's solid that is not built (past
/// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error.
/// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to
/// the part of the shadow they cast too ([`shadow::scoped_art`]).
fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, String> {
if id == CLASSIC_EXTRUDE {
if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE {
return Ok(None);
}
let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) };
@@ -775,12 +789,12 @@ type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow,
fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let classic = fx.id == CLASSIC_EXTRUDE;
let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE;
let colored = !classic && view_options(&p).has_colored_lights();
let masks = has_masks(shapes);
// The paint copies of every solid together: past the allowance the source path is drawn.
let mut paint_bytes = 0usize;
if fx.id == ROTATE {
if is_plane(&fx.id) {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
let mut out = vec![];
@@ -792,7 +806,8 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) {
return None;
}
out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored))));
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
out.push((s.scopes.as_slice(), Arc::new(art)));
}
}
return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() });
@@ -1001,7 +1016,7 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
let view = view_options(&p);
let mut out = vec![];
let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit);
if fx.id == ROTATE {
if is_plane(&fx.id) {
for s in shapes {
for item in &s.items {
let (paint, opacity, _) = paint_of(item, s.opacity);
+40 -5
View File
@@ -1,6 +1,6 @@
//! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic
//! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading
//! color.
//! color. Rotate (Classic): the plane Rotate turns, drawn with the No Shading and Diffuse surfaces.
use std::sync::Arc;
use serde_json::Value;
@@ -12,6 +12,10 @@ use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Ext
use crate::util::{flag, num, text};
pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude";
pub const CLASSIC_ROTATE: &str = "threeD.classicRotate";
/// The surfaces Rotate (Classic) takes: No Shading and Diffuse.
pub const ROTATE_SURFACES: [&str; 2] = ["none", "diffuse"];
/// The width of a Wireframe edge, in points.
const WIRE: f64 = 0.5;
@@ -104,8 +108,39 @@ const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"
/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and
/// `shadingCustom` name a surface, a shading color and a #rrggbb color.
pub fn validate(p: &Value) -> Result<(), String> {
validate_classic(CLASSIC_EXTRUDE, p, &RANGES, &FLAGS)
}
/// Rotate (Classic)'s numeric parameters: (key, lowest, highest, whole numbers only).
const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [
("rotationX", -180.0, 180.0, false),
("rotationY", -180.0, 180.0, false),
("rotationZ", -180.0, 180.0, false),
("perspectiveAngle", 0.0, 160.0, false),
("lightRotation", -180.0, 180.0, false),
("lightHeight", 0.0, 90.0, false),
("lightIntensity", 0.0, 100.0, false),
("ambient", 0.0, 100.0, false),
("blendSteps", 1.0, 256.0, true),
];
/// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude &
/// Bevel (Classic)'s, with `surface` No Shading or Diffuse only.
pub fn validate_rotate(p: &Value) -> Result<(), String> {
validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?;
if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) {
return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", ")));
}
Ok(())
}
/// Check the parameters `p` of classic 3D effect `id` (its defaults filled in): every key is one of
/// its parameters, the numbers `ranges` lists, as (key, lowest, highest, whole numbers only), are in
/// range, the `flags` are true or false, and `surface`, `shadingColor` and `shadingCustom` name a
/// surface, a shading color and a #rrggbb color.
pub(crate) fn validate_classic(id: &str, p: &Value, ranges: &[(&str, f64, f64, bool)], flags: &[&str]) -> Result<(), String> {
let Some(map) = p.as_object() else { return Ok(()) };
let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null);
let defaults = crate::default_params(id).unwrap_or(Value::Null);
let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default();
for key in map.keys() {
if !known.contains(&key.as_str()) {
@@ -113,7 +148,7 @@ pub fn validate(p: &Value) -> Result<(), String> {
return Err(format!("unknown parameter `{key}`; the command takes: {keys}"));
}
}
for (key, min, max, whole) in RANGES {
for &(key, min, max, whole) in ranges {
let Some(v) = map.get(key) else { continue };
let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) };
if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) {
@@ -123,7 +158,7 @@ pub fn validate(p: &Value) -> Result<(), String> {
return Err(format!("`{key}` must be between {min} and {max} (got {x})"));
}
}
for key in FLAGS {
for &key in flags {
if map.get(key).is_some_and(|v| !v.is_boolean()) {
return Err(format!("`{key}` must be true or false"));
}
@@ -161,7 +196,7 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color {
/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by
/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by
/// its own transparency, so the shading follows the gradient's coverage.
pub(super) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
let shading = classic_shading(p);
let lights = lights(p);
let shade = shading_color(p);
+1 -1
View File
@@ -55,7 +55,7 @@ fn needs(n: &Node) -> bool {
/// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut
/// for the solid effect `n` carries ([`resolve_for`]).
pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result<Cow<'a, Node>, String> {
let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE);
let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| super::is_plane(&fx.id));
resolve_for(doc, n, label, every_fill)
}
+77
View File
@@ -889,3 +889,80 @@ fn classic_parameters_are_checked() {
assert!(err.starts_with(says), "{p}: {err}");
}
}
/// The red square, stroked black, with Rotate (Classic) and `params`: its surfaces, the fill's
/// plane first, then the stroke's outline.
fn classic_rotate(params: Value) -> Vec<Node> {
let mut n = square_node();
n.appearance.effects.push(fx(CLASSIC_ROTATE, params));
solid_art(&n).unwrap().children().unwrap().iter().map(|s| (**s).clone()).collect()
}
#[test]
fn classic_rotate_turns_every_fill_and_stroke_as_one_plane() {
let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0});
let surfaces = classic_rotate(front.clone());
assert_eq!(surfaces.len(), 2, "the fill's plane and the stroke's outline");
assert!(surfaces.iter().all(|s| s.children().unwrap().len() == 1), "each is one flat face");
let fill = surfaces[0].visual_bounds().unwrap();
assert!((fill.width() - 100.0).abs() < 1.0 && (fill.height() - 100.0).abs() < 1.0, "{fill:?}");
// Turned 60° about the vertical axis, the square shows half its width and all its height,
// about the center of its bounds.
let turned = classic_rotate(json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}))[0].visual_bounds().unwrap();
assert!((turned.width() - 50.0).abs() < 1.0 && (turned.height() - 100.0).abs() < 1.0, "{turned:?}");
assert!((turned.center().x - 150.0).abs() < 1.0, "{turned:?}");
// Turned past 90°, the back of the plane is drawn.
let back = classic_rotate(json!({"rotationX": 0, "rotationY": 150, "rotationZ": 0}));
assert!(back[0].visual_bounds().is_some_and(|b| (b.width() - 100.0 * 30f64.to_radians().cos()).abs() < 1.0));
}
#[test]
fn classic_rotate_shades_with_no_shading_or_diffuse() {
let rgb = |p: Value| fill_rgb(&classic_rotate(p)[0].children().unwrap()[0]);
// No Shading keeps the paint, the color itself kept.
let mut n = square_node();
n.appearance.effects.push(fx(CLASSIC_ROTATE, json!({"surface": "none"})));
let art = solid_art(&n).unwrap();
let AppearanceItem::Fill(f) = &faces(&art)[0].appearance.items[0] else { panic!("a fill first") };
assert_eq!(f.paint, Paint::solid(RED));
// Diffuse: straight on with the light in front, the face is fully lit; turned away from the
// light, it is the red darkened toward black.
let lit = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "lightHeight": 90});
assert!(close3(rgb(lit), RED.to_rgb()));
let [r, g, b] = rgb(json!({"rotationX": 0, "rotationY": 70, "rotationZ": 0, "lightHeight": 90}));
assert!(r < 0.8 - 0.05 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4, "{r} {g} {b}");
// No light: the ambient share of the red. A custom shading color darkens toward that color;
// None keeps the paint.
let dark = rgb(json!({"lightIntensity": 0, "ambient": 20, "blendSteps": 256}));
assert!((dark[0] - 0.8 * 0.2).abs() < 0.01, "{dark:?}");
let blue = rgb(json!({"lightIntensity": 0, "ambient": 20, "shadingColor": "custom", "shadingCustom": "#0000ff"}));
assert!(blue[2] > blue[0] / 8.0 + 0.01, "{blue:?}");
assert!(close3(rgb(json!({"lightIntensity": 0, "shadingColor": "none"})), RED.to_rgb()));
}
#[test]
fn classic_rotate_parameters_are_checked() {
let n = square_node();
let check = |p: Value| validate_solid(&n, CLASSIC_ROTATE, &crate::merged_params(CLASSIC_ROTATE, &p));
assert!(check(json!({})).is_ok());
assert!(check(json!({"surface": "none", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 1})).is_ok());
for (p, says) in [
(json!({"surface": "plastic"}), "`surface` must be one of none, diffuse"),
(json!({"surface": "wireframe"}), "`surface` must be one of none, diffuse"),
(json!({"surface": 2}), "`surface` must be one of"),
(json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"),
(json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"rotationX": 181}), "`rotationX` must be between -180 and 180 (got 181)"),
(json!({"perspectiveAngle": 161}), "`perspectiveAngle` must be between 0 and 160 (got 161)"),
(json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"),
(json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
(json!({"lightBehind": 1}), "`lightBehind` must be true or false"),
(json!({"highlightSize": 50}), "unknown parameter `highlightSize`"),
(json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"),
(json!({"shadow": true}), "unknown parameter `shadow`"),
(json!({"lights": []}), "lights must contain one to ten lights"),
] {
let err = check(p.clone()).unwrap_err();
assert!(err.starts_with(says), "{p}: {err}");
}
}
+362
View File
@@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() {
assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}");
}
/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as
/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the
/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map
/// moves it 0.6 × 25 = 15.
#[test]
fn a_warp_draws_with_the_map_its_version_names() {
let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0));
// u(1 - u)(1 - 2u) peaks at √3 / 18.
let peak = 360.0 * 3f64.sqrt() / 18.0;
for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] {
let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap();
assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}");
assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}");
}
}
/// A straight line has a box with no width or no height, and the net bends it as the middle of
/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye,
/// Inflate and Squeeze a horizontal line stays straight.
#[test]
fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() {
let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::<Vec<_>>();
let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0));
let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical);
assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}");
let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0));
for style in ["bulge", "fisheye", "inflate", "squeeze"] {
let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal);
assert!(
anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)),
"{style}: {out:?}"
);
}
}
/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none,
/// so a saved warp's params don't gain one.
#[test]
fn new_warps_take_the_nets_version() {
for (s, _) in WARP_STYLES {
let id = format!("warp.{s}");
let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}");
let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(0), "{id}");
assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}");
}
let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") };
assert!(e.params.get("version").is_none());
}
#[test]
fn transform_copies_and_move() {
let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square());
@@ -318,6 +369,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001);
}
/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`.
fn warp_test_image(id: u64) -> std::sync::Arc<Node> {
use vectorcraft_doc::{ImageObject, NodeId};
let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im))))
}
/// How many images `art` holds, and the keys they have.
fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet<String>) {
let (mut n, mut keys) = (0, std::collections::BTreeSet::new());
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
n += 1;
keys.insert(im.key.clone());
}
});
(n, keys)
}
#[test]
fn warped_images_stay_within_the_object_budget() {
use std::rc::Rc;
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS);
let none = std::collections::HashSet::new();
let k = || std::collections::BTreeSet::from(["k".to_string()]);
let warp = |art: &mut Node, warps: usize| {
let mut warped = crate::reshape::WarpedImages::new();
for _ in 0..warps {
crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped);
}
warped
};
// The art has room for one image's pieces only: neither image is cut, and each gets its own
// key back.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
let warped = warp(&mut art, 1);
let max = art.count() + cost;
crate::reshape::cut_warped_images_within(&mut art, &warped, max);
assert_eq!(warp_test_images(&art), (2, k()));
// With room for both, each is cut into 200 pieces once, however many warps it took.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
let warped = warp(&mut art, 3);
let max = art.count() + 2 * cost;
crate::reshape::cut_warped_images_within(&mut art, &warped, max);
assert_eq!(warp_test_images(&art), (400, k()));
// Images clipped by their frames: the second frame (the second path) kept its shape, so its
// image stays whole, and the first is cut. The third's frame is a group of two paths, the
// second of them kept, with an image after them, and its image sits in a group of its own:
// both stay whole too.
let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default()));
let clip_group = |id: u64, children: Vec<Arc<Node>>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true }));
let nested = clip_group(
4,
vec![
Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])),
Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])),
],
);
let mut art =
Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]);
let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new());
crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped);
assert_eq!(at, 4);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art), (203, k()));
}
/// An image key from the document that looks like a key of the table is not read as one, and a
/// hidden image stays out of the bounds the later effects take, as it does before it is warped.
#[test]
fn warped_images_keep_document_keys_and_hidden_images_apart() {
use std::rc::Rc;
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
let none = std::collections::HashSet::new();
let mut warped = crate::reshape::WarpedImages::new();
let lookalike = format!("{}0", warped.prefix());
let mut b = (*warp_test_image(3)).clone();
if let NodeKind::Image(im) = &mut b.kind {
im.key = lookalike.clone();
}
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]);
warped.reserve(&art);
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike])));
// A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured.
let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()));
let mut hidden = (*warp_test_image(5)).clone();
hidden.visible = false;
let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0));
if let Some(children) = layer.children_mut() {
*children = vec![square, Arc::new(hidden)];
}
let before = layer.geometric_bounds();
let mut warped = crate::reshape::WarpedImages::new();
warped.reserve(&layer);
crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped);
assert_eq!(warped.bounds(&layer), before);
// A shown image is measured by the pieces it is cut into.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]);
let mut warped = crate::reshape::WarpedImages::new();
warped.reserve(&art);
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
let measured = warped.bounds(&art);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(measured, art.geometric_bounds());
assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}");
// An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed
// with no area is not.
for (scale, pieces) in [(1e-7, 200), (0.0, 1)] {
let mut image = (*warp_test_image(2)).clone();
if let NodeKind::Image(im) = &mut image.kind {
im.xf = Affine::scale(scale);
}
let mut art = Node::group(NodeId(1), vec![Arc::new(image)]);
let mut warped = crate::reshape::WarpedImages::new();
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art).0, pieces, "{scale}");
}
}
/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its
/// pixels as they are; within the budget, both bend.
#[test]
fn an_image_whose_frame_passes_the_budget_stays_whole() {
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let clipped = || {
let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut frame.kind {
*clipping = true;
}
Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true })
};
let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
let k = || std::collections::BTreeSet::from(["k".to_string()]);
let mut art = clipped();
crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None);
assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4));
assert_eq!(warp_test_images(&art), (1, k()));
let mut art = clipped();
crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None);
assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4));
assert_eq!(warp_test_images(&art), (200, k()));
}
/// The first image under `n` (the first piece of a cut image).
fn first_image(n: &Node) -> Option<vectorcraft_doc::ImageObject> {
let mut first = None;
n.walk(&mut |c| {
if first.is_none()
&& let NodeKind::Image(im) = &c.kind
{
first = Some((**im).clone());
}
});
first
}
#[test]
fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() {
let image = |effects: Vec<Effect>| {
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = effects;
n
};
// Each piece's clipping path, and the image's frame (the outer clip group's clipping path).
let clips = |art: &Node| {
let (mut pieces, mut frame) = (vec![], None);
art.walk(&mut |c| {
if let NodeKind::Group { children, clip: true } = &c.kind
&& let Some(path) = children.first().and_then(|p| p.path_data())
{
if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 {
pieces.push(path.anchor_count());
} else if frame.is_none() {
frame = Some(path.anchor_count());
}
}
});
(pieces, frame)
};
let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
for after in [
fx("distort.roughen", json!({"size": 5, "detail": 10})),
fx("stylize.roundCorners", json!({"radius": 5})),
fx("path.offsetPath", json!({"offset": -2})),
] {
let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap();
let (pieces, frame) = clips(&art);
assert_eq!(pieces.len(), 200, "{}", after.id);
assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id);
assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id);
}
// Two warps cut the image once, through both maps; a Transform after them moves the pieces.
let flag = fx("warp.flag", json!({"bend": 30, "version": 1}));
let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0}));
let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap();
assert_eq!(warp_test_images(&art).0, 200);
let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap();
assert_eq!(warp_test_images(&moved).0, 200);
let box1 = Rect::new(0.0, 0.0, 40.0, 40.0);
let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params));
// The flag bends the box the arc leaves, which the arc's frame measures.
let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap();
let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params));
// The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4).
for (art, by) in [(&art, 0.0), (&moved, 100.0)] {
let piece = first_image(art).unwrap();
for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0);
assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p);
}
}
}
/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it
/// bends the frame.
#[test]
fn an_images_pixels_take_the_map_their_warps_version_names() {
for version in [json!(null), json!(0), json!(1)] {
let mut params = json!({"bend": 50});
if !version.is_null() {
params["version"] = version.clone();
}
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = vec![fx("warp.arc", params.clone())];
let art = crate::reshape::reshape(None, &n, None).unwrap();
let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &params));
let piece = first_image(&art).unwrap();
for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}");
}
// The frame has an anchor where the map takes each corner.
let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap();
for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] {
let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6);
assert!(near, "{version}: {corner:?}");
}
}
}
/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's
/// bounds to where its image's pieces draw them.
#[test]
fn a_nested_groups_gradient_follows_its_warped_image() {
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]);
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed)));
let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default());
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]);
g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1})));
let art = crate::group::evaluate_container(None, &g).unwrap();
assert_eq!(warp_test_images(&art).0, 200);
let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None);
art.walk(&mut |c| {
if c.id == NodeId(5) && to.is_none() {
to = c.geometric_bounds();
}
});
let to = to.unwrap();
assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}");
let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height());
let mut checked = 0;
art.walk(&mut |c| {
if c.id != NodeId(5) {
return;
}
for item in &c.appearance.items {
let Paint::Gradient(gp) = item.paint() else { continue };
let got = gp.geom.unwrap();
assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}");
checked += 1;
}
});
assert!(checked > 0);
}
/// An image under a geometry effect other than a Warp draws as the image does: colorized by
/// a grayscale image's fill color, and smoothed or not.
#[test]
fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() {
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))];
n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
n.extras_mut().pixelated = true;
let art = crate::reshape::reshape(None, &n, None).unwrap();
let mut seen = vec![];
art.walk(&mut |c| {
if matches!(c.kind, NodeKind::Image(_)) {
seen.push((c.extras().colorize.clone(), c.extras().pixelated));
}
});
assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]);
}
/// What `effects` make of `path` within `budget`.
fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData {
apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget)
+34 -11
View File
@@ -1,25 +1,49 @@
//! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion.
//!
//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is
//! split into short pieces whose control points are mapped (see `map_nonlinear`).
//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]);
//! one without (saved before the version existed) is a map on normalized box coordinates
//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control
//! points are mapped (see `map_nonlinear`).
use serde_json::Value;
use vectorcraft_geom::{PathData, Point, Rect};
use crate::util::{map_nonlinear_within, num, text};
pub use vectorcraft_doc::live::{WarpStyle, warp_point};
pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version};
/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent.
pub fn map_version(p: &Value) -> u32 {
let v = num(p, "version", 0.0);
if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 }
}
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, warp_map(b, style, p))
}
/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each
/// point of a path to.
pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box<dyn Fn(Point) -> Point> {
let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0;
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical");
if uses_net(map_version(p)) {
let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical);
let (w, h) = (b.width(), b.height());
// A box with no width or no height takes the middle of the patch along that side.
return Box::new(move |q: Point| {
let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 });
b.origin() + net.at(u, v).to_vec2()
});
}
let c = b.center();
let hw = (b.width() / 2.0).max(1e-9);
let hh = (b.height() / 2.0).max(1e-9);
let f = |q: Point| {
Box::new(move |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
@@ -28,18 +52,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
warp_point(style, bend, dh, dv, hh / hw, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, f)
})
}
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
/// tests both sides of it).
/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as
/// none: the threshold must match the one in [`warp_point`]'s Arc case
/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any
/// bend moves the outline.
pub(crate) fn bends(id: &str, p: &Value) -> bool {
let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
let bend = share("bend", 50.0);
let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 };
bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
}
+2
View File
@@ -5,6 +5,7 @@ mod arrange;
mod cleanup;
mod color;
mod contour;
mod cutting;
mod document;
mod image;
mod pathtools;
@@ -23,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
v.extend(cleanup::specs());
v.extend(color::specs());
v.extend(contour::specs());
v.extend(cutting::specs());
v.extend(document::specs());
v.extend(image::specs());
v.extend(pathtools::specs());
+11
View File
@@ -0,0 +1,11 @@
//! 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`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialog are the UI's.
mod stencil;
use super::super::CommandSpec;
pub fn specs() -> Vec<CommandSpec> {
stencil::specs()
}
@@ -0,0 +1,167 @@
//! Stencil Bridges (`addon.cutting.bridges`), a command of the built-in Cutting Tools add-on: it
//! cuts bridges out of the selected art so that a stencil cut from it holds together. Each island
//! of the stencil sheet (the inside of an O, a hole in a shape) stays joined to the sheet by strips
//! of a set width across the art (<https://en.wikipedia.org/wiki/Stencil> describes islands and
//! bridges). The art becomes one compound path with the gaps cut, and type is measured by its
//! glyph outlines. The original goes to a new hidden layer or is replaced. The geometry is
//! `vectorcraft_pathops::addons::stencil`.
#[cfg(test)]
mod tests;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops::addons::stencil::{BRIDGE_WIDTH_RANGE, BridgeDirection, BridgeSpec, MAX_BRIDGES_PER_ISLAND, stencil_bridges};
use super::super::super::edit::selected_roots;
use super::super::super::object::choice;
use super::super::super::pathops::{length_given, node_path, pf_leaves, shape_node, style_of};
use super::super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or};
use crate::{EngineError, Result, Session};
/// Stencil Bridges' command (the id in [`specs`]).
pub const BRIDGES: &str = "addon.cutting.bridges";
/// The ways bridges run, as `direction` takes them.
pub const DIRECTIONS: [&str; 3] = ["vertical", "horizontal", "shortest"];
/// A bridge's width when none is given, in points.
pub const DEFAULT_WIDTH: f64 = 2.0;
/// The name of the hidden layer the original art goes to (numbered when it is taken).
const ORIGINAL_LAYER: &str = "Stencil Original";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.cutting.bridges",
"Stencil Bridges…",
["Object", "Plug-ins", "Cutting Tools"],
None,
"{width?: pt 0.01..1000 (2), count?: whole number 1..16 of bridges each island gets (1; fewer where it has no room and more where other islands are joined to the sheet through it), direction?: \"vertical\" | \"horizontal\" | \"shortest\" (the shortest crossing among 12 directions 15° apart) (\"vertical\"), keepOriginal?: true (the selected objects that make up the art go to a new hidden layer named \"Stencil Original\"; false: they are replaced)} cut bridges out of the selected art so that a stencil cut from it holds together: every island (an area the art encloses, such as the inside of an O or a hole in a shape) is joined to the area around the art by strips `width` wide across the art, the shortest crossings first, then spread around the island. The art is measured by the area its outlines enclose, whatever their paint (open paths closed as a fill closes them; type by its glyph outlines; groups by their members; hidden objects left out) and becomes one compound path with the gaps cut, painted as the topmost selected shape, in the place of the topmost selected object that has outlines. Selected objects with no outlines in the art stay where they are. Art with no islands, or none a bridge of the width fits, is left as it is, with a warning and no undo step; islands the cut result still encloses are counted in `unbridged`, with a warning. Art that reaches more than 10000000 pt from the origin, has more than 200000 anchors, whose combined area has more than 4000 outlines, whose outlines need more than 200000 straight segments, or that takes more work to measure than a fixed budget allows is refused. One undo step; the new path is selected → {id, islands, bridges, unbridged, original (the hidden layer's id, or null), warning?}",
has_selection,
bridges
)]
}
fn bridges(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = BRIDGES;
let width = length_given(p, "width", C)?.unwrap_or(DEFAULT_WIDTH);
let (lo, hi) = BRIDGE_WIDTH_RANGE;
if !(lo..=hi).contains(&width) {
return Err(bad(C, format!("`width` must be from {lo} to {hi} pt")));
}
let count = number_or(p, "count", 1.0, C)?;
if count.fract() != 0.0 || !(1.0..=MAX_BRIDGES_PER_ISLAND as f64).contains(&count) {
return Err(bad(C, format!("`count` must be a whole number from 1 to {MAX_BRIDGES_PER_ISLAND}")));
}
let direction = match choice(p, C, "direction", &DIRECTIONS)?.unwrap_or("vertical") {
"horizontal" => BridgeDirection::Horizontal,
"shortest" => BridgeDirection::Shortest,
_ => BridgeDirection::Vertical,
};
let keep = switch_or(p, "keepOriginal", true, C)?;
let selected = selected_roots(s)?;
let (bridged, style, roots) = {
let d = &s.doc()?.doc;
// The art's outlines, the selected objects they come from, and the topmost object that
// has some, whose paint the result takes.
let mut parts: Vec<(PathData, FillRule)> = vec![];
let mut roots = vec![];
let mut top = None;
for id in selected {
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
let mut leaves = vec![];
shown_leaves(d, n, &mut leaves);
let before = parts.len();
for leaf in leaves {
if let Some(part) = node_path(&leaf).filter(|(p, _)| !p.is_empty()) {
parts.push(part);
top = Some(leaf);
}
}
if parts.len() > before {
roots.push(id);
}
}
let Some(top) = top else {
let label = "Stencil Bridges";
return Err(EngineError::Other(format!("{label}: select paths, compound paths, groups or text")));
};
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
let spec = BridgeSpec { width, count: count as usize, direction };
let bridged = stencil_bridges(&refs, spec).map_err(|e| EngineError::Other(e.to_string()))?;
(bridged, style_of(&top), roots)
};
let summary = |id: Option<NodeId>, original: Option<NodeId>| {
json!({
"id": id.map(|i| i.0), "islands": bridged.islands, "bridges": bridged.bridges, "unbridged": bridged.unbridged,
"original": original.map(|i| i.0),
})
};
if bridged.islands == 0 {
let mut r = summary(None, None);
// The warnings are written as `EngineError::Other` so that the i18n message scan
// (`untranslated_report`) finds them.
r["warning"] = json!(EngineError::Other("The selected art has no islands to bridge".into()).to_string());
return Ok(r);
}
if bridged.bridges == 0 {
let mut r = summary(None, None);
r["warning"] = json!(EngineError::Other("No bridge of this width fits across the selected art".into()).to_string());
return Ok(r);
}
let path = bridged.path.clone();
let (id, original) = s.edit("Stencil Bridges", |d, sel| {
let mut made = shape_node(d, path, None);
style.apply(&mut made);
let mut taken = vec![];
let id = d.wrap(&roots, |nodes| {
taken = nodes;
made
})?;
let original = if keep { Some(hide_originals(d, taken)?) } else { None };
sel.set([id]);
Ok((id, original))
})?;
let mut r = summary(Some(id), original);
if bridged.unbridged > 0 {
r["warning"] = json!(EngineError::Other("Some islands could not be joined to the area around the art".into()).to_string());
}
Ok(r)
}
/// The leaf shapes of `n` as [`pf_leaves`] gives them, leaving out hidden objects and the members
/// of hidden groups.
fn shown_leaves(d: &Document, n: &Node, out: &mut Vec<Node>) {
if !n.visible {
return;
}
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => {
for c in children {
shown_leaves(d, c, out);
}
}
_ => pf_leaves(d, n, out),
}
}
/// Put `nodes` in a new hidden layer above the others, in their order, and return its id.
fn hide_originals(d: &mut Document, nodes: Vec<Arc<Node>>) -> Result<NodeId> {
let mut taken = std::collections::HashSet::new();
d.walk(|n| {
if n.is_layer() {
taken.extend(n.name.clone());
}
});
let name = std::iter::once(ORIGINAL_LAYER.to_string())
.chain((2..).map(|i| format!("{ORIGINAL_LAYER} {i}")))
.find(|name| !taken.contains(name))
.unwrap_or_else(|| ORIGINAL_LAYER.to_string());
let layer = d.add_layer(Some(&name));
for (i, n) in nodes.into_iter().enumerate() {
d.insert(Some(layer), i, Arc::unwrap_or_clone(n))?;
}
d.node_mut(layer).ok_or(EngineError::NoNode(layer))?.visible = false;
Ok(layer)
}
@@ -0,0 +1,328 @@
//! Tests for `addon.cutting.bridges`: bridges cut out of the selected art keep a stencil's islands
//! attached.
use kurbo::Shape;
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use super::super::super::super::pathops::node_path;
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
/// `outer` less each of `holes` (x, y, width, height), as one compound path, selected.
fn shape_with_holes(s: &mut Session, outer: (f64, f64, f64, f64), holes: &[(f64, f64, f64, f64)]) -> NodeId {
let mut ids = vec![rect(s, outer.0, outer.1, outer.2, outer.3)];
for h in holes {
ids.push(rect(s, h.0, h.1, h.2, h.3));
}
select(s, &ids);
let r = s.execute("object.pathfinder.minusFront", &json!({})).unwrap();
let id = NodeId(r["ids"][0].as_u64().unwrap());
select(s, &[id]);
id
}
/// A square O: 100 pt across at (100, 100), with bars 20 pt thick.
fn letter_o(s: &mut Session) -> NodeId {
shape_with_holes(s, (100.0, 100.0, 100.0, 100.0), &[(120.0, 120.0, 60.0, 60.0)])
}
fn id_of(r: &Value) -> NodeId {
NodeId(r["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
/// Is `(x, y)` inside the area object `id` fills?
fn fills(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
let (path, rule) = node_path(&node(s, id)).unwrap();
let w = path.to_bezpath().winding(kurbo::Point::new(x, y));
match rule {
vectorcraft_geom::FillRule::EvenOdd => w % 2 != 0,
vectorcraft_geom::FillRule::NonZero => w != 0,
}
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn an_o_gets_a_bridge_and_the_original_goes_to_a_hidden_layer() {
let mut s = session();
let o = letter_o(&mut s);
let fill = node(&s, o).appearance.fill_paint();
let steps = undo_steps(&s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)));
assert!(r.get("warning").is_none(), "{r}");
let made = id_of(&r);
assert_eq!(s.doc().unwrap().selection.objects, vec![made]);
assert_eq!(undo_steps(&s), steps + 1, "one undo step");
// The bridge crosses the top bar in the middle; the rest of the O stays, painted as it was.
assert!(!fills(&s, made, 150.0, 110.0));
assert!(fills(&s, made, 130.0, 110.0) && fills(&s, made, 150.0, 190.0) && fills(&s, made, 110.0, 150.0));
assert_eq!(node(&s, made).appearance.fill_paint(), fill);
// The original, unchanged, is on a new hidden layer.
let layer = NodeId(r["original"].as_u64().unwrap());
let l = node(&s, layer);
assert_eq!((l.name.as_deref(), l.visible), (Some("Stencil Original"), false));
let held = s.doc().unwrap().doc.children(Some(layer)).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>());
assert_eq!(held, Some(vec![o]));
// The island is joined to the sheet: the result has none left.
let again = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!(again["islands"], json!(0));
assert!(again["warning"].as_str().is_some_and(|w| w.contains("no islands")), "{again}");
assert_eq!(undo_steps(&s), steps + 1, "no islands: no undo step");
// Undo puts the O back where it was and takes the layer away.
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(layer).is_none() && d.node(made).is_none() && d.node(o).is_some_and(|n| n.visible));
}
#[test]
fn keep_original_off_replaces_the_art() {
let mut s = session();
let o = letter_o(&mut s);
let layers = s.doc().unwrap().doc.layers.len();
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap();
assert_eq!(r["original"], Value::Null);
let d = &s.doc().unwrap().doc;
assert!(d.node(o).is_none() && d.node(id_of(&r)).is_some());
assert_eq!(d.layers.len(), layers);
}
#[test]
fn the_bridge_is_as_wide_as_asked() {
for width in [2.0, 8.0] {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": width})).unwrap();
let made = id_of(&r);
let half = width / 2.0;
assert!(!fills(&s, made, 150.0 - half * 0.9, 110.0) && !fills(&s, made, 150.0 + half * 0.9, 110.0), "{width}");
assert!(fills(&s, made, 150.0 - half * 1.2, 110.0) && fills(&s, made, 150.0 + half * 1.2, 110.0), "{width}");
}
}
#[test]
fn the_width_takes_units() {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": "2 mm"})).unwrap();
let made = id_of(&r);
// 2 mm is about 5.67 pt.
assert!(!fills(&s, made, 152.5, 110.0) && fills(&s, made, 154.0, 110.0));
}
#[test]
fn the_shortest_crossing_takes_the_thinnest_bar() {
let mut s = session();
// The left bar is 5 pt thick, the others 20.
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(105.0, 120.0, 75.0, 60.0)]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "shortest"})).unwrap();
let made = id_of(&r);
assert!(!fills(&s, made, 102.5, 150.0), "the bridge crosses the thin bar");
assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 190.0, 150.0));
}
#[test]
fn horizontal_bridges_cross_the_sides() {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "direction": "horizontal"})).unwrap();
let made = id_of(&r);
assert!(!fills(&s, made, 110.0, 150.0) || !fills(&s, made, 190.0, 150.0));
assert!(fills(&s, made, 150.0, 110.0) && fills(&s, made, 150.0, 190.0));
}
#[test]
fn a_shape_with_two_holes_gets_a_bridge_for_each() {
let mut s = session();
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 50.0), &[(110.0, 110.0, 30.0, 30.0), (160.0, 110.0, 30.0, 30.0)]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 3, "count": 2})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(4), json!(0)));
let made = id_of(&r);
for x in [125.0, 175.0] {
assert!(!fills(&s, made, x, 105.0) && !fills(&s, made, x, 145.0), "{x}: a bridge above and below each hole");
}
}
#[test]
fn type_is_bridged_by_its_glyph_outlines_and_stays_type() {
let mut s = session();
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "O", "size": 144})).unwrap()["id"].as_u64().unwrap());
select(&mut s, &[t]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 3})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(1), json!(0)), "{r}");
assert!(matches!(node(&s, id_of(&r)).kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
// The type itself is not converted: it is on the hidden layer, still type.
assert!(matches!(node(&s, t).kind, NodeKind::Text(_)));
assert!(!node(&s, NodeId(r["original"].as_u64().unwrap())).visible);
}
#[test]
fn the_result_is_painted_as_the_topmost_object_with_outlines() {
let mut s = session();
let o = letter_o(&mut s);
// Type holding only a space draws no outlines, so it does not set the paint, though it is on top.
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap());
select(&mut s, &[o]);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let red = node(&s, o).appearance.fill_paint();
select(&mut s, &[o, t]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!(node(&s, id_of(&r)).appearance.fill_paint(), red);
}
#[test]
fn hidden_members_of_a_group_are_left_out() {
let mut s = session();
let o = letter_o(&mut s);
// A hidden square that would fill the O's hole, grouped with it.
let cover = rect(&mut s, 120.0, 120.0, 60.0, 60.0);
select(&mut s, &[o, cover]);
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
select(&mut s, &[cover]);
s.execute("object.hide", &json!({})).unwrap();
select(&mut s, &[g]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone()), (json!(1), json!(1)), "{r}");
}
#[test]
fn art_without_islands_changes_nothing() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
select(&mut s, &[a]);
let (steps, count) = (undo_steps(&s), s.doc().unwrap().doc.node_count());
let r = s.execute("addon.cutting.bridges", &json!({})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["id"].clone()), (json!(0), json!(0), Value::Null));
assert!(r["warning"].as_str().is_some(), "{r}");
assert_eq!((undo_steps(&s), s.doc().unwrap().doc.node_count()), (steps, count));
}
#[test]
fn an_island_too_narrow_for_the_width_is_reported() {
let mut s = session();
shape_with_holes(&mut s, (100.0, 100.0, 100.0, 100.0), &[(149.0, 120.0, 2.0, 60.0)]);
let steps = undo_steps(&s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(1), json!(0), json!(1)));
assert!(r["warning"].as_str().is_some_and(|w| w.contains("No bridge")), "{r}");
assert_eq!(undo_steps(&s), steps);
}
#[test]
fn islands_left_without_a_bridge_are_reported_beside_the_ones_bridged() {
let mut s = session();
// A wide hole on the left and a 2 pt slot on the right.
shape_with_holes(&mut s, (100.0, 100.0, 200.0, 100.0), &[(120.0, 120.0, 60.0, 60.0), (249.0, 120.0, 2.0, 60.0)]);
let steps = undo_steps(&s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
assert_eq!((r["islands"].clone(), r["bridges"].clone(), r["unbridged"].clone()), (json!(2), json!(1), json!(1)));
assert!(r["warning"].as_str().is_some_and(|w| w.contains("could not be joined")), "{r}");
assert_eq!(undo_steps(&s), steps + 1);
assert!(!fills(&s, id_of(&r), 150.0, 110.0), "the wide hole is bridged");
}
#[test]
fn each_run_keeps_its_original_on_a_layer_of_its_own() {
let mut s = session();
letter_o(&mut s);
let first = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
shape_with_holes(&mut s, (300.0, 100.0, 100.0, 100.0), &[(320.0, 120.0, 60.0, 60.0)]);
let second = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap();
let name = |r: &Value| node(&s, NodeId(r["original"].as_u64().unwrap())).name;
assert_eq!((name(&first).as_deref(), name(&second).as_deref()), (Some("Stencil Original"), Some("Stencil Original 2")));
}
#[test]
fn bad_params_are_refused() {
let mut s = session();
letter_o(&mut s);
let steps = undo_steps(&s);
for (key, p) in [
("width", json!({"width": 0})),
("width", json!({"width": 5000})),
("width", json!({"width": "wide"})),
("count", json!({"count": 0})),
("count", json!({"count": 1.5})),
("count", json!({"count": 17})),
("count", json!({"count": "two"})),
("direction", json!({"direction": "diagonal"})),
("direction", json!({"direction": 90})),
("keepOriginal", json!({"keepOriginal": "yes"})),
] {
let e = s.execute("addon.cutting.bridges", &p).unwrap_err().to_string();
assert!(e.contains(key), "{p}: {e}");
}
assert_eq!(undo_steps(&s), steps);
assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run");
}
#[test]
fn selected_objects_without_outlines_stay_where_they_are() {
for keep in [true, false] {
let mut s = session();
let o = letter_o(&mut s);
// Type holding only a space has no outlines.
let t = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": " ", "size": 24})).unwrap()["id"].as_u64().unwrap());
let layer = |s: &Session, id: NodeId| s.doc().unwrap().doc.layer_of(id);
let before = layer(&s, t);
select(&mut s, &[o, t]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": keep})).unwrap();
assert_eq!(r["bridges"], json!(1), "{r}");
assert!(node(&s, t).visible && layer(&s, t) == before, "keep {keep}: the type stays where it was");
if keep {
let held =
s.doc().unwrap().doc.children(Some(NodeId(r["original"].as_u64().unwrap()))).map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>());
assert_eq!(held, Some(vec![o]));
}
}
}
#[test]
fn the_result_takes_the_place_of_the_topmost_selected_object() {
let mut s = session();
let o = letter_o(&mut s);
let between = rect(&mut s, 400.0, 100.0, 50.0, 50.0);
let o2 = shape_with_holes(&mut s, (500.0, 100.0, 100.0, 100.0), &[(520.0, 120.0, 60.0, 60.0)]);
let above = rect(&mut s, 400.0, 300.0, 50.0, 50.0);
select(&mut s, &[o, o2]);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "keepOriginal": false})).unwrap();
let made = id_of(&r);
let d = &s.doc().unwrap().doc;
let order: Vec<NodeId> = d.children(d.layer_of(made)).unwrap().iter().map(|n| n.id).collect();
let at = |id: NodeId| order.iter().position(|i| *i == id).unwrap();
assert!(at(between) < at(made) && at(made) < at(above), "{order:?}");
}
#[test]
fn the_result_is_one_path_when_one_outline_is_left() {
for (count, plain) in [(1, true), (2, false)] {
let mut s = session();
letter_o(&mut s);
let r = s.execute("addon.cutting.bridges", &json!({"width": 4, "count": count})).unwrap();
let kind = node(&s, id_of(&r)).kind;
// One bridge leaves a C, one outline; two leave two pieces.
assert_eq!(matches!(kind, NodeKind::Path { .. }), plain, "count {count}");
assert_eq!(matches!(kind, NodeKind::Compound { .. }), !plain, "count {count}");
}
}
+7 -4
View File
@@ -21,7 +21,7 @@ use super::super::menucmds::whole_or;
use super::super::object::choice;
use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges};
use super::super::typecmd::refresh_bounds;
use super::super::typemenu::text_ids;
use super::super::typemenu::{bake_dynamic_text, text_ids};
use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets};
use crate::{EngineError, Result, Session};
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release Type on a Path…",
["Object", "Plug-ins", "Type Tools"],
None,
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
has_selection,
release_path
),
@@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
/// `t`, type on a path, as point type on one straight line: the same runs and character and
/// paragraph attributes, its baseline starting where its first character sat on the path (in the
/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for
/// other type, or when it can't be placed.
/// object's own text space, so its transform stays). Type on a Path Options are dropped, and
/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it
/// can't be placed.
fn released_text(t: &TextObject) -> Option<TextObject> {
let TextKind::OnPath { path, .. } = &t.kind else { return None };
let db = vectorcraft_text::FontDb::global();
@@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option<TextObject> {
(p.x.is_finite() && p.y.is_finite()).then_some(p)
})?;
let mut out = t.clone();
// Point type has no brackets to fit: it keeps the sizes the path drew it at.
bake_dynamic_text(&mut out);
out.kind = TextKind::Point;
out.path_effect = Default::default();
out.path_align = Default::default();
+8 -5
View File
@@ -32,8 +32,8 @@ use super::opacitymask::percent;
use super::paint::paint_from;
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
use super::{
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets,
ok, paint_targets, str_param, switch_or, targets,
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets,
object_param, ok, paint_targets, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -726,8 +726,8 @@ fn clear(n: &mut Node) {
fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.transfer";
let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?;
let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?;
if source == target {
return Err(bad(C, "`source` and `target` are the same object"));
}
@@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
/// Sample enabled attributes with transform-aware character dimensions and atomic validation.
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
let defaults_only = switch_or(p, "defaultsOnly", false, C)?;
if defaults_only && (reverse || append || p.get("ids").is_some()) {
@@ -1512,6 +1512,9 @@ impl Picked {
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
// The space before a line's first character belongs to where the character is,
// not to how it looks: a sample from one doesn't move the target's (#5111).
kerning_before: st.kerning_before,
..cs.clone()
};
}
+29 -1
View File
@@ -11,7 +11,9 @@ use std::collections::HashSet;
use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation};
use vectorcraft_brush::{
self as brush, ArtBrush, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation,
};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes};
@@ -169,6 +171,7 @@ pub struct BuiltinBrushLibrary {
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes },
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
@@ -290,6 +293,31 @@ fn calligraphic_nibs() -> BrushLibrary {
built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes)
}
/// A bristle brush: `shape`, then size (pt), bristle length, density, thickness, paint opacity
/// and stiffness (%).
fn bristle(name: &str, shape: BristleShape, [size, length, density, thickness, opacity, stiffness]: [f64; 6]) -> Brush {
Brush { name: name.into(), kind: BrushKind::Bristle(Bristle { shape, size, length, density, thickness, opacity, stiffness }) }
}
/// Bristle Brushes: one bristle brush for each tip shape, from a fine liner to wide fans for
/// blending and texture.
fn bristle_brushes() -> BrushLibrary {
use BristleShape::*;
let brushes = vec![
bristle("Fine Liner", RoundPoint, [2.0, 140.0, 30.0, 30.0, 90.0, 75.0]),
bristle("Pointed Flat", FlatPoint, [5.0, 120.0, 55.0, 35.0, 80.0, 60.0]),
bristle("Stubby Round", RoundBlunt, [8.0, 60.0, 70.0, 50.0, 85.0, 80.0]),
bristle("Dry Scrub", FlatBlunt, [10.0, 40.0, 25.0, 60.0, 60.0, 95.0]),
bristle("Soft Dome", RoundCurve, [12.0, 160.0, 85.0, 20.0, 45.0, 25.0]),
bristle("Filbert", FlatCurve, [9.0, 110.0, 65.0, 40.0, 70.0, 45.0]),
bristle("Angled Round", RoundAngle, [7.0, 130.0, 50.0, 35.0, 75.0, 55.0]),
bristle("Angled Flat", FlatAngle, [11.0, 100.0, 60.0, 45.0, 80.0, 65.0]),
bristle("Fan Blender", RoundFan, [16.0, 220.0, 40.0, 15.0, 35.0, 20.0]),
bristle("Grass Fan", FlatFan, [20.0, 260.0, 15.0, 25.0, 65.0, 35.0]),
];
built("Bristle Brushes", Document::new(100.0, 100.0), brushes)
}
fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush {
let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() });
Brush { name: name.into(), kind }
+11 -2
View File
@@ -34,9 +34,9 @@ mod symbols;
pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint};
use brushes::{
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art,
brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush,
brush_remove, brush_replace_art, brush_selection_art, brush_set_current, brush_unused, expand_brush,
};
pub(crate) use dynamic::{edit_part, part_only, selected_part};
pub(crate) use dynamic::{edit_part, fit_parts, part_only, selected_part};
use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance};
use symbols::selected_instances;
pub(crate) use symbols::{
@@ -76,6 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_place_art
),
cmd!(
"brush.replaceArt",
"Replace Brush Art",
[],
None,
"{name, ids?|id?, applyToStrokes?: true} make the selected art (several objects become one group, as in brush.new) the art of a scatter, art or pattern brush, keeping its other options, as Alt-dragging (Option-dragging on macOS) art onto the brush in the Brushes panel does; a pattern brush takes a group named for one of its tiles, or a group of such groups (as brush.placeArt places them), tile by tile (of two named for one tile, the frontmost), each with the repeat frame an unpainted rectangle at its back marks (other tiles stay as they are), and other art as its side tile; when strokes use the brush and applyToStrokes is false, the brush and its strokes stay as they were and a copy of the brush with the new art, named `<name> copy`, goes after it and becomes the current brush; otherwise the strokes redraw with the new art; one undo step; refused for a calligraphic or bristle brush, with nothing selected, and for art (or a tile) with no width → {name: the brush that has the new art}",
has_doc,
brush_replace_art
),
cmd!(
"brush.apply",
"Apply Brush",
+139 -4
View File
@@ -239,7 +239,7 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
&& v.get(k).is_none()
{
let art = selection_art(s, p)?.ok_or_else(|| bad(C, format!("{ty} brushes need selected art (or params.{k})")))?;
if art.geometric_bounds().is_none_or(|b| b.width() <= 1e-6) {
if !has_width(&art, &s.doc()?.doc.symbol_boxes()) {
return Err(bad(C, "the selected art has no width"));
}
v[k] = serde_json::to_value(&art).unwrap_or_default();
@@ -249,6 +249,12 @@ pub(super) fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name }))
}
/// Whether art `n` has width for a brush to be made of, with symbol instances measured by their
/// symbols' art in `symbols`.
fn has_width(n: &Node, symbols: &vectorcraft_doc::SymbolBoxes) -> bool {
n.geometric_bounds_with(symbols).is_some_and(|b| b.width() > 1e-6)
}
/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters.
fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> {
let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS);
@@ -367,6 +373,12 @@ fn art_brushes(n: &Node, out: &mut Vec<String>) {
pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let lib = brush::library(d);
let names = brush_unused_in(d, &lib);
Ok(json!({ "names": names }))
}
/// The brushes of `lib` no stroke in `d` paints with, in library order ([`brush_unused`]).
fn brush_unused_in(d: &Document, lib: &[Brush]) -> Vec<String> {
let mut todo = vec![];
let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art));
let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()));
@@ -379,8 +391,7 @@ pub(super) fn brush_unused(s: &mut Session, _: &Value) -> Result<Value> {
used.insert(name);
}
}
let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect();
Ok(json!({ "names": names }))
lib.iter().map(|b| b.name.clone()).filter(|n| !used.contains(n)).collect()
}
pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
@@ -484,7 +495,7 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it
/// places them.
/// places them; `brush.replaceArt` reads a group of that name as that tile.
const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"];
/// The space between the pattern brush tiles `brush.placeArt` places, in points.
@@ -506,6 +517,18 @@ fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_
}
}
/// Gives tile `i` of `p` ([`TILE_NAMES`] order) the art `art` and the repeat frame `frame`. The tile
/// no longer comes from a pattern swatch, and a corner draws its own art.
fn set_tile(p: &mut brush::PatternBrush, i: usize, art: Node, frame: Option<vectorcraft_geom::Rect>) {
match i {
0 => (p.side, p.side_frame, p.side_swatch) = (art, frame, None),
1 => (p.outer_corner, p.outer_corner_frame, p.outer_corner_swatch, p.outer_source) = (Some(art), frame, None, None),
2 => (p.inner_corner, p.inner_corner_frame, p.inner_corner_swatch, p.inner_source) = (Some(art), frame, None, None),
3 => (p.start, p.start_frame, p.start_swatch) = (Some(art), frame, None),
_ => (p.end, p.end_frame, p.end_swatch) = (Some(art), frame, None),
}
}
/// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art
/// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile
/// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame
@@ -570,6 +593,118 @@ pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0 }))
}
/// The repeat frame an unpainted rectangle at the back of a tile group marks ([`placed_art`]): the
/// rectangle's bounds, when `n` is a path of one closed subpath through the four corners of its
/// bounds, without curves, whose fills and strokes paint nothing and that has no other appearance.
fn frame_marker(n: &Node) -> Option<vectorcraft_geom::Rect> {
let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return None };
let unpainted = n.appearance.effects.is_empty()
&& n.appearance.items.iter().all(|i| match i {
AppearanceItem::Fill(f) => f.paint.is_none(),
AppearanceItem::Stroke(st) => st.paint.is_none(),
});
let [sp] = path.subpaths.as_slice() else { return None };
let r = n.geometric_bounds()?;
let corners = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)];
let at_corners = sp.anchors.len() == 4
&& sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p)
&& corners.iter().all(|c| sp.anchors.iter().any(|a| (a.p.x, a.p.y) == *c));
(unpainted && sp.closed && at_corners && r.width() > 1e-9 && r.height() > 1e-9).then_some(r)
}
/// The tiles in `art` for a pattern brush, by tile ([`TILE_NAMES`] order), with their repeat frames:
/// a group named for a tile, as `brush.placeArt` places them, is the art of its tile (an unpainted
/// rectangle at its back marks the tile's frame, [`frame_marker`], and is left out of the art; a
/// group left with one member gives that member), and so is each member of a group whose members
/// are all such groups; any other art is the side tile.
fn tiles_of(art: Node) -> Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> {
let named = |c: &Node| matches!(c.kind, NodeKind::Group { clip: false, .. }) && c.name.as_deref().is_some_and(|n| TILE_NAMES.contains(&n));
let tiles = match &art.kind {
_ if named(&art) => vec![Arc::new(art)],
NodeKind::Group { children, clip: false } if !children.is_empty() && children.iter().all(|c| named(c)) => children.clone(),
_ => return vec![(0, art, None)],
};
let mut out: Vec<(usize, Node, Option<vectorcraft_geom::Rect>)> = vec![];
for t in tiles {
let Some(i) = t.name.as_deref().and_then(|n| TILE_NAMES.iter().position(|x| *x == n)) else { continue };
let mut g = (*t).clone();
let mut frame = None;
if let NodeKind::Group { children, .. } = &mut g.kind
&& let Some(f) = children.first().and_then(|c| frame_marker(c))
{
frame = Some(f);
children.remove(0);
}
g.name = None;
// The group `brush.placeArt` put around the tile's art: the art itself goes back, so a
// tile does not gain a group each time it goes out and back.
if let NodeKind::Group { children, clip: false } = &g.kind
&& let [only] = children.as_slice()
&& g == Node::group(g.id, children.clone())
{
g = (**only).clone();
}
// A tile named twice: the frontmost one counts.
out.retain(|(j, ..)| *j != i);
out.push((i, g, frame));
}
out
}
/// `brush.replaceArt`: the selected art (or `ids`/`id`; several objects become one group, as in
/// `brush.new`) becomes the art of scatter, art or pattern brush `name`, keeping its other options,
/// as one undo step. A pattern brush takes the tiles `brush.placeArt` places by their names
/// ([`tiles_of`]) and other art as its side tile. When strokes use the brush and `applyToStrokes` is
/// false, the brush and its strokes stay as they were and a copy of the brush with the new art is
/// added after it and becomes the current brush; otherwise the strokes redraw with the new art.
pub(super) fn brush_replace_art(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.replaceArt";
let name = name_param(p, C)?.to_string();
let apply = super::switch_or(p, "applyToStrokes", true, C)?;
let mut lib = brush::library(&s.doc()?.doc);
let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
if lib[i].art().is_empty() {
return Err(bad(C, no_art(&name)));
}
let art = selection_art(s, p)?.ok_or_else(|| bad(C, "nothing selected"))?;
let d = &s.doc()?.doc;
let symbols = d.symbol_boxes();
let check = |n: &Node| if has_width(n, &symbols) { Ok(()) } else { Err(bad(C, "the selected art has no width")) };
let mut b = lib[i].clone();
match &mut b.kind {
BrushKind::Scatter(sc) => {
check(&art)?;
sc.art = art;
}
BrushKind::Art(a) => {
check(&art)?;
a.art = art;
}
BrushKind::Pattern(pb) => {
for (t, art, frame) in tiles_of(art) {
check(&art)?;
set_tile(pb, t, art, frame);
}
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return Err(bad(C, no_art(&name))),
}
let used = !brush_unused_in(d, &lib).contains(&name);
if apply || !used {
lib[i] = b;
save_library(s, "Replace Brush Art", lib, &[])?;
return Ok(json!({ "name": name }));
}
b.name = brush::unique_name(&lib, &format!("{} copy", name.trim()));
let copy = b.name.clone();
lib.insert(i + 1, b);
s.edit("Replace Brush Art", |d, _| {
d.unknown.insert(brush::CURRENT_KEY.into(), json!(copy));
brush::store(d, &lib);
Ok(())
})?;
Ok(json!({ "name": copy }))
}
pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> {
let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?);
let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect();
+34 -9
View File
@@ -2,7 +2,7 @@
//! appearance of objects in the symbol's art and stays linked to the symbol
//! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance
//! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still
//! there ([`fit_overrides`]).
//! there and draws them ([`fit_overrides`]).
use std::sync::Arc;
@@ -27,7 +27,7 @@ pub(super) fn type_id(dynamic: bool) -> &'static str {
}
/// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or
/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art.
/// missing) symbol keeps none, an instance of a dynamic one those its art still draws ([`fit_parts`]).
pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) {
for id in ids {
let art = match d.node(*id) {
@@ -40,7 +40,7 @@ pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) {
let Some(n) = d.node_mut(*id) else { continue };
let mut e = n.extras_mut();
match art {
Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art),
Some(art) => fit_parts(&mut e.symbol_overrides, &art),
None => e.symbol_overrides.clear(),
}
}
@@ -177,17 +177,42 @@ fn check_paint_kinds(p: &Value, art: &Node, path: &[u32], cmd: &str) -> Result<(
fn solid_fill_check(p: &Value, art: &Node, path: &[u32], fill: Option<&vectorcraft_color::Paint>, cmd: &str) -> Result<()> {
let asked = p.get("fill").is_some_and(|v| !v.is_null());
let is_instance = symbol_overrides::part(art, path).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. }));
let draws_nothing = |f: &vectorcraft_color::Paint| match f {
vectorcraft_color::Paint::Gradient(_) => true,
vectorcraft_color::Paint::Pattern { .. } => true,
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => false,
};
if asked && is_instance && fill.is_some_and(draws_nothing) {
if asked && is_instance && fill.is_some_and(|f| !stains(f)) {
return Err(bad(cmd, "fill on a symbol instance must be a solid color or none"));
}
Ok(())
}
/// Can a symbol instance's fill be `f`? An instance stains its art with a solid fill only.
fn stains(f: &vectorcraft_color::Paint) -> bool {
match f {
vectorcraft_color::Paint::Gradient(_) => false,
vectorcraft_color::Paint::Pattern { .. } => false,
vectorcraft_color::Paint::None | vectorcraft_color::Paint::Solid { .. } => true,
}
}
/// Keep the appearance changes `overrides` that name an object of `art` (a symbol's new art), each
/// without a fill or stroke that object does not draw ([`paint_refusal`], [`stains`]); a change
/// left with nothing in it is dropped.
pub(crate) fn fit_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
symbol_overrides::retain_parts(overrides, art);
for o in overrides.iter_mut() {
let Some(n) = symbol_overrides::part(art, &o.path) else { continue };
let instance = matches!(n.kind, NodeKind::SymbolInstance { .. });
if paint_refusal(n, "fill", FIT).is_some() || (instance && o.fill.as_ref().is_some_and(|f| !stains(f))) {
o.fill = None;
}
if paint_refusal(n, "stroke", FIT).is_some() {
o.stroke = None;
}
}
overrides.retain(|o| !o.is_empty());
}
/// The command name [`fit_parts`] gives [`paint_refusal`], whose refusal it does not show.
const FIT: &str = "symbol.update";
/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or
/// an object of `paint.setFill`'s paint params sets it.
fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<Option<vectorcraft_color::Paint>>> {
+2 -2
View File
@@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids,
number_or, point_param, str_param, switch_or,
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or,
object_param, point_param, str_param, switch_or,
};
mod live_paint;
@@ -1,7 +1,7 @@
use super::{
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point,
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json,
is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint,
json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
};
/// `options` as stored on a Live Paint group: none for the defaults.
@@ -303,7 +303,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
};
crate::cmd::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let group = id_param(p, "group");
let group = object_param(p, "group", C)?;
let roots = if group.is_none() { root_ids(s, p)? } else { vec![] };
let (g, face) = s.edit("Live Paint Bucket", |d, sel| {
let gid = match group {
@@ -367,7 +367,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "livePaint.strokeEdge";
let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?;
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?;
crate::cmd::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
@@ -390,7 +390,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?;
let g = match object_param(p, "group", "livePaint.info")? {
Some(g) => g,
None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?,
};
let d = &s.doc()?.doc;
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?;
let faces: Vec<Value> = b::faces(n)
+3 -1
View File
@@ -783,12 +783,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
/// with Line-end Punctuation Half Width and Standard burasagari. Imported text keeps its own.
/// with Line-end Punctuation Half Width and Standard burasagari, and Korean words kept whole
/// (Korean Line Break Anywhere off). Imported text keeps its own.
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
vectorcraft_doc::ParaStyle {
justify: vectorcraft_doc::Justify::Auto,
mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf,
burasagari: vectorcraft_doc::Burasagari::Standard,
korean_line_break_anywhere: false,
..Default::default()
}
}
+5 -5
View File
@@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project
use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots};
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param,
matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param,
named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool {
fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.set";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?);
if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 {
@@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.copy";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let t = f64_req(p, "t", C)?.clamp(0.0, 1.0);
// When the profile comes out as it was (the copy replaces a side of a discontinuous point that has
// its widths already), the document and its undo history stay as they are.
@@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthPoint.remove";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let indices = match (p.get("indices"), p.get("index")) {
(Some(a), _) => point_indices(a),
(None, Some(i)) => point_indices(&json!([i])),
+2 -2
View File
@@ -18,8 +18,8 @@ use super::create::{add_look, insert_look};
use super::newart::NewArt;
use super::pathops::{Beside, remove_each};
use super::{
CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param,
switch_or,
CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok,
str_param, switch_or,
};
use crate::EngineError;
+5 -5
View File
@@ -1,7 +1,7 @@
use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound};
use super::{
Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2,
add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or,
add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or,
};
// ---------- Curvature ----------
@@ -41,7 +41,7 @@ enum CurvatureOp {
pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
check_numbers(p, &["x", "y", "t"], C)?;
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
@@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.removeAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let curvature = switch_or(p, "curvature", false, C)?;
@@ -220,7 +220,7 @@ fn remove_anchors_of(
pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
@@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reshapeSegment";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95);
+2 -2
View File
@@ -4,7 +4,7 @@ use super::common::{
};
use super::{
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath,
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po,
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
};
/// Split a filled path into connected pieces (each outer contour with its holes).
@@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn(
pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.split";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let anchor = usize_opt(p, "anchor", C)?;
let seg = usize_opt(p, "segment", C)?;
+5 -4
View File
@@ -4,7 +4,8 @@ use super::common::{
};
use super::{
Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point,
Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or,
Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param,
switch_or,
};
// ---------- Pencil / Paintbrush ----------
@@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let keep_selected = switch_or(p, "keepSelected", true, C)?;
let filled = switch_or(p, "fill", false, C)?;
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?;
let at_start = str_param(ext, "end") == Some("start");
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
@@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
// open subpath, that subpath starting at the end the stroke ends on, it ends at its end).
let connect = match p.get("connect").filter(|v| !v.is_null()) {
Some(cn) => {
let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?;
let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?;
if other == id {
return Err(bad(C, "connect needs a path other than the one extended"));
}
@@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({ "id": id.0 }));
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?;
let within = number_or(rd, "within", 12.0, C)?;
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
+3 -3
View File
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
use super::{
CommandSpec, Result, Session, always, bad, can_clear_history, can_redo, can_undo, check_numbers, checked_ids_param, clock_date, cmd, date_param,
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index,
number_or, ok, point_param, str_param, switch_or,
f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or,
object_param, ok, point_param, str_param, switch_or,
};
use crate::{Clipboard, EngineError, inspect};
@@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
}
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?;
let mut opts = slice_opts(p, "document.node")?;
let summary = switch_or(p, "summary", false, "document.node")?;
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
+5 -7
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Lighting Preset",
[],
None,
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}",
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
has_doc,
lighting_preset
),
@@ -676,12 +676,10 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
}
// Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options.
if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) {
return Err(bad(
C,
format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0),
));
// The classic 3D effects have no lighting presets; their lights are set in their own options.
if let Some(e) = effect.filter(|e| effects::is_classic_three_d(&e.id)) {
let name = effects::effect_info(&e.id).map_or("This effect", |info| info.label.trim_end_matches('…'));
return Err(bad(C, format!("object {}: {name} at index {index} has no lighting presets; set its lights with effect.setParams", id.0)));
}
}
let mut options = p.clone();
@@ -115,3 +115,32 @@ fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection
let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params;
assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0)));
}
#[test]
fn lighting_presets_refuse_revolve_classic() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("path.create", &json!({"d":"M100 40 L130 60 L130 140 L100 160"})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color":"#3366cc"})).unwrap();
s.execute("effect.apply", &json!({"effect":"threeD.classicRevolve","item":null,"params":{"surface":"diffuse"}})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"topLeft"})).unwrap_err().to_string();
assert!(err.contains(&format!("object {id}: Revolve (Classic) at index 0 has no lighting presets")), "{err}");
assert_eq!(s.doc().unwrap().doc, before);
assert!(!s.doc().unwrap().is_dirty());
}
#[test]
fn lighting_presets_refuse_rotate_classic() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x":100,"y":100,"width":80,"height":60})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect":"threeD.classicRotate","item":null,"params":{"lightRotation":40}})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let err = s.execute("effect.lightingPreset", &json!({"ids":[id],"index":0,"item":null,"preset":"diffuse"})).unwrap_err().to_string();
assert!(err.contains(&format!("object {id}: Rotate (Classic) at index 0 has no lighting presets")), "{err}");
assert_eq!(s.doc().unwrap().doc, before);
assert!(!s.doc().unwrap().is_dirty());
}
@@ -4,7 +4,7 @@ use vectorcraft_doc::{NodeId, NodeKind};
use crate::Session;
const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"];
const TYPES: [&str; 6] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.classicRotate", "threeD.revolve"];
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
+30 -8
View File
@@ -1,7 +1,8 @@
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
//! it gives ([`read_pdf`]).
//! title is the one the file the document was opened from gave itself ([`DocMetadata::title`]),
//! else the document's name. The SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or
//! `.ai` opens with the File Info it gives ([`read_pdf`]).
use serde_json::{Map, Value, json};
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
@@ -22,7 +23,7 @@ const READ_ONLY: [&str; 6] = ["created", "modified", "colorMode", "units", "artb
pub(crate) fn info_json(d: &Document) -> Value {
let m = &d.metadata;
json!({
"title": d.title,
"title": d.info_title(),
"author": m.author,
"authorTitle": m.author_title,
"description": m.description,
@@ -48,14 +49,22 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "params must be an object")),
};
let d = &s.doc()?.doc;
let (mut title, mut m) = (d.title.clone(), d.metadata.clone());
let (mut title, mut m) = (d.info_title().to_string(), d.metadata.clone());
for (k, v) in o {
apply(&mut title, &mut m, k, v)?;
}
let d = &s.doc()?.doc;
if title != d.title || m != d.metadata {
// A title File Info changes becomes the document's own, as it always has (the tab and Save As
// offer it where the document has no file), and replaces the one its file gave.
let retitled = title != d.info_title();
if retitled {
m.title = None;
}
if retitled || m != d.metadata {
s.edit("File Info", |d, _| {
d.title = title;
if retitled {
d.title = title;
}
d.metadata = m;
Ok(())
})?;
@@ -64,8 +73,8 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
/// field that `file.info` would take (a value it refuses is left out), and the created and
/// modified dates.
/// field that `file.info` would take (a value it refuses is left out), its title ([`imported_title`])
/// as the File Info title, and the created and modified dates.
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
let fields = [
@@ -83,6 +92,7 @@ pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
let _ = apply(&mut title, m, k, &v);
}
}
m.title = f.title.as_deref().and_then(imported_title);
m.created = f.created.or(m.created);
m.modified = f.modified.or(m.modified);
}
@@ -141,6 +151,18 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
Ok(s.to_string())
}
/// The File Info title `raw`, read from a file, makes: runs of white space (line breaks too)
/// become single spaces, other control characters are dropped, at most [`MAX_TEXT`] characters
/// are kept, as `file.info` takes a title. `None` when nothing is left, or when it is the
/// Untitled-N name New gives (what a document that never had a title writes to its files).
pub(crate) fn imported_title(raw: &str) -> Option<String> {
let words: Vec<String> =
raw.split_whitespace().map(|w| w.chars().filter(|c| !c.is_control()).collect::<String>()).filter(|w| !w.is_empty()).collect();
let title: String = words.join(" ").chars().take(MAX_TEXT).collect();
let title = title.trim_end();
super::fileio::is_own_title(title).then(|| title.to_string())
}
/// Keywords from a list or a comma- or semicolon-separated string: trimmed, each once (ignoring
/// case), empty ones dropped.
fn keywords(v: &Value) -> Result<Vec<String>> {
+3
View File
@@ -17,6 +17,7 @@
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
//! - `metafile`: EMF and WMF export, open and place.
//! - `hpgl`: HP-GL export options and plots.
//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no
//! built-in format reads).
//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`).
@@ -40,6 +41,7 @@ mod load;
mod mesh3d;
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
mod heifread;
mod hpgl;
mod jp2read;
mod metafile;
mod naming;
@@ -141,6 +143,7 @@ pub(crate) use export_names::{
artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return,
};
use export_names::{distinct_names_by, write_encoded};
pub(crate) use formats::is_own_title;
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
pub use formats::{
DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters,
@@ -536,6 +536,7 @@ fn dataset_output_extensions(run: &Run<'_>, doc: &vectorcraft_doc::Document, def
| "dxf"
| "emf"
| "wmf"
| "hpgl"
| "ai"
)
};
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -464,7 +464,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
}
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
// themselves: SVG only when it outlines or embeds fonts).
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf" | "hpgl"))
&& let Some(w) = crate::cmd::fonts::substitution_warning(doc)
{
enc.warnings.push(w);
@@ -503,7 +503,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf" | "hpgl") { use_artboards(p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
@@ -536,6 +536,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
"eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
"hpgl" => return super::hpgl::encode(doc, p, use_artboards),
"ai" => return super::save::encode_ai(C, f, doc, p),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
@@ -591,7 +592,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
// File Info as PNG text chunks.
let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) {
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(doc.info_title()))
} else {
bytes
};
+1 -1
View File
@@ -370,7 +370,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
let o = EpsOptions {
region,
origin,
title: doc.title.clone(),
title: doc.info_title().to_owned(),
created,
native: Some(native(&editing, if whole { None } else { board }, set.include_linked)),
..set.eps.clone()
+21 -4
View File
@@ -1,6 +1,7 @@
use super::super::unique_name;
use super::{
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, json, metafile, native, pdf, screens, svg, text, unsupported,
Result, Session, TEMPLATE_FORMATS, UNSUPPORTED, Unsupported, Value, bad, dxf, eps, hpgl, json, metafile, native, pdf, screens, svg, text,
unsupported,
};
/// One option a format's encoder reads from the export params.
@@ -479,8 +480,17 @@ pub const FORMATS: &[Format] = &[
},
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them.
reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false),
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths and written as pen moves.
Format {
id: "hpgl",
label: "HP-GL",
extensions: &["plt", "hpgl", "hgl", "hpg"],
mime: "application/vnd.hp-hpgl",
read: true,
write: true,
raster: false,
options: hpgl::OPTIONS,
},
Format {
id: "tga",
label: "Targa",
@@ -920,13 +930,20 @@ pub(crate) fn import_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
!doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
is_own_title(doc.info_title())
}
/// Is `title` one a person or a file gave, not blank and not the Untitled-N name New gives?
pub(crate) fn is_own_title(title: &str) -> bool {
!title.trim().is_empty() && !title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone();
d.title = file_stem(path);
// A File Info title that is no title of its own (blank, or an Untitled-N name) would hide it.
d.metadata.title = None;
Some(d)
}
+126
View File
@@ -0,0 +1,126 @@
//! HP-GL for every path that writes plot files (`vectorcraft_cad::hpgl`): `document.export {format:
//! hpgl}` and Export As (one plot of an artboard, or one per artboard with Use Artboards), with the
//! options parsed from the params.
use serde_json::Value;
use vectorcraft_cad::hpgl::write::{DEFAULT_TOLERANCE, MAX_PENS, TOLERANCE_RANGE};
use vectorcraft_cad::hpgl::{HpglOptions, Origin, Version};
use vectorcraft_doc::Document;
use super::super::{Result, bad, number_or, switch_or, text_param};
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
/// The options `document.formats` lists for HP-GL.
pub(super) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
super::USE_ARTBOARDS,
FormatOption {
name: "hpglVersion",
ty: "string",
default: "\"hpgl\"",
description: "hpgl (IN, SP, PU and PD only; a coordinate outside -32,768 to 32,767 gets a warning) or hpgl2 (also the pens' colors, NP and PC, and each pen's paths as one encoded polyline, PE)",
},
FormatOption {
name: "curveTolerance",
ty: "number",
default: "0.1",
description: "how far the straight moves a curve becomes may lie from it, in points (0.01 to 10)",
},
FormatOption {
name: "pens",
ty: "array",
default: "null",
description: "the pen table, pen 1's color first: up to 255 \"#rrggbb\" colors, each path drawn with the pen of the nearest color; null: a pen for each color in the order the art first draws in it, numbered alike in every file of one export",
},
FormatOption {
name: "origin",
ty: "string",
default: "\"bottomLeft\"",
description: "bottomLeft or center: where (0, 0) lies on the artboard (or the art's bounds)",
},
FormatOption {
name: "sortPaths",
ty: "boolean",
default: "false",
description: "draw each pen's next path from the end nearest where the pen stopped, instead of in paint order",
},
FormatOption {
name: "insideFirst",
ty: "boolean",
default: "false",
description: "draw a path only after the pen's paths that lie inside it (a letter's counter before the letter, a hole before the part), the paths otherwise in paint order; with sortPaths, the nearest of the paths free to draw comes next",
},
];
/// The HP-GL options in `p` (the region is set per plot).
fn parse(p: &Value) -> Result<HpglOptions> {
let mut o = HpglOptions::default();
if let Some(s) = text_param(p, "hpglVersion", C)? {
let key = "hpglVersion";
o.version = Version::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Version::ALL.map(Version::id).join(", "))))?;
}
let x = number_or(p, "curveTolerance", DEFAULT_TOLERANCE, C)?;
if !TOLERANCE_RANGE.contains(&x) {
let (key, min, max) = ("curveTolerance", TOLERANCE_RANGE.start(), TOLERANCE_RANGE.end());
return Err(bad(C, format!("`{key}` must be between {min} and {max} (got {x})")));
}
o.tolerance = x;
match p.get("pens") {
None | Some(Value::Null) => {}
Some(v) => {
let colors: Option<Vec<[u8; 3]>> = v.as_array().filter(|a| (1..=MAX_PENS).contains(&a.len())).and_then(|a| a.iter().map(hex).collect());
o.pens = colors.ok_or_else(|| bad(C, "`pens` must be an array of 1 to 255 #rrggbb colors"))?;
}
}
if let Some(s) = text_param(p, "origin", C)? {
let key = "origin";
o.origin = Origin::from_id(s).ok_or_else(|| bad(C, format!("`{key}` must be one of {}", Origin::ALL.map(Origin::id).join(", "))))?;
}
o.sort = switch_or(p, "sortPaths", false, C)?;
o.inside_first = switch_or(p, "insideFirst", false, C)?;
Ok(o)
}
/// A `#rrggbb` color.
fn hex(v: &Value) -> Option<[u8; 3]> {
let s = v.as_str()?.strip_prefix('#').filter(|s| s.len() == 6 && s.bytes().all(|b| b.is_ascii_hexdigit()))?;
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
}
/// Encode `doc` as HP-GL: a plot of the chosen artboard (default the first), or with
/// `use_artboards` one plot per chosen artboard (default all), with one pen numbering for them all.
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) -> Result<Encoded> {
let opts = parse(p)?;
let f = super::format("hpgl").ok_or_else(|| bad(C, "no HP-GL format"))?;
let pick: ArtboardPick = super::encode::options(f, p)?;
let n = doc.artboards.len();
let boards = match use_artboards {
Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()),
_ => vec![pick.one(n).map_err(|e| bad(C, e))?],
};
let regions = boards
.iter()
.map(|&b| doc.artboards.get(b).map(|a| a.rect).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))))
.collect::<Result<Vec<_>>>()?;
let plots = vectorcraft_cad::hpgl::export_regions(doc, &opts, &regions).map_err(|e| bad(C, e))?;
let mut enc = Encoded::default();
for (b, out) in boards.into_iter().zip(plots) {
enc.files.push((Some(b), out.bytes));
for w in out.warnings {
if !enc.warnings.contains(&w) {
enc.warnings.push(w);
}
}
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
}
+1 -1
View File
@@ -145,7 +145,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
}
}
}
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
let o = vectorcraft_eps::LegacyAiOptions { title: doc.info_title().to_owned(), created, hidden, outline_text };
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
for w in placed.iter().chain(&out.warnings) {
note(&mut enc.warnings, w);
+12 -1
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use vectorcraft_render::effects::pixel::LoadedTexture;
use super::super::fileinfo::imported_title;
use super::super::{Result, Session, bad, bool_or, clipboard, parse_range, str_param};
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, jxlread, psdread, read_file, readable_format_for_name};
@@ -550,6 +551,15 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
}
// The title the file gives itself (an SVG's `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title`
// or Info `Title`, which the readers put in File Info) is separate from the document's name.
// Restored File Info is preserved exactly; newly imported SVG candidates and a restored
// document's legacy name fallback are cleaned as imported text. The name stays the file's (#3626).
if !restored && matches!(format.id, "svg" | "svgz") {
doc.metadata.title = doc.metadata.title.as_deref().and_then(imported_title);
}
let held = restored.then_some(doc.title.as_str()).filter(|t| *t != title);
doc.metadata.title = doc.metadata.title.or_else(|| held.and_then(imported_title));
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
@@ -678,6 +688,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
if template {
doc.template = false;
doc.title = s.next_untitled();
doc.metadata.title = None;
}
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
// closed, its top-level layers as its groups (#1475). Ours carry their own.
@@ -711,7 +722,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
st.import_losses = losses;
st.links_from = read_from;
let opened = &s.documents()[index];
let (title, file_info_title) = (opened.title(), opened.doc.title.clone());
let (title, file_info_title) = (opened.title(), opened.doc.info_title().to_string());
Ok(super::merge(
json!({ "index": index, "title": title, "fileInfoTitle": file_info_title, "format": format.id, "warnings": warnings, "restored": restored }),
links.to_json(),
+11 -2
View File
@@ -731,7 +731,7 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
// With the artboards' background colors, as the pages draw them.
let art = super::export::with_artboard_fills(art, &params, 0..art.artboards.len()).map_err(|e| bad(cmd, e))?;
let o = vectorcraft_eps::AiEditingOptions {
title: doc.title.clone(),
title: doc.info_title().to_owned(),
created: doc.metadata.created.or_else(vectorcraft_doc::metadata::output_time),
};
let (data, lost) = vectorcraft_eps::ai_private_data(&art, &o).map_err(|e| bad(cmd, e))?;
@@ -885,7 +885,13 @@ impl SaveJob {
/// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several),
/// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?:
/// [path…], linked?: [path…]}`.
pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
pub fn write(&self, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
self.write_unless(|_| None, write)
}
/// [`SaveJob::write`], once `stop` has seen the paths of every file it writes to a path, in
/// order: when `stop` returns a value, nothing is written and that value is returned.
pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let (plan, doc) = (&self.plan, &*self.doc);
let mut enc = self.encode()?;
let warnings = std::mem::take(&mut enc.warnings);
@@ -899,6 +905,9 @@ impl SaveJob {
return Ok(out);
};
let files = enc.named(doc, path);
if let Some(stopped) = stop(&files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>()) {
return Ok(stopped);
}
for (p, bytes) in &files {
write(p, bytes)?;
}
+1 -1
View File
@@ -196,7 +196,7 @@ pub(super) fn name_without_device(name: &str) -> String {
pub(crate) const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
/// The writable formats a format row can't name.
pub(crate) const UNWRITTEN_FORMATS: [&str; 5] = ["template", "txt", "dxf", "emf", "wmf"];
pub(crate) const UNWRITTEN_FORMATS: [&str; 6] = ["template", "txt", "dxf", "emf", "wmf", "hpgl"];
/// One format row: what to write, at which size, with which suffix, into which sub-folder.
struct Row {
+2
View File
@@ -431,6 +431,7 @@ fn export_extensions_name_every_writable_format() {
"eps",
"emf",
"wmf",
"plt",
"tga",
"psd"
]
@@ -466,6 +467,7 @@ fn formats_query_lists_readers_writers_and_options() {
"eps",
"emf",
"wmf",
"hpgl",
"tga",
"psd"
]
+131 -4
View File
@@ -1,8 +1,9 @@
//! HP-GL plotter files through the commands: `document.open` by extension or by content, File ›
//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't
//! HP-GL refused with a message.
//! Place as one group of paths, the dialogs' filters and the format list, files that aren't
//! HP-GL refused with a message, and `document.export` of one artboard, each or the art, with its
//! options checked.
use serde_json::json;
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use super::*;
@@ -100,5 +101,131 @@ fn the_dialogs_and_the_format_list_name_hpgl() {
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
let r = Session::new().execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone();
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)));
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(true), Some(false)));
for o in ["useArtboards", "hpglVersion", "curveTolerance", "pens", "origin", "sortPaths", "insideFirst"] {
assert!(f["options"].get(o).is_some(), "{o}: {f}");
}
assert_eq!(writable_format(None, Some("x/part.plt")).unwrap().id, "hpgl");
assert_eq!(writable_format(None, Some("x/part.hpgl")).unwrap().id, "hpgl");
}
fn bytes(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// Two 100 pt artboards side by side, a 30 pt square on each, the second one red.
fn two_boards() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2})).unwrap();
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let red = s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("paint.setStroke", &json!({"ids": [red], "color": "#ff0000"})).unwrap();
s
}
#[test]
fn export_writes_a_plot_of_one_artboard_or_each() {
let mut s = two_boards();
let r = s.execute("document.export", &json!({"format": "hpgl"})).unwrap();
assert_eq!(r["format"], "hpgl");
// The first artboard's square, 1016 units to the inch from its bottom-left corner.
assert_eq!(String::from_utf8(bytes(&r)).unwrap(), "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
let second = String::from_utf8(bytes(&s.execute("document.export", &json!({"format": "hpgl", "artboard": 1})).unwrap())).unwrap();
assert_eq!(second, "IN;PU;SP1;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;", "its own square, its own pen 1");
assert_eq!(bytes(&s.execute("document.serialize", &json!({"format": "hpgl"})).unwrap()), bytes(&r));
let r = s.execute("document.export", &json!({"format": "hpgl", "useArtboards": true})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(files[1]["name"].as_str().unwrap().ends_with("-Artboard-2.plt"), "{files:?}");
// One pen numbering for every file: the red square is pen 2 in the second.
assert_eq!(String::from_utf8(bytes(&files[1])).unwrap(), "IN;PU;SP2;PU141,1270;PD564,1270,564,847,141,847,141,1270;PU;SP0;");
// The bounds of the art: both squares, the second pen red, read back as two paths.
let art = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false, "hpglVersion": "hpgl2"})).unwrap());
assert!(art.starts_with(b"IN;NP4;PC1,0,0,0;PC2,255,0,0;"), "{:?}", String::from_utf8_lossy(&art));
let back = Session::new().execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&art)})).unwrap();
assert_eq!(back["format"], "hpgl", "{back}");
// One file holds one artboard.
assert!(s.execute("document.export", &json!({"format": "hpgl", "artboards": [0, 1]})).is_err());
}
/// A plot read back opens with the same squares: as wide as the art drew them, a point at most
/// apart from the 30 pt sides plus the 0.35 mm pen.
#[test]
fn a_plot_reads_back_as_its_art() {
let mut s = two_boards();
let plot = bytes(&s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).unwrap());
let mut back = Session::new();
back.execute("document.open", &json!({"name": "art.plt", "dataBase64": b64(&plot)})).unwrap();
let doc = &back.doc().unwrap().doc;
let mut paths = 0;
doc.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. })));
assert_eq!(paths, 2);
let board = doc.artboards[0].rect;
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
let pen = 0.35 * 72.0 / 25.4;
assert!((board.width() - (second + 30.0 + pen)).abs() < 0.1, "{board:?}");
assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}");
}
#[test]
fn export_options_are_checked() {
let mut s = two_boards();
let refused = |s: &mut Session, p: Value| s.execute("document.export", &p).expect_err("refused").to_string();
let cases = [
(json!({"format": "hpgl", "hpglVersion": "hpgl3"}), "`hpglVersion` must be one of hpgl, hpgl2"),
(json!({"format": "hpgl", "hpglVersion": 2}), "`hpglVersion` must be a string"),
(json!({"format": "hpgl", "curveTolerance": 0}), "`curveTolerance` must be between 0.01 and 10 (got 0)"),
(json!({"format": "hpgl", "curveTolerance": "fine"}), "`curveTolerance` must be a number"),
(json!({"format": "hpgl", "pens": []}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "pens": ["#ff0000", "red"]}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "pens": "#ff0000"}), "`pens` must be an array of 1 to 255 #rrggbb colors"),
(json!({"format": "hpgl", "origin": "top"}), "`origin` must be one of bottomLeft, center"),
(json!({"format": "hpgl", "sortPaths": "yes"}), "`sortPaths` must be true or false"),
(json!({"format": "hpgl", "insideFirst": 1}), "`insideFirst` must be true or false"),
];
for (p, want) in cases {
let e = refused(&mut s, p.clone());
assert!(e.contains(want), "{p}: {e}");
}
let many: Vec<String> = (0..256).map(|i| format!("#{:02x}0000", i % 256)).collect();
assert!(refused(&mut s, json!({"format": "hpgl", "pens": many})).contains("`pens`"));
// Given options take effect: the center as the origin, a pen table (black is pen 2), and the
// square starting at its corner nearest the origin.
let p = json!({"format": "hpgl", "origin": "CENTER", "pens": ["#ff0000", "#000000"], "curveTolerance": 10, "sortPaths": true});
let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
assert_eq!(plot, "IN;PU;SP2;PU-141,141;PD-564,141,-564,564,-141,564,-141,141;PU;SP0;");
}
/// `insideFirst` draws the square inside the other one first, though it was made last.
#[test]
fn inside_first_draws_the_inner_square_first() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap();
s.execute("shape.rectangle", &json!({"x": 30, "y": 30, "width": 20, "height": 20})).unwrap();
let starts = |s: &mut Session, p: Value| {
let plot = String::from_utf8(bytes(&s.execute("document.export", &p).unwrap())).unwrap();
(plot.find("PU141,1270;").unwrap(), plot.find("PU423,988;").unwrap())
};
let (outer, inner) = starts(&mut s, json!({"format": "hpgl"}));
assert!(outer < inner);
let (outer, inner) = starts(&mut s, json!({"format": "hpgl", "insideFirst": true}));
assert!(inner < outer);
}
/// Export Selection plots the selected square alone (the red one, made last), from the
/// bottom-left corner of its bounds.
#[test]
fn export_selection_writes_a_plot_of_the_selection() {
let mut s = two_boards();
let r = s.execute("document.exportSelection", &json!({"format": "hpgl"})).unwrap();
assert_eq!(r["format"], "hpgl");
let plot = String::from_utf8(bytes(&r)).unwrap();
assert_eq!(plot.matches("PD").count(), 1, "{plot}");
assert!(plot.starts_with("IN;PU;SP1;PU7,430;PD"), "{plot}");
}
+11 -1
View File
@@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() {
assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}");
let p = search(&root, "missing", Limits::default(), 1, &[]);
assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}");
// Each failure has the same reason without the folder's path.
let briefs = [
("missing", "the folder can't be read"),
("file.ttf", "the path isn't a folder"),
("refused", "VectorCraft doesn't search other apps' or the system's folders"),
("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"),
];
for (picked, why) in briefs {
assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}");
}
}
#[test]
@@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() {
let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
if enforced {
assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}");
assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}");
} else {
assert_eq!(found(&root, &p), ["locked/y.ttf"]);
}
+51 -15
View File
@@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024;
/// How a search ends when every walker thread stopped on a bug.
const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)";
/// Why a search couldn't start, without the folder's path ([`Progress::failure`]).
const UNREADABLE: &str = "the folder can't be read";
const NOT_A_FOLDER: &str = "the path isn't a folder";
const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users";
const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders";
const UNLISTED: &str = "the folder can't be listed";
/// What a search looks for.
pub trait Visitor: Send + Sync {
/// Whether the regular file named `name` is to be read: asked of the files a folder lists until
@@ -77,8 +84,9 @@ pub enum End {
/// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item
/// keeps at most [`Limits::per_item`] files, without ending the search.
Limit,
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search.
Failed(String),
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why,
/// and the same without the folder's path.
Failed(String, &'static str),
}
/// Where a search stands ([`Search::progress`]).
@@ -114,7 +122,7 @@ impl Progress {
None => "searching",
Some(End::Done | End::Found) => "done",
Some(End::Stopped | End::Time | End::Limit) => "stopped",
Some(End::Failed(_)) => "failed",
Some(End::Failed(..)) => "failed",
}
}
@@ -131,10 +139,38 @@ impl Progress {
/// Why a failed search failed.
pub fn error(&self) -> Option<&str> {
match &self.end {
Some(End::Failed(e)) => Some(e),
Some(End::Failed(e, _)) => Some(e),
_ => None,
}
}
/// Why a failed search failed, without the folder's path.
pub fn failure(&self) -> Option<&'static str> {
match &self.end {
Some(End::Failed(_, why)) => Some(why),
_ => None,
}
}
/// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]),
/// for a reply to it: the files found inside them. Returns the folder searched (`given` until a
/// walker checked it) and why the search failed, when that folder lies inside them too;
/// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in
/// force, everything is kept.
pub fn keep_readable(&mut self, given: &str) -> (Option<String>, Option<String>) {
let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() };
if crate::file_access::current().is_none() {
return (Some(folder), self.error().map(str::to_string));
}
// A path that isn't valid text is never shown: its lossy form names another file.
let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
self.hits.retain(|(path, _)| readable(path));
if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) {
(Some(folder), self.error().map(str::to_string))
} else {
(None, self.failure().map(str::to_string))
}
}
}
/// Walker threads shared out among searches: all of them together run at most `cap` threads.
@@ -306,7 +342,7 @@ pub(crate) fn start_in(
return Err("the search couldn't start: try again in a moment".into());
}
if w.alive == 0 && w.end.is_none() {
shared.finish(&mut w, End::Failed(INTERNAL.into()));
shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
}
Ok(Search { shared })
@@ -401,13 +437,13 @@ impl Shared {
fn prepare(&self, root: &Path) {
let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system));
let path = root.display();
let checked = match std::fs::canonicalize(root) {
Err(e) => Err(format!("{path}: {e}")),
Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")),
let (checked, brief) = match std::fs::canonicalize(root) {
Err(e) => (Err(format!("{path}: {e}")), UNREADABLE),
Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER),
Ok(folder) => match rules.refusal(&folder) {
None => Ok(folder),
Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")),
Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")),
None => (Ok(folder), INTERNAL),
Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED),
Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS),
},
};
let mut w = self.lock();
@@ -416,7 +452,7 @@ impl Shared {
w.folder = folder.clone();
w.stack.push((folder, 0));
}
Err(e) => self.finish(&mut w, End::Failed(e)),
Err(e) => self.finish(&mut w, End::Failed(e, brief)),
}
}
@@ -430,7 +466,7 @@ impl Shared {
let mut w = self.lock();
// The picked folder itself: the search can't run, and says why.
if depth == 0 {
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display())));
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED));
} else {
w.unreadable += 1;
w.folders += 1;
@@ -610,7 +646,7 @@ fn walker(s: &Shared) {
if done.is_err() {
let mut w = s.lock();
match job {
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())),
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)),
Job::List(..) => w.unreadable += 1,
}
}
@@ -640,7 +676,7 @@ impl Drop for Alive {
let mut w = self.0.lock();
w.alive = w.alive.saturating_sub(1);
if w.alive == 0 && w.end.is_none() {
self.0.finish(&mut w, End::Failed(INTERNAL.into()));
self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
drop(w);
self.0.pool.release(1);
+18 -10
View File
@@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Font Files",
[],
None,
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
),
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Font Files",
[],
None,
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
always,
add
),
@@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
/// Where the session's last search stands (`text.findFontFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) };
let p = f.search.progress();
let mut p = f.search.progress();
// Only what the caller asking now may read: a search another caller started can hold more.
let (folder, error) = p.keep_readable(&f.folder);
let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()];
for (path, items) in &p.hits {
for i in items {
@@ -293,16 +295,18 @@ fn state(s: &Session) -> Value {
json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files })
})
.collect();
let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
"id": f.id, "state": p.state(), "folder": folder, "fonts": fonts,
"id": f.id, "state": p.state(), "fonts": fonts,
"searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
if let Some(folder) = folder {
out["folder"] = json!(folder);
}
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
if let Some(e) = p.error() {
if let Some(e) = error {
out["error"] = json!(e);
}
out
@@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
// VectorCraft's own Fonts folder: no automation root applies to it.
crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?;
let report = copy_into(&files, dir);
let pairs = |v: &[(PathBuf, PathBuf)]| {
v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>()
// The caller sees the Fonts folder and the copies in it only where its read roots reach.
let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
let shown = |p: &Path| -> String {
let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() };
shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
};
let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::<Vec<_>>();
let mut out = json!({
"folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(),
"folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::<Vec<_>>(),
});
if !report.copied.is_empty() || !report.kept.is_empty() {
// The fonts are cataloged and type set in them redraws, as Refresh Font List does it.
+11 -8
View File
@@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect};
use super::{
CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc,
has_selection, id_param, ids_param, index, ok, str_param, switch_or,
has_selection, ids_param, index, object_param, ok, str_param, switch_or,
};
use crate::EngineError;
@@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
/// The Layers panel's target circle.
fn target(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.target";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
if !st.doc.is_editable(id) {
@@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.setCurrent";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let st = s.doc_mut()?;
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
st.active_layer = Some(layer);
@@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option<NodeId>, id: NodeId) -> bool {
/// The selection column: select a row's art.
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.selectAll";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let add = switch_or(p, "add", false, C)?;
let st = s.doc()?;
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
@@ -747,9 +747,12 @@ enum Place {
/// Drag rows in the Layers panel.
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.move";
let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
let mut ids = match ids_param(p, "ids", C)? {
Some(ids) => ids,
None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?],
};
ids.truncate(MAX_ROWS);
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?;
let place = match str_param(p, "place").unwrap_or("inside") {
"above" => Place::Above,
"below" => Place::Below,
@@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "node.move";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let parent = id_param(p, "parent");
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
let parent = object_param(p, "parent", C)?;
let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index);
let d = &s.doc()?.doc;
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
+16 -7
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Linked Files",
[],
None,
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
)]
@@ -52,6 +52,8 @@ pub(crate) struct LinkSearch {
pub(crate) id: u64,
/// The folder as given.
pub(crate) folder: String,
/// The path of the document the links were taken from, which a relative link path starts from.
pub(crate) doc: Option<String>,
pub(crate) links: Vec<Sought>,
/// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]).
pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>,
@@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
if links.is_empty() {
return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" }));
}
let doc = super::links::LinkBase::of(st).doc.map(str::to_string);
// A new search stops the last one.
s.link_search = None;
let threads = s.search_threads.unwrap_or_else(findfiles::threads);
@@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
let visitor = Arc::new(LinkFiles::new(&links, sizes.clone()));
let search =
findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?;
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search });
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search });
Ok(state(s))
}
@@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize {
/// Where the session's last search stands (`links.findFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) };
let p = l.search.progress();
let mut p = l.search.progress();
// Only what the caller asking now may read: a search another caller started can hold more.
let (folder, error) = p.keep_readable(&l.folder);
let n = l.links.len();
let sizes = lock(&l.sizes).clone();
// Per linked file: the files found, each with whether it is the same file.
@@ -267,19 +272,23 @@ fn state(s: &Session) -> Value {
f
})
.collect();
json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files })
let mut row = json!({ "name": link.name, "ids": link.ids, "files": files });
super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref());
row
})
.collect();
let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
"id": l.id, "state": p.state(), "folder": folder, "links": links,
"id": l.id, "state": p.state(), "links": links,
"searched": { "folders": p.folders, "files": p.files, "read": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
if let Some(folder) = folder {
out["folder"] = json!(folder);
}
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
if let Some(e) = p.error() {
if let Some(e) = error {
out["error"] = json!(e);
}
out
+2 -2
View File
@@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
/// relative path taken from the document's folder) lies inside them.
fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
row[key] = json!(path);
} else if let Some(o) = row.as_object_mut() {
+1 -1
View File
@@ -21,7 +21,7 @@ use super::expand::gradient_sampler;
use super::pathops::Beside;
use super::{
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi,
has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param,
has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param,
};
mod blend;
+5 -1
View File
@@ -324,7 +324,11 @@ pub(super) fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
if anchors.contains(&index) {
return Err(bad(C, "a key object sits on that point"));
}
if sp.anchors.len() <= 2 {
// A closed spine pinned open keeps a copy of its first point at its end (#6809), so the
// points left must be two apart, not only two in number.
let mut rest = sp.anchors.iter().enumerate().filter(|(i, _)| *i != index).map(|(_, a)| a.p);
let first = rest.next();
if sp.anchors.len() <= 2 || first.is_none_or(|f| rest.all(|q| q.distance(f) < 0.01)) {
return Err(bad(C, "a spine keeps at least two points"));
}
sp.anchors.remove(index);
+4 -4
View File
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make with Warp…",
["Object", "Envelope Distort"],
Some("Cmd+Alt+Shift+W"),
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
has_selection,
env_make_warp
),
@@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset with Warp…",
["Object", "Envelope Distort"],
None,
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content",
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content",
has_envelope,
env_reset_warp
),
@@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Info",
[],
None,
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
has_doc,
env_info
),
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Options…",
["Object", "Envelope Distort"],
None,
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_doc,
env_options
),
+13 -8
View File
@@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr
use super::mesh::{handle_param, index_param, whole};
use super::{
Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint,
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param,
PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param,
};
// ---------- Envelope ----------
@@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
check_numbers(p, WARP_NUMBERS, cmd)?;
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
// A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version`
// names one.
let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur {
(style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0);
}
if let Some(n) = p.get("version") {
version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?;
}
if let Some(s) = text_param(p, "style", cmd)? {
let s = s.strip_prefix("warp.").unwrap_or(s);
@@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
Some(o) => o == "horizontal",
None => given,
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version })
}
/// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
@@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
};
let v = match &**kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version})
}
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
@@ -419,7 +424,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.setMeshPoint";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
+3 -3
View File
@@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec
use super::envelope::{outline_of, rows_cols};
use super::{
Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad,
check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param,
check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param,
};
// ---------- Gradient mesh ----------
@@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols:
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
/// envelope's grid too (see [`edit_grid`]).
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f))
}
@@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20;
/// points}…]}`. Each mesh comes once, with its points in order and without repeats.
fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
let one = |q: &Value| -> Result<(NodeId, BTreeSet<usize>)> {
let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?;
let points = match q.get("points") {
None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]),
Some(Value::Array(v)) if v.len() <= MAX_POINTS => v
File diff suppressed because one or more lines are too long
+8
View File
@@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
}
// A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net.
let newest = vectorcraft_render::effects::WARP_NET;
if effect.starts_with("warp.")
&& let Some(v) = given.get("version")
&& vectorcraft_render::effects::warp_version(v).is_none()
{
return Err(bad(id, format!("`version` must be 0 or {newest}")));
}
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
+8 -8
View File
@@ -12,7 +12,7 @@ use super::draw2::usize_opt;
use super::pathops::Beside;
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path,
has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets,
has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<
}
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p, "path.appendAnchor")?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
s.edit("Pen", |d, sel| {
@@ -206,7 +206,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
/// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only
/// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape.
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
let handle = |key: &str| match p.get(key).filter(|h| !h.is_null()) {
None => Ok(None),
@@ -303,7 +303,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
}
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
@@ -321,7 +321,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
/// tool's Shift-drag).
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
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")?);
@@ -341,7 +341,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
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"))?;
let data = PathData::new(
subs.iter()
@@ -1562,7 +1562,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz
}
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?;
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
@@ -1684,7 +1684,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
}
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?;
let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0);
+4 -2
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Image Info",
[],
None,
"{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
"{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
has_doc,
image_info
),
@@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
let [a, b, c, d, ..] = im.xf.as_coeffs();
let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l);
let size = n.geometric_bounds().unwrap_or_default();
// A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info.
let doc_path = super::links::LinkBase::of(st).doc;
Ok(json!({
"id": id.0,
"name": n.display_name(),
"linked": im.link.is_some(),
"link": im.link.as_ref().map(|l| &l.path),
"link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)),
"colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)),
"pixelWidth": im.width,
"pixelHeight": im.height,
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.color.strokeToFill",
"addon.color.swatchLegend",
"addon.contour.set",
"addon.cutting.bridges",
"addon.image.removeBackground",
"addon.pathtools.addPoints",
"addon.pathtools.close",
@@ -110,6 +111,7 @@ pub(super) const ALLOWED: &[&str] = &[
"brush.options",
"brush.placeArt",
"brush.remove",
"brush.replaceArt",
"brush.selectionArt",
"brush.setCurrent",
"brush.unused",
@@ -643,6 +645,8 @@ pub(super) const ALLOWED: &[&str] = &[
"type.opticalMarginAlignment",
"type.orientation.horizontal",
"type.orientation.vertical",
"type.pathCenter.bottom",
"type.pathCenter.top",
"type.pathOptions",
"type.resetHorizontalScale",
"type.resetTracking",
+96 -1
View File
@@ -19,7 +19,9 @@
//! while their copies are restored or discarded the area is held, so two apps launched together
//! never both take it. An area's lock file or folder can be removed while its app runs, so each
//! recovery save and heartbeat checks that the lock still holds the area, and locks it again or
//! takes a new area.
//! takes a new area. The copies it had in the old area stay there until it has written them again
//! in the new one, and an entry `<new area>/keeps-<old area>` ([`mark_left`]) tells other apps
//! that those copies belong to a running app, so none of them offers or discards them meanwhile.
//!
//! A heartbeat alone can't tell a gone app from one whose timers stopped (a background tab), so
//! when unsure an area counts as held, and its app repairs what another app did meanwhile: each
@@ -61,6 +63,9 @@ const BLOB_EXT: &str = ".blob";
/// The heartbeat entry of an area in a store without locks: when its app last said it runs (Unix
/// seconds, as text).
const BEAT: &str = "heartbeat";
/// The start of an entry `<area>/keeps-<left area>`: the app holding `<area>` still keeps the copies
/// it left in `<left area>` ([`mark_left`]).
const KEEPS: &str = "keeps-";
/// The least time without a heartbeat after which an area's app counts as gone (seconds); else
/// three Data Recovery intervals.
const MIN_STALE: i64 = 180;
@@ -535,6 +540,8 @@ pub struct Recovery {
/// store: each stays until its document has a copy in the new area, or needs none. They are
/// still this session's: never offered or discarded as a crash's here.
left: BTreeMap<u64, (Arc<dyn RecoveryStore>, String)>,
/// The `<own area>/keeps-<left area>` entries this session wrote in its area ([`mark_left`]).
marked: BTreeSet<String>,
}
impl Recovery {
@@ -609,6 +616,15 @@ fn beating(store: &dyn RecoveryStore, area: &str, stale: i64, mine: Option<i64>)
/// beat only lets another app offer the copies once three intervals have passed.
pub fn heartbeat(s: &mut Session) -> Vec<u64> {
let mut lost = keep_hold(s);
// An entry that couldn't be written before, or that was removed since (by the user, a
// cleaner), is written now.
if let Some(own) = &s.recovery.own
&& !s.recovery.marked.is_empty()
&& let Ok(names) = own.store.list()
{
s.recovery.marked.retain(|n| names.contains(n));
}
mark_left(s);
if let Some(own) = s.recovery.own.as_ref().filter(|o| o.beats) {
let _ = beat(own.store.as_ref(), &own.area);
}
@@ -678,6 +694,36 @@ fn drop_left(s: &mut Session, uid: u64) {
let meta = store.remove(&format!("{file}{META_EXT}"));
if doc.is_ok() && meta.is_ok() {
s.recovery.left.remove(&uid);
mark_left(s);
}
}
/// Record in this session's area each area it left that still holds copies of its documents: an
/// entry `<own area>/keeps-<left area>`, so another app leaves those copies alone while this one
/// runs ([`keeper`]). The entries of areas that hold none of its copies any more go. Best effort:
/// an entry not written or removed now is written or removed at the next heartbeat.
fn mark_left(s: &mut Session) {
let Some(own) = &s.recovery.own else { return };
let (store, area) = (own.store.clone(), own.area.clone());
let want: BTreeSet<String> = s
.recovery
.left
.values()
.filter(|(st, _)| st.location() == store.location())
.filter_map(|(_, f)| f.split_once('/').map(|(a, _)| a))
.filter(|a| *a != area)
.map(|a| format!("{area}/{KEEPS}{a}"))
.collect();
let gone: Vec<String> = s.recovery.marked.difference(&want).cloned().collect();
for n in gone {
if store.remove(&n).is_ok() {
s.recovery.marked.remove(&n);
}
}
for n in want {
if !s.recovery.marked.contains(&n) && store.write(&n, b"").is_ok() {
s.recovery.marked.insert(n);
}
}
}
@@ -726,6 +772,9 @@ pub fn claim(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<String>
}
};
s.recovery.own = Some(Own { store: store.clone(), area: area.clone(), hold, beats });
// The entries written before are in the area this session had (released or left).
s.recovery.marked.clear();
mark_left(s);
return Ok(area);
}
Err(EngineError::Other("no free recovery area".into()))
@@ -742,6 +791,8 @@ pub fn release(s: &mut Session) {
drop_left(s, uid);
}
let Some(Own { store, area, hold, .. }) = s.recovery.own.take() else { return };
// Its `keeps-` entries go with the area's other entries.
s.recovery.marked.clear();
let mine = |n: &String| n.split_once('/').is_some_and(|(a, _)| a == area);
for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) {
let _ = store.remove(name);
@@ -987,6 +1038,7 @@ impl RecoveryJob {
let _ = store.remove(&format!("{file}{DOC_EXT}"));
let _ = store.remove(&format!("{file}{META_EXT}"));
}
mark_left(s);
}
// Best effort, as in `remove_copy`.
_ => {
@@ -1208,6 +1260,10 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
}
match store.lock(area) {
Ok(Lock::Held(h)) => {
// Copies an app left here when it had to leave the area stay its own while it runs.
if let Some(who) = keeper(s, store, area) {
return who;
}
hold.push(h);
Owner::Gone
}
@@ -1216,6 +1272,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) {
return Owner::Running;
}
if let Some(who) = keeper(s, store, area) {
return who;
}
// Best effort: without it another app might offer the copies too, which loses nothing.
if take && let Ok(t) = beat(store, area) {
s.recovery.adopted.insert(area.to_string(), t);
@@ -1225,6 +1284,42 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
}
}
/// Who keeps the copies left in `area` by an app that had to leave it, from the entries
/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`:
/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is
/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be
/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing).
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> {
let Ok(names) = store.list() else { return Some(Owner::Running) };
let mark = format!("/{KEEPS}{area}");
let mut who = None;
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) {
match holder(s, store, k) {
Owner::Mine => return Some(Owner::Mine),
Owner::Running => who = Some(Owner::Running),
Owner::Gone => {}
}
}
who
}
/// Whose area `area` is, without taking it over: this session's, a running app's, or nobody's (a
/// lock taken to tell goes at once). An area whose lock fails (not a recovery area of this
/// account, a lock file that can't be opened) is nobody's.
fn holder(s: &Session, store: &dyn RecoveryStore, area: &str) -> Owner {
if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
return Owner::Mine;
}
match store.lock(area) {
Ok(Lock::Held(_)) | Err(_) => Owner::Gone,
Ok(Lock::Busy) => Owner::Running,
Ok(Lock::Unsupported) => {
let runs = store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied());
if runs { Owner::Running } else { Owner::Gone }
}
}
}
/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The
/// area is removed while it is still held, so an app taking a new area of that name meanwhile
/// finds it held or gone and takes the next name.
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -13,8 +13,8 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin
use vectorcraft_geom::{Rect, shapes};
use super::{
CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, id_param, ids_param, index, number_or, switch_or,
targets,
CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, f64_req, has_doc, has_selection, ids_param, index, number_or, object_param,
switch_or, targets,
};
/// The most slices one Divide Slices makes.
@@ -680,7 +680,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
fn set_rect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.slice.setRect";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let r = rect_param(p, C)?;
let d = &s.doc()?.doc;
if d.slice(id).is_none() {
+26 -2
View File
@@ -206,11 +206,31 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool {
ids.is_empty() && p.get("ids").is_none() && p.get("id").is_none()
}
/// The objects `stroke.set` edits with no `item`: a selected group or layer with a stroke row of
/// its own edits that row, as the Stroke panel does with the group targeted (#6739); the other
/// targets reach their painted leaves, as before.
fn own_stroke_or_leaves(s: &Session, p: &Value) -> Result<Vec<vectorcraft_doc::NodeId>> {
let picked = super::targets(s, p)?;
let d = &s.doc()?.doc;
let own = |id: &vectorcraft_doc::NodeId| {
d.node(*id).is_some_and(|n| {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.is_compound_shape() && n.appearance.stroke().is_some()
})
};
let (mut out, rest): (Vec<_>, Vec<_>) = picked.into_iter().partition(own);
for id in super::leaf_targets(s, &rest)? {
if !out.contains(&id) {
out.push(id);
}
}
Ok(out)
}
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.set";
let change = StrokeChange::parse(p, C)?;
let item = item_target(s, p, C)?.of_kind(s, false);
let mut ids = item.targets(s, p)?;
let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? };
let new_art = no_targets(&ids, p);
if item == ItemTarget::Top {
// Images and symbol instances take no stroke from the panel.
@@ -261,7 +281,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(());
}
if index.is_none() && n.appearance.stroke().is_none() {
n.appearance.set_stroke(Paint::solid(Color::BLACK));
// Options alone (cap, join, miter limit, dashes, alignment) don't make a stroke
// visible: the row made for them has no paint. A weight, an arrowhead or a profile
// still gives the object the black stroke the panel draws (#6739).
let visible = change.weight.is_some() || sa.is_some() || ea.is_some() || profile.is_some();
n.appearance.set_stroke(if visible { Paint::solid(Color::BLACK) } else { Paint::None });
}
if let Some(st) = n.appearance.stroke_at_mut(index) {
set(st);
+4 -4
View File
@@ -19,7 +19,7 @@ use vectorcraft_doc::symbol_overrides::SymbolOverride;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration};
use vectorcraft_geom::{Affine, Point};
use super::brushsym::{natural_size, place_xf};
use super::brushsym::{fit_parts, natural_size, place_xf};
use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param};
/// The name of the undo step that enters symbol editing mode.
@@ -425,8 +425,8 @@ fn each_changed_instance(n: &mut Node, name: &str, f: &mut dyn FnMut(&mut Vec<Sy
}
/// Each instance's appearance changes to objects of symbol `name`'s art follow those objects
/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes to
/// objects the art still has. That covers the instances on the layers and in the art of the
/// through an edit of the editing layer (see [`follow_paths`]), and keep only the changes the art
/// still draws ([`fit_parts`]). That covers the instances on the layers and in the art of the
/// document's other symbols, graph designs, pattern tiles (the tiles pattern editing's Cancel puts
/// back included) and brushes, opacity-mask art included. The editing layer holds no instance of
/// the symbol, since [`sync`] refuses one.
@@ -435,7 +435,7 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new:
let mut follow = |o: &mut Vec<SymbolOverride>| {
follow_paths(o, old, new);
match &art {
Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art),
Some(art) => fit_parts(o, art),
None => o.clear(),
}
};
+71 -8
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Tab Stops",
["Window", "Tabs"],
None,
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, or no more than each targeted paragraph already holds, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
has_selection,
set
),
@@ -38,10 +38,6 @@ pub fn specs() -> Vec<CommandSpec> {
fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
const C: &str = "text.tabs.set";
let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?;
let max = TabStop::MAX_STOPS;
if arr.len() > max {
return Err(bad(C, format!("at most {max} tab stops")));
}
let mut stops = vec![];
for s in arr {
let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?;
@@ -90,10 +86,39 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
// More than the limit is taken only when every targeted paragraph already holds at least as
// many stops, so a paragraph that holds more can still lose them one at a time. The supplied
// list is counted before it is read.
let max = TabStop::MAX_STOPS;
let supplied = p.get("stops").and_then(Value::as_array).map_or(0, Vec::len);
if supplied > max && supplied > fewest_held(s, p)? {
return Err(bad("text.tabs.set", format!("at most {max} tab stops")));
}
let stops = parse_stops(p)?;
apply(s, p, stops, "Tab Stops")
}
/// The fewest tab stops a paragraph that `text.tabs.set` with `p` targets holds (0 with none).
fn fewest_held(s: &Session, p: &Value) -> Result<usize> {
let ids = text_targets(s, p, "text.tabs.set")?;
let range = TextRange::parse(p, "text.tabs.set")?;
let doc = &s.doc()?.doc;
let mut fewest: Option<usize> = None;
for id in &ids {
if let Some(NodeKind::Text(t)) = doc.node(*id).map(|node| &node.kind) {
let held = match para_span(range, t) {
None => t.para_styles().map(|pa| pa.tabs.len()).min(),
Some(r) => r.map(|i| t.para_at(i).tabs.len()).min(),
};
fewest = match (fewest, held) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
}
Ok(fewest.unwrap_or(0))
}
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
let ids = text_targets(s, p, "text.tabs.set")?;
let range = TextRange::parse(p, "text.tabs.set")?;
@@ -159,14 +184,52 @@ mod tests {
let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string();
assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters"));
assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character"));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb"})).unwrap();
let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect();
assert!(err(Value::from(many.clone())).contains("at most 256 tab stops"));
let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap();
assert_eq!(ok.len(), 256);
let e = s.execute("text.tabs.set", &json!({ "stops": many.clone() })).unwrap_err().to_string();
assert!(e.contains("at most 256 tab stops"), "{e}");
// The supplied list is counted, not the stops left once those at one position merge.
let same: Vec<Value> = (0..257).map(|_| json!({"position": 5})).collect();
assert!(s.execute("text.tabs.set", &json!({ "stops": same })).unwrap_err().to_string().contains("at most 256 tab stops"));
assert_eq!(s.execute("text.tabs.set", &json!({ "stops": &many[..256] })).unwrap()["stops"], json!(256));
let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap();
assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€'));
}
/// A paragraph that already holds more than 256 stops (from a file) can lose them one at a
/// time: a list no longer than every targeted paragraph's own is taken, a longer one is not, and
/// a paragraph with fewer stops targeted beside it keeps the limit.
#[test]
fn stops_can_be_removed_from_a_paragraph_holding_more_than_the_limit() {
use vectorcraft_doc::{NodeId, NodeKind, TabAlign, TabStop};
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap());
let held: Vec<TabStop> =
(0..300).map(|i| TabStop { position: f64::from(i), align: TabAlign::Left, leader: String::new(), align_on: '.' }).collect();
s.edit("Load", |d, _| {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.edit_paras(None, |pa| pa.tabs = held.clone());
}
Ok(())
})
.unwrap();
let list = |n: i32| json!({ "stops": (0..n).map(|i| json!({"position": i})).collect::<Vec<_>>() });
assert!(s.execute("text.tabs.set", &list(301)).unwrap_err().to_string().contains("at most 256 tab stops"));
assert_eq!(s.execute("text.tabs.set", &list(299)).unwrap()["stops"], json!(299));
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"].as_array().unwrap().len(), 299);
// The second paragraph (bytes 4 on) now holds 2 stops: 298 for both paragraphs is refused,
// and 298 for the first alone is taken.
s.execute("text.tabs.set", &json!({"stops": [{"position": 1}, {"position": 2}], "start": 4, "end": 4})).unwrap();
let mut both = list(298);
assert!(s.execute("text.tabs.set", &both).is_err());
both["start"] = json!(0);
both["end"] = json!(0);
assert_eq!(s.execute("text.tabs.set", &both).unwrap()["stops"], json!(298));
}
#[test]
fn set_get_clear_tabs() {
let mut s = Session::new();
+63 -42
View File
@@ -11,8 +11,8 @@ use super::menucmds::whole_or;
use super::stroke::StrokeChange;
use super::typecmd::refresh_bounds;
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
index, number_or, point_param, str_param, switch_or, targets, text_param,
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index,
number_or, object_param, point_param, str_param, switch_or, targets, text_param,
};
pub fn specs() -> Vec<CommandSpec> {
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
has_doc,
set_range_style
),
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Step Type",
[],
None,
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}",
has_doc,
type_step
),
@@ -148,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Tracking and Kerning",
[],
Some("Cmd+Alt+Q"),
"{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}",
"{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}",
has_selection,
reset_tracking
),
@@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> {
fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.editRange";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len, C)?;
@@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId
fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.startList";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let at = offset_or(p, "at", len, C)?.min(len);
@@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
}
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?;
if switch_or(p, "story", false, "text.getRange")? {
return get_story(s, p, id);
}
@@ -430,6 +430,8 @@ pub(crate) struct CharChange {
leading: Option<Option<f64>>,
tracking: Option<f64>,
kerning: Option<Option<f64>>,
/// The space before a character that starts a line by itself (`kerningBefore`).
kerning_before: Option<f64>,
optical_kerning: Option<bool>,
baseline_shift: Option<f64>,
h_scale: Option<f64>,
@@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result<Option<Option<f64>>> {
impl CharChange {
/// `setup` gives the superscript, subscript and small caps proportions.
pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result<Self> {
check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?;
check_switches(
p,
&["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"],
@@ -720,6 +722,7 @@ impl CharChange {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None),
Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)),
},
kerning_before: num("kerningBefore"),
optical_kerning: match p.get("kerning") {
Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true),
Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false),
@@ -777,6 +780,7 @@ impl CharChange {
&& self.leading.is_none()
&& self.tracking.is_none()
&& self.kerning.is_none()
&& self.kerning_before.is_none()
&& self.optical_kerning.is_none()
&& self.baseline_shift.is_none()
&& self.h_scale.is_none()
@@ -883,6 +887,9 @@ impl CharChange {
if let Some(v) = self.kerning {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.kerning_before {
st.kerning_before = v.clamp(-1000.0, 10000.0);
}
if let Some(v) = self.optical_kerning {
st.optical_kerning = v;
}
@@ -1254,7 +1261,7 @@ fn drawn_glyph(
fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setGlyphAlternate";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let gid = match p.get("gid") {
None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")),
Some(Value::Null) => None,
@@ -1344,7 +1351,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result<Value> {
fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.glyphAlternates";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
let t = text_ref(s, id)?;
let (ch, bytes, face) = glyph_target(&t, p, C)?;
let default = face.glyph_for(ch);
@@ -1359,7 +1366,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result<Value> {
fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.touchType";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
check_numbers(p, &["start"], C)?;
let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?;
if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
@@ -1585,7 +1592,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.createInPath";
let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?;
let on_path = match str_param(p, "mode").unwrap_or("area") {
"area" => false,
"onPath" | "path" => true,
@@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> {
s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default()
}
/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it
/// came from a caret ([`type_ranges`]).
pub(crate) type TypeRange = (NodeId, usize, usize, Option<usize>);
/// The text ranges a type shortcut changes: the range of `id`, else all the text of the type
/// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the
/// story after Select All in threaded text, [`editing_story`]), else all the text of every selected
/// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte
/// (None: nothing changes there). Each range comes with whether it came from a caret.
pub(crate) fn type_ranges(
s: &mut Session,
p: &Value,
c: &str,
at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>,
) -> Result<Vec<(NodeId, usize, usize, bool)>> {
/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret.
pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result<Vec<TypeRange>> {
// (text, its range; None: all of it).
let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? {
// An object that isn't type is refused as `ids` of it is.
@@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges(
let text = text_ref(s, id)?.plain_text();
let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len())));
let (a, b, caret) = if a < b {
(a, b, false)
(a, b, None)
} else {
match at_caret(&text, a) {
Some((a, b)) => (a, b, true),
Some((x, y)) => (x, y, Some(a)),
None => continue,
}
};
@@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
Step::Tracking | Step::Kerning => prefs.tracking_increment,
Step::BaselineShift => prefs.baseline_shift_increment,
};
// The ranges stepped. At a caret: kerning is the space after the character before it, leading
// the paragraph's; the rest wait for a selection.
// The ranges stepped. At a caret: kerning is the space after the character before it, or at
// the start of a line the space before the character after it (#5111), leading the
// paragraph's; the rest wait for a selection.
let ranges = type_ranges(s, p, C, |text, a| match step {
Step::Kerning => Some((edit::prev_char(text, a), a)),
Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)),
Step::Leading => {
let para = edit::paragraph_at(text, a);
Some((para.start, para.end))
}
_ => None,
})?;
// At a caret, the kerning stepped: (whether it is the space before the character, its value).
let mut caret_kerning = Vec::with_capacity(ranges.len());
for &(id, a, b, caret) in &ranges {
let value = if matches!(step, Step::Kerning) && caret {
let t = text_ref(s, id)?;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))
} else {
None
let value = match caret {
Some(c) if matches!(step, Step::Kerning) => {
let t = text_ref(s, id)?;
if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) {
Some((true, edit::style_at(&t.runs, a + 1).kerning_before))
} else {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)))
}
}
_ => None,
};
caret_kerning.push(value);
}
@@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => {
let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0));
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
}
Step::Kerning => match caret_kerning.get(range_index).copied().flatten() {
Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0),
caret => {
let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v);
st.kerning = Some((current + inc).clamp(-1000.0, 10000.0));
}
},
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
@@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
/// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in
/// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret,
/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there.
/// The automatic method remains selected.
/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns
/// there: of the character before it, or at the start of a line the space before the character
/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected.
fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?;
if !ranges.is_empty() {
s.edit("Reset Tracking and Kerning", |d, _| {
for &(id, a, b, caret) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| {
st.kerning = None;
if !caret {
let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before);
edit::style_range(&mut t.runs, a, b, |st| match before {
Some(true) => st.kerning_before = 0.0,
Some(false) => st.kerning = None,
None => {
st.kerning = None;
st.kerning_before = 0.0;
st.tracking = 0.0;
}
});
@@ -1975,7 +1996,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.discardEmpty";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?;
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
return Ok(json!({"removed": false}));
}
+10 -1
View File
@@ -188,8 +188,10 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
/// from a style instead leaves the text's own value alone.
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
// A chosen glyph belongs to one character, not to a reusable character style.
// A chosen glyph belongs to one character, and the space before a line's first character to
// where it stands (#5111), not to a reusable character style.
m.remove("glyphAlternate");
m.remove("kerningBefore");
for (k, x) in [
("font_version", json!(v.font_version)),
("fontAxes", json!(v.font_axes)),
@@ -243,6 +245,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
("kinsoku_type", json!(v.kinsoku_type)),
("roman_hanging", json!(v.roman_hanging)),
("kashida", json!(v.kashida)),
("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)),
] {
m.insert(k.into(), x);
}
@@ -530,6 +533,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
if m.contains_key("glyphAlternate") {
return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)"));
}
if m.contains_key("kerningBefore") {
return Err(bad(
&c,
"`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)",
));
}
let mut st = with_attrs(&CharStyle::default(), m, &c)?;
let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key);
for (key, value, lo, hi) in

Some files were not shown because too many files have changed in this diff Show More