mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 06:05:08 +00:00
Merge main into feat/eps-save-use-artboards
This commit is contained in:
@@ -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`) | |
|
||||
|
||||
@@ -122,10 +122,12 @@ 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.
|
||||
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
|
||||
- 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged.
|
||||
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
|
||||
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
|
||||
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
|
||||
@@ -138,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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control server (browsers can't listen on sockets);
|
||||
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||
//! `Services::inbox`;
|
||||
//! `Services::picked_inbox`;
|
||||
//! - Save / Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
|
||||
|
||||
@@ -17,7 +17,7 @@ use vectorcraft_ui_egui::i18n;
|
||||
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
use vectorcraft_ui_egui::stylus::StylusFeed;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
use vectorcraft_ui_egui::{PickedInbox, Services, VectorcraftApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::locks::WebLocks;
|
||||
@@ -25,6 +25,18 @@ use crate::pencil::SharedPointers;
|
||||
|
||||
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// The files waiting to open, by where they came from (`Services::inbox`, `picked_inbox` and
|
||||
/// `page_inbox`).
|
||||
#[derive(Clone, Default)]
|
||||
struct OpenInboxes {
|
||||
/// Dropped on the page.
|
||||
dropped: Inbox,
|
||||
/// The open picker's answers.
|
||||
picked: PickedInbox,
|
||||
/// Handed over by the page's script (`window.vectorcraft.openFile`).
|
||||
page: Inbox,
|
||||
}
|
||||
|
||||
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
|
||||
/// whether Shift was held: where dropped files land.
|
||||
type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
|
||||
@@ -48,7 +60,7 @@ const MAX_RESTARTS: u32 = 3;
|
||||
#[derive(Clone)]
|
||||
struct Host {
|
||||
runner: eframe::WebRunner,
|
||||
inbox: Inbox,
|
||||
inbox: OpenInboxes,
|
||||
place_inbox: PlaceInbox,
|
||||
script_inbox: ScriptInbox,
|
||||
drag: DragPos,
|
||||
@@ -77,7 +89,7 @@ pub fn start() {
|
||||
crate::fonts::load_startup_fonts().await;
|
||||
let host = Host {
|
||||
runner: eframe::WebRunner::new(),
|
||||
inbox: Arc::default(),
|
||||
inbox: OpenInboxes::default(),
|
||||
place_inbox: Arc::default(),
|
||||
script_inbox: Arc::default(),
|
||||
drag: DragPos::default(),
|
||||
@@ -132,13 +144,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
crate::api::connect(None, host.inbox.clone(), ctx.clone());
|
||||
crate::api::connect(None, host.inbox.page.clone(), ctx.clone());
|
||||
app
|
||||
}
|
||||
None => {
|
||||
// `window.vectorcraft`: the page's control channel (crate::api).
|
||||
let (control_tx, control_rx) = std::sync::mpsc::channel();
|
||||
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
|
||||
crate::api::connect(Some(control_tx), host.inbox.page.clone(), ctx.clone());
|
||||
if let Err(e) = crate::api::install() {
|
||||
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
|
||||
}
|
||||
@@ -409,7 +421,7 @@ impl eframe::App for WebShell {
|
||||
for f in dropped {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
let target = app.drop_target(&name, pos, shift);
|
||||
let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
|
||||
let (inbox, place_inbox, repaint) = (self.host.inbox.dropped.clone(), self.host.place_inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
@@ -432,14 +444,14 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn services(
|
||||
inbox: Inbox,
|
||||
inbox: OpenInboxes,
|
||||
place_inbox: PlaceInbox,
|
||||
script_inbox: ScriptInbox,
|
||||
ctx: egui::Context,
|
||||
locks: Option<WebLocks>,
|
||||
stylus: StylusFeed,
|
||||
) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let open_inbox = inbox.picked.clone();
|
||||
let picked = place_inbox.clone();
|
||||
let place_ctx = ctx.clone();
|
||||
let screen_color = eye_dropper(ctx.clone());
|
||||
@@ -488,11 +500,12 @@ fn services(
|
||||
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| {
|
||||
d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
|
||||
});
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
// A canceled pick answers `None`.
|
||||
let answer = match dialog.pick_file().await {
|
||||
Some(file) => Some((file.file_name(), file.read().await)),
|
||||
None => None,
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(answer);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
@@ -500,7 +513,9 @@ fn services(
|
||||
screen_color,
|
||||
// A download that couldn't be started fails the save or export it is for (#1693).
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
|
||||
inbox: Some(inbox),
|
||||
inbox: Some(inbox.dropped),
|
||||
picked_inbox: Some(inbox.picked),
|
||||
page_inbox: Some(inbox.page),
|
||||
recovery_store: Some(Arc::new(BrowserStore { locks })),
|
||||
// File → Print: the browser's print dialog.
|
||||
print: Some(Box::new(BrowserPrint)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
+15
-4
@@ -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;
|
||||
@@ -281,6 +281,10 @@ pub struct DxfOptions {
|
||||
pub scale_lineweights: bool,
|
||||
pub colors: ColorDepth,
|
||||
pub raster: RasterFormat,
|
||||
/// Placed images are written as IMAGE entities with their files (else left out, undecoded).
|
||||
pub include_images: bool,
|
||||
/// Filled areas get a solid HATCH after their outlines (else the outlines alone).
|
||||
pub include_hatches: bool,
|
||||
pub preserve: Preserve,
|
||||
/// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`]
|
||||
/// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have
|
||||
@@ -303,6 +307,8 @@ impl Default for DxfOptions {
|
||||
scale_lineweights: false,
|
||||
colors: ColorDepth::default(),
|
||||
raster: RasterFormat::default(),
|
||||
include_images: true,
|
||||
include_hatches: true,
|
||||
preserve: Preserve::default(),
|
||||
alter_paths: false,
|
||||
outline_text: false,
|
||||
@@ -322,7 +328,8 @@ pub enum DxfPreset {
|
||||
/// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight
|
||||
/// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize
|
||||
/// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths),
|
||||
/// and placed images as PNG.
|
||||
/// and placed images and fill hatches left out (fills are their outlines), with PNG as the
|
||||
/// format images take when they are put back in.
|
||||
Cnc,
|
||||
}
|
||||
|
||||
@@ -360,6 +367,8 @@ impl DxfPreset {
|
||||
scale_lineweights: false,
|
||||
colors: ColorDepth::Aci256,
|
||||
raster: RasterFormat::Png,
|
||||
include_images: false,
|
||||
include_hatches: false,
|
||||
preserve: Preserve::Editability,
|
||||
alter_paths: false,
|
||||
outline_text: true,
|
||||
@@ -377,6 +386,8 @@ impl DxfPreset {
|
||||
&& o.scale_lineweights == c.scale_lineweights
|
||||
&& o.colors == c.colors
|
||||
&& o.raster == c.raster
|
||||
&& o.include_images == c.include_images
|
||||
&& o.include_hatches == c.include_hatches
|
||||
&& o.preserve == c.preserve
|
||||
&& o.alter_paths == c.alter_paths
|
||||
&& o.outline_text == c.outline_text;
|
||||
|
||||
+44
-5
@@ -1,5 +1,6 @@
|
||||
//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint
|
||||
//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes
|
||||
//! order. Fills are their outlines then solid hatches (the outlines alone in R12 or with
|
||||
//! hatches left out), placed images are linked image files unless left out, strokes
|
||||
//! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or
|
||||
//! filled outlines where the look needs them.
|
||||
|
||||
@@ -28,6 +29,8 @@ const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are wr
|
||||
const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque";
|
||||
const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines";
|
||||
const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out";
|
||||
const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)";
|
||||
const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)";
|
||||
const WIDE_LINES: &str =
|
||||
"lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width";
|
||||
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
|
||||
@@ -339,6 +342,32 @@ fn subpaths(bp: &BezPath) -> Vec<Sub> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `bp` with each open subpath that can enclose an area closed, as a fill closes it: two or more
|
||||
/// segments, or one curve. A single straight segment stays open.
|
||||
fn closed_fill(bp: &BezPath) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let (mut segs, mut curved) = (0usize, false);
|
||||
let close = |out: &mut BezPath, segs: usize, curved: bool| {
|
||||
if segs >= 2 || (segs == 1 && curved) {
|
||||
out.close_path();
|
||||
}
|
||||
};
|
||||
for el in bp.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(_) => {
|
||||
close(&mut out, segs, curved);
|
||||
(segs, curved) = (0, false);
|
||||
}
|
||||
PathEl::ClosePath => (segs, curved) = (0, false),
|
||||
PathEl::LineTo(_) => segs += 1,
|
||||
PathEl::QuadTo(..) | PathEl::CurveTo(..) => (segs, curved) = (segs + 1, true),
|
||||
}
|
||||
out.push(*el);
|
||||
}
|
||||
close(&mut out, segs, curved);
|
||||
out
|
||||
}
|
||||
|
||||
/// The most pixels of an image written (it is held decoded, as RGBA and encoded at once).
|
||||
const MAX_IMAGE_PIXELS: u64 = 64 << 20;
|
||||
|
||||
@@ -878,13 +907,13 @@ impl<'a> Scene<'a> {
|
||||
Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st))
|
||||
}
|
||||
|
||||
/// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser
|
||||
/// and cutter software reads the outlines and skips hatches. `clean`: the path has no
|
||||
/// A filled area as its outlines followed by a solid hatch (R12, or with hatches left out: the
|
||||
/// outlines alone). Laser and cutter software reads the outlines and skips hatches. `clean`: the path has no
|
||||
/// overlaps, so the hatch's even-odd rule reads it as it is.
|
||||
fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) {
|
||||
if !self.o.version.handles() {
|
||||
self.warn(NO_FILLS);
|
||||
self.lines(bp, style, 0.0);
|
||||
self.lines(&closed_fill(bp), style, 0.0);
|
||||
return;
|
||||
}
|
||||
let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1;
|
||||
@@ -893,11 +922,17 @@ impl<'a> Scene<'a> {
|
||||
.flatten()
|
||||
.map(|p| p.to_bezpath());
|
||||
let outline = normalized.as_ref().unwrap_or(bp);
|
||||
// A fill with no loop of three points or more encloses no area: no outline and no hatch.
|
||||
let loops: Vec<Vec<Point>> = polygons(outline).into_iter().filter(|p| p.len() >= 3).collect();
|
||||
if loops.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.lines(outline, style, 0.0);
|
||||
// The outlines close as the fill does, so an open path's fill keeps its closing edge.
|
||||
self.lines(&closed_fill(outline), style, 0.0);
|
||||
if !self.o.include_hatches {
|
||||
self.warn(HATCHES_OFF);
|
||||
return;
|
||||
}
|
||||
let h = self.handle();
|
||||
let layer = self.layer.clone();
|
||||
self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch");
|
||||
@@ -1285,6 +1320,10 @@ impl<'a> Scene<'a> {
|
||||
self.warn(NO_IMAGES);
|
||||
return;
|
||||
}
|
||||
if !self.o.include_images {
|
||||
self.warn(IMAGES_OFF);
|
||||
return;
|
||||
}
|
||||
let Some(i) = self.image_def(im) else { return };
|
||||
let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return };
|
||||
let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1)));
|
||||
|
||||
@@ -843,10 +843,151 @@ fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_stro
|
||||
assert!(out.images.is_empty());
|
||||
assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions { include_images: true, ..o.clone() }), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions { include_hatches: true, ..o.clone() }), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc));
|
||||
}
|
||||
|
||||
/// A 4 × 2 px PNG placed at (10, 20) under key `raster-1`, and before it in paint order
|
||||
/// `fill`'s rectangle.
|
||||
fn image_and_fill_doc(fill: Paint) -> Document {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = doc_with(|d| {
|
||||
let im = ImageObject {
|
||||
key: "raster-1".into(),
|
||||
width: 4,
|
||||
height: 2,
|
||||
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
vec![
|
||||
path(d, shapes::rectangle(Rect::new(50.0, 10.0, 150.0, 60.0)), fill, Paint::None, 0.0),
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))),
|
||||
]
|
||||
});
|
||||
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
|
||||
d
|
||||
}
|
||||
|
||||
const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)";
|
||||
const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)";
|
||||
|
||||
#[test]
|
||||
fn fills_without_hatches_keep_their_outlines() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle");
|
||||
assert_eq!(out.warnings, [HATCHES_OFF]);
|
||||
let e = entities(&out);
|
||||
assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight");
|
||||
// Hatches stay by default, with no note.
|
||||
let on = export(&d, &opts(&d)).unwrap();
|
||||
assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF));
|
||||
// The outlines are the ones written beside hatches, also for overlapping subpaths, which are
|
||||
// merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way.
|
||||
let compound = doc_with(|d| {
|
||||
let mut bp = shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)).to_bezpath();
|
||||
bp.extend(shapes::rectangle(Rect::new(40.0, 40.0, 90.0, 90.0)).to_bezpath().elements().iter().copied());
|
||||
let p = PathData::from_bezpath(&bp);
|
||||
vec![
|
||||
path(d, p, Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
path(d, shapes::rectangle(Rect::new(10.0, 70.0, 90.0, 70.0)), Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
path(d, shapes::line(Point::new(100.0, 10.0), Point::new(150.0, 60.0)), Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
]
|
||||
});
|
||||
let without_hatches = |o: &DxfOutput| {
|
||||
let mut e = entities(o);
|
||||
e.retain(|x| kind(x) != "HATCH");
|
||||
e.into_iter().map(|x| x.into_iter().filter(|p| p.0 != 5).collect::<Pairs>()).collect::<Vec<_>>()
|
||||
};
|
||||
let (c_on, c_off) =
|
||||
(export(&compound, &opts(&compound)).unwrap(), export(&compound, &DxfOptions { include_hatches: false, ..opts(&compound) }).unwrap());
|
||||
assert_eq!(kinds(&c_on), ["LWPOLYLINE", "HATCH"], "one merged outline");
|
||||
assert_eq!(without_hatches(&c_off), without_hatches(&c_on));
|
||||
assert_eq!(without_hatches(&out), without_hatches(&on));
|
||||
// Strokes outlined for their look are areas too: their outlines stay.
|
||||
let altered =
|
||||
export(&d, &DxfOptions { include_hatches: false, alter_paths: true, preserve: Preserve::Editability, version: DxfVersion::R14, ..opts(&d) })
|
||||
.unwrap();
|
||||
assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered));
|
||||
// Nothing filled: no note.
|
||||
let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty());
|
||||
// R12 has no hatches anyway: the same outlines, and its own note alone.
|
||||
let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap();
|
||||
let (off, on) = (r12(false), r12(true));
|
||||
assert_eq!(off.bytes, on.bytes);
|
||||
assert_eq!(off.warnings, on.warnings);
|
||||
assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_path_s_fill_outline_keeps_its_closing_edge() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((10.0, 10.0));
|
||||
bp.line_to((90.0, 10.0));
|
||||
bp.line_to((50.0, 80.0));
|
||||
let open = PathData::from_bezpath(&bp);
|
||||
let d = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0)]);
|
||||
let closed = |e: &Pairs| get(e, 70).and_then(|v| v.trim().parse::<i64>().ok()).is_some_and(|v| v & 1 == 1);
|
||||
for include_hatches in [false, true] {
|
||||
let out = export(&d, &DxfOptions { include_hatches, ..opts(&d) }).unwrap();
|
||||
let e: Vec<Pairs> = entities(&out).into_iter().filter(|x| kind(x) == "LWPOLYLINE").collect();
|
||||
assert_eq!(e.len(), 2, "the fill's outline and the stroke: {:?}", kinds(&out));
|
||||
assert!(closed(&e[0]), "the fill's outline closes as the fill does (hatches {include_hatches})");
|
||||
assert!(!closed(&e[1]), "the stroke stays open (hatches {include_hatches})");
|
||||
}
|
||||
// R12 writes the outline alone, closed too.
|
||||
let fill_only = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::None, 0.0)]);
|
||||
let r12 = export(&fill_only, &DxfOptions { version: DxfVersion::R12, ..opts(&fill_only) }).unwrap();
|
||||
let e = entities(&r12);
|
||||
assert_eq!(kind(&e[0]), "POLYLINE", "{:?}", kinds(&r12));
|
||||
assert!(closed(&e[0]), "R12's outline closes as the fill does");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_can_be_left_out_with_their_files() {
|
||||
let d = image_and_fill_doc(Paint::None);
|
||||
let out = export(&d, &DxfOptions { include_images: false, ..opts(&d) }).unwrap();
|
||||
assert!(entities(&out).is_empty() && out.images.is_empty(), "{:?}", kinds(&out));
|
||||
let p = pairs(&out.bytes);
|
||||
assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES")));
|
||||
assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE")));
|
||||
assert_eq!(out.warnings, [IMAGES_OFF]);
|
||||
// An image that can't be decoded is not read when images are left out.
|
||||
let mut broken = d.clone();
|
||||
broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None });
|
||||
assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]);
|
||||
assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded")));
|
||||
// R12 has no images anyway: its own note alone.
|
||||
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap();
|
||||
assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both() {
|
||||
let d = image_and_fill_doc(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
let run = |o: DxfOptions| export(&d, &DxfOptions { region: d.artboards[0].rect, ..o }).unwrap();
|
||||
let both = run(DxfOptions::default());
|
||||
assert_eq!(kinds(&both), ["LWPOLYLINE", "HATCH", "IMAGE"]);
|
||||
assert_eq!(both.images.len(), 1);
|
||||
let no_images = run(DxfOptions { include_images: false, ..DxfOptions::default() });
|
||||
assert_eq!((kinds(&no_images), no_images.images.len()), (vec!["LWPOLYLINE".to_string(), "HATCH".into()], 0));
|
||||
let no_hatches = run(DxfOptions { include_hatches: false, ..DxfOptions::default() });
|
||||
assert_eq!((kinds(&no_hatches), no_hatches.images.len()), (vec!["LWPOLYLINE".to_string(), "IMAGE".into()], 1));
|
||||
// CNC / CAM: the cutting path alone, with no image file and both notes.
|
||||
let cnc = run(DxfPreset::Cnc.options());
|
||||
assert_eq!(kinds(&cnc), ["LWPOLYLINE"]);
|
||||
assert!(cnc.images.is_empty());
|
||||
assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings);
|
||||
let custom = DxfPreset::Custom.options();
|
||||
assert!(custom.include_images && custom.include_hatches, "Custom keeps both");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_become_dxf_layers() {
|
||||
let mut d = shapes_doc();
|
||||
|
||||
@@ -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);
|
||||
|
||||
+91
-14
@@ -1051,17 +1051,51 @@ 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>>)> {
|
||||
if spec.no_spine {
|
||||
return None;
|
||||
}
|
||||
layout_spine(keys, spec)
|
||||
}
|
||||
|
||||
/// The spine `spec` stores, unless the blend was made without one.
|
||||
fn stored_spine(spec: &BlendSpec) -> Option<&PathData> {
|
||||
spec.spine.as_ref().filter(|_| !spec.no_spine)
|
||||
}
|
||||
|
||||
/// [`blend_spine`] for placing the steps: a blend made without a spine still places them on the
|
||||
/// straight lines between its key centers.
|
||||
fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> {
|
||||
if keys.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let Some(stored) = &spec.spine else {
|
||||
let Some(stored) = stored_spine(spec) else {
|
||||
let centers: Vec<Point> = keys.iter().map(|k| center_of(k)).collect();
|
||||
return Some((PathData::single(SubPath::polyline(¢ers, 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) {
|
||||
@@ -1075,21 +1109,62 @@ pub fn blend_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Op
|
||||
/// [`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
|
||||
@@ -1127,10 +1202,10 @@ impl Rail {
|
||||
/// `None` when nothing moves: no stored spine and the steps keep the page's orientation.
|
||||
fn new(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<Self> {
|
||||
let rotate = spec.orientation == BlendOrientation::AlignToPath;
|
||||
if spec.spine.is_none() && !rotate {
|
||||
if stored_spine(spec).is_none() && !rotate {
|
||||
return None;
|
||||
}
|
||||
let (path, anchors) = blend_spine(keys, spec)?;
|
||||
let (path, anchors) = layout_spine(keys, spec)?;
|
||||
let spine = Spine::new(&path)?;
|
||||
let total = spine.length();
|
||||
let k = keys.len();
|
||||
@@ -1192,10 +1267,10 @@ impl Rail {
|
||||
/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing
|
||||
/// moves.
|
||||
pub(crate) fn rail_reach(keys: &[Arc<Node>], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option<Rect> {
|
||||
if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath {
|
||||
if stored_spine(spec).is_none() && spec.orientation != BlendOrientation::AlignToPath {
|
||||
return None;
|
||||
}
|
||||
let spine = blend_spine(keys, spec)?.0.bounds()?;
|
||||
let spine = layout_spine(keys, spec)?.0.bounds()?;
|
||||
let radius = keys
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
@@ -1404,7 +1479,9 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
for j in 1..=n {
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
let u = j as f64 / (n + 1) as f64;
|
||||
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||
// Easing needs a spine: a blend made without one keeps its steps evenly spaced.
|
||||
let eased = rail.is_some() && !spec.no_spine;
|
||||
let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
||||
let mut s = lerp.at_eased(t, tc);
|
||||
if let Some(r) = &rail {
|
||||
r.place(&mut s, i, t);
|
||||
|
||||
@@ -49,7 +49,7 @@ mod expand;
|
||||
mod mesh;
|
||||
mod warp;
|
||||
|
||||
pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec};
|
||||
pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, BlendSpineMode};
|
||||
#[cfg(test)]
|
||||
use blend_spec::{blend_color_shift, blend_ramp};
|
||||
pub use envelope::{
|
||||
|
||||
@@ -107,6 +107,11 @@ pub struct BlendSpec {
|
||||
/// on anchor points); `None`: chosen so closed shapes don't twist.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub starts: Vec<Option<u32>>,
|
||||
/// The blend was made without a spine (Create Spine › Never): it has no spine to show, edit,
|
||||
/// reverse or release, and no easing. Its steps still sit on the straight lines between the
|
||||
/// key centers. Replace Spine gives it one.
|
||||
#[serde(default, rename = "noSpine", skip_serializing_if = "is_false", deserialize_with = "lenient_bool")]
|
||||
pub no_spine: bool,
|
||||
}
|
||||
|
||||
impl BlendSpec {
|
||||
@@ -116,6 +121,41 @@ impl BlendSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Blend panel's Create Spine setting: whether a new blend gets a spine.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BlendSpineMode {
|
||||
/// The usual rule: every new blend gets a spine, the straight lines between its key centers.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Every new blend gets a spine, whatever its key objects are.
|
||||
Always,
|
||||
/// New blends get no spine.
|
||||
Never,
|
||||
}
|
||||
|
||||
impl BlendSpineMode {
|
||||
/// Every mode, in the order the Blend panel lists them.
|
||||
pub const ALL: [Self; 3] = [Self::Auto, Self::Always, Self::Never];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Always => "always",
|
||||
Self::Never => "never",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.id() == id)
|
||||
}
|
||||
|
||||
/// Whether a new blend made with this mode gets a spine.
|
||||
pub fn creates_spine(self) -> bool {
|
||||
self != Self::Never
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend Options set with no blend selected: what new blends start with.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlendDefaults {
|
||||
@@ -123,6 +163,29 @@ pub struct BlendDefaults {
|
||||
pub spacing: BlendSpacing,
|
||||
#[serde(default)]
|
||||
pub orientation: BlendOrientation,
|
||||
/// Create Spine (`object.blend.createSpine`).
|
||||
#[serde(default, rename = "createSpine", skip_serializing_if = "is_auto", deserialize_with = "spine_mode")]
|
||||
pub create_spine: BlendSpineMode,
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!*v
|
||||
}
|
||||
|
||||
fn is_auto(v: &BlendSpineMode) -> bool {
|
||||
*v == BlendSpineMode::Auto
|
||||
}
|
||||
|
||||
/// Reading a native file never fails over a `noSpine` that is not a boolean: the blend keeps its
|
||||
/// spine.
|
||||
fn lenient_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
|
||||
Ok(serde_json::Value::deserialize(d)?.as_bool().unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Reading the preferences never fails over an unknown Create Spine value: it becomes Auto.
|
||||
fn spine_mode<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BlendSpineMode, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(v.as_str().and_then(BlendSpineMode::from_id).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
|
||||
|
||||
@@ -156,6 +156,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
|
||||
BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules },
|
||||
BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn },
|
||||
BuiltinStyleLibrary { id: "textures", name: "Textures", make: textures },
|
||||
BuiltinStyleLibrary { id: "painted-sketched", name: "Painted and Sketched", make: painted_sketched },
|
||||
BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes },
|
||||
BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects },
|
||||
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
|
||||
@@ -323,6 +324,59 @@ fn hand_drawn() -> Vec<GraphicStyle> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Painted and Sketched: one catalog paint or sketch effect over the whole appearance, so it
|
||||
/// repaints the fill and the stroke together (watercolor, dry brush, pastels, charcoal and ink).
|
||||
fn painted_sketched() -> Vec<GraphicStyle> {
|
||||
vec![
|
||||
style(
|
||||
"Washed Blue",
|
||||
vec![F(fill("#8ab9c9"))],
|
||||
vec![fx("artistic.watercolor", json!({"brushDetail": 8.0, "shadowIntensity": 2.0, "texture": 2.0}))],
|
||||
),
|
||||
style(
|
||||
"Bristle Ochre",
|
||||
vec![F(fill("#c4975a")), S(stroke("#76533d", 1.0))],
|
||||
vec![fx("artistic.dryBrush", json!({"brushSize": 4.0, "brushDetail": 7.0, "texture": 2.0}))],
|
||||
),
|
||||
style(
|
||||
"Pastel Heather",
|
||||
vec![F(fill("#a78ca5"))],
|
||||
vec![fx(
|
||||
"artistic.roughPastels",
|
||||
json!({
|
||||
"strokeLength": 8.0,
|
||||
"strokeDetail": 9.0,
|
||||
"texture": "canvas",
|
||||
"scaling": 100.0,
|
||||
"relief": 5.0,
|
||||
"lightDirection": "topLeft",
|
||||
"invert": false
|
||||
}),
|
||||
)],
|
||||
),
|
||||
style(
|
||||
"Charcoal Shade",
|
||||
vec![F(fill("#666666"))],
|
||||
vec![fx("sketch.charcoal", json!({"thickness": 3.0, "detail": 3.0, "lightDarkBalance": 55.0}))],
|
||||
),
|
||||
style(
|
||||
"Ink Contour",
|
||||
vec![F(fill("#d9e2df")), S(stroke("#26323b", 1.0))],
|
||||
vec![fx("brushStrokes.inkOutlines", json!({"strokeLength": 6.0, "darkIntensity": 24.0, "lightIntensity": 8.0}))],
|
||||
),
|
||||
style(
|
||||
"Wet Ink",
|
||||
vec![F(fill("#637b85"))],
|
||||
vec![fx("brushStrokes.sumiE", json!({"strokeWidth": 7.0, "strokePressure": 5.0, "contrast": 20.0}))],
|
||||
),
|
||||
style(
|
||||
"Fine Pen",
|
||||
vec![F(fill("#d8d4c6"))],
|
||||
vec![fx("sketch.graphicPen", json!({"strokeLength": 6.0, "lightDarkBalance": 55.0, "strokeDirection": "rightDiagonal"}))],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and
|
||||
/// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper).
|
||||
fn textures() -> Vec<GraphicStyle> {
|
||||
@@ -603,6 +657,36 @@ mod tests {
|
||||
assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_and_sketched_follows_textures_with_one_effect_per_style() {
|
||||
let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect();
|
||||
let at = ids.iter().position(|&id| id == "painted-sketched").expect("listed");
|
||||
assert_eq!(ids[at - 1], "textures");
|
||||
let lib = builtin_style_library("painted-sketched").unwrap();
|
||||
assert_eq!(lib.name, "Painted and Sketched");
|
||||
let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Washed Blue", "Bristle Ochre", "Pastel Heather", "Charcoal Shade", "Ink Contour", "Wet Ink", "Fine Pen"]);
|
||||
let effects: Vec<&str> = lib.styles.iter().flat_map(|g| g.appearance.effects.iter().map(|e| e.id.as_str())).collect();
|
||||
assert_eq!(
|
||||
effects,
|
||||
[
|
||||
"artistic.watercolor",
|
||||
"artistic.dryBrush",
|
||||
"artistic.roughPastels",
|
||||
"sketch.charcoal",
|
||||
"brushStrokes.inkOutlines",
|
||||
"brushStrokes.sumiE",
|
||||
"sketch.graphicPen"
|
||||
]
|
||||
);
|
||||
assert!(lib.styles.iter().all(|g| g.appearance.items.iter().all(|i| i.effects().is_empty())), "no effects on a fill or stroke");
|
||||
// No other built-in library holds a style of the same name.
|
||||
for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "painted-sketched") {
|
||||
let other = builtin_style_library(b.id).unwrap();
|
||||
assert!(other.styles.iter().all(|g| !names.contains(&g.name.as_str())), "{}", b.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcstyles_round_trip() {
|
||||
let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ pub struct TabStop {
|
||||
pub align_on: char,
|
||||
}
|
||||
|
||||
impl TabStop {
|
||||
/// The largest stop position `text.tabs.set` and a paragraph style take, in points.
|
||||
pub const MAX_POSITION: f64 = 100_000.0;
|
||||
/// The most leader characters a stop repeats.
|
||||
pub const MAX_LEADER: usize = 8;
|
||||
/// The most stops `text.tabs.set` and a paragraph style take in one list.
|
||||
pub const MAX_STOPS: usize = 256;
|
||||
}
|
||||
|
||||
fn default_align_on() -> char {
|
||||
'.'
|
||||
}
|
||||
@@ -356,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
@@ -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…",
|
||||
|
||||
@@ -83,12 +83,14 @@ 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;
|
||||
|
||||
@@ -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(¶ms);
|
||||
let options = options_of(kind, ¶ms);
|
||||
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)
|
||||
|
||||
@@ -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, ¶ms).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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt
|
||||
Some(v) => Ok(Some(vec![checked_id(s, v, command)?])),
|
||||
}
|
||||
}
|
||||
/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form:
|
||||
/// for a command that looks its objects up itself, outside the document's layers too.
|
||||
pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
return ids_param(p, "ids", command);
|
||||
}
|
||||
match p.get("id").filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(r)) if r.starts_with('$') => {
|
||||
Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
|
||||
}
|
||||
Some(v) => {
|
||||
let id = v.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {v}: expected a non-negative integer")))?;
|
||||
Ok(Some(vec![id]))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
|
||||
pub(crate) fn index(v: u64) -> usize {
|
||||
usize::try_from(v).unwrap_or(usize::MAX)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -33,10 +33,10 @@ 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_remove,
|
||||
brush_selection_art, brush_set_current, brush_unused, expand_brush,
|
||||
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art,
|
||||
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::{
|
||||
@@ -67,6 +67,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_selection_art
|
||||
),
|
||||
cmd!(
|
||||
"brush.placeArt",
|
||||
"Place Brush Art",
|
||||
[],
|
||||
None,
|
||||
"{name, x?, y?} place a copy of the art a scatter, art or pattern brush is made of, centered on (x, y) (default: the center of the first artboard), and select it, as dragging the brush from the Brushes panel onto an empty part of the canvas does; a pattern brush places its tiles left to right (side, outer corner, inner corner, start, end; the ones it has art for) 10 pt apart, each in a group named Side Tile, Outer Corner Tile, Inner Corner Tile, Start Tile or End Tile, with an unpainted rectangle at the back of a tile that has a repeat frame marking that frame; a calligraphic or bristle brush is refused → {id}",
|
||||
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",
|
||||
|
||||
@@ -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> {
|
||||
@@ -483,6 +494,217 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": out }))
|
||||
}
|
||||
|
||||
/// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it
|
||||
/// 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.
|
||||
const TILE_GAP: f64 = 10.0;
|
||||
|
||||
/// The message for a brush that is not made of art.
|
||||
fn no_art(name: &str) -> String {
|
||||
format!("`{name}` has no art (only scatter, art and pattern brushes do)")
|
||||
}
|
||||
|
||||
/// Tile `i` of `p` ([`TILE_NAMES`] order): its art and its repeat frame, when it has art.
|
||||
fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_geom::Rect>)> {
|
||||
match i {
|
||||
0 => Some((&p.side, p.side_frame)),
|
||||
1 => p.outer_corner.as_ref().map(|n| (n, p.outer_corner_frame)),
|
||||
2 => p.inner_corner.as_ref().map(|n| (n, p.inner_corner_frame)),
|
||||
3 => p.start.as_ref().map(|n| (n, p.start_frame)),
|
||||
_ => p.end.as_ref().map(|n| (n, p.end_frame)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// marking that frame. `None` for a brush that is not made of art. Symbol instances in the art are
|
||||
/// measured by their symbols' art in `symbols`.
|
||||
fn placed_art(b: &Brush, symbols: &vectorcraft_doc::SymbolBoxes) -> Option<Node> {
|
||||
match &b.kind {
|
||||
BrushKind::Scatter(sc) => Some(sc.art.clone()),
|
||||
BrushKind::Art(a) => Some(a.art.clone()),
|
||||
BrushKind::Pattern(pb) => {
|
||||
let mut tiles = vec![];
|
||||
let mut x = 0.0;
|
||||
for (i, tile_name) in TILE_NAMES.iter().enumerate() {
|
||||
let Some((art, frame)) = tile(pb, i) else { continue };
|
||||
let mut children = vec![];
|
||||
if let Some(f) = frame {
|
||||
let marker = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::shapes::rectangle(f),
|
||||
vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0),
|
||||
);
|
||||
children.push(Arc::new(marker));
|
||||
}
|
||||
children.push(Arc::new(art.clone()));
|
||||
let mut g = Node::group(NodeId(0), children);
|
||||
g.name = Some((*tile_name).to_string());
|
||||
let Some(r) = g.geometric_bounds_with(symbols) else { continue };
|
||||
// Each tile sits right of the one before it, centered on the line y = 0.
|
||||
g.transform(vectorcraft_geom::Affine::translate((x - r.x0, -r.center().y)), false);
|
||||
x += r.width() + TILE_GAP;
|
||||
tiles.push(Arc::new(g));
|
||||
}
|
||||
Some(Node::group(NodeId(0), tiles))
|
||||
}
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `brush.placeArt`: a copy of the art brush `name` is made of, centered on (`x`, `y`) (default: the
|
||||
/// center of the first artboard) in the layer new art goes into, and selected, as one undo step.
|
||||
/// Scatter and art brushes place their art; pattern brushes place their tiles ([`placed_art`]).
|
||||
pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "brush.placeArt";
|
||||
let name = name_param(p, C)?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let center = d.artboards.first().map(|a| a.rect.center()).unwrap_or(super::Point::ZERO);
|
||||
let at = super::Point::new(super::number_or(p, "x", center.x, C)?, super::number_or(p, "y", center.y, C)?);
|
||||
let b = brush::find(d, &name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut art = placed_art(&b, &symbols).ok_or_else(|| bad(C, no_art(&name)))?;
|
||||
if let Some(r) = art.geometric_bounds_with(&symbols) {
|
||||
art.transform(vectorcraft_geom::Affine::translate(at - r.center()), false);
|
||||
}
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let id = s.edit("Place Brush Art", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
let id = art.id;
|
||||
d.insert(parent, usize::MAX, art)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
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();
|
||||
|
||||
@@ -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>>> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Document Info panel data, its categories and the text report (Document Info › Save…, File →
|
||||
//! Package), and Object → Make Pixel Perfect.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut images: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut image_rows: Vec<ImageRow> = vec![];
|
||||
// Whether the request being served may read each linked file (a relative path read from the
|
||||
// document's folder), checked once per file.
|
||||
let mut readable: HashMap<String, bool> = HashMap::new();
|
||||
let link_doc = super::links::LinkBase::of(st).doc;
|
||||
let mut may_read = |stored: &str| {
|
||||
*readable.entry(stored.to_string()).or_insert_with(|| {
|
||||
let resolved = super::fileio::document_path(stored, link_doc).ok();
|
||||
crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok()
|
||||
})
|
||||
};
|
||||
let mut patterns = BTreeSet::new();
|
||||
let mut symbols = BTreeSet::new();
|
||||
let mut spot = BTreeSet::new();
|
||||
@@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("textObjects")
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
// A linked file outside a confined request's read roots: `link` is null, and the row
|
||||
// names the file alone.
|
||||
let link = im.link.as_ref().map(|l| (l, may_read(&l.path)));
|
||||
images.insert(
|
||||
im.key.clone(),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }),
|
||||
);
|
||||
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
|
||||
image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone())));
|
||||
image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() })));
|
||||
Some("images")
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Refresh Deferred Effects",
|
||||
["Effect"],
|
||||
None,
|
||||
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
|
||||
"{ids?|id?: [object id] (objects of the document, also in the art of a symbol, pattern, brush or opacity mask; an object without a deferred stage adds nothing; an id of no object is an error; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
|
||||
has_deferred,
|
||||
refresh_deferred
|
||||
),
|
||||
@@ -150,16 +150,27 @@ fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) ->
|
||||
None => rest.push(object),
|
||||
}
|
||||
}
|
||||
if rest.is_empty() {
|
||||
for (id, n) in definition_objects(d, &rest) {
|
||||
out.entry(id).or_insert((n, false));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The objects of `ids` found in the art `d` draws from outside its layers: the art of its
|
||||
/// symbols, patterns and brushes, and opacity masks (in that art or in the layers).
|
||||
fn definition_objects(d: &Document, ids: &[u64]) -> std::collections::HashMap<u64, Arc<Node>> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
if ids.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let ids: std::collections::HashSet<u64> = ids.iter().copied().collect();
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let symbols = d.symbols.iter().map(|s| &*s.art);
|
||||
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
|
||||
let layers = d.layers.iter().map(|a| &**a);
|
||||
let mut look = |n: &Node| {
|
||||
if rest.contains(&n.id.0) {
|
||||
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
|
||||
if ids.contains(&n.id.0) {
|
||||
out.entry(n.id.0).or_insert_with(|| Arc::new(n.clone()));
|
||||
}
|
||||
};
|
||||
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
|
||||
@@ -231,10 +242,19 @@ fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
|
||||
let ids = super::named_ids(p)?;
|
||||
let objects = prune(&doc, &fx);
|
||||
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
// An object of the art drawn outside the layers is valid whether or not it has a stage.
|
||||
let elsewhere: Vec<u64> = ids.iter().flatten().filter(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)).map(|id| id.0).collect();
|
||||
let found = definition_objects(&doc, &elsewhere);
|
||||
if let Some(id) = elsewhere.iter().find(|id| !found.contains_key(id)) {
|
||||
return Err(EngineError::NoNode(NodeId(*id)));
|
||||
}
|
||||
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
|
||||
// Only the objects with a stage have anything to refresh.
|
||||
let ids: Option<Vec<u64>> = ids.map(|ids| {
|
||||
let mut ids: Vec<u64> = ids.iter().map(|id| id.0).filter(|id| objects.contains_key(id)).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
});
|
||||
let counts = fx.refresh(ids.as_deref(), |targets| {
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut whole = Vec::new();
|
||||
@@ -656,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())
|
||||
|
||||
@@ -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;
|
||||
@@ -90,9 +92,9 @@ pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option,
|
||||
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
|
||||
templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths,
|
||||
reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
|
||||
template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
|
||||
@@ -124,7 +126,9 @@ pub(crate) use access::list_dir;
|
||||
pub use access::write_file_with;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file};
|
||||
pub(crate) use access::{
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
|
||||
};
|
||||
#[cfg(all(test, windows))]
|
||||
pub(crate) use access::{document_path_on, document_path_with};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -42,6 +42,29 @@ pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
(out, seen)
|
||||
}
|
||||
|
||||
/// May a reply to the request being served name `path`, a file or folder the client did not give?
|
||||
/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]).
|
||||
pub(crate) fn shown(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name,
|
||||
/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such
|
||||
/// as `..` or a folder ending in a separator).
|
||||
pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> {
|
||||
shown_link(path, None)
|
||||
}
|
||||
|
||||
/// A link's `stored` path as a reply to the request being served names it: whole when the file it
|
||||
/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it.
|
||||
pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> {
|
||||
if shown(resolved.unwrap_or(stored)) {
|
||||
return std::borrow::Cow::Borrowed(stored);
|
||||
}
|
||||
let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default();
|
||||
std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
|
||||
@@ -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
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export DXF",
|
||||
[],
|
||||
None,
|
||||
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused",
|
||||
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; includeImages and includeHatches false, so fills are their outlines and no image files are written; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), includeImages?: true (false: placed images and their files are left out), includeHatches?: true (false: fills are their outlines, without solid hatches), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused",
|
||||
has_doc,
|
||||
export_dxf
|
||||
)]
|
||||
@@ -37,7 +37,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[
|
||||
name: "dxfPreset",
|
||||
ty: "string",
|
||||
default: "\"custom\"",
|
||||
description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it",
|
||||
description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, no images, no fill hatches); the options given override it",
|
||||
},
|
||||
FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" },
|
||||
FormatOption {
|
||||
@@ -60,6 +60,18 @@ pub(crate) const OPTIONS: &[FormatOption] = &[
|
||||
default: "\"png\"",
|
||||
description: "png or jpeg: the files placed images are written to, next to the drawing",
|
||||
},
|
||||
FormatOption {
|
||||
name: "includeImages",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "placed images are written, linked to their files; false leaves them and their files out",
|
||||
},
|
||||
FormatOption {
|
||||
name: "includeHatches",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "filled areas get a solid hatch after their outlines; false writes the outlines alone",
|
||||
},
|
||||
FormatOption {
|
||||
name: "preserve",
|
||||
ty: "string",
|
||||
@@ -133,6 +145,8 @@ struct Params {
|
||||
scale_lineweights: Option<bool>,
|
||||
colors: Option<Value>,
|
||||
raster_format: Option<String>,
|
||||
include_images: Option<bool>,
|
||||
include_hatches: Option<bool>,
|
||||
preserve: Option<String>,
|
||||
alter_paths: Option<bool>,
|
||||
outline_text: Option<bool>,
|
||||
@@ -176,6 +190,8 @@ fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> {
|
||||
scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights),
|
||||
colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?,
|
||||
raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?,
|
||||
include_images: q.include_images.unwrap_or(d.include_images),
|
||||
include_hatches: q.include_hatches.unwrap_or(d.include_hatches),
|
||||
preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?,
|
||||
alter_paths: q.alter_paths.unwrap_or(d.alter_paths),
|
||||
outline_text: q.outline_text.unwrap_or(d.outline_text),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, ®ions).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(®ions)));
|
||||
Ok(enc)
|
||||
}
|
||||
@@ -890,7 +890,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);
|
||||
@@ -907,6 +913,9 @@ impl SaveJob {
|
||||
if let Some(source) = &plan.lossy_source {
|
||||
super::check_files_not_lossy_overwrite(source, files.iter().map(|(p, _)| p.as_str()), plan.mode.command())?;
|
||||
}
|
||||
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)?;
|
||||
}
|
||||
@@ -1042,11 +1051,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let out = job.write_streamed(&mut super::write_file_with)?;
|
||||
let mut out = job.write_streamed(&mut super::write_file_with)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The reply `out` of a save with the `path` param `given`, as the request being served may see it
|
||||
/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is
|
||||
/// left out, and the `files` and `linked` written beside it are named by their file names, when they
|
||||
/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it
|
||||
/// is outside them. Without roots in force `out` is unchanged.
|
||||
pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) {
|
||||
use super::access::{shown, shown_path};
|
||||
|
||||
if crate::file_access::current().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(o) = out.as_object_mut() else { return };
|
||||
if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) {
|
||||
o.remove("folder");
|
||||
}
|
||||
// Every path the reply names then is the one the client gave, or beside it.
|
||||
if given.is_some_and(|g| !g.is_empty()) {
|
||||
return;
|
||||
}
|
||||
if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) {
|
||||
o.remove("path");
|
||||
}
|
||||
for key in ["files", "linked"] {
|
||||
if let Some(Value::Array(rows)) = o.get_mut(key) {
|
||||
for row in rows {
|
||||
if let Value::String(p) = row
|
||||
&& !shown(p)
|
||||
{
|
||||
*row = json!(shown_path(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
match (&st.path, st.is_dirty()) {
|
||||
@@ -1133,7 +1178,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
@@ -644,12 +644,86 @@ impl Output {
|
||||
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
|
||||
/// the params remembered in the document (`document.exportSettings`).
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A folder the client gave, as against the one its `exportPreset` keeps.
|
||||
let given = p.get("folder").is_some();
|
||||
let p = with_preset(s, p)?;
|
||||
let r = export(s, &p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
Some(folder) => leave_out_folder_from_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
remember(s, &p);
|
||||
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
|
||||
leave_out_unreadable_folder(&mut r, folder);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error of an export into `folder`, a folder outside the read roots that the client did not
|
||||
/// give: `folder` as the path the error is about names it by its own name
|
||||
/// ([`super::access::shown_path`]), and the rest of the message stays.
|
||||
fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError {
|
||||
let name = super::access::shown_path(folder);
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) },
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start
|
||||
/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or
|
||||
/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message
|
||||
/// names stays whole, even one inside `folder`.
|
||||
fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(folder)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
|
||||
/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
|
||||
/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
|
||||
/// as the reply of an export to a ZIP names them.
|
||||
fn leave_out_unreadable_folder(r: &mut Value, folder: &str) {
|
||||
if super::access::shown(folder) {
|
||||
return;
|
||||
}
|
||||
let Some(o) = r.as_object_mut() else { return };
|
||||
o.remove("path");
|
||||
let inside = format!("{folder}/");
|
||||
if let Some(Value::Array(files)) = o.get_mut("files") {
|
||||
for file in files {
|
||||
// A ZIP's files are named relative to it already.
|
||||
if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) {
|
||||
*file = json!(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export settings kept in the document or a preset, as the request being served may see them: a
|
||||
/// `folder` outside the read roots ([`super::access::shown`]) is left out.
|
||||
fn leave_out_unreadable_settings_folder(mut settings: Map<String, Value>) -> Map<String, Value> {
|
||||
if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) {
|
||||
settings.remove("folder");
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
// ---------- export presets ----------
|
||||
|
||||
/// A saved batch export: what to write and how to name and sort it, to use again
|
||||
@@ -727,7 +801,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value {
|
||||
}
|
||||
|
||||
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) }))
|
||||
let saved: Vec<ScreenExportPreset> = s
|
||||
.prefs
|
||||
.screen_export_presets
|
||||
.iter()
|
||||
.map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) })
|
||||
.collect();
|
||||
Ok(json!({ "presets": preset_list(&saved) }))
|
||||
}
|
||||
|
||||
/// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would
|
||||
@@ -831,6 +911,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(too_many());
|
||||
}
|
||||
let count = presets.len();
|
||||
let presets: Vec<ScreenExportPreset> =
|
||||
presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect();
|
||||
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?;
|
||||
if text.len() > MAX_PRESET_FILE_BYTES {
|
||||
return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
|
||||
@@ -1388,12 +1470,31 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map<String, Value>)
|
||||
|
||||
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
|
||||
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "settings": s.doc()?.doc.export_settings }))
|
||||
Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, ScreenSize, safe_file_name};
|
||||
use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name};
|
||||
|
||||
/// A hidden export folder is named by its name where a message is about it, and a root that
|
||||
/// starts with the same text, or a longer path, stays whole.
|
||||
#[test]
|
||||
fn a_hidden_folder_is_named_by_its_name_in_a_message() {
|
||||
let f = "/work/out";
|
||||
let cases = [
|
||||
(
|
||||
"automation path rejected: outside the write root /work/out/in: /work/out",
|
||||
"automation path rejected: outside the write root /work/out/in: out",
|
||||
),
|
||||
("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"),
|
||||
("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"),
|
||||
("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"),
|
||||
];
|
||||
for (msg, want) in cases {
|
||||
assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Files given one name are numbered in turn, each numbering going on from the last one (so
|
||||
/// thousands of them don't each try every number used before), and a file asking again for
|
||||
|
||||
@@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\`
|
||||
/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole.
|
||||
#[test]
|
||||
fn a_hidden_path_shows_as_its_file_name_on_every_platform() {
|
||||
let windows = "C:\\Users\\someone\\art\\red.png";
|
||||
crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || {
|
||||
for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] {
|
||||
assert_eq!(shown_path(path), name, "{path}");
|
||||
}
|
||||
});
|
||||
assert_eq!(shown_path(windows), windows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_every_readable_format() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
@@ -418,6 +431,7 @@ fn export_extensions_name_every_writable_format() {
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"plt",
|
||||
"tga",
|
||||
"psd"
|
||||
]
|
||||
@@ -453,6 +467,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"hpgl",
|
||||
"tga",
|
||||
"psd"
|
||||
]
|
||||
|
||||
@@ -84,6 +84,11 @@ fn the_cnc_preset_sets_the_options_that_are_not_given() {
|
||||
true
|
||||
)
|
||||
);
|
||||
assert_eq!((o.include_images, o.include_hatches), (false, false), "CNC / CAM leaves out images and fill hatches");
|
||||
let given = dxf::options(&json!({"dxfPreset": "cnc", "includeImages": true, "includeHatches": true})).unwrap();
|
||||
assert_eq!((given.include_images, given.include_hatches), (true, true), "the options given override it");
|
||||
let custom = dxf::options(&json!({})).unwrap();
|
||||
assert_eq!((custom.include_images, custom.include_hatches), (true, true), "Custom keeps both");
|
||||
assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default");
|
||||
let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string();
|
||||
assert!(e.contains("custom or cnc"), "{e}");
|
||||
@@ -151,6 +156,44 @@ fn placed_images_come_back_as_linked_files() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// `includeImages` and `includeHatches` leave out placed images (with their files) and fill
|
||||
/// hatches, each on its own, through both commands, with and without a path; CNC / CAM leaves
|
||||
/// out both, and the options given override it.
|
||||
#[test]
|
||||
fn placed_images_and_fill_hatches_can_be_left_out() {
|
||||
let mut s = super::tests_svg::image_session();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap();
|
||||
let counts = |r: &Value| {
|
||||
let e = entities(&text(r));
|
||||
let n = |k: &str| e.iter().filter(|x| *x == k).count();
|
||||
(n("LWPOLYLINE"), n("HATCH"), n("IMAGE"), r.get("linked").and_then(Value::as_array).map_or(0, Vec::len))
|
||||
};
|
||||
let warned = |r: &Value, w: &str| r["warnings"].as_array().unwrap().iter().any(|x| x.as_str().is_some_and(|x| x.starts_with(w)));
|
||||
let all = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
|
||||
assert_eq!(counts(&all), (1, 1, 1, 1));
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "includeImages": false})).unwrap();
|
||||
assert_eq!(counts(&r), (1, 1, 0, 0));
|
||||
assert!(warned(&r, "placed images are left out") && !warned(&r, "fill hatches"), "{r}");
|
||||
let r = s.execute("document.exportDxf", &json!({"includeHatches": false})).unwrap();
|
||||
assert_eq!(counts(&r), (1, 0, 1, 1));
|
||||
assert!(warned(&r, "fill hatches are left out") && !warned(&r, "placed images"), "{r}");
|
||||
let r = s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap();
|
||||
assert_eq!(counts(&r), (1, 0, 0, 0));
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc", "includeImages": true})).unwrap();
|
||||
assert_eq!(counts(&r), (1, 0, 1, 1));
|
||||
// With a path, no image file is written next to the drawing.
|
||||
let dir = std::env::temp_dir().join(format!("vc-dxf-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("cut.dxf").to_string_lossy().to_string();
|
||||
let r = s.execute("document.exportDxf", &json!({"path": path, "dxfPreset": "cnc"})).unwrap();
|
||||
assert!(r.get("linked").is_none(), "{r}");
|
||||
let files: Vec<String> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
assert_eq!(files, ["cut.dxf"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
|
||||
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
|
||||
/// image's key.
|
||||
@@ -181,6 +224,8 @@ fn bad_dxf_options_are_refused() {
|
||||
json!({"rasterFormat": "gif"}),
|
||||
json!({"preserve": "both"}),
|
||||
json!({"alterPaths": "yes"}),
|
||||
json!({"includeImages": "no"}),
|
||||
json!({"includeHatches": 0}),
|
||||
] {
|
||||
let e = s.execute("document.exportDxf", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("DXF"), "{p}: {e}");
|
||||
@@ -203,6 +248,8 @@ fn formats_list_dxf_and_what_can_not_be_written() {
|
||||
"scaleLineweights",
|
||||
"colors",
|
||||
"rasterFormat",
|
||||
"includeImages",
|
||||
"includeHatches",
|
||||
"preserve",
|
||||
"alterPaths",
|
||||
"outlineText",
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -667,6 +667,118 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() {
|
||||
assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder");
|
||||
}
|
||||
|
||||
/// An export into the folder an export preset keeps, for a client whose read roots don't hold that
|
||||
/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder
|
||||
/// inside the read roots, and one the client gives, stay whole.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("preset-roots");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap();
|
||||
let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, p: Value| confine(roots.as_ref(), || export(s, p));
|
||||
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"}));
|
||||
assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}");
|
||||
assert!(out.join("1x/Artboard-1.png").is_file());
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}));
|
||||
assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}");
|
||||
assert!(!r.to_string().contains(&folder), "{r}");
|
||||
// Inside the read roots, or given by the client: whole.
|
||||
assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")]));
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}));
|
||||
assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}");
|
||||
}
|
||||
|
||||
/// An export into the folder an export preset keeps, refused because that folder is outside the
|
||||
/// client's read and write roots: the error names the folder by its name only. A folder the client
|
||||
/// gives stays whole in the error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("preset-refused");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string();
|
||||
|
||||
let e = run(json!({"exportPreset": "Kept"}));
|
||||
assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}");
|
||||
let e = run(json!({"exportPreset": "Kept", "folder": folder}));
|
||||
assert!(e.contains(&folder), "{e}");
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written");
|
||||
// A preset folder that holds the write root: the root stays whole in the error.
|
||||
let parent = tmp.0.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": parent});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap();
|
||||
let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string();
|
||||
let root = inside.display().to_string();
|
||||
let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(e.contains(&format!("root {root}: {name}")), "{e}");
|
||||
}
|
||||
|
||||
/// The export settings the document keeps and the saved presets, read by a client whose read roots
|
||||
/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and
|
||||
/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest.
|
||||
/// Inside the read roots the folder stays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn kept_settings_leave_out_a_folder_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("settings-roots");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "svg"}], "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
export(&mut s, json!({"exportPreset": "Kept"}));
|
||||
let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap();
|
||||
let ask = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, cmd: &str, p: Value| {
|
||||
confine(roots.as_ref(), || s.execute(cmd, &p).unwrap())
|
||||
};
|
||||
let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone();
|
||||
|
||||
let r = ask(&mut s, &confined, "document.exportSettings", json!({}));
|
||||
assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}");
|
||||
let r = ask(&mut s, &confined, "document.exportPresets.list", json!({}));
|
||||
assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}");
|
||||
let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]}));
|
||||
let data = r["data"].as_str().unwrap();
|
||||
assert!(!data.contains(&folder) && data.contains("svg"), "{data}");
|
||||
let file = inside.join("kept.vcexportpresets");
|
||||
let path = file.to_string_lossy().replace('\\', "/");
|
||||
ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path}));
|
||||
assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder));
|
||||
|
||||
// Inside the read roots: whole.
|
||||
assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder));
|
||||
assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder));
|
||||
assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_settings_apply_to_their_rows_and_rows_win() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use vectorcraft_doc::live::BlendSpineMode;
|
||||
|
||||
use super::common::{
|
||||
DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of,
|
||||
spacing_param, starts_param, wrap,
|
||||
@@ -61,6 +63,19 @@ fn extend_spine(spec: &mut BlendSpec, old: &[Arc<Node>], old_spec: &BlendSpec, k
|
||||
spec.spine = Some(path);
|
||||
}
|
||||
|
||||
/// Does `p` set easing (`easingType` or `ramp`)? Easing needs a spine.
|
||||
fn gives_easing(p: &Value) -> bool {
|
||||
["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null()))
|
||||
}
|
||||
|
||||
/// The Create Spine mode `p` gives as `key`, or `current` when it is omitted or null.
|
||||
fn spine_mode_param(p: &Value, cmd: &str, key: &str, current: BlendSpineMode) -> Result<BlendSpineMode> {
|
||||
match p.get(key).filter(|v| !v.is_null()) {
|
||||
None => Ok(current),
|
||||
Some(v) => v.as_str().and_then(BlendSpineMode::from_id).ok_or_else(|| bad(cmd, format!("`{key}` must be one of auto, always, never"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.make";
|
||||
let roots = roots_param(s, p)?;
|
||||
@@ -69,16 +84,22 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let starts = starts_param(p, &roots)?;
|
||||
let base = first_blend(&s.doc()?.doc, &roots);
|
||||
let defaults = s.prefs.blend_options.unwrap_or_default();
|
||||
// Create Spine applies to a new blend; one taking more keys keeps whether it has a spine.
|
||||
let create_spine = spine_mode_param(p, C, "createSpine", defaults.create_spine)?;
|
||||
let cur = match base.as_ref().map(|n| &n.kind) {
|
||||
Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation },
|
||||
_ => s.prefs.blend_options.unwrap_or_default(),
|
||||
Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation, ..defaults },
|
||||
_ => defaults,
|
||||
};
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
|
||||
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
|
||||
_ => Default::default(),
|
||||
let (ease0, color0, no_spine) = match base.as_ref().map(|n| &n.kind) {
|
||||
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift, spec.no_spine),
|
||||
_ => (Default::default(), 0.0, !create_spine.creates_spine()),
|
||||
};
|
||||
if no_spine && gives_easing(p) {
|
||||
return Err(bad(C, "the blend has no spine"));
|
||||
}
|
||||
let ease = ease_param(p, C, ease0)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
|
||||
let id = s.edit("Make Blend", |d, sel| {
|
||||
@@ -91,7 +112,7 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.knockout = vectorcraft_doc::Knockout::On;
|
||||
n
|
||||
});
|
||||
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
|
||||
let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, no_spine, ..Default::default() };
|
||||
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
|
||||
extend_spine(&mut spec, old, old_spec, &children);
|
||||
}
|
||||
@@ -303,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);
|
||||
@@ -346,9 +371,12 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Nothing to set them on: the options new blends start with (easing is per blend).
|
||||
let d = s.prefs.blend_options.unwrap_or_default();
|
||||
let spacing = spacing_param(p, C, d.spacing)?;
|
||||
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
|
||||
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation), ..d });
|
||||
return Ok(json!({ "defaults": true }));
|
||||
};
|
||||
if gives_easing(p) && any_without_spine(s) {
|
||||
return Err(bad(C, "the blend has no spine"));
|
||||
}
|
||||
let spacing = spacing_param(p, C, cur.spacing)?;
|
||||
let orientation = orientation_param(p, cur.orientation);
|
||||
let ease = ease_param(p, C, cur.ease)?;
|
||||
@@ -384,6 +412,7 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
v["colorShift"] = json!(spec.color_shift);
|
||||
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
|
||||
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
|
||||
v["hasSpine"] = json!(!spec.no_spine);
|
||||
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
|
||||
&& let Some((path, anchors)) = live::blend_spine(children, &spec)
|
||||
&& let Some(sp) = path.subpaths.first()
|
||||
@@ -403,6 +432,19 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The Blend panel's Create Spine: with `mode`, sets whether new blends get a spine; without, reads
|
||||
/// it. A tool setting kept with the preferences, not an undo step; blends already made keep theirs.
|
||||
pub(super) fn blend_create_spine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.createSpine";
|
||||
let mut d = s.prefs.blend_options.unwrap_or_default();
|
||||
let mode = spine_mode_param(p, C, "mode", d.create_spine)?;
|
||||
if mode != d.create_spine {
|
||||
d.create_spine = mode;
|
||||
s.prefs.blend_options = Some(d);
|
||||
}
|
||||
Ok(json!({ "mode": mode.id() }))
|
||||
}
|
||||
|
||||
pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let blends = selected_of(s, is_blend);
|
||||
let ids = s.edit("Expand Blend", |d, sel| {
|
||||
@@ -507,6 +549,8 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?;
|
||||
d.remove(path)?;
|
||||
if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) {
|
||||
// A blend made without a spine gets this one.
|
||||
spec.no_spine = false;
|
||||
spec.spine = Some(spine);
|
||||
spec.key_anchors.clear();
|
||||
keys_to_spine(children, spec);
|
||||
@@ -517,7 +561,16 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Is any selected blend one made without a spine?
|
||||
fn any_without_spine(s: &Session) -> bool {
|
||||
let Ok(st) = s.doc() else { return false };
|
||||
selected_of(s, is_blend).iter().any(|b| matches!(st.doc.node(*b).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine))
|
||||
}
|
||||
|
||||
pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if any_without_spine(s) {
|
||||
return Err(bad("object.blend.reverseSpine", "the blend has no spine"));
|
||||
}
|
||||
edit_blends(s, "Reverse Spine", |children, spec| {
|
||||
if spec.spine.is_some() && pin(children, spec) {
|
||||
// The keys keep their anchors, which now count from the other end.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::blend::{
|
||||
blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack,
|
||||
spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine,
|
||||
blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
};
|
||||
use super::common::{has_blend, has_envelope, has_mesh};
|
||||
use super::envelope::{
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Blend"],
|
||||
Some("Cmd+Alt+B"),
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
|
||||
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even), createSpine?: auto|always|never (default: the mode object.blend.createSpine sets; never: a blend without a spine, which takes no easing)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name, transparency and whether it has a spine) instead of nesting → {id}",
|
||||
has_doc,
|
||||
blend_make
|
||||
),
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Release",
|
||||
["Object", "Blend"],
|
||||
Some("Cmd+Alt+Shift+B"),
|
||||
"{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}",
|
||||
"{} release the selected blends: the key objects come back and the spine (if the blend has one) stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}",
|
||||
has_blend,
|
||||
blend_release
|
||||
),
|
||||
@@ -40,6 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
blend_options
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.createSpine",
|
||||
"Create Spine",
|
||||
[],
|
||||
None,
|
||||
"{mode?: auto|always|never} the Blend panel's Create Spine: whether new blends get a spine (auto and always: the straight lines between the key centers, as before; never: no spine to show, edit, reverse or release, and no easing, until Replace Spine gives one); with no `mode`, reads it; blends already made keep theirs (a tool setting kept with the preferences, not an undo step) → {mode}",
|
||||
always,
|
||||
blend_create_spine
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.expand",
|
||||
"Expand",
|
||||
@@ -54,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Spine",
|
||||
["Object", "Blend"],
|
||||
None,
|
||||
"{} use the selected path as the spine of the selected blend (the path is consumed)",
|
||||
"{} use the selected path as the spine of the selected blend (the path is consumed); a blend made without a spine gets this one",
|
||||
has_blend,
|
||||
blend_replace_spine
|
||||
),
|
||||
@@ -63,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reverse Spine",
|
||||
["Object", "Blend"],
|
||||
None,
|
||||
"{} reverse the order of the key objects along the spine",
|
||||
"{} reverse the order of the key objects along the spine; an error when a selected blend has no spine",
|
||||
has_blend,
|
||||
blend_reverse_spine
|
||||
),
|
||||
@@ -72,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blend Info",
|
||||
[],
|
||||
None,
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], hasSpine?: false for a blend made without a spine (no `spine` then), spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
|
||||
always,
|
||||
blend_info
|
||||
),
|
||||
|
||||
@@ -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 ----------
|
||||
@@ -419,7 +419,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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reflect…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
"{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
reflect
|
||||
),
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fileio::{create_dir, write_file};
|
||||
use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file};
|
||||
use super::links::LinkBase;
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Package",
|
||||
[],
|
||||
None,
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder. The warnings and the report name a file outside the automation read roots by its file name",
|
||||
has_doc,
|
||||
package
|
||||
)]
|
||||
@@ -178,8 +178,10 @@ impl<'a> Collector<'a> {
|
||||
|
||||
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
|
||||
/// copy of `source` finds it only by its name, beside it.
|
||||
/// `path` is the link's path as the warning names it.
|
||||
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
|
||||
if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) {
|
||||
let source = shown_path(source);
|
||||
let message = format!(
|
||||
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
|
||||
);
|
||||
@@ -207,11 +209,13 @@ impl<'a> Collector<'a> {
|
||||
let section = self.sections.len();
|
||||
self.sections.push(vec![]);
|
||||
let mut lines = vec![];
|
||||
// The packaged document's sections read as before; a placed document's name its file.
|
||||
// The packaged document's sections read as before; a placed document's name its file. The
|
||||
// report and the warnings name a file outside a confined request's read roots by its name.
|
||||
let shown_source = shown_path(source);
|
||||
let heading = |lines: &mut Vec<String>, title: &str| {
|
||||
lines.push(title.into());
|
||||
if depth > 0 {
|
||||
lines.push(format!("Document: {source}"));
|
||||
lines.push(format!("Document: {shown_source}"));
|
||||
}
|
||||
};
|
||||
// A file's saved path is only meaningful inside its own document.
|
||||
@@ -243,17 +247,21 @@ impl<'a> Collector<'a> {
|
||||
heading(&mut lines, "LINKED FILES");
|
||||
for file in linked_files(doc) {
|
||||
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
|
||||
// The stored path, whole when the place it names (read from this document's folder) is
|
||||
// inside the read roots. A file found elsewhere does not show a stored path outside them.
|
||||
let resolved = document_path(&file.path, Some(source)).ok();
|
||||
let shown = shown_link(&file.path, resolved.as_deref()).into_owned();
|
||||
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
|
||||
// The ancestor is already included (possibly the root document). A
|
||||
// cyclic reference cannot have a final content hash until both files
|
||||
// have been written, so leave its size/hash unspecified, not stale.
|
||||
self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle"));
|
||||
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = self.copies.get(found_path).cloned() {
|
||||
if by_name {
|
||||
self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
|
||||
self.renamed(&mut lines, source, &shown, &file.name, &existing.name);
|
||||
}
|
||||
// Another document may place other pages of a PDF copied already.
|
||||
if self.opts.copy_fonts
|
||||
@@ -265,8 +273,8 @@ impl<'a> Collector<'a> {
|
||||
continue;
|
||||
}
|
||||
let Some(mut bytes) = file.bytes else {
|
||||
lines.push(format!("{}: not found ({}), not copied", file.name, file.path));
|
||||
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) });
|
||||
lines.push(format!("{}: not found ({shown}), not copied", file.name));
|
||||
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) });
|
||||
continue;
|
||||
};
|
||||
if self.copies.len() >= 10_000 {
|
||||
@@ -277,7 +285,7 @@ impl<'a> Collector<'a> {
|
||||
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
|
||||
let destination_file = format!("{dir}{unique}");
|
||||
if by_name {
|
||||
self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
|
||||
self.renamed(&mut lines, source, &shown, &file.name, &destination_file);
|
||||
}
|
||||
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
|
||||
// read from its editing data links are copied beside it.
|
||||
@@ -285,7 +293,7 @@ impl<'a> Collector<'a> {
|
||||
let placed_file = pages.is_some();
|
||||
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
|
||||
} else {
|
||||
self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?;
|
||||
}
|
||||
@@ -296,7 +304,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
if placed_file && super::place::document::is_native(&file.name, &bytes) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
|
||||
} else {
|
||||
match vectorcraft_format::load_file(&bytes) {
|
||||
Ok(saved) => {
|
||||
@@ -320,8 +328,7 @@ impl<'a> Collector<'a> {
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!(
|
||||
"{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked",
|
||||
file.path
|
||||
"{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked"
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -329,7 +336,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path),
|
||||
format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -337,7 +344,7 @@ impl<'a> Collector<'a> {
|
||||
let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) };
|
||||
self.copies.insert(found_path.to_string(), copy.clone());
|
||||
local.insert(file.path.clone(), copy);
|
||||
lines.push(format!("{} → {destination_file}", file.path));
|
||||
lines.push(format!("{shown} → {destination_file}"));
|
||||
self.entries.push((destination_file, bytes));
|
||||
}
|
||||
lines.push(String::new());
|
||||
@@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res
|
||||
// The report.
|
||||
if let Some(info) = info {
|
||||
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
|
||||
let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
|
||||
let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path));
|
||||
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
|
||||
text.push_str(&info);
|
||||
entries.push((report_name, text.into_bytes()));
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
@@ -23,8 +23,10 @@ pub struct BuiltinPatternLibrary {
|
||||
}
|
||||
|
||||
/// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order.
|
||||
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] =
|
||||
&[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }];
|
||||
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[
|
||||
BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns },
|
||||
BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures },
|
||||
];
|
||||
|
||||
/// The library category of the built-in pattern libraries.
|
||||
pub const CATEGORY: &str = "patterns";
|
||||
@@ -154,6 +156,106 @@ fn geometric_patterns(d: &mut Document) {
|
||||
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
|
||||
}
|
||||
|
||||
/// SplitMix64, a small generator with a fixed seed per pattern, so a grit pattern draws the same
|
||||
/// marks every time it is made.
|
||||
struct SplitMix64(u64);
|
||||
|
||||
impl SplitMix64 {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A number from `lo` up to, but not including, `hi`.
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo)
|
||||
}
|
||||
}
|
||||
|
||||
/// A round dot of radius `r` at (`x`, `y`).
|
||||
fn dot(x: f64, y: f64, r: f64) -> PathData {
|
||||
shapes::ellipse(Rect::new(x - r, y - r, x + r, y + r))
|
||||
}
|
||||
|
||||
/// An irregular fleck of four to seven corners about (`x`, `y`), at most `r` from it.
|
||||
fn fleck(rng: &mut SplitMix64, x: f64, y: f64, r: f64) -> PathData {
|
||||
use std::f64::consts::TAU;
|
||||
let n = 4 + (rng.next() % 4) as usize;
|
||||
let turn = rng.range(0.0, TAU);
|
||||
let points: Vec<(f64, f64)> = (0..n)
|
||||
.map(|k| {
|
||||
let a = turn + TAU * (k as f64 + rng.range(-0.3, 0.3)) / n as f64;
|
||||
let reach = r * rng.range(0.55, 1.0);
|
||||
(x + reach * a.cos(), y + reach * a.sin())
|
||||
})
|
||||
.collect();
|
||||
polygon(&points)
|
||||
}
|
||||
|
||||
/// A thin straight scratch `len` long and `width` wide, centered on (`x`, `y`) at a random angle.
|
||||
fn scratch(rng: &mut SplitMix64, x: f64, y: f64, len: f64, width: f64) -> PathData {
|
||||
let (s, c) = rng.range(0.0, std::f64::consts::PI).sin_cos();
|
||||
let (hx, hy, wx, wy) = (c * len / 2.0, s * len / 2.0, -s * width / 2.0, c * width / 2.0);
|
||||
polygon(&[(x - hx - wx, y - hy - wy), (x + hx - wx, y + hy - wy), (x + hx + wx, y + hy + wy), (x - hx + wx, y - hy + wy)])
|
||||
}
|
||||
|
||||
/// A grit pattern of `count` marks scattered over a square grid tile of side `size`, drawn from
|
||||
/// `seed` (`mark` draws one at a point). A mark that reaches past an edge is kept whole: the tiling
|
||||
/// draws the part outside the tile on the next tile, so the tiles join without a seam.
|
||||
fn grit(d: &mut Document, name: &str, size: f64, seed: u64, count: usize, mark: impl Fn(&mut SplitMix64, f64, f64) -> PathData) {
|
||||
let mut rng = SplitMix64(seed);
|
||||
let marks = (0..count)
|
||||
.map(|_| {
|
||||
let (x, y) = (rng.range(0.0, size), rng.range(0.0, size));
|
||||
mark(&mut rng, x, y)
|
||||
})
|
||||
.collect();
|
||||
add(d, name, size, size, TileType::Grid, marks);
|
||||
}
|
||||
|
||||
/// Grit Textures: seamless black grain, grit, sand, speckle, stipple, scratch and fleck textures on
|
||||
/// a clear background, each drawn from its own fixed seed.
|
||||
fn grit_textures(d: &mut Document) {
|
||||
grit(d, "Fine Grain", 24.0, 1, 320, |rng, x, y| dot(x, y, rng.range(0.15, 0.35)));
|
||||
grit(d, "Coarse Grit", 30.0, 2, 150, |rng, x, y| {
|
||||
let r = rng.range(0.45, 1.1);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
grit(d, "Sand", 24.0, 3, 280, |rng, x, y| {
|
||||
let r = rng.range(0.2, 0.45);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
grit(d, "Speckle", 30.0, 4, 90, |rng, x, y| dot(x, y, rng.range(0.3, 0.9)));
|
||||
// Stipple: one dot in each cell of a 12 × 12 grid, moved a little off the cell's center.
|
||||
let mut rng = SplitMix64(5);
|
||||
let (size, cells) = (24.0, 12);
|
||||
let step = size / f64::from(cells);
|
||||
let dots = (0..cells)
|
||||
.flat_map(|row| (0..cells).map(move |col| (col, row)))
|
||||
.map(|(col, row)| {
|
||||
let x = (f64::from(col) + 0.5) * step + rng.range(-0.3, 0.3) * step;
|
||||
let y = (f64::from(row) + 0.5) * step + rng.range(-0.3, 0.3) * step;
|
||||
dot(x, y, rng.range(0.3, 0.45))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Stipple", size, size, TileType::Grid, dots);
|
||||
grit(d, "Scratches", 36.0, 6, 45, |rng, x, y| {
|
||||
let (len, width) = (rng.range(2.0, 7.0), rng.range(0.15, 0.35));
|
||||
scratch(rng, x, y, len, width)
|
||||
});
|
||||
grit(d, "Sparse Flecks", 36.0, 7, 32, |rng, x, y| {
|
||||
let r = rng.range(0.35, 0.8);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
grit(d, "Dense Flecks", 18.0, 8, 220, |rng, x, y| {
|
||||
let r = rng.range(0.2, 0.5);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -168,4 +270,60 @@ mod tests {
|
||||
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
|
||||
}
|
||||
}
|
||||
|
||||
fn grit_doc() -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
grit_textures(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grit_textures_draw_the_same_marks_every_time() {
|
||||
let (a, b) = (grit_doc(), grit_doc());
|
||||
assert_eq!(a.patterns.len(), 8);
|
||||
assert_eq!(a.patterns, b.patterns);
|
||||
}
|
||||
|
||||
/// Every grit tile is 18 to 36 pt with at most 600 marks, each with an area, touching the tile
|
||||
/// and near it. Marks reach past the edges, and the tiling draws a tile's art on the next tile
|
||||
/// across each edge a mark crosses. No mark is another mark moved by a whole tile, which the tiling would draw
|
||||
/// twice in one place.
|
||||
#[test]
|
||||
fn grit_tiles_join_without_a_seam() {
|
||||
let mut crossing = 0;
|
||||
for p in &grit_doc().patterns {
|
||||
let (w, h) = (p.width(), p.height());
|
||||
assert!((18.0..=36.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
|
||||
assert!(!p.art.is_empty() && p.art.len() <= 600, "{}: {} marks", p.name, p.art.len());
|
||||
let bounds: Vec<Rect> = p
|
||||
.art
|
||||
.iter()
|
||||
.map(|n| match &n.kind {
|
||||
vectorcraft_doc::NodeKind::Path { path, .. } => path.bounds().unwrap_or_else(|| panic!("{}: an empty mark", p.name)),
|
||||
_ => panic!("{}: a mark that is not a path", p.name),
|
||||
})
|
||||
.collect();
|
||||
let near = |a: &Rect, b: Rect| {
|
||||
(a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9
|
||||
};
|
||||
let band = p.tile.inflate(w / 4.0, h / 4.0);
|
||||
for b in &bounds {
|
||||
assert!(b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name);
|
||||
assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name);
|
||||
assert!(b.x0 >= band.x0 && b.y0 >= band.y0 && b.x1 <= band.x1 && b.y1 <= band.y1, "{}: {b:?} is too far out", p.name);
|
||||
// The tile past each edge the mark crosses draws this tile's art.
|
||||
let (dx, dy) = (f64::from(i8::from(b.x1 > w) - i8::from(b.x0 < 0.0)), f64::from(i8::from(b.y1 > h) - i8::from(b.y0 < 0.0)));
|
||||
for (x, y) in [(dx, 0.0), (0.0, dy), (dx, dy)] {
|
||||
let next = p.tile + kurbo::Vec2::new(x * w, y * h);
|
||||
assert!(p.offsets_covering(next).contains(&kurbo::Vec2::ZERO), "{}: {b:?} is cut off at {next:?}", p.name);
|
||||
}
|
||||
crossing += usize::from(dx != 0.0 || dy != 0.0);
|
||||
for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] {
|
||||
let moved = *b + kurbo::Vec2::new(dx, dy);
|
||||
assert!(!bounds.iter().any(|o| near(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(crossing > 0, "no mark reaches past an edge");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,14 +760,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// When the write failed, the old stored module stays in the folder and needs its grant at
|
||||
// the next launch. A grant that can't be removed still approves the old bytes, so the store
|
||||
// gets the module it held back. When that module can't be read or written back, or there
|
||||
// was none, no module goes with the grant, and the grant stops counting for this session. A
|
||||
// grants file that is too large or doesn't hold a list approves nothing, and the new module
|
||||
// stays; one that can't be opened or read may approve the old bytes once it reads again.
|
||||
// was none, no module goes with the grant, and the grant stops counting for every session in
|
||||
// the process attached to the folder. A grants file that is too large or doesn't hold a list
|
||||
// approves nothing, and the new module stays; one that can't be opened or read may approve
|
||||
// the old bytes once it reads again.
|
||||
let writes = s.grant_writes();
|
||||
if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) {
|
||||
let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref()));
|
||||
stored = !back && holds_new(s);
|
||||
if !back || matches!(previous, Ok(None)) {
|
||||
s.ignore_stored_grant(&m.id);
|
||||
s.ignore_stored_grant(&m.id, writes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -108,7 +109,9 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"brush.move",
|
||||
"brush.new",
|
||||
"brush.options",
|
||||
"brush.placeArt",
|
||||
"brush.remove",
|
||||
"brush.replaceArt",
|
||||
"brush.selectionArt",
|
||||
"brush.setCurrent",
|
||||
"brush.unused",
|
||||
@@ -676,6 +679,7 @@ pub(super) const DENIED: &[&str] = &[
|
||||
"addon.pen.nextSegment",
|
||||
"effect.refreshDeferred",
|
||||
"graphicStyle.setOptions",
|
||||
"object.blend.createSpine",
|
||||
"object.blend.options",
|
||||
"object.liquify",
|
||||
"object.pathfinder.options",
|
||||
|
||||
@@ -769,7 +769,7 @@ impl Session {
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get),
|
||||
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}; under automation read roots a folder preference outside them is null, and an export preset's folder outside them is left out", always, get),
|
||||
cmd!(
|
||||
query "prefs.set",
|
||||
"Set Preference",
|
||||
@@ -780,7 +780,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
set
|
||||
),
|
||||
cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset),
|
||||
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list),
|
||||
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference; under automation read roots a folder preference's value outside them is null", always, list),
|
||||
cmd!(
|
||||
query "prefs.export",
|
||||
"Export Preferences",
|
||||
@@ -886,8 +886,42 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "imported": values.len() }))
|
||||
}
|
||||
|
||||
/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks.
|
||||
const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"];
|
||||
|
||||
/// The scratch disk choices that are not folders.
|
||||
const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"];
|
||||
|
||||
/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the
|
||||
/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside
|
||||
/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is
|
||||
/// left out. An empty value (the default folder) and a scratch
|
||||
/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are.
|
||||
fn shown_prefs(prefs: &Prefs) -> Value {
|
||||
let mut all = prefs.to_json();
|
||||
if crate::file_access::current().is_none() {
|
||||
return all;
|
||||
}
|
||||
// The app trims a folder before it uses it.
|
||||
let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err();
|
||||
for key in PATH_PREFS {
|
||||
let Some(path) = all[key].as_str() else { continue };
|
||||
if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) {
|
||||
all[key] = Value::Null;
|
||||
}
|
||||
}
|
||||
if let Some(presets) = all.get_mut("screenExportPresets").and_then(Value::as_array_mut) {
|
||||
for settings in presets.iter_mut().filter_map(|p| p.get_mut("settings").and_then(Value::as_object_mut)) {
|
||||
if settings.get("folder").and_then(Value::as_str).is_some_and(hidden) {
|
||||
settings.remove("folder");
|
||||
}
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = s.prefs.to_json();
|
||||
let all = shown_prefs(&s.prefs);
|
||||
match str_param(p, "key") {
|
||||
Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()),
|
||||
Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))),
|
||||
@@ -915,7 +949,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if document_units && values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
s.set_document_units(s.default_units())?;
|
||||
}
|
||||
let all = s.prefs.to_json();
|
||||
let all = shown_prefs(&s.prefs);
|
||||
Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect()))
|
||||
}
|
||||
|
||||
@@ -929,11 +963,11 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut next = s.prefs.clone();
|
||||
next.reset(cat);
|
||||
s.apply_prefs(next);
|
||||
Ok(json!({ "prefs": s.prefs.to_json() }))
|
||||
Ok(json!({ "prefs": shown_prefs(&s.prefs) }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let all = s.prefs.to_json();
|
||||
let all = shown_prefs(&s.prefs);
|
||||
Ok(Value::Array(
|
||||
PREF_SPECS
|
||||
.iter()
|
||||
|
||||
@@ -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;
|
||||
@@ -274,7 +279,60 @@ impl FolderStore {
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
builder.create(dir).map_err(|e| Self::folder_error(dir, &e, true))
|
||||
}
|
||||
|
||||
/// The first folder on the way to `dir` (`dir` included), from the top down, that is not a
|
||||
/// folder (`true`) or is missing (`false`). None when every one of them is a folder or one of
|
||||
/// them can't be checked.
|
||||
fn in_the_way(dir: &std::path::Path) -> Option<(&std::path::Path, bool)> {
|
||||
let mut way: Vec<&std::path::Path> = dir.ancestors().filter(|p| !p.as_os_str().is_empty()).collect();
|
||||
way.reverse();
|
||||
for p in way {
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_dir() => {}
|
||||
Ok(_) => return Some((p, true)),
|
||||
Err(m) if m.kind() == std::io::ErrorKind::NotFound => return Some((p, false)),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The message for error `e` on folder `dir`, which was being made (`making`) or read. It
|
||||
/// names the first folder on the way to `dir` that is not a folder, or is missing (when
|
||||
/// `making`, the one that couldn't be made), and otherwise `dir`.
|
||||
fn folder_error(dir: &std::path::Path, e: &std::io::Error, making: bool) -> String {
|
||||
match Self::in_the_way(dir) {
|
||||
Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
|
||||
Some((p, false)) if making => format!("couldn't make {}: {e}", p.display()),
|
||||
Some((p, false)) if p != dir => format!("{} is missing", p.display()),
|
||||
_ => format!("{}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for error `e` on file `f`. It names the first folder on the way to `f` that is
|
||||
/// not a folder or is missing, and otherwise `f`.
|
||||
fn file_error(f: &std::path::Path, e: &std::io::Error) -> String {
|
||||
match f.parent().and_then(Self::in_the_way) {
|
||||
Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
|
||||
Some((p, false)) => format!("{} is missing", p.display()),
|
||||
None => format!("{}: {e}", f.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome `r` of removing file `f`. A file that is not there counts as removed, unless a
|
||||
/// file is on the way to it: Windows reports that as a missing file, and it is named as for a
|
||||
/// listing.
|
||||
pub(crate) fn removed(f: &std::path::Path, r: std::io::Result<()>) -> std::result::Result<(), String> {
|
||||
match r {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => match f.parent().and_then(Self::in_the_way) {
|
||||
Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
|
||||
_ => Ok(()),
|
||||
},
|
||||
Err(e) => Err(Self::file_error(f, &e)),
|
||||
Ok(()) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix)
|
||||
@@ -282,7 +340,7 @@ impl FolderStore {
|
||||
/// linked to a folder of its own in a shared recovery folder looked free, and claiming it
|
||||
/// wrote the copies through the link.
|
||||
fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> {
|
||||
let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let m = std::fs::symlink_metadata(dir).map_err(|e| Self::folder_error(dir, &e, false))?;
|
||||
if !m.file_type().is_dir() {
|
||||
return Err(format!("{}: not a folder of its own", dir.display()));
|
||||
}
|
||||
@@ -294,11 +352,17 @@ impl FolderStore {
|
||||
}
|
||||
|
||||
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
|
||||
/// A file on the way to `dir` is an error: Windows reports such a folder as missing.
|
||||
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(format!("{}: {e}", dir.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return match Self::in_the_way(dir) {
|
||||
Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
|
||||
_ => Ok(vec![]),
|
||||
};
|
||||
}
|
||||
Err(e) => return Err(Self::folder_error(dir, &e, false)),
|
||||
};
|
||||
Ok(entries
|
||||
.filter_map(|e| e.ok())
|
||||
@@ -333,7 +397,7 @@ impl FolderStore {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => Ok(Lock::Busy),
|
||||
std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy),
|
||||
_ => Err(format!("{}: {e}", path.display())),
|
||||
_ => Err(Self::file_error(path, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +409,7 @@ impl FolderStore {
|
||||
Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
|
||||
// The lock file was removed meanwhile, and locking it failed instead.
|
||||
Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(Self::file_error(path, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,10 +457,10 @@ impl RecoveryStore for FolderStore {
|
||||
if let Some(dir) = f.parent() {
|
||||
Self::own_area(dir)?;
|
||||
}
|
||||
if !std::fs::symlink_metadata(&f).map_err(|e| format!("{}: {e}", f.display()))?.file_type().is_file() {
|
||||
if !std::fs::symlink_metadata(&f).map_err(|e| Self::file_error(&f, &e))?.file_type().is_file() {
|
||||
return Err(format!("{}: not a plain file", f.display()));
|
||||
}
|
||||
std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display()))
|
||||
std::fs::read(&f).map_err(|e| Self::file_error(&f, &e))
|
||||
}
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
|
||||
let f = self.file(name)?;
|
||||
@@ -404,14 +468,11 @@ impl RecoveryStore for FolderStore {
|
||||
Self::create_area(dir)?;
|
||||
Self::own_area(dir)?;
|
||||
}
|
||||
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
|
||||
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| Self::file_error(&f, &e))
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::result::Result<(), String> {
|
||||
let f = self.file(name)?;
|
||||
match std::fs::remove_file(&f) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
Self::removed(&f, std::fs::remove_file(&f))
|
||||
}
|
||||
fn location(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
@@ -479,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 {
|
||||
@@ -553,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);
|
||||
}
|
||||
@@ -622,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,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()))
|
||||
@@ -686,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);
|
||||
@@ -931,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`.
|
||||
_ => {
|
||||
@@ -1153,6 +1261,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
|
||||
}
|
||||
@@ -1161,6 +1273,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);
|
||||
@@ -1170,6 +1285,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
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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); supplied align, leader and alignOn must be strings, and alignOn cannot be empty",
|
||||
"{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
|
||||
),
|
||||
@@ -41,7 +41,7 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
|
||||
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"))?;
|
||||
if !(0.0..=100_000.0).contains(&position) {
|
||||
if !(0.0..=TabStop::MAX_POSITION).contains(&position) {
|
||||
return Err(bad(C, "positions must be between 0 and 100000 pt"));
|
||||
}
|
||||
let align = match s.get("align") {
|
||||
@@ -60,16 +60,25 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(value)) => value.as_str(),
|
||||
Some(_) => return Err(bad(C, "`leader` must be a string")),
|
||||
};
|
||||
let (label, max) = ("`leader`", TabStop::MAX_LEADER);
|
||||
if leader.chars().count() > max {
|
||||
return Err(bad(C, format!("{label} must be at most {max} characters")));
|
||||
}
|
||||
.chars()
|
||||
.take(8)
|
||||
.collect();
|
||||
let align_on = match s.get("alignOn") {
|
||||
None | Some(Value::Null) => '.',
|
||||
Some(Value::String(value)) => value.chars().next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?,
|
||||
Some(Value::String(value)) => {
|
||||
let mut chars = value.chars();
|
||||
let c = chars.next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?;
|
||||
if chars.next().is_some() {
|
||||
let label = "`alignOn`";
|
||||
return Err(bad(C, format!("{label} must be one character")));
|
||||
}
|
||||
c
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`alignOn` must be a string")),
|
||||
};
|
||||
stops.push(TabStop { position, align, leader, align_on });
|
||||
stops.push(TabStop { position, align, leader: leader.to_string(), align_on });
|
||||
}
|
||||
stops.sort_by(|a, b| a.position.total_cmp(&b.position));
|
||||
stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
|
||||
@@ -77,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")?;
|
||||
@@ -128,7 +166,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -139,6 +177,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A leader of more than eight characters, an `alignOn` of more than one character and a list
|
||||
/// of more than 256 stops are errors, not cut short.
|
||||
#[test]
|
||||
fn long_leaders_long_align_on_and_too_many_stops_are_errors() {
|
||||
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();
|
||||
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();
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef};
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TabStop, TextStyleDef};
|
||||
|
||||
use super::clipboard::resources::import_text_styles;
|
||||
use super::typecmd::{refresh_bounds, text_targets};
|
||||
@@ -125,7 +125,7 @@ macro_rules! style_cmds {
|
||||
}
|
||||
|
||||
/// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`.
|
||||
const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
|
||||
const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new or paraStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = style_cmds!(
|
||||
@@ -148,13 +148,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"paraStyle",
|
||||
"Paragraph Styles",
|
||||
"Paragraph",
|
||||
"; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt"
|
||||
"; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position"
|
||||
));
|
||||
v.push(spec(
|
||||
"paraStyle.edit",
|
||||
"Edit Paragraph Style",
|
||||
&[],
|
||||
"{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt",
|
||||
"{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position",
|
||||
|s, p| edit_style(s, p, Kind::Para),
|
||||
true,
|
||||
));
|
||||
@@ -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
|
||||
@@ -569,8 +578,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
|
||||
char_attrs(&st)
|
||||
}
|
||||
Kind::Para => {
|
||||
let st = with_attrs(&ParaStyle::default(), m, &c)?;
|
||||
let mut st = with_attrs(&ParaStyle::default(), m, &c)?;
|
||||
para_ranges(&st, m, kept, &c)?;
|
||||
sort_tabs(&mut st, m, kept);
|
||||
para_attrs(&st)
|
||||
}
|
||||
};
|
||||
@@ -587,7 +597,8 @@ const PARA_DISTANCE: f64 = 1296.0;
|
||||
/// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the
|
||||
/// ranges `text.setFormat` keeps them to: the indents and the space before and after, each
|
||||
/// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as
|
||||
/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked.
|
||||
/// `attrs` gives them), and the number of tab stops with the position and leader of each stop
|
||||
/// `kept` does not hold. A value `kept` has is not checked.
|
||||
fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>, c: &str) -> Result<()> {
|
||||
let within = |key: &str, value: f64, lo: f64, hi: f64| {
|
||||
if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) }
|
||||
@@ -644,18 +655,37 @@ fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<Str
|
||||
}
|
||||
}
|
||||
if changed("tabs") {
|
||||
let kept_tabs: Vec<f64> = kept
|
||||
.and_then(|k| k.get("tabs"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect())
|
||||
.unwrap_or_default();
|
||||
for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) {
|
||||
within("tabs.position", stop.position, 0.0, 100_000.0)?;
|
||||
let kept_tabs = kept_tab_stops(kept);
|
||||
let max = TabStop::MAX_STOPS;
|
||||
if st.tabs.len() > max.max(kept_tabs.len()) {
|
||||
return Err(bad(c, format!("at most {max} tab stops")));
|
||||
}
|
||||
for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(stop)) {
|
||||
within("tabs.position", stop.position, 0.0, TabStop::MAX_POSITION)?;
|
||||
let (label, max) = ("`tabs.leader`", TabStop::MAX_LEADER);
|
||||
if stop.leader.chars().count() > max {
|
||||
return Err(bad(c, format!("{label} must be at most {max} characters")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tab stops `kept` holds, each as it reads (a stop that does not read is left out).
|
||||
fn kept_tab_stops(kept: Option<&Map<String, Value>>) -> Vec<TabStop> {
|
||||
let stops = kept.and_then(|k| k.get("tabs")).and_then(Value::as_array);
|
||||
stops.into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect()
|
||||
}
|
||||
|
||||
/// `st`'s tab stops sorted by position, keeping the first of stops within 1e-6 pt of each other,
|
||||
/// as `text.tabs.set` keeps them, when `attrs` changes them from `kept`.
|
||||
fn sort_tabs(st: &mut ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>) {
|
||||
if attrs.contains_key("tabs") && kept.and_then(|k| k.get("tabs")) != attrs.get("tabs") {
|
||||
st.tabs.sort_by(|a, b| a.position.total_cmp(&b.position));
|
||||
st.tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a
|
||||
/// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes.
|
||||
fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> {
|
||||
@@ -1011,10 +1041,29 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
|
||||
|
||||
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
|
||||
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
|
||||
/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name
|
||||
/// is empty, which are skipped. → (the styles, the names in `src` of those skipped)
|
||||
/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, whose
|
||||
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
|
||||
/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
|
||||
/// `src` of those skipped)
|
||||
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
|
||||
const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"];
|
||||
const RANGED: [&str; 16] = [
|
||||
"size",
|
||||
"h_scale",
|
||||
"v_scale",
|
||||
"leading",
|
||||
"baseline_shift",
|
||||
"tcyUpDown",
|
||||
"tcyLeftRight",
|
||||
"stroke_width",
|
||||
"left_indent",
|
||||
"right_indent",
|
||||
"first_line_indent",
|
||||
"space_before",
|
||||
"space_after",
|
||||
"hyphenation",
|
||||
"justification",
|
||||
"tabs",
|
||||
];
|
||||
let (mut defs, mut skipped) = (vec![], vec![]);
|
||||
for def in kind.defs(src) {
|
||||
let name = def.name.as_str();
|
||||
|
||||
@@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
|
||||
|
||||
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
|
||||
/// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to
|
||||
/// one character, so it is not a reusable style attribute.
|
||||
/// one character, and the space before a line's first character to where it stands (#5111), so
|
||||
/// neither is a reusable style attribute.
|
||||
#[test]
|
||||
fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
use vectorcraft_doc::CharAlign;
|
||||
@@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() {
|
||||
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
|
||||
let mut reusable = settable::<CharStyle>();
|
||||
reusable.remove("glyphAlternate");
|
||||
reusable.remove("kerningBefore");
|
||||
assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute");
|
||||
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
|
||||
assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute");
|
||||
@@ -1729,3 +1731,77 @@ fn paragraph_style_attributes_are_checked_before_a_definition_changes() {
|
||||
assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept
|
||||
/// as it is, and a stop at a held position with another alignment is a changed stop. A changed
|
||||
/// stop's leader has at most eight characters and its Align On one character, a changed list has
|
||||
/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position,
|
||||
/// keeping the first of stops at one position.
|
||||
#[test]
|
||||
fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() {
|
||||
let (mut s, _) = session_with_text("Text");
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap();
|
||||
let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let held: Vec<Value> =
|
||||
(0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect();
|
||||
doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone()));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let mut moved = held.clone();
|
||||
moved[0]["align"] = json!("right");
|
||||
for (stops, why) in [
|
||||
(Value::from(moved), "`tabs.position`"),
|
||||
(json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"),
|
||||
(Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()), "at most 256 tab stops"),
|
||||
(json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"),
|
||||
] {
|
||||
for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] {
|
||||
let params = match cmd {
|
||||
"paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}),
|
||||
_ => json!({"name": "Body", "attrs": {"tabs": stops}}),
|
||||
};
|
||||
let error = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains(why), "{cmd}: {error}");
|
||||
}
|
||||
}
|
||||
let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>());
|
||||
let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string();
|
||||
assert!(error.contains("at most 256 tab stops"), "{error}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// The held stops stay while one is dropped and another attribute changes.
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap();
|
||||
assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299));
|
||||
let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]);
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap();
|
||||
assert_eq!(
|
||||
def(&s)["tabs"],
|
||||
json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."},
|
||||
{"position": 72.0, "align": "right", "alignOn": "."}])
|
||||
);
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap();
|
||||
assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set");
|
||||
}
|
||||
|
||||
/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing,
|
||||
/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes,
|
||||
/// and load the others with their stops.
|
||||
#[test]
|
||||
fn loading_skips_paragraph_styles_out_of_range() {
|
||||
let file = [
|
||||
("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})),
|
||||
("Indent", json!({"left_indent": 2000.0})),
|
||||
("Space", json!({"space_after": -1300.0})),
|
||||
("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})),
|
||||
("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})),
|
||||
("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})),
|
||||
];
|
||||
let data = styles_file(&[], &file);
|
||||
for cmd in ["paraStyle.load", "textStyle.loadAll"] {
|
||||
let (mut s, _) = session_with_text("Title");
|
||||
let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
|
||||
let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r };
|
||||
assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}");
|
||||
let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap();
|
||||
assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{length_given, number_given, shape_node};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok,
|
||||
str_param, switch_or, targets, text_param,
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param,
|
||||
ok, str_param, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1273,7 +1273,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mut items = Vec::with_capacity(raw.len());
|
||||
for it in raw {
|
||||
let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::checked_refs(it.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
@@ -1304,7 +1304,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapePath";
|
||||
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 mut refs = super::select::checked_refs(p.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
@@ -1335,7 +1335,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setPathHandle";
|
||||
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 = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
|
||||
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
|
||||
|
||||
@@ -397,6 +397,49 @@ fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() {
|
||||
assert!(text(&s).para_at(1).roman_hanging);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Korean Line Break Anywhere: new type has it off and saves that;
|
||||
/// `text.setFormat {koreanLineBreakAnywhere}` sets it per paragraph in one undo step and refuses a
|
||||
/// value that is not true or false, a paragraph saved before the option opens with it on, and
|
||||
/// paragraph styles carry it. `text.setRangeStyle`, a character command, does not take it.
|
||||
#[test]
|
||||
fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "가나다\n라마"})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere, "new type has it off");
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["korean_line_break_anywhere"], false);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// Only the first paragraph (the caret's range).
|
||||
s.execute("text.setFormat", &json!({"koreanLineBreakAnywhere": true, "start": 0, "end": 0})).unwrap();
|
||||
assert!(text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("korean_line_break_anywhere").is_none());
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
|
||||
assert!(back.korean_line_break_anywhere);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(text(&s).para_at(0).korean_line_break_anywhere);
|
||||
// Saved before the option: on, the line breaks it was laid out with.
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert!(old.korean_line_break_anywhere);
|
||||
assert!(s.execute("text.setFormat", &json!({"ids": [id], "koreanLineBreakAnywhere": "yes"})).is_err());
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id, "koreanLineBreakAnywhere": false})).is_err());
|
||||
assert!(text(&s).para_at(0).korean_line_break_anywhere, "a refused call changes nothing");
|
||||
// A paragraph style carries it.
|
||||
s.execute("paraStyle.new", &json!({"name": "Anywhere", "attrs": {"korean_line_break_anywhere": true}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Anywhere", "id": id})).unwrap();
|
||||
assert!(text(&s).para_at(1).korean_line_break_anywhere);
|
||||
s.execute("paraStyle.new", &json!({"name": "Words", "attrs": {"korean_line_break_anywhere": false}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Words", "id": id})).unwrap();
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
|
||||
}
|
||||
|
||||
/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one
|
||||
/// undo step, the paragraph is saved with it only when it is not None and opens with it, an
|
||||
/// unknown value changes nothing, and paragraph styles carry it.
|
||||
|
||||
@@ -140,6 +140,35 @@ fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes()
|
||||
assert_eq!(undo_len(&s), n + 2, "already off: no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_point_type_turns_block_off_keeping_its_sizes() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line");
|
||||
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
|
||||
let sizes = |t: &TextObject| t.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.leading)).collect::<Vec<_>>();
|
||||
let fitted = sizes(&text(&s, id));
|
||||
let n = undo_len(&s);
|
||||
s.execute("type.convertToPointType", &json!({})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||||
let t = text(&s, id);
|
||||
assert!(matches!(t.kind, TextKind::Point));
|
||||
assert!(!t.dynamic_text, "point type has Dynamic Text off");
|
||||
assert_eq!(sizes(&t), fitted, "point type keeps the sizes Block set");
|
||||
assert!(serde_json::to_value(&t).unwrap().get("dynamicText").is_none(), "saved without dynamicText");
|
||||
// Back to area type, the frame is not refitted: Block stays off.
|
||||
s.execute("type.convertToAreaType", &json!({})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(matches!(t.kind, TextKind::Area { .. }));
|
||||
assert!(!t.dynamic_text, "Block does not come on again");
|
||||
assert_eq!(sizes(&t), fitted);
|
||||
// Undo gives the Block frame back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let t = text(&s, id);
|
||||
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
|
||||
assert_eq!(sizes(&t), fitted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_and_other_type_are_refused() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert To Point Type",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}",
|
||||
"{ids?|id?} area type → point type; soft line wraps become line breaks, a frame of threaded text leaves its thread with the text it holds, and Dynamic Text (Block) is turned off, keeping the sizes it set → {ids}",
|
||||
has_selection,
|
||||
to_point
|
||||
),
|
||||
@@ -311,7 +311,10 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let mut b = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
|
||||
// The frame is worked out in line space, where the lines are; vertical type turns it
|
||||
// into text space with the lines.
|
||||
let text_bounds = if lay.bounds.area() > 0.0 { lay.bounds } else { t.estimate_bounds() };
|
||||
let mut b = lay.line_xf.inverse().transform_rect_bbox(text_bounds);
|
||||
// Across, the frame spans the lines as they are aligned: from their starts to their ends
|
||||
// without the tracking after their last characters (`LineInfo::trailing`), not to the
|
||||
// ink, which tracking can pull past an advance. Area type aligns them alike.
|
||||
@@ -332,7 +335,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
};
|
||||
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
|
||||
let frame = lay.line_xf.transform_rect_bbox(Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(frame) };
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -368,6 +371,8 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Point type is in no thread: its first paragraph starts in it.
|
||||
t.first_para_continues = false;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
// The lines are in line space; the frame's bounds are taken there too.
|
||||
let fb = lay.line_xf.inverse().transform_rect_bbox(fb);
|
||||
let plain = t.plain_text();
|
||||
let baseline = lay.lines.first().map(|l| l.baseline).unwrap_or(fb.y0 + t.first_style().size * 0.8);
|
||||
// Soft wraps → explicit breaks (replace the space before the wrap when there is one).
|
||||
@@ -410,8 +415,14 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
|
||||
_ => x0,
|
||||
};
|
||||
// Point type has no Dynamic Text: Block is turned off, keeping the sizes it set, so
|
||||
// converting back to area type does not turn it on again.
|
||||
t.dynamic_text = false;
|
||||
t.kind = TextKind::Point;
|
||||
t.xf *= Affine::translate((ox, baseline));
|
||||
// The origin is in the area type's line space; point type's line space puts its own
|
||||
// origin there (vertical point type turns its lines about a different point).
|
||||
let point_xf = if lay.vertical { vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).line_xf } else { Affine::IDENTITY };
|
||||
t.xf *= lay.line_xf * Affine::translate((ox, baseline)) * point_xf.inverse();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -8,7 +8,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::select::selectable_ids;
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param};
|
||||
|
||||
/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in
|
||||
/// points, opacity in percent.
|
||||
@@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec<NodeId> {
|
||||
}
|
||||
|
||||
fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?;
|
||||
let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?;
|
||||
let st = s.doc()?;
|
||||
let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
|
||||
let w = s.menu.wand.clone();
|
||||
|
||||
@@ -171,10 +171,6 @@ pub struct Session {
|
||||
/// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder
|
||||
/// ([`Session::attach_scripts_dir`]).
|
||||
scripts_dir: Option<std::path::PathBuf>,
|
||||
/// The plug-in ids whose grant in the plug-in store should be gone but could not be removed:
|
||||
/// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful
|
||||
/// [`Session::store_grant`] for the id takes it out.
|
||||
ignored_plugin_grants: std::sync::Mutex<std::collections::BTreeSet<String>>,
|
||||
/// A filter's output that a run made in the background handed in for `plugin.run` to apply
|
||||
/// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it.
|
||||
plugin_output: Option<cmd::plugin::HandedIn>,
|
||||
@@ -306,6 +302,8 @@ pub struct Session {
|
||||
pub(crate) press_point: Option<Option<(usize, cmd::gradient::StopOwner)>>,
|
||||
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
|
||||
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
|
||||
/// The rulers' origin being dragged out of their corner ([`Session::ruler_origin_drag`]).
|
||||
pub(crate) ruler_origin: Option<vectorcraft_tools::rulerguide::OriginDrag>,
|
||||
/// Which selected type overflows its frame, for the ports every tool but the Type tools shows.
|
||||
pub(crate) port_marks: vectorcraft_tools::text::PortMarks,
|
||||
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user