Merge main into fix/i18n-plugin-messages

This commit is contained in:
dotCooCoo
2026-11-12 09:23:31 -08:00
355 changed files with 24838 additions and 2403 deletions
Generated
+1
View File
@@ -6326,6 +6326,7 @@ dependencies = [
"craft-window",
"eframe",
"egui",
"futures-lite",
"image",
"log",
"muda",
+1
View File
@@ -128,6 +128,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 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-11: Pattern libraries: Patterns › Optical Effects, a third built-in pattern library, of VectorCraft's own designs, holds eight black wave, moiré and op-art tiles (Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses) of 32 to 48 pt, each drawn the same way every time; each tile is one period of its design, and art that reaches past a tile edge continues 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).
+3
View File
@@ -107,6 +107,9 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
# Waits for the screen color sampler's portal answer and for the portal leaving the bus at the same
# time (src/linux_screen_color/request.rs). Apache-2.0 OR MIT, already in the build through zbus.
futures-lite = "2"
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
+9 -3
View File
@@ -1,10 +1,10 @@
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
use super::portal_save;
#[cfg(target_os = "linux")]
use super::tablet;
#[cfg(all(windows, not(target_vendor = "win7")))]
use super::windows_stylus;
use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing};
#[cfg(target_os = "linux")]
use super::{linux_screen_color, tablet};
#[cfg(target_os = "macos")]
use super::{mac_save_format, mac_screen_color, mac_tablet};
@@ -354,8 +354,10 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (clip, clip_probe) = (parent.clone(), parent.clone());
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "linux"))]
let sampled_ui = ui.clone();
#[cfg(target_os = "linux")]
let sampled_parent = p1.clone();
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
@@ -381,6 +383,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
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)),
save_write: Some(std::sync::Arc::new(fileio::write_save_file)),
save_stream: Some(std::sync::Arc::new(fileio::write_save_file_with)),
// Native saves stream into the file as they are encoded.
stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
@@ -406,6 +410,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
// Sample Color from Screen: the system's color sampler.
#[cfg(target_os = "macos")]
screen_color: Some(mac_screen_color::sampler(sampled_ui)),
#[cfg(target_os = "linux")]
screen_color: Some(linux_screen_color::sampler(sampled_ui, sampled_parent)),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
@@ -0,0 +1,42 @@
//! Linux: Sample Color from Screen (`vectorcraft_ui_egui::screen_color`) through the XDG desktop
//! portal's color sampler ([`portal`]). A thread of its own asks the portal and waits for the
//! person, so the UI keeps drawing; it sends the color to the UI and wakes the window. The dialog's
//! parent is the window, named on that thread as for the portal's save dialog (`portal_save`).
mod portal;
mod request;
/// The portal's color sampler over the window `parent` holds; `ui` is woken when it answers.
pub fn sampler(ui: egui::Context, parent: super::host_services::Parent) -> vectorcraft_ui_egui::screen_color::StartSampler {
Box::new(move || {
let (tx, rx) = std::sync::mpsc::channel();
let (ui, window) = (ui.clone(), parent.clone());
std::thread::Builder::new()
.name("screen-color".into())
.spawn(move || {
// The thread names the window and holds it until the export that names it is dropped.
let (handle, export) = match window.as_deref() {
Some(window) => {
let (handle, export) = super::portal_save::exported_parent(window);
(handle, Some(export))
}
None => (String::new(), None),
};
let picked = portal::connect(zbus::blocking::connection::Builder::session()).and_then(|conn| portal::pick(&conn, &handle));
drop(export);
drop(window);
let answer = picked.map_err(|failure| {
log::warn!("the portal's screen color sampler gave no color: {}", failure.detail());
failure.message().to_string()
});
// The UI may be gone (the app quitting): nothing waits for the color then.
let _ = tx.send(answer);
ui.request_repaint();
})
.map_err(|e| {
log::warn!("could not start the screen color sampler's thread: {e}");
"the screen color sampler failed".to_string()
})?;
Ok(rx)
})
}
@@ -0,0 +1,319 @@
//! The XDG desktop portal's `Screenshot.PickColor`
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html>):
//! the call returns a request path at once, and the request's `Response` signal later carries the
//! color the person picked, as sRGB components from 0 to 1, or a code for a cancel or another end.
use std::collections::HashMap;
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::{OwnedValue, Value};
/// How long the portal has to return a request path for `PickColor`. VectorCraft's own limit,
/// 25 seconds as libdbus's default for a method call. The portal returns the path before it shows
/// the sampler, so the person's time with the sampler does not count.
const CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25);
/// Why no color came back, as the status bar shows it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum Failure {
/// No session bus, no portal, or a portal without the Screenshot interface's `PickColor`.
Unavailable(String),
/// The portal did not return a request path in time, or it left the bus before it answered.
NoAnswer(String),
/// The portal ended the request without a color, or its answer can't be read.
Failed(String),
}
impl Failure {
/// The status bar's message (English; the status bar translates it).
pub(super) fn message(&self) -> &'static str {
match self {
Failure::Unavailable(_) => "this system has no screen color sampler",
Failure::NoAnswer(_) => "the screen color sampler did not answer",
Failure::Failed(_) => "the screen color sampler failed",
}
}
/// What went wrong, for the log.
pub(super) fn detail(&self) -> &str {
match self {
Failure::Unavailable(d) | Failure::NoAnswer(d) | Failure::Failed(d) => d,
}
}
}
/// D-Bus errors that mean the session has no portal, or none that answers `PickColor`.
const UNAVAILABLE: &[&str] = &[
"org.freedesktop.DBus.Error.ServiceUnknown",
"org.freedesktop.DBus.Error.NameHasNoOwner",
"org.freedesktop.DBus.Error.UnknownMethod",
"org.freedesktop.DBus.Error.UnknownInterface",
"org.freedesktop.DBus.Error.UnknownObject",
];
/// The connection `builder` makes, with [`CALL_TIMEOUT`] for its method calls.
pub(super) fn connect(builder: zbus::Result<zbus::blocking::connection::Builder<'_>>) -> Result<Connection, Failure> {
builder.and_then(|b| b.method_timeout(CALL_TIMEOUT).build()).map_err(|e| Failure::Unavailable(e.to_string()))
}
/// Shows the portal's color sampler over the window `parent` names and waits for the color, `None`
/// when the person canceled it. `conn`'s method timeout bounds the call; the wait for the person
/// after it has no bound, and ends when the portal answers or leaves the bus.
pub(super) fn pick(conn: &Connection, parent: &str) -> Result<Option<[f32; 3]>, Failure> {
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
let token = super::request::token("vectorcraft_color").map_err(Failure::Failed)?;
// Made before the call: a portal can send its answer before the call returns.
let replies = super::request::Responses::new(conn).map_err(Failure::Failed)?;
let proxy = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
.map_err(|e| Failure::Failed(e.to_string()))?;
let options = HashMap::from([("handle_token", Value::from(token.as_str()))]);
let call = proxy.call_method("PickColor", &(parent, options)).map_err(|e| call_failure(&e))?;
let reply = replies.wait(&call).map_err(Failure::NoAnswer)?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| Failure::Failed(e.to_string()))?;
answer(code, &results)
}
/// The [`Failure`] for an error the `PickColor` call returned. zbus reports a call that outlives
/// the connection's method timeout as an `InputOutput` error of kind `TimedOut`.
fn call_failure(e: &zbus::Error) -> Failure {
match e {
zbus::Error::MethodError(name, ..) if UNAVAILABLE.contains(&name.as_str()) => Failure::Unavailable(e.to_string()),
zbus::Error::InputOutput(io) if io.kind() == std::io::ErrorKind::TimedOut => Failure::NoAnswer(e.to_string()),
_ => Failure::Failed(e.to_string()),
}
}
/// The color in a `Response` with `code` and `results`: response 0 carries it, 1 is a cancel, and
/// any other code ends the request without one. The components are passed on as given;
/// `screen_color::sampled` clamps them to 0 to 1, as for the other systems' samplers.
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<[f32; 3]>, Failure> {
match code {
0 => {}
1 => return Ok(None),
_ => return Err(Failure::Failed(format!("the portal ended the request (response {code})"))),
}
let color = results.get("color").ok_or_else(|| Failure::Failed("no color in the portal's answer".into()))?;
// Matched field by field: zvariant's tuple conversion panics on a structure with fewer fields.
match &**color {
Value::Structure(s) => match s.fields() {
[Value::F64(r), Value::F64(g), Value::F64(b)] => Ok(Some([*r, *g, *b].map(|v| v as f32))),
_ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))),
},
_ => Err(Failure::Failed(format!("the portal's color is not three numbers: {color:?}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn color(rgb: (f64, f64, f64)) -> HashMap<String, OwnedValue> {
HashMap::from([("color".into(), OwnedValue::try_from(Value::from(rgb)).unwrap())])
}
fn failed<T: std::fmt::Debug>(r: Result<T, Failure>) -> bool {
matches!(r, Err(Failure::Failed(_)))
}
#[test]
fn a_picked_color_keeps_its_srgb_values() {
assert_eq!(answer(0, &color((0.25, 0.5, 0.75))), Ok(Some([0.25, 0.5, 0.75])));
assert_eq!(answer(0, &color((0.0, 1.0, 0.0))), Ok(Some([0.0, 1.0, 0.0])));
// Outside 0 to 1: passed on for `screen_color::sampled` to clamp.
assert_eq!(answer(0, &color((-0.5, 1.5, 1.0))), Ok(Some([-0.5, 1.5, 1.0])));
}
#[test]
fn a_cancel_needs_no_color() {
assert_eq!(answer(1, &HashMap::new()), Ok(None));
assert_eq!(answer(1, &color((0.25, 0.5, 0.75))), Ok(None));
}
#[test]
fn other_response_codes_are_failures() {
for code in [2, 3, u32::MAX] {
assert!(failed(answer(code, &color((0.25, 0.5, 0.75)))), "{code}");
}
}
#[test]
fn a_success_without_a_valid_color_is_a_failure() {
assert!(failed(answer(0, &HashMap::new())));
let wrong = HashMap::from([("color".into(), OwnedValue::from(1_u32))]);
assert!(failed(answer(0, &wrong)));
// Fewer fields, more fields, and fields of another type: an error, not a panic.
for wrong in [Value::from((0.5, 0.5)), Value::from((0.5, 0.5, 0.5, 0.5)), Value::from((1_u32, 0.5, 0.5)), Value::from(vec![0.5, 0.5, 0.5])] {
let wrong = HashMap::from([("color".into(), OwnedValue::try_from(wrong).unwrap())]);
assert!(failed(answer(0, &wrong)));
}
}
#[test]
fn call_errors_say_whether_the_system_has_a_sampler() {
let reply = |name: &str| {
let call = zbus::Message::method_call("/org/freedesktop/portal/desktop", "PickColor").unwrap().build(&()).unwrap();
let error = zbus::Message::error(&call.header(), name).unwrap().build(&("detail",)).unwrap();
zbus::Error::from(error)
};
for name in UNAVAILABLE {
assert!(matches!(call_failure(&reply(name)), Failure::Unavailable(_)), "{name}");
}
assert!(matches!(call_failure(&reply("org.freedesktop.portal.Error.NotAllowed")), Failure::Failed(_)));
let timed_out = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::TimedOut.into()));
assert!(matches!(call_failure(&timed_out), Failure::NoAnswer(_)));
let broken = zbus::Error::InputOutput(std::sync::Arc::new(std::io::ErrorKind::BrokenPipe.into()));
assert!(matches!(call_failure(&broken), Failure::Failed(_)));
let messages = [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())].map(|f| f.message());
assert_eq!(
messages,
["this system has no screen color sampler", "the screen color sampler did not answer", "the screen color sampler failed"]
);
assert_eq!(call_failure(&broken).detail(), broken.to_string());
}
/// Every language that translates the sampler's other messages translates these too. The
/// i18n tests of `vectorcraft-ui-egui` do not read this crate's messages.
#[test]
fn the_messages_are_translated_where_the_sampler_is() {
use vectorcraft_ui_egui::i18n::{Lang, message};
const OPEN: &str = "the screen color sampler is already open";
let langs: Vec<Lang> = Lang::all().filter(|&lang| message(lang, OPEN) != OPEN).collect();
assert!(!langs.is_empty(), "no language translates {OPEN:?}");
for lang in langs {
for failure in [Failure::Unavailable(String::new()), Failure::NoAnswer(String::new()), Failure::Failed(String::new())] {
assert_ne!(message(lang, failure.message()), failure.message(), "{}", lang.code());
}
}
}
/// A session bus of its own (`dbus-daemon`), `None` when there is none to run.
fn private_bus() -> Option<(Daemon, String)> {
use std::io::BufRead as _;
let spawning = crate::spawning();
let daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn();
drop(spawning);
let Ok(daemon) = daemon else {
eprintln!("no dbus-daemon: skipped");
return None;
};
let mut daemon = Daemon(daemon);
let mut address = String::new();
std::io::BufReader::new(daemon.0.stdout.take().unwrap()).read_line(&mut address).unwrap();
Some((daemon, address.trim().to_string()))
}
/// Stops the private bus when the test ends, unwinding included.
struct Daemon(std::process::Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// The client's connection to the private bus at `address`, made as the sampler makes its own.
fn client(address: &str) -> Connection {
connect(zbus::blocking::connection::Builder::address(address)).unwrap()
}
/// The portal's name, owned on the private bus at `address` before any call, so the bus never
/// starts an installed portal in its place.
fn portal(address: &str) -> Connection {
zbus::blocking::connection::Builder::address(address).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap()
}
/// Through a real D-Bus: an answer that comes before the call returns is kept; answers on
/// other request paths or from other connections are ignored; a cancel gives no color; and a
/// portal that leaves the bus while the person picks ends the wait.
#[test]
fn the_answer_is_the_portals_response_on_the_returned_path() {
use zbus::blocking::MessageIterator;
use zbus::zvariant::OwnedObjectPath;
let Some((_daemon, address)) = private_bus() else { return };
let address = address.as_str();
let server = portal(address);
let impostor = zbus::blocking::connection::Builder::address(address).unwrap().build().unwrap();
let incoming = MessageIterator::from(&server);
let worker = std::thread::spawn(move || {
let mut calls = Vec::new();
for msg in incoming {
let msg = msg.unwrap();
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("PickColor") {
continue;
}
let (parent, options): (String, HashMap<String, OwnedValue>) = msg.body().deserialize().unwrap();
let token = String::try_from(options.get("handle_token").unwrap().try_clone().unwrap()).unwrap();
assert!(token.starts_with("vectorcraft_color_"));
assert!(token.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_'));
assert!(!calls.iter().any(|(_, old)| old == &token));
calls.push((parent, token));
// A portal older than version 0.9 answers on a path of its own, not the token's.
let path = format!("/org/freedesktop/portal/desktop/request/independent/pick_{}", calls.len());
let handle = OwnedObjectPath::try_from(path.as_str()).unwrap();
if calls.len() == 4 {
server.reply(&header, &handle).unwrap();
assert!(server.release_name("org.freedesktop.portal.Desktop").unwrap());
break;
}
let to = header.sender().cloned().map(zbus::names::BusName::from);
// Another connection can't end the wait by claiming the portal left.
impostor
.emit_signal(
to.clone(),
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"NameOwnerChanged",
&("org.freedesktop.portal.Desktop", server.unique_name().unwrap().as_str(), ""),
)
.unwrap();
let wrong = color((1.0, 0.0, 0.0));
impostor.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap();
let unrelated = "/org/freedesktop/portal/desktop/request/unrelated";
server.emit_signal(to.clone(), unrelated, "org.freedesktop.portal.Request", "Response", &(0_u32, &wrong)).unwrap();
let code = if calls.len() == 2 { 1_u32 } else { 0_u32 };
server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &color((0.25, 0.5, 0.75)))).unwrap();
// The reply that names the path goes last: every answer above came before it.
server.reply(&header, &handle).unwrap();
}
calls
});
let client = client(address);
assert_eq!(pick(&client, "x11:12"), Ok(Some([0.25, 0.5, 0.75])));
assert_eq!(pick(&client, "wayland:example"), Ok(None));
assert_eq!(pick(&client, ""), Ok(Some([0.25, 0.5, 0.75])));
assert!(matches!(pick(&client, ""), Err(Failure::NoAnswer(_))));
let calls = worker.join().unwrap();
assert_eq!(calls.iter().map(|(parent, _)| parent.as_str()).collect::<Vec<_>>(), ["x11:12", "wayland:example", "", ""]);
}
/// Through a real D-Bus: a portal without `PickColor` ends the request as a system without a
/// sampler, and the sampler's connection gives the call [`CALL_TIMEOUT`].
#[test]
fn a_portal_without_pick_color_ends_the_request() {
let Some((_daemon, address)) = private_bus() else { return };
let address = address.as_str();
let server = portal(address);
let incoming = zbus::blocking::MessageIterator::from(&server);
let worker = std::thread::spawn(move || {
for msg in incoming {
let msg = msg.unwrap();
let header = msg.header();
if header.member().map(|m| m.as_str()) == Some("PickColor") {
server.reply_error(&header, "org.freedesktop.DBus.Error.UnknownMethod", &("no PickColor",)).unwrap();
break;
}
}
});
let client = client(address);
assert_eq!(client.method_timeout(), Some(CALL_TIMEOUT));
let failure = pick(&client, "");
assert!(matches!(failure, Err(Failure::Unavailable(_))), "{failure:?}");
worker.join().unwrap();
}
}
@@ -0,0 +1,127 @@
//! The answer to the color sampler's XDG desktop portal request
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html>).
//! The wait ends with the request's `Response`, or with an error when the portal leaves the bus
//! first.
use std::io::Read as _;
use futures_lite::{StreamExt as _, future};
use zbus::blocking::{Connection, MessageIterator};
use zbus::zvariant::OwnedObjectPath;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
/// A request token: `prefix`, then 128 bits from the operating system's random device, so the
/// token is unique and can't be guessed, as the Request documentation asks.
pub(super) fn token(prefix: &str) -> Result<String, String> {
let mut bytes = [0; 16];
std::fs::File::open("/dev/urandom")
.and_then(|mut file| file.read_exact(&mut bytes))
.map_err(|e| format!("could not create a portal request token: {e}"))?;
let random: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
Ok(format!("{prefix}_{random}"))
}
/// Subscriptions made before a portal method call, on every request path: a portal older than
/// version 0.9 ignores `handle_token` and answers on the request path it returns, and its answer
/// can come before the call's reply. Each stream keeps every signal from the time it is made.
pub(super) struct Responses {
replies: MessageIterator,
owners: MessageIterator,
}
impl Responses {
pub(super) fn new(conn: &Connection) -> Result<Self, String> {
let reply_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DESKTOP)
.and_then(|b| b.interface("org.freedesktop.portal.Request"))
.and_then(|b| b.member("Response"))
.map_err(|e| e.to_string())?
.build();
let owner_rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender("org.freedesktop.DBus")
.and_then(|b| b.path("/org/freedesktop/DBus"))
.and_then(|b| b.interface("org.freedesktop.DBus"))
.and_then(|b| b.member("NameOwnerChanged"))
.and_then(|b| b.arg(0, DESKTOP))
.map_err(|e| e.to_string())?
.build();
Ok(Self {
replies: MessageIterator::for_match_rule(reply_rule, conn, None).map_err(|e| e.to_string())?,
owners: MessageIterator::for_match_rule(owner_rule, conn, None).map_err(|e| e.to_string())?,
})
}
/// The `Response` on the request path `call` returned, from the connection that returned it.
/// An error when that connection leaves the bus first, or the bus connection closes.
pub(super) fn wait(self, call: &zbus::Message) -> Result<zbus::Message, String> {
enum Event {
Reply(Option<zbus::Result<zbus::Message>>),
Owner(Option<zbus::Result<zbus::Message>>),
}
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
let portal = call.header().sender().map(|s| s.as_str().to_string()).ok_or("the portal answered without a sender")?;
let mut replies = self.replies.into_inner();
let mut owners = self.owners.into_inner();
future::block_on(async {
loop {
let event = future::or(async { Event::Reply(replies.next().await) }, async { Event::Owner(owners.next().await) }).await;
match event {
Event::Reply(reply) => {
let reply = reply.ok_or("the portal closed without an answer")?.map_err(|e| e.to_string())?;
let header = reply.header();
if header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == Some(portal.as_str())
{
return Ok(reply);
}
}
Event::Owner(message) => {
let message = message.ok_or("the portal connection closed")?.map_err(|e| e.to_string())?;
if owner_disappeared(&message, &portal)? {
return Err("the desktop portal disappeared while waiting for its answer".into());
}
}
}
}
})
}
}
/// Whether `message` is the bus's `NameOwnerChanged` saying that the connection `portal` no longer
/// owns the portal's name. The bus itself must send it: another connection can't end the wait.
fn owner_disappeared(message: &zbus::Message, portal: &str) -> Result<bool, String> {
if message.header().sender().map(|s| s.as_str()) != Some("org.freedesktop.DBus") {
return Ok(false);
}
let (name, old, new): (String, String, String) = message.body().deserialize().map_err(|e| e.to_string())?;
Ok(name == DESKTOP && old == portal && new != portal)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_bus_can_report_portal_owner_loss() {
let changed = |sender, name, old, new| {
zbus::Message::signal("/org/freedesktop/DBus", "org.freedesktop.DBus", "NameOwnerChanged")
.unwrap()
.sender(sender)
.unwrap()
.build(&(name, old, new))
.unwrap()
};
let forged = changed(":1.42", DESKTOP, ":1.7", "");
assert!(!owner_disappeared(&forged, ":1.7").unwrap());
let real = changed("org.freedesktop.DBus", DESKTOP, ":1.7", "");
assert!(owner_disappeared(&real, ":1.7").unwrap());
let other = changed("org.freedesktop.DBus", "org.example.Other", ":1.7", "");
assert!(!owner_disappeared(&other, ":1.7").unwrap());
let old = changed("org.freedesktop.DBus", DESKTOP, ":1.8", "");
assert!(!owner_disappeared(&old, ":1.7").unwrap());
let acquired = changed("org.freedesktop.DBus", DESKTOP, "", ":1.7");
assert!(!owner_disappeared(&acquired, ":1.7").unwrap());
}
}
+4 -7
View File
@@ -35,7 +35,6 @@ trait Backend {
fn suspend(&mut self, window: &Self::Window);
fn restore(&mut self, window: &Self::Window);
fn build(&mut self, request: &Request) -> Option<Self::Cursor>;
fn current(&self) -> Option<Self::Cursor>;
fn set(&mut self, cursor: &Self::Cursor);
}
@@ -73,9 +72,10 @@ impl<B: Backend> State<B> {
&& *key == request.key
&& *owned == window
{
if self.backend.current().as_ref() != Some(cursor) {
self.backend.set(cursor);
}
// AppKit can show the system arrow when the pointer moves while
// `NSCursor.currentCursor` (the app's last set cursor) still reads as ours, so a
// comparison would keep the arrow. Setting the cursor again costs nothing.
self.backend.set(cursor);
return;
}
self.release();
@@ -135,9 +135,6 @@ impl Backend for AppKit {
fn build(&mut self, request: &Request) -> Option<Self::Cursor> {
build(&request.bitmap)
}
fn current(&self) -> Option<Self::Cursor> {
Some(NSCursor::currentCursor())
}
fn set(&mut self, cursor: &Self::Cursor) {
cursor.set();
}
+5 -14
View File
@@ -6,7 +6,6 @@ use std::cell::Cell;
struct Fake {
window: Option<u8>,
suspended: Vec<u8>,
current: Option<Key>,
sets: usize,
builds: usize,
restores: Vec<u8>,
@@ -27,17 +26,12 @@ impl Backend for Fake {
fn restore(&mut self, w: &u8) {
self.suspended.retain(|v| v != w);
self.restores.push(*w);
self.current = None;
}
fn build(&mut self, r: &Request) -> Option<Key> {
self.builds += 1;
(!self.fail).then_some(r.key)
}
fn current(&self) -> Option<Key> {
self.current
}
fn set(&mut self, cursor: &Key) {
self.current = Some(*cursor);
fn set(&mut self, _: &Key) {
self.sets += 1;
}
}
@@ -66,19 +60,16 @@ fn deliver(state: &mut State<Fake>, request: Option<Request>) {
}
#[test]
fn mac_cursor_ownership_deduplicates_reasserts_and_restores() {
fn mac_cursor_ownership_reasserts_every_pass_and_restores() {
let req = request(1.0);
let mut state = fake_state();
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.suspended, [1]);
assert_eq!((state.backend.builds, state.backend.sets), (1, 1));
// The pointer moves and AppKit shows the system arrow, but the app's current cursor
// still reads as ours. The next pass sets it again without rebuilding or re-suspending.
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.sets, 1);
// Simulate a platform fallback replacing the cursor after finalization. The next pass
// reasserts; this proves the protocol, not AppKit timing or absence of transient flicker.
state.backend.current = None;
deliver(&mut state, Some(req.clone()));
assert_eq!(state.backend.sets, 2);
assert_eq!((state.backend.builds, state.backend.sets, state.backend.suspended.len()), (1, 2, 1));
deliver(&mut state, None);
assert_eq!(state.backend.restores, [1]);
deliver(&mut state, Some(req.clone()));
+2
View File
@@ -32,6 +32,8 @@ mod cursor_theme;
mod gpu;
mod hang_watch;
#[cfg(target_os = "linux")]
mod linux_screen_color;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
#[cfg(all(target_os = "macos", feature = "wgpu"))]
+51 -8
View File
@@ -102,13 +102,22 @@ fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Sav
log::warn!("the portal's save dialog picked a file name that isn't UTF-8: nothing is saved");
return Ok(None);
};
let filter = results.get("current_filter").and_then(|v| {
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
let filter = results.get("current_filter").and_then(|v| filter_label(v));
Ok(Some(Saved { path, filter }))
}
/// The label of a file type as the portal sends it, a `(sa(us))` structure; `None` for a value of
/// another shape.
fn filter_label(value: &Value<'_>) -> Option<String> {
match value {
Value::Structure(s) => match s.fields() {
[Value::Str(label), Value::Array(globs)] if globs.element_signature() == "(us)" => Some(label.to_string()),
_ => None,
},
_ => None,
}
}
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere
/// (`file://host/`) or another scheme.
fn file_uri_path(uri: &str) -> Option<Vec<u8>> {
@@ -233,6 +242,13 @@ fn parent(window: &winit::window::Window) -> (String, Option<wayland::Export>) {
}
}
/// [`parent`] for another dialog of the portal (the screen color sampler, `linux_screen_color`):
/// the identifier stays valid until the second value is dropped.
#[cfg(target_os = "linux")]
pub(crate) fn exported_parent(window: &winit::window::Window) -> (String, impl Send + 'static) {
parent(window)
}
/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off
/// Linux).
#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
@@ -396,6 +412,36 @@ mod tests {
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
}
/// A `current_filter` of another shape than `(sa(us))` (a structure with fewer, other or more
/// fields, a string) gives the path alone; `uris` of another shape is an error.
#[test]
fn an_answer_of_another_shape_is_not_read() {
let malformed = [
Value::from(("SVG".to_string(),)),
Value::from(("SVG".to_string(), vec![(0u32,)])),
Value::from(("SVG".to_string(), vec![("*.svg".to_string(), 0u32)])),
Value::from((1u32, vec![(0u32, "*.svg".to_string())])),
Value::from(("SVG".to_string(), vec![(0u32, "*.svg".to_string())], 1u32)),
Value::from("SVG"),
];
for filter in malformed {
let mut r = results("file:///tmp/card", None);
r.insert("current_filter".to_string(), OwnedValue::try_from(filter).unwrap());
assert_eq!(answer(0, &r), Ok(Some(Saved { path: "/tmp/card".into(), filter: None })));
}
let malformed = [
Value::from(vec![1u32]),
Value::from(vec![("file:///tmp/card".to_string(),)]),
Value::from(Vec::<String>::new()),
Value::from("file:///tmp/card"),
];
for uris in malformed {
let mut r = HashMap::new();
r.insert("uris".to_string(), OwnedValue::try_from(uris).unwrap());
assert!(answer(0, &r).is_err());
}
}
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
/// is an error, and rfd shows the dialog instead.
#[test]
@@ -486,10 +532,7 @@ mod tests {
}
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
let filter = options.get("current_filter").and_then(|v| {
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
let filter = options.get("current_filter").and_then(|v| filter_label(v));
let calls = log.lock().map(|mut l| {
l.push((text("current_name"), filter));
l.len()
+12 -9
View File
@@ -370,11 +370,14 @@ pub struct ArtBrush {
pub scale: ArtScale,
/// Width in percent.
pub width: f64,
/// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from
/// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest.
/// How the width varies along a stroke: `None` (Fixed) always `width`; a mode that follows the
/// pen goes from `width` to [`Self::width_max`] over the ranges [`Variation`] gives: Pressure
/// from the lightest pressure to the heaviest, Tilt from the pen lying flat to upright, Bearing
/// and Rotation from −180° to 180° (half way at 0°).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_mode: Option<Variation>,
/// The width at the heaviest pen pressure (%), for Pressure (`width` without one).
/// The second width (%) of a mode that follows the pen: at the heaviest pressure, upright, or
/// at 180° (`width` without one).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_max: Option<f64>,
pub flip_along: bool,
@@ -388,11 +391,11 @@ impl ArtBrush {
/// The range of the width (%).
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
/// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the
/// width follows the pressure.
/// The widths (%) where the pen reads 0 and 1 for the width's mode (the lightest and the
/// heaviest pressure, for Pressure): `width` for both unless the width follows the pen.
pub fn width_range(&self) -> (f64, f64) {
match self.width_mode {
Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)),
Some(m) if m.follows_pen() => (self.width, self.width_max.unwrap_or(self.width)),
_ => (self.width, self.width),
}
}
@@ -713,8 +716,8 @@ impl Brush {
}
BrushKind::Art(a) => {
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
// The width follows the pen's pressure or stays fixed.
if a.width_mode != Some(Variation::Pressure) {
// The width follows the pen (its pressure, tilt, bearing or rotation) or stays fixed.
if !a.width_mode.is_some_and(Variation::follows_pen) {
a.width_mode = None;
}
a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width));
@@ -806,7 +809,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),
BrushKind::Art(a) => warp::art(a, bp, w, stroke),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
+56
View File
@@ -1371,6 +1371,62 @@ fn art_brush_width_follows_the_pressure() {
assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}");
}
/// Art Brush Options: Width on Tilt goes from Width with the pen lying flat to the second width
/// upright, and on Bearing or Rotation from Width at −180° to the second width at 180°, along the
/// stroke as the pen recorded it; each mode reads only its own input.
#[test]
fn art_brush_width_follows_tilt_bearing_and_rotation() {
let brush = |mode: Variation| {
let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
if let BrushKind::Art(a) = &mut b.kind {
(a.width, a.width_mode, a.width_max) = (50.0, Some(mode), Some(300.0));
}
b
};
let bp = line(0.0, 0.0, 100.0, 0.0);
let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st);
// Tilt, upright at the start (300 %: 6 pt either side of 4 pt art) to flat at the end (50 %: 1 pt).
let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
let out = draw(&brush(Variation::Tilt), &upright_to_flat);
assert!(near(half_width(&out, 0.0, 1.0), 6.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
assert!(near(half_width(&out, 99.0, 100.0), 1.0, 0.1), "{}", half_width(&out, 99.0, 100.0));
assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0));
// Bearing and Rotation: half way at 0°, a quarter of the way at −90°, three quarters at 90°.
for (mode, angle, half) in [
(Variation::Bearing, 0.0, 3.5),
(Variation::Bearing, -90.0, 2.25),
(Variation::Bearing, 90.0, 4.75),
(Variation::Rotation, -90.0, 2.25),
(Variation::Rotation, 90.0, 4.75),
] {
let st = if mode == Variation::Bearing { posed(Some(0.5), Some(angle), None) } else { posed(None, None, Some(angle)) };
let h = bounds(&draw(&brush(mode), &st)).height();
assert!(near(h, 2.0 * half, 1e-6), "{mode:?} {angle}: {h}");
}
// Each mode reads its own input: without it (the pressure, or another of the pen's values
// recorded instead), the art is half way between the two widths.
let others = [
(Variation::Tilt, posed(None, Some(90.0), Some(90.0))),
(Variation::Tilt, pressed(vec![(0.0, 0.0), (1.0, 1.0)])),
(Variation::Bearing, posed(Some(0.0), None, Some(90.0))),
(Variation::Rotation, posed(Some(0.0), Some(90.0), None)),
(Variation::Pressure, posed(Some(0.0), Some(90.0), Some(90.0))),
];
for (mode, st) in others {
let h = bounds(&draw(&brush(mode), &st)).height();
assert!(near(h, 7.0, 1e-6), "{mode:?}: {h}");
}
// The pen's modes are kept when a definition is read, and Random is read as Fixed.
let lib = parse_library(&serde_json::json!([
{"name": "t", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "tilt", "width_max": 300.0},
{"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "bearing", "width_max": 300.0},
{"name": "r", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "rotation", "width_max": 300.0},
{"name": "x", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 300.0},
]));
let modes: Vec<_> = lib.iter().map(|b| if let BrushKind::Art(a) = &b.kind { a.width_mode } else { None }).collect();
assert_eq!(modes, [Some(Variation::Tilt), Some(Variation::Bearing), Some(Variation::Rotation), None]);
}
/// Definitions from files and commands are kept within their ranges.
#[test]
fn sanitize_keeps_values_within_their_ranges() {
+6 -5
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use kurbo::ParamCurveNearest;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer};
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
@@ -503,9 +503,10 @@ fn art_on_run(
bend_widened(t, art, b, along, cross, widen)
}
/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as
/// wide as the pressure recorded at each point of the stroke gives (half way without a recording).
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec<Node> {
/// An art brush's art along `bp`. With a width that follows the pen (its pressure, tilt, bearing
/// or rotation), the art is as wide as what `stroke` recorded at each point gives (half way
/// without a recording).
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer) -> Vec<Node> {
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
let (light, heavy) = a.width_range();
let width = light.max(heavy);
@@ -522,7 +523,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
}
// A run past the start of a closed track wraps round to it.
let at = if s > l { s - l } else { s };
let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0);
let p = a.width_mode.and_then(|m| PenAt::of(stroke, at / l).control(m)).unwrap_or(0.5);
(light + (heavy - light) * p) / width
};
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] };
+178 -4
View File
@@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize};
use crate::Color;
use crate::freeform::Freeform;
mod spread;
pub use spread::{MAX_SPREAD_LEVEL, MAX_SPREAD_RUNS, MAX_SPREAD_STOPS, Padded};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientKind {
#[default]
@@ -94,6 +97,82 @@ impl GradientMethod {
}
}
/// What a linear, radial, conic or diamond gradient paints past the ends of its stops (the
/// Gradient panel's Spread). With Repeat and Reflect, the stops run [`Gradient::repeats`] times
/// along the vector (around the circle, for a conic), and keep going past both of its ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
pub enum GradientSpread {
/// The first and last stops' colors carry on past the ends, as gradients always did.
#[default]
Pad,
/// The stops start again at each end.
Repeat,
/// The stops run back the other way at each end, so every other run is mirrored.
Reflect,
}
impl GradientSpread {
/// Every spread, in the order the Gradient panel lists them.
pub const ALL: [GradientSpread; 3] = [GradientSpread::Pad, GradientSpread::Repeat, GradientSpread::Reflect];
/// Parse a spread name (`pad`, `repeat`, `reflect`; any case).
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
Self::Pad => "Pad",
Self::Repeat => "Repeat",
Self::Reflect => "Reflect",
}
}
pub fn is_pad(&self) -> bool {
*self == Self::Pad
}
}
impl<'de> Deserialize<'de> for GradientSpread {
/// A spread name; a file's unknown name or other value reads as Pad.
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Name(String),
Other(serde::de::IgnoredAny),
}
Ok(match Raw::deserialize(d)? {
Raw::Name(s) => Self::parse(&s).unwrap_or_default(),
Raw::Other(_) => Self::Pad,
})
}
}
/// Most times a gradient's stops run along its vector ([`Gradient::repeats`]): VectorCraft's own
/// limit.
pub const MAX_REPEATS: u32 = 100;
fn one_repeat() -> u32 {
1
}
fn is_one_repeat(n: &u32) -> bool {
*n == 1
}
/// A file's repeat count: a number, rounded and kept within 1 to [`MAX_REPEATS`]; anything else
/// reads as 1.
fn lenient_repeats<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Number(f64),
Other(serde::de::IgnoredAny),
}
Ok(match Raw::deserialize(d)? {
Raw::Number(n) if n.is_finite() => n.round().clamp(1.0, f64::from(MAX_REPEATS)) as u32,
_ => 1,
})
}
/// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring
/// expanded stops may stray from the perceptual mix: under half a colour level.
const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
@@ -259,6 +338,15 @@ pub struct Gradient {
/// How the colours between the stops are mixed (Classic unless set; written only when not).
#[serde(default, skip_serializing_if = "GradientMethod::is_classic")]
pub method: GradientMethod,
/// What it paints past the ends of its stops (Pad unless set; written only when not). A
/// freeform gradient has no ends and ignores it.
#[serde(default, skip_serializing_if = "GradientSpread::is_pad")]
pub spread: GradientSpread,
/// How many times the stops run along the vector with Repeat and Reflect, 1 to
/// [`MAX_REPEATS`] (use [`Self::repeat_count`]: a value set in code may be out of range); Pad
/// ignores it. Written only when it is not 1.
#[serde(default = "one_repeat", skip_serializing_if = "is_one_repeat", deserialize_with = "lenient_repeats")]
pub repeats: u32,
}
impl Default for Gradient {
@@ -271,7 +359,55 @@ impl Default for Gradient {
impl Gradient {
/// A gradient of `kind` through `stops`, with the default options.
pub fn new(kind: GradientKind, stops: Vec<GradientStop>) -> Self {
Self { kind, stops, dither: false, method: GradientMethod::Classic }
Self { kind, stops, dither: false, method: GradientMethod::Classic, spread: GradientSpread::Pad, repeats: 1 }
}
/// [`Self::repeats`] kept within 1 to [`MAX_REPEATS`].
pub fn repeat_count(&self) -> u32 {
self.repeats.clamp(1, MAX_REPEATS)
}
/// Does it paint something other than its end colors past the ends of its stops, or run its
/// stops more than once: a linear, radial, conic or diamond gradient that repeats or reflects?
pub fn is_spread(&self) -> bool {
!self.spread.is_pad() && self.kind != GradientKind::Freeform
}
/// The spread and count as the gradient paints them: Pad and 1 for one that pads (whatever
/// count it keeps) and for a freeform one, else its spread and [`Self::repeat_count`]. Equal
/// for two gradients whose spreads paint alike.
pub fn painted_spread(&self) -> (GradientSpread, u32) {
if self.is_spread() { (self.spread, self.repeat_count()) } else { (GradientSpread::Pad, 1) }
}
/// The position (0..1) along the stops that gradient parameter `t` (0 at the start of the
/// vector, 1 at its end; [`GradientGeom::param_at`]) samples: `t` itself with Pad (the stops
/// clamp it), else `t` × [`Self::repeat_count`] wrapped back into 0..1, every other run
/// mirrored with Reflect. A `t` that is not finite is returned as it is.
pub fn spread_param(&self, t: f64) -> f64 {
self.spreader()(t)
}
/// How many wedges a conic gradient is written as ([`GradientGeom::conic_wedges`]) where
/// `per_turn` would do for stops that run once around: that many for each time the stops run
/// around, at most [`MAX_CONIC_WEDGES`]. Sample each wedge at [`Self::spread_param`] of its
/// turn.
pub fn conic_wedge_count(&self, per_turn: usize) -> usize {
let runs = if self.is_spread() { self.repeat_count() as usize } else { 1 };
per_turn.saturating_mul(runs).clamp(1, MAX_CONIC_WEDGES)
}
/// [`Self::spread_param`] as a function that holds no borrow of the gradient.
pub fn spreader(&self) -> impl Fn(f64) -> f64 + Copy + use<> {
let spread = if self.is_spread() { self.spread } else { GradientSpread::Pad };
let n = f64::from(self.repeat_count());
move |t: f64| {
if !t.is_finite() {
return t;
}
match spread {
GradientSpread::Pad => t,
GradientSpread::Repeat => (t * n).rem_euclid(1.0),
GradientSpread::Reflect => {
let w = (t * n).rem_euclid(2.0);
if w > 1.0 { 2.0 - w } else { w }
}
}
}
}
/// Colour and opacity at `t` (honours midpoints and the method).
pub fn sample(&self, t: f32) -> (Color, f32) {
@@ -544,10 +680,18 @@ impl Gradient {
GradientStop { opacity, ..GradientStop::new(offset, color) }
};
let d = to - from;
let mut stops = vec![stop(0.0, from)];
let expanded = self.expanded_stops();
// Where stops share the offset `from` (a sharp step, as where the runs of a repeating
// gradient meet), the piece starts with the color after the step; sampling gives the one
// before it.
let first = match expanded.iter().rev().find(|(t, ..)| *t == from) {
Some(&(_, color, opacity)) if d > 1e-6 => GradientStop { opacity, ..GradientStop::new(0.0, color) },
_ => stop(0.0, from),
};
let mut stops = vec![first];
if d > 1e-6 {
stops.extend(
self.expanded_stops()
expanded
.into_iter()
.filter(|(t, ..)| *t > from && *t < to)
.map(|(t, color, opacity)| GradientStop { opacity, ..GradientStop::new((t - from) / d, color) }),
@@ -954,6 +1098,35 @@ impl GradientGeom {
self.set_focal(Some(m * q));
}
}
/// The placement whose level `t` is this one's level `k`·`t` (`k` > 0), for a linear, radial or
/// diamond gradient: the vector `k` times as long from the same start, and for an off-center
/// radial the extent ellipse scaled `k` times about the focal point, which stays. A conic keeps
/// its placement (its level is a turn).
pub fn levels_scaled(&self, kind: GradientKind, k: f64) -> GradientGeom {
if kind == GradientKind::Conic || !(k.is_finite() && k > 0.0) {
return *self;
}
let u = self.end - self.start;
let mut out = *self;
if kind == GradientKind::Radial
&& let (Some(f), Some(frame)) = (self.focal, self.unit_frame())
{
// Level t of an off-center radial is the extent ellipse scaled by t about the line
// from the focal point to the center: the new center sits on that line, `k` times as
// far from the focal point.
let f = (frame.inverse() * f).to_vec2();
out.start = frame * (f * (1.0 - k)).to_point();
}
out.end = out.start + u * k;
out
}
/// The placement over which one run of the stops of a gradient that repeats `n` times along
/// its vector lies ([`Self::levels_scaled`] by 1/`n`).
pub fn period(&self, kind: GradientKind, n: u32) -> GradientGeom {
self.levels_scaled(kind, 1.0 / f64::from(n.max(1)))
}
pub fn angle_deg(&self) -> f64 {
let v = self.end - self.start;
(-v.y).atan2(v.x).to_degrees()
@@ -1148,12 +1321,13 @@ impl GradientPaint {
inks: bool,
) -> impl Iterator<Item = [u8; N]> + use<N> {
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither);
let spread = self.gradient.spreader();
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
let (x0, y0) = (area.x0, area.y0);
let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end));
down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| {
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
let c = ramp.at(geom.param_at(kind, p) as f32);
let c = ramp.at(spread(geom.param_at(kind, p)) as f32);
std::array::from_fn(|k| {
if k + 1 == N {
return (c[k].clamp(0.0, 1.0) * 255.0).round() as u8;
+268
View File
@@ -0,0 +1,268 @@
//! A repeating or reflecting gradient as one that pads, for formats whose gradients only pad
//! past their ends: the stops repeated (every other run mirrored, for Reflect) as many times as
//! the painted area needs, and the vector stretched over those runs.
use kurbo::{Point, Rect, Vec2};
use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop};
/// Most runs of the stops [`Gradient::padded_over`] writes.
pub const MAX_SPREAD_RUNS: usize = 256;
/// Most stops [`Gradient::padded_over`] writes, all runs together.
pub const MAX_SPREAD_STOPS: usize = 16384;
/// The farthest level from its center a radial or diamond gradient's runs are written out to by
/// [`Gradient::padded_over`] (its stops' offsets, single-precision fractions of it, stay apart),
/// nearer for stops closer together than a run ([`MIN_OFFSET_GAP`]).
pub const MAX_SPREAD_LEVEL: f64 = 65536.0;
/// The least difference between two stops' offsets [`Gradient::padded_over`] writes as
/// different: sixteen times single precision's step just below 1.
const MIN_OFFSET_GAP: f64 = 1e-6;
/// A repeating or reflecting gradient written as one that pads ([`Gradient::padded_over`]).
#[derive(Clone, Debug, PartialEq)]
pub struct Padded {
/// The gradient with its stops repeated, padding past its ends.
pub gradient: Gradient,
/// Where those runs lie.
pub geom: GradientGeom,
/// Where along `geom` (0 to 1) the first run written starts: 0 for a linear gradient, the
/// first run's level over the last one's outer level for a radial or diamond one.
pub first: f64,
/// How many runs are written, evenly spaced from [`Self::first`] to 1.
pub runs: usize,
/// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows),
/// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written.
pub capped: bool,
}
impl Gradient {
/// A linear, radial or diamond gradient that repeats or reflects, placed at `geom`, as a
/// gradient that pads and paints `area` the same way: its stops repeated, each run's
/// placement [`GradientGeom::period`] of `geom`, as many runs as the area reaches (a radial or
/// diamond from its first level, 0, the first stop's color inside the area's innermost run).
/// `None` for a gradient that pads, a conic or
/// freeform one, one without stops, and a placement whose levels at the corners are not
/// finite.
pub fn padded_over(&self, geom: &GradientGeom, area: Rect) -> Option<Padded> {
self.padded_within(geom, area, MAX_SPREAD_STOPS)
}
/// [`Self::padded_over`] with at most `max_stops` stops (and at least one run).
pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option<Padded> {
let kind = self.kind;
if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() {
return None;
}
let period = geom.period(kind, self.repeat_count());
let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)];
let ts = corners.map(|p| period.param_at(kind, p));
if ts.iter().any(|t| !t.is_finite()) {
return None;
}
let centered = kind.is_centered();
// The level's range over the area. A linear gradient's extremes lie at corners. Radial and
// diamond levels are nested convex rings from level 0: the largest at a corner, the
// smallest 0 when the area holds the rings' center, else on its edge.
let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() };
let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0);
let one = whole_run(&self.stops);
let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1);
let capped = hi - lo > most as f64;
// Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear)
// those around the vector's start where the area reaches it, else the area's runs nearest
// to it.
let lo = match capped {
true if !centered => (-((most / 2) as f64)).clamp(lo, hi - most as f64),
_ => lo,
};
let n = (hi - lo).min(most as f64).max(1.0);
// A radial or diamond writes its runs among the levels from 0, which its stops' offsets
// (single-precision fractions of the outermost level) tell apart only so far out, the
// nearer the closer its stops lie: past that, the runs end there and the gradient pads.
let gap = one.windows(2).map(|w| f64::from(w[1].offset - w[0].offset)).filter(|d| *d > 0.0).fold(1.0, f64::min);
// A whole level, so the runs keep their places (and Reflect its mirrored ones).
let farthest = MAX_SPREAD_LEVEL.min(gap / MIN_OFFSET_GAP).floor().max(n);
let far = centered && lo + n > farthest;
let (lo, capped) = if far { ((farthest - n).max(0.0), true) } else { (lo, capped) };
// `lo` is a whole number, possibly past what an integer holds: its parity in floating point.
let first_odd = lo.rem_euclid(2.0) >= 1.0;
let (stops, geom) = if centered {
// The levels from 0, the runs from level `lo` (the first stop's color inside it).
(self.runs(&one, first_odd, lo, n as usize, lo + n), period.levels_scaled(kind, lo + n))
} else {
let u = period.end - period.start;
(
self.runs(&one, first_odd, 0.0, n as usize, n),
GradientGeom { start: period.start + u * lo, end: period.start + u * (lo + n), ..period },
)
};
let gradient = Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() };
let first = if centered { lo / (lo + n) } else { 0.0 };
Some(Padded { gradient, geom, first, runs: n as usize, capped })
}
/// The gradient as it runs from the start of its vector (0) to its end (1), as one that
/// pads: a gradient that repeats or reflects with its stops repeated [`Self::repeat_count`]
/// times between 0 and 1 (every other run mirrored, for Reflect), for what only shows that
/// stretch (a gradient along or across a stroke, a swatch's preview). Others as they are.
pub fn unrolled(&self) -> std::borrow::Cow<'_, Gradient> {
self.unrolled_within(MAX_SPREAD_STOPS)
}
/// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs
/// that fit, the last one pads.
pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> {
use std::borrow::Cow;
if !self.is_spread() {
return Cow::Borrowed(self);
}
// Without stops there is nothing to repeat: it pads all the same.
if self.stops.is_empty() {
return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() });
}
let one = whole_run(&self.stops);
let n = self.repeat_count() as usize;
let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1);
let stops = self.runs(&one, false, 0.0, kept, n as f64);
Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() })
}
/// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as
/// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect),
/// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and
/// one without stops.
pub fn cycle(&self) -> Option<(Gradient, u32)> {
if !self.is_spread() || self.stops.is_empty() {
return None;
}
let one = whole_run(&self.stops);
let runs = if self.spread == GradientSpread::Reflect { 2 } else { 1 };
let stops = self.runs(&one, false, 0.0, runs, runs as f64);
Some((Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }, runs as u32))
}
/// `n` runs of `one` (one run of the stops, [`whole_run`]) on a line of `of` runs from 0 to 1,
/// the first of them `start` runs along it and an odd-numbered run when `first_odd` (with
/// Reflect, the odd-numbered runs are mirrored).
fn runs(&self, one: &[GradientStop], first_odd: bool, start: f64, n: usize, of: f64) -> Vec<GradientStop> {
let mirrored_run = (self.spread == GradientSpread::Reflect).then(|| mirror(one));
let mut stops = Vec::with_capacity(one.len() * n);
for j in 0..n {
let run = match &mirrored_run {
Some(m) if (j % 2 == 1) != first_odd => m.as_slice(),
_ => one,
};
stops.extend(run.iter().map(|s| GradientStop { offset: ((start + j as f64 + f64::from(s.offset)) / of) as f32, ..s.clone() }));
}
stops
}
}
/// The smallest level of radial or diamond gradient `geom` over `area`, less a run (for rounding):
/// 0 when the area holds the rings' center (a radial's focal point, a diamond's center), else the
/// smallest along its sides, worked out in the gradient's unit frame from the sides' ends rather
/// than from samples along them, so a long side does not hide a run near it. 0 for a placement
/// without a frame.
fn inner_level(geom: &GradientGeom, kind: GradientKind, area: Rect) -> f64 {
let center = if kind == GradientKind::Radial { geom.focal_point() } else { geom.start };
if area.contains(center) {
return 0.0;
}
let frame = if kind == GradientKind::Diamond { geom.diamond_frame() } else { geom.unit_frame() };
let Some(to_unit) = frame.map(|m| m.inverse()) else { return 0.0 };
let focal = geom.focal.filter(|_| kind == GradientKind::Radial).map(|f| (to_unit * f).to_vec2());
let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]
.map(|p| (to_unit * p).to_vec2());
let mut least = f64::INFINITY;
for (&a, &b) in corners.iter().zip(corners.iter().cycle().skip(1)) {
let side = if kind == GradientKind::Diamond { diamond_least(a, b) } else { radial_least(a, b, focal) };
least = least.min(side);
}
if least.is_finite() { (least - 1.0).max(0.0) } else { 0.0 }
}
/// The smallest diamond level, |x| + |y| in its unit frame, on the segment from `a` to `b`: the
/// level is linear between the points where the segment crosses an axis, so the smallest is at
/// an end or at one of those crossings.
fn diamond_least(a: Vec2, b: Vec2) -> f64 {
let d = b - a;
let crossings = [(d.x != 0.0).then(|| -a.x / d.x), (d.y != 0.0).then(|| -a.y / d.y)];
[Some(0.0), Some(1.0), crossings[0], crossings[1]]
.into_iter()
.flatten()
.filter(|u| (0.0..=1.0).contains(u))
.map(|u| {
let p = a + d * u;
p.x.abs() + p.y.abs()
})
.fold(f64::INFINITY, f64::min)
}
/// Halvings at most in [`radial_least`]: enough to bring any finite span below a quarter level.
const RADIAL_HALVINGS: usize = 1100;
/// The smallest radial level on the segment from `a` to `b` in the unit frame, at most a quarter
/// level under it: the distance to the center for a centered radial. An off-center radial's level
/// `t` is the circle of radius `t` about `f`(1 − `t`), `f` its focal point; the segment's distance
/// from that center less `t` falls as `t` grows (the center moves |`f`| < 1 per level), and the
/// smallest level is where it reaches 0, found by halving. 0 for a focal point not inside the
/// extent ellipse.
fn radial_least(a: Vec2, b: Vec2, focal: Option<Vec2>) -> f64 {
let Some(f) = focal else { return distance_to_segment(a, b, Vec2::ZERO) };
let reach = f.hypot();
if reach.is_nan() || reach >= 1.0 {
return 0.0;
}
let past = |t: f64| distance_to_segment(a, b, f * (1.0 - t)) <= t;
// At this level the circle reaches the segment's nearest point to the ellipse's center.
let mut hi = (distance_to_segment(a, b, Vec2::ZERO) / (1.0 - reach)).max(1.0);
let mut lo = 0.0;
if !hi.is_finite() || past(lo) {
return 0.0;
}
for _ in 0..RADIAL_HALVINGS {
let mid = lo + (hi - lo) / 2.0;
if hi - lo <= 0.25 || mid <= lo || mid >= hi {
break;
}
if past(mid) { hi = mid } else { lo = mid }
}
lo
}
/// How far `c` is from the segment from `a` to `b`.
fn distance_to_segment(a: Vec2, b: Vec2, c: Vec2) -> f64 {
let d = b - a;
let along = (c - a).dot(d) / d.hypot2();
let u = if along.is_finite() { along.clamp(0.0, 1.0) } else { 0.0 };
(a + d * u - c).hypot()
}
/// The stops of one run, from offset 0 to 1: the end colors held out to the ends.
fn whole_run(stops: &[GradientStop]) -> Vec<GradientStop> {
let mut out = stops.to_vec();
if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() {
out.insert(0, GradientStop { offset: 0.0, ..first });
}
if let Some(last) = out.last_mut() {
// The last stop's midpoint leads nowhere; the copy at 1 takes over.
if last.offset < 1.0 {
let end = GradientStop { offset: 1.0, ..last.clone() };
out.push(end);
}
}
out
}
/// A run mirrored: the stops in reverse, each midpoint measured from the other side.
fn mirror(run: &[GradientStop]) -> Vec<GradientStop> {
let mut g = Gradient::new(GradientKind::Linear, run.to_vec());
g.reverse();
g.stops
}
#[cfg(test)]
mod tests;
+368
View File
@@ -0,0 +1,368 @@
use kurbo::Vec2;
use super::*;
use crate::gradient::{GradientPaint, MAX_REPEATS};
use crate::{Color, GradientMethod};
fn spread(kind: GradientKind, spread: GradientSpread, repeats: u32) -> Gradient {
Gradient { spread, repeats, ..Gradient::new(kind, Gradient::default().stops) }
}
fn black_white() -> Vec<GradientStop> {
let mut a = GradientStop::new(0.0, Color::BLACK);
a.midpoint = 0.3;
vec![a, GradientStop::new(1.0, Color::WHITE)]
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
/// Repeat wraps the parameter, `repeats` times along the vector and on past both ends; Reflect
/// runs every other run backwards; Pad leaves it to the stops to clamp; a freeform gradient has
/// no ends to spread past.
#[test]
fn spread_maps_the_parameter_to_a_place_along_the_stops() {
let repeat = spread(GradientKind::Linear, GradientSpread::Repeat, 3);
for (t, want) in [(0.0, 0.0), (0.1, 0.3), (0.5, 0.5), (1.0 / 3.0 + 0.1, 0.3), (1.1, 0.3), (-0.1, 0.7)] {
assert!(close(repeat.spread_param(t), want), "repeat {t}: {}", repeat.spread_param(t));
}
let reflect = spread(GradientKind::Radial, GradientSpread::Reflect, 3);
for (t, want) in [(0.0, 0.0), (0.1, 0.3), (1.0 / 3.0 + 0.1, 0.7), (2.0 / 3.0 + 0.1, 0.3), (1.0, 1.0), (-0.1, 0.3)] {
assert!(close(reflect.spread_param(t), want), "reflect {t}: {}", reflect.spread_param(t));
}
let pad = spread(GradientKind::Linear, GradientSpread::Pad, 3);
assert_eq!((pad.spread_param(1.7), pad.spread_param(-0.2)), (1.7, -0.2));
let free = spread(GradientKind::Freeform, GradientSpread::Repeat, 3);
assert_eq!(free.spread_param(0.7), 0.7);
assert!(!free.is_spread() && !pad.is_spread() && repeat.is_spread());
assert!(repeat.spread_param(f64::NAN).is_nan());
// A count out of range (set in code) counts as the nearest one in range.
assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 0).repeat_count(), 1);
assert_eq!(spread(GradientKind::Linear, GradientSpread::Repeat, 5000).repeat_count(), MAX_REPEATS);
}
/// The native format writes the spread and count only when they are not Pad and 1, reads a file
/// without them as Pad and 1, and reads an unknown spread or a count out of range as the nearest
/// one it can hold.
#[test]
fn the_file_keeps_the_spread_and_reads_bad_values_leniently() {
let plain = serde_json::to_value(Gradient::default()).unwrap();
assert!(plain.get("spread").is_none() && plain.get("repeats").is_none(), "{plain}");
let g = spread(GradientKind::Conic, GradientSpread::Reflect, 4);
let v = serde_json::to_value(&g).unwrap();
assert_eq!((v["spread"].as_str(), v["repeats"].as_u64()), (Some("Reflect"), Some(4)));
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), g);
let read = |spread: serde_json::Value, repeats: serde_json::Value| {
let mut v = serde_json::to_value(Gradient::default()).unwrap();
v["spread"] = spread;
v["repeats"] = repeats;
let g: Gradient = serde_json::from_value(v).unwrap();
(g.spread, g.repeats)
};
assert_eq!(read("Repeat".into(), 3.4.into()), (GradientSpread::Repeat, 3));
assert_eq!(read("Spiral".into(), (-5).into()), (GradientSpread::Pad, 1));
assert_eq!(read(7.into(), 1e12.into()), (GradientSpread::Pad, MAX_REPEATS));
assert_eq!(read(serde_json::Value::Null, "many".into()), (GradientSpread::Pad, 1));
let missing: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap();
assert_eq!((missing.spread, missing.repeats), (GradientSpread::Pad, 1));
}
/// One run's placement: a level of the period is the gradient's level times the count, for
/// linear, diamond and off-center radial gradients; a conic keeps its placement.
#[test]
fn a_period_holds_one_run_of_the_stops() {
let p = Point::new(72.0, 61.0);
let linear = GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(60.0, 40.0), aspect: 1.0, focal: None };
let radial = GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(60.0, 50.0), aspect: 0.6, focal: Some(Point::new(55.0, 52.0)) };
let diamond = GradientGeom { end: Point::new(58.0, 56.0), focal: None, ..radial };
for (kind, geom) in [(GradientKind::Linear, linear), (GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] {
let period = geom.period(kind, 4);
assert!(close(period.param_at(kind, p), 4.0 * geom.param_at(kind, p)), "{kind:?}");
assert_eq!(period.focal, geom.focal, "{kind:?}");
}
assert_eq!(radial.period(GradientKind::Conic, 4), radial);
assert_eq!(linear.period(GradientKind::Linear, 0), linear, "a count of 0 counts as 1");
}
/// The stops repeated over the area: as many runs as its corners reach, each in place.
#[test]
fn a_linear_gradient_pads_with_its_stops_repeated_over_the_area() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, black_white()) };
let p = g.padded_over(&geom, Rect::new(-5.0, 0.0, 25.0, 10.0)).unwrap();
assert!(!p.capped);
// Runs of 10 from -10 to 30: four of them.
assert_eq!((p.geom.start, p.geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0)));
let offsets: Vec<f32> = p.gradient.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]);
assert!(p.gradient.stops.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6));
assert_eq!((p.gradient.spread, p.gradient.repeats), (GradientSpread::Pad, 1));
// Every point gets the color the gradient gives it.
for x in [-4.0, 1.0, 7.5, 13.0, 24.0] {
let q = Point::new(x, 5.0);
let want = g.sample(g.spread_param(geom.param_at(GradientKind::Linear, q)) as f32).0;
let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0;
assert!(want.to_rgb().iter().zip(got.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-4), "{x}: {want:?} vs {got:?}");
}
}
#[test]
fn reflect_mirrors_every_other_run_and_its_midpoints() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Reflect, ..Gradient::new(GradientKind::Linear, black_white()) };
let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap();
let colors: Vec<Color> = p.gradient.stops.iter().map(|s| s.color).collect();
assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]);
// Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7.
assert!((p.gradient.stops[2].midpoint - 0.7).abs() < 1e-6, "{:?}", p.gradient.stops);
}
#[test]
fn stops_inside_a_run_hold_their_colors_to_its_ends() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None };
let stops = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)];
let g = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, stops) };
let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 20.0, 10.0)).unwrap();
let offsets: Vec<f32> = p.gradient.stops.iter().map(|s| s.offset).collect();
assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]);
}
/// An off-center radial grows about its focal point, a diamond about its center; every point
/// keeps its color.
#[test]
fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() {
let c = Point::new(50.0, 50.0);
let radial = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) };
let diamond = GradientGeom { focal: None, ..radial };
for (kind, geom) in [(GradientKind::Radial, radial), (GradientKind::Diamond, diamond)] {
let g = Gradient { spread: GradientSpread::Reflect, repeats: 2, ..Gradient::new(kind, black_white()) };
let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 100.0)).unwrap();
assert!(p.gradient.stops.len() / 2 >= 10, "{kind:?}: {}", p.gradient.stops.len());
assert_eq!(p.geom.focal, geom.focal);
for q in [Point::new(72.0, 61.0), Point::new(10.0, 95.0), Point::new(52.0, 49.0)] {
let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb();
let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{kind:?} {q:?}: {want:?} vs {got:?}");
}
}
}
/// A run of the stops far too short for the area, or a gradient of many stops, is written with at
/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without
/// stops have nothing to pad.
#[test]
fn far_repeats_and_many_stops_are_capped() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) };
let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap();
assert!(p.capped);
assert_eq!(p.gradient.stops.len(), 2 * MAX_SPREAD_RUNS);
let many: Vec<GradientStop> = (0..=4000).map(|i| GradientStop::new(i as f32 / 4000.0, Color::gray(i as f32 / 4000.0))).collect();
let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) };
let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom };
let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap();
assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len());
assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS);
assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads");
assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none());
let empty = Gradient { spread: GradientSpread::Repeat, ..Gradient::new(GradientKind::Linear, vec![]) };
assert!(empty.padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none());
}
/// The runs written are the ones the area shows: a radial or diamond centered a thousand runs
/// from the area writes the hundred it crosses, not the first 256 from the center; a linear
/// gradient over an area of 1,100 runs far before its start, capped, keeps the runs nearest it.
/// Every point checked gets the color the gradient gives it.
#[test]
fn the_runs_written_cover_a_distant_area() {
let centered = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(1.0, 50.0), aspect: 1.0, focal: None };
let before = GradientGeom { start: Point::new(5000.0, 0.0), end: Point::new(5001.0, 0.0), aspect: 1.0, focal: None };
for (kind, geom, area) in [
(GradientKind::Radial, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)),
(GradientKind::Diamond, centered, Rect::new(1000.0, 0.0, 1100.0, 100.0)),
(GradientKind::Linear, before, Rect::new(0.0, 0.0, 1100.0, 100.0)),
] {
let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) };
let p = g.padded_over(&geom, area).unwrap();
assert_eq!(p.capped, kind == GradientKind::Linear, "{kind:?}");
assert!(p.gradient.stops.len() <= MAX_SPREAD_STOPS);
for q in [Point::new(1000.5, 0.5), Point::new(1050.25, 50.0), Point::new(1099.5, 99.5), Point::new(1010.75, 90.0)] {
let want = g.sample(g.spread_param(geom.param_at(kind, q)) as f32).0.to_rgb();
let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}");
}
}
}
/// A tiny radial centered just outside the area, beside a side between any evenly spaced
/// samples of it: the runs written start from the area's true innermost level.
#[test]
fn the_innermost_level_is_found_between_samples() {
let c = Point::new(-0.0005, 7.8125);
let geom = GradientGeom { start: c, end: c + Vec2::new(0.001, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) };
let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 1000.0, 1000.0)).unwrap();
for q in [Point::new(0.05, 7.8125), Point::new(0.1, 7.9)] {
let want = g.sample(g.spread_param(geom.param_at(GradientKind::Radial, q)) as f32).0.to_rgb();
let got = p.gradient.sample(p.geom.param_at(GradientKind::Radial, q) as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{q:?}: {want:?} vs {got:?}");
}
}
/// An area four billion units wide whose long side passes 50 runs from a tiny radial or diamond's
/// center: the runs written start from that side's true innermost level, so the runs beside it
/// keep their colors.
#[test]
fn the_innermost_level_is_found_along_a_very_long_side() {
let area = Rect::new(-1.3e9, 0.0, 2.9e9, 1.0);
for x in [0.3, 12345.678, -7777.25, 98765.4321] {
let c = Point::new(x, -0.00005);
let run = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None };
let off_center = GradientGeom { focal: Some(c + Vec2::new(0.5e-6, 0.0)), ..run };
for (kind, geom) in [(GradientKind::Diamond, run), (GradientKind::Radial, run), (GradientKind::Radial, off_center)] {
let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(kind, black_white()) };
let p = g.padded_over(&geom, area).unwrap();
assert!(p.capped, "{kind:?}");
for q in [Point::new(x, 1e-5), Point::new(x + 2e-5, 2e-5), Point::new(x - 1e-5, 0.0001)] {
let level = geom.param_at(kind, q);
assert!((40.0..200.0).contains(&level), "{kind:?} {q:?}: level {level}");
let want = g.sample(g.spread_param(level) as f32).0.to_rgb();
let got = p.gradient.sample(p.geom.param_at(kind, q) as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{kind:?} {q:?}: {want:?} vs {got:?}");
}
}
}
}
/// Runs a billion levels from a radial's center are past what the stops' offsets can tell apart:
/// the gradient is written capped, its stops still apart and in order.
#[test]
fn runs_too_far_from_the_center_are_capped() {
let c = Point::new(0.0, 0.0);
let geom = GradientGeom { start: c, end: c + Vec2::new(1e-6, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Radial, black_white()) };
let p = g.padded_over(&geom, Rect::new(1000.0, 0.0, 1000.0001, 0.0001)).unwrap();
assert!(p.capped);
assert!(p.gradient.stops.windows(2).all(|w| w[0].offset <= w[1].offset), "in order");
let distinct = p.gradient.stops.windows(2).filter(|w| w[1].offset > w[0].offset).count();
assert!(distinct * 2 + 2 >= p.gradient.stops.len(), "the runs stay apart: {distinct} of {}", p.gradient.stops.len());
}
/// A run of 1,000 close stops is written only as far out as their offsets stay apart: around
/// level 50,000 it is capped, every stop written apart from the next.
#[test]
fn close_stops_are_written_only_where_they_stay_apart() {
let many: Vec<GradientStop> = (0..=1000).map(|i| GradientStop::new(i as f32 / 1000.0, Color::gray(i as f32 / 1000.0))).collect();
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.001, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Repeat, repeats: 1, ..Gradient::new(GradientKind::Diamond, many) };
let p = g.padded_over(&geom, Rect::new(50.0, 0.0, 50.002, 0.002)).unwrap();
assert!(p.capped);
// Only where one run meets the next do two stops share an offset.
let runs = p.gradient.stops.len() / 1001;
let shared = p.gradient.stops.windows(2).filter(|w| w[1].offset <= w[0].offset).count();
assert!(shared < runs, "{shared} stops share an offset in {runs} runs");
// Stops 0.0001234 of a run apart are written out to level 123, on whole runs: an area within
// it gets the gradient's colors, Reflect's mirrored runs included.
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.0001234, Color::gray(0.5)), GradientStop::new(1.0, Color::WHITE)];
let g = Gradient { spread: GradientSpread::Reflect, repeats: 1, ..Gradient::new(GradientKind::Diamond, stops) };
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0), aspect: 1.0, focal: None };
let p = g.padded_over(&geom, Rect::new(121.0, 0.0, 123.9, 0.2)).unwrap();
assert!(p.capped, "past level 123 it pads");
for x in [121.3, 121.9, 122.5] {
let q = Point::new(x, 0.1);
let want = g.sample(g.spread_param(geom.param_at(GradientKind::Diamond, q)) as f32).0.to_rgb();
let got = p.gradient.sample(p.geom.param_at(GradientKind::Diamond, q) as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}");
}
}
/// A piece of a gradient that starts where two runs meet (a gradient along a stroke, sliced at a
/// corner there) starts with the new run's first color, not the old run's last.
#[test]
fn a_piece_starting_at_a_sharp_step_takes_the_color_after_it() {
let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, Gradient::default().stops) };
let piece = g.unrolled().span(0.5, 1.0);
assert_eq!(piece.stops.first().map(|s| s.color), Some(Color::WHITE), "{:?}", piece.stops);
assert_eq!(piece.stops.last().map(|s| s.color), Some(Color::BLACK));
let before = g.unrolled().span(0.0, 0.5);
assert_eq!((before.stops[0].color, before.stops.last().unwrap().color), (Color::WHITE, Color::BLACK));
}
/// An area very far past a very short reflecting vector (a file's numbers) is padded without the
/// run count overflowing.
#[test]
fn a_far_area_pads_without_overflowing() {
let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(1e-300, 0.0), aspect: 1.0, focal: None };
let g = Gradient { spread: GradientSpread::Reflect, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) };
let p = g.padded_over(&geom, Rect::new(1e3, 0.0, 2e3, 1.0));
assert!(p.is_none_or(|p| p.gradient.stops.len() <= MAX_SPREAD_STOPS));
// Runs of 1e-8: the area's levels lie past 1e19, more than an integer holds.
let geom = GradientGeom { end: Point::new(1e-6, 0.0), ..geom };
let p = g.padded_over(&geom, Rect::new(1e11, 0.0, 1e11 + 1.0, 1.0)).unwrap();
assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {} {:?}", p.capped, p.gradient.stops.len(), p.geom);
}
/// Unrolled, the stops run `repeats` times between 0 and 1 (every other run mirrored with
/// Reflect); one cycle is a run, or a run and its mirror.
#[test]
fn unrolled_and_cycle_stops() {
let g = Gradient { spread: GradientSpread::Reflect, repeats: 3, ..Gradient::new(GradientKind::Linear, black_white()) };
let u = g.unrolled();
let offsets: Vec<f32> = u.stops.iter().map(|s| (s.offset * 3.0).round()).collect();
assert_eq!(offsets, [0.0, 1.0, 1.0, 2.0, 2.0, 3.0]);
let colors: Vec<Color> = u.stops.iter().map(|s| s.color).collect();
assert_eq!(colors, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK, Color::BLACK, Color::WHITE]);
for t in [0.05, 0.4, 0.71, 0.99] {
let (a, b) = (g.sample(g.spread_param(t) as f32).0.to_rgb(), u.sample(t as f32).0.to_rgb());
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{t}");
}
let (cycle, runs) = g.cycle().unwrap();
assert_eq!((runs, cycle.stops.len()), (2, 4));
let repeat = Gradient { spread: GradientSpread::Repeat, ..g.clone() };
assert_eq!(repeat.cycle().unwrap().1, 1);
assert!(Gradient::default().cycle().is_none());
assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_)));
}
/// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and
/// PDF write as images) repeat with the stops, for each kind.
#[test]
fn pixels_repeat_and_reflect_for_every_kind() {
let b = Rect::new(0.0, 0.0, 40.0, 40.0);
let c = Point::new(20.0, 20.0);
for kind in [GradientKind::Linear, GradientKind::Radial, GradientKind::Conic, GradientKind::Diamond] {
for sp in [GradientSpread::Repeat, GradientSpread::Reflect] {
for method in [GradientMethod::Classic, GradientMethod::Perceptual] {
let mut g = GradientPaint::new(Gradient { spread: sp, repeats: 3, method, ..Gradient::new(kind, Gradient::default().stops) });
let geom = if kind == GradientKind::Linear {
GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(40.0, 10.0), aspect: 1.0, focal: None }
} else {
GradientGeom { start: c, end: c + Vec2::new(18.0, 4.0), aspect: 0.8, focal: None }
};
g.geom = Some(geom);
let px: Vec<[u8; 4]> = g.pixels(b, b, 40, 40, false, &Color::to_rgb).collect();
for (i, got) in px.iter().enumerate().step_by(37) {
let p = Point::new((i % 40) as f64 + 0.5, (i / 40) as f64 + 0.5);
let t = g.gradient.spread_param(geom.param_at(kind, p));
// Not where Repeat jumps from the last stop back to the first.
if sp == GradientSpread::Repeat && !(0.03..0.97).contains(&t) {
continue;
}
let want = g.gradient.sample(t as f32).0.to_rgba8(1.0);
assert!(got[0].abs_diff(want[0]) <= 2, "{kind:?} {sp:?} {method:?} {p:?}: {got:?} vs {want:?}");
}
}
}
}
}
/// A conic gradient's wedges: as many for each time the stops run around as for one turn, at most
/// so many.
#[test]
fn conic_wedges_count_each_run() {
let g = spread(GradientKind::Conic, GradientSpread::Repeat, 4);
assert_eq!(g.conic_wedge_count(360), 1440);
assert_eq!(spread(GradientKind::Conic, GradientSpread::Pad, 4).conic_wedge_count(360), 360);
assert_eq!(spread(GradientKind::Conic, GradientSpread::Reflect, MAX_REPEATS).conic_wedge_count(360), crate::gradient::MAX_CONIC_WEDGES);
}
+1 -1
View File
@@ -16,7 +16,7 @@ pub mod swatch;
pub use blend::BlendMode;
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop};
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop};
pub use libraries::SwatchLibrary;
pub use swatch::{Swatch, SwatchGroup, default_swatches};
+3 -1
View File
@@ -196,8 +196,10 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
let at = if mirrored { 1.0 - s.offset } else { s.offset };
format!("{} {}%", css_color(&s.color, s.opacity), at * 100.0)
};
// Stops that repeat or reflect run that many times across the box.
let unrolled = g.gradient.unrolled();
let stops: Vec<String> =
if mirrored { g.gradient.stops.iter().rev().map(stop).collect() } else { g.gradient.stops.iter().map(stop).collect() };
if mirrored { unrolled.stops.iter().rev().map(stop).collect() } else { unrolled.stops.iter().map(stop).collect() };
match g.gradient.kind {
GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")),
// CSS sweeps clockwise from straight up; the angle is counted counterclockwise
+23
View File
@@ -261,6 +261,29 @@ fn css_preserves_fractional_gradient_stop_positions() {
}
}
/// A gradient swatch that repeats or reflects has its stops written that many times across the
/// box, every other run backwards for Reflect.
#[test]
fn css_writes_spread_gradient_swatches_with_their_stops_repeated() {
for (spread, want) in [
(crate::GradientSpread::Repeat, "#ffffff 0%, #000000 50%, #ffffff 50%, #000000 100%"),
(crate::GradientSpread::Reflect, "#ffffff 0%, #000000 50%, #000000 50%, #ffffff 100%"),
] {
let lib = SwatchLibrary {
name: "x".into(),
swatches: vec![Swatch {
name: "Bands".into(),
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { spread, repeats: 2, ..Gradient::default() }))),
global: false,
spot: false,
}],
groups: vec![],
};
let text = written(&lib, PaletteFormat::Css);
assert!(text.contains(&format!("--bands: linear-gradient(90deg, {want});")), "{spread:?}: {text}");
}
}
#[test]
fn holds_counts_the_swatches_each_format_writes() {
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
+24
View File
@@ -152,6 +152,11 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
}
})
.collect();
// Two gradients that repeat (or reflect) step their count from one to the other; a spread
// that differs switches halfway, as the method does.
if a.gradient.is_spread() && a.gradient.spread == b.gradient.spread {
out.gradient.repeats = lerp_f32(a.gradient.repeat_count() as f32, b.gradient.repeat_count() as f32, t).round() as u32;
}
out.angle = a.angle + (b.angle - a.angle) * t as f64;
out.swatch = None;
if let (Some(ga), Some(gb)) = (a.geom, b.geom) {
@@ -1656,6 +1661,25 @@ mod tests {
assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up");
}
/// A blend between two gradients that repeat steps the count; one between a gradient that
/// pads and one that repeats switches halfway, as the method does.
#[test]
fn blend_between_repeating_gradients_steps_the_count() {
use vectorcraft_color::GradientSpread;
let spread = |spread, repeats| Paint::Gradient(Box::new(GradientPaint::new(Gradient { spread, repeats, ..Gradient::default() })));
let at = |a: &Paint, b: &Paint, t: f32| match lerp_paint(a, b, t) {
Paint::Gradient(g) => (g.gradient.spread, g.gradient.repeat_count()),
p => panic!("a gradient, not {p:?}"),
};
let (two, twenty) = (spread(GradientSpread::Repeat, 2), spread(GradientSpread::Repeat, 20));
assert_eq!(at(&two, &twenty, 0.25), (GradientSpread::Repeat, 7));
assert_eq!(at(&two, &twenty, 0.5), (GradientSpread::Repeat, 11));
assert_eq!(at(&two, &twenty, 1.0), (GradientSpread::Repeat, 20));
let pad = spread(GradientSpread::Pad, 1);
assert_eq!(at(&pad, &twenty, 0.25), (GradientSpread::Pad, 1));
assert_eq!(at(&pad, &twenty, 0.75), (GradientSpread::Repeat, 20));
}
#[test]
fn blend_between_tints_of_one_swatch_keeps_the_link() {
let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint };
+26 -4
View File
@@ -133,6 +133,13 @@ pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Deco
Some((d.colortype().ok()?, d))
}
/// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images
/// and TIFFs without one. Reads only its header.
pub fn tiff_orientation(bytes: &[u8]) -> Option<u16> {
let (_, mut d) = tiff(bytes)?;
d.find_tag_unsigned::<u16>(tiff::tags::Tag::Orientation).ok().flatten()
}
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
@@ -309,10 +316,10 @@ fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
Inks::new(w, h, read_samples(&mut d, n, 4)?)
}
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
/// ink, which the image decoder doesn't read, their ink amounts written again by
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
/// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and
/// Place store them: as they are, or, when they store one plane per ink, which the image decoder
/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution
/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
let inks = tiff_inks(bytes)?;
let (_, mut d) = tiff(bytes)?;
@@ -322,6 +329,21 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
}
/// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their
/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned
/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`]
/// doesn't read.
pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec<u8>, (u32, u32))> {
let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?;
// Four 8-bit inks to a pixel, turned as four 8-bit channels are.
let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?);
px.apply_orientation(orientation);
let inks = Inks::new(px.width(), px.height(), px.into_bytes())?;
let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?;
Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height)))
}
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
+2 -2
View File
@@ -450,7 +450,7 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
envelope,
NodeKind::Envelope {
content,
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: false,
options: Default::default(),
@@ -1192,7 +1192,7 @@ fn objects_at_edit_contents_uses_general_order() {
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 }),
fidelity: 100.0,
editing: true,
options: Default::default(),
+197 -3
View File
@@ -60,12 +60,12 @@ pub use envelope::{
pub(crate) use expand::max_outset;
pub use expand::{
Hooks, MAX_GENERATED, Outliner, PatternTiles, affine_near, expand_deep, expand_deep_hooks, expand_live, expand_live_hooks, expand_live_with,
expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, subtree_has_live,
expanded_group, expanded_group_hooks, is_live, mesh_quad_nodes, mesh_warp_cost, mesh_warp_image, subtree_has_live,
};
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, uses_net, warp_point, warp_version};
#[cfg(test)]
mod tests {
@@ -132,6 +132,200 @@ mod tests {
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
/// Each warp style's Bézier net, at points worked out by a separate implementation of the same
/// nets: (style, bend %, horizontal and vertical distortion %, vertical, box w × h, (u, v)) →
/// the point in the box (origin at its top left, y down).
#[test]
fn warp_nets_bend_each_style_as_its_own_patch() {
#[rustfmt::skip]
let samples = [
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -31.1605665, 30.1243045),
("arc", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 170.241998, 58.8743443),
("arc", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 50.6087118, 47.6275948),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 18.8784581, 35.5028749),
("arcLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.486168, 119.348666),
("arcLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 38.1073767, 36.0911629),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 17.3830689, 17.1599587),
("arcUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.498463, 86.7390371),
("arcUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 46.1081993, 36.0252855),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 11.6570838),
("arch", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 57.3903712),
("arch", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 49.2508226, 36.2064484),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 16.261527, 22.6628335),
("bulge", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 164.984631, 116.087703),
("bulge", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.965576, 36.2064484),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 10.6489723, 25.9914004),
("shellLower", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 208.565464, 99.4817585),
("shellLower", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 41.043987, 36.1302767),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -21.8346746, 41.6042708),
("shellUpper", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.791499, 90.3680631),
("shellUpper", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.9282896, 36.1475509),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 55.92),
("flag", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 55.44),
("flag", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 58.098, 35.91),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 40.8864),
("wave", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 83.7792),
("wave", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 42.33, 35.91),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 19.632),
("fish", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 62.352),
("fish", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 44.994, 35.91),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 20.0, 146.64),
("rise", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 160.0, 102.48),
("rise", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 118.686, 35.91),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 9.1136, 27.2784),
("fisheye", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.2208, 94.1472),
("fisheye", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 47.514, 35.7048),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, -0.16, 27.84),
("inflate", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 166.48, 97.68),
("inflate", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.54),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 40.16, 27.84),
("squeeze", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 153.52, 97.68),
("squeeze", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.75, 36.0),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.1, 0.3, 32.2472, 27.9588),
("twist", 60.0, 0.0, 0.0, false, 200.0, 100.0, 0.8, 0.9, 149.1136, 89.2224),
("twist", -40.0, 30.0, -50.0, true, 150.0, 60.0, 0.25, 0.6, 45.56325, 33.8769),
];
for (style, bend, dh, dv, vertical, w, h, u, v, x, y) in samples {
let Some(st) = WarpStyle::from_id(style) else { panic!("{style}") };
let p = WarpNet::new(st, bend / 100.0, dh / 100.0, dv / 100.0, w, h, vertical).at(u, v);
assert!(p.distance(Point::new(x, y)) < 1e-6, "{style} {bend} {dh} {dv} {vertical} ({u}, {v}): {p:?} vs ({x}, {y})");
}
}
/// Arc's edges are concentric circles: at 100 % on a 200 × 100 box the bottom edge is the half
/// circle through the bottom corners about the bottom's middle, and the top edge the one 100
/// further out. One cubic for a half circle strays up to 1.84 % of the radius from the circle.
#[test]
fn warp_net_arc_edges_are_concentric_circles() {
let net = WarpNet::new(WarpStyle::Arc, 1.0, 0.0, 0.0, 200.0, 100.0, false);
let center = Point::new(100.0, 100.0);
for i in 0..=20 {
let u = f64::from(i) / 20.0;
for (v, r) in [(0.0, 200.0), (1.0, 100.0)] {
let d = net.at(u, v).distance(center);
assert!((d - r).abs() < 0.0184 * r, "u {u}, v {v}: {d} from the center, not {r}");
// Its ends and middle lie on the circle.
if i % 10 == 0 {
assert!((d - r).abs() < 1e-9, "u {u}, v {v}: {d} from the center, not {r}");
}
}
}
// Bend 0 leaves the box as it is.
for st in [WarpStyle::Arc, WarpStyle::ShellLower, WarpStyle::Bulge, WarpStyle::Twist] {
let flat = WarpNet::new(st, 0.0, 0.0, 0.0, 200.0, 100.0, false);
assert!(flat.at(0.3, 0.7).distance(Point::new(60.0, 70.0)) < 1e-9, "{st:?}");
}
}
/// The horizontal distortion scales each column of the net about its middle (narrowing the left
/// side for a positive value), then the vertical one each row (narrowing the top).
#[test]
fn warp_net_distortion_scales_the_columns_then_the_rows() {
let corners = |dh: f64, dv: f64| {
let net = WarpNet::new(WarpStyle::Arc, 0.0, dh, dv, 200.0, 100.0, false);
[net.at(0.0, 0.0), net.at(1.0, 0.0), net.at(0.0, 1.0), net.at(1.0, 1.0)]
};
let near = |got: [Point; 4], want: [(f64, f64); 4]| got.iter().zip(want).all(|(p, (x, y))| p.distance(Point::new(x, y)) < 1e-9);
assert!(near(corners(0.5, 0.0), [(0.0, 25.0), (200.0, -25.0), (0.0, 75.0), (200.0, 125.0)]), "{:?}", corners(0.5, 0.0));
assert!(near(corners(0.0, 0.5), [(50.0, 0.0), (150.0, 0.0), (-50.0, 100.0), (250.0, 100.0)]), "{:?}", corners(0.0, 0.5));
// Both coordinates scale: after the columns the top row runs from (0, 15) to (200, -15), and
// it then scales by 1.4 about its middle, (100, 0).
assert!(near(corners(0.3, -0.4), [(-40.0, 21.0), (240.0, -21.0), (40.0, 91.0), (160.0, 109.0)]), "{:?}", corners(0.3, -0.4));
}
/// Numbers no warp can take still give a finite net: a bend or distortion that isn't a number
/// counts as 0, and out of range as its limit; a size that isn't a number as 0.
#[test]
fn warp_net_takes_any_numbers() {
for st in [WarpStyle::Arc, WarpStyle::ShellUpper, WarpStyle::Wave, WarpStyle::Inflate, WarpStyle::Twist] {
for (b, dh, w, h) in
[(f64::NAN, 0.2, 10.0, 10.0), (5.0, f64::INFINITY, 10.0, 0.0), (-1e300, -3.0, f64::NAN, 1e-300), (1e-300, 0.0, 0.0, 0.0)]
{
for vertical in [false, true] {
let p = WarpNet::new(st, b, dh, dh, w, h, vertical).at(0.3, 0.6);
assert!(p.x.is_finite() && p.y.is_finite(), "{st:?} {b} {dh} {w} {h}: {p:?}");
}
}
}
let clamped = WarpNet::new(WarpStyle::Flag, 7.0, 0.0, 0.0, 200.0, 100.0, false);
assert_eq!(clamped, WarpNet::new(WarpStyle::Flag, 1.0, 0.0, 0.0, 200.0, 100.0, false));
}
/// A box near the largest finite size still warps: Flag on a 1e308 × 100 box moves points only
/// up and down, as on a 1 × 1e-306 one scaled up, and on a 1e308 × 1e-300 box keeps the box's
/// height. Fisheye at 100 % on a 1.7e308 square, whose net goes past the largest finite number,
/// gives finite points inside the box and past it, and the unit square's points scaled up where
/// those are finite. Fisheye moves y by the height alone, so on boxes of the largest finite
/// width and a height of 1e-300 or 100 every y is the unit square's times the height, also
/// where x goes past the largest finite number.
#[test]
fn warp_net_bends_a_huge_box_without_overflowing() {
let w = 1e308;
let flag = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 100.0, false);
let small = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, 1.0, 100.0 / w, false);
for (u, v) in [(0.0, 0.0), (0.3, 0.6), (0.9, 1.0), (1.0, 0.5)] {
let (p, q) = (flag.at(u, v), small.at(u, v));
assert!(p.is_finite(), "({u}, {v}): {p:?}");
assert!((p.x - u * w).abs() <= 1e-12 * w, "({u}, {v}): {p:?}");
assert!((p.y - q.y * w).abs() <= 1e-9 * p.y.abs().max(1.0), "({u}, {v}): {p:?} against {q:?}");
}
let thin = WarpNet::new(WarpStyle::Flag, 0.5, 0.0, 0.0, w, 1e-300, false);
assert_eq!(thin.at(0.0, 1.0), Point::new(0.0, 1e-300));
let fisheye = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.7e308, 1.7e308, false);
for (u, v) in [(0.1, 0.1), (0.8, 0.3), (1.5, 0.5), (3.0, 3.0)] {
let p = fisheye.at(u, v);
assert!(p.is_finite(), "({u}, {v}): {p:?}");
}
let unit = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, 1.0, 1.0, false);
let near = |a: f64, b: f64| (a - b).abs() <= 1e-12 * b.abs().max(f64::MIN_POSITIVE);
let (p, q) = (fisheye.at(0.1, 0.1), unit.at(0.1, 0.1));
assert!(near(p.x, q.x * 1.7e308) && near(p.y, q.y * 1.7e308), "{p:?} against {q:?}");
// The bottom of a box with a subnormal height stays exactly at that height: the net is
// built on the box halved, and the height's lowest bit but one is set.
let low = f64::MIN_POSITIVE / 4.0 + f64::from_bits(2);
let sub = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, low, false).at(0.0, 1.0);
assert_eq!(sub.y, low, "{sub:?}");
for h in [1e-300, 100.0] {
let tall = WarpNet::new(WarpStyle::Fisheye, 1.0, 0.0, 0.0, f64::MAX, h, false);
for (u, v) in [(0.1, 0.1), (0.9, 0.4), (0.5, 0.8)] {
let (p, q) = (tall.at(u, v), unit.at(u, v));
assert!(p.is_finite() && near(p.y, q.y * h), "h {h}, ({u}, {v}): {p:?} against {q:?}");
}
}
}
/// Content with no width or no height takes the middle of a warp envelope's patch along that
/// side: under Arc a vertical line stays vertical, under Bulge a horizontal line stays straight.
#[test]
fn warp_envelopes_bend_flat_content_as_the_middle_of_the_patch() {
let warp = |style: &str| EnvelopeKind::Warp { style: style.into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let arc = warp("arc");
let map = envelope_mapper(&arc, Rect::new(100.0, 0.0, 100.0, 100.0));
for y in [0.0, 30.0, 100.0] {
let p = map(Point::new(100.0, y));
assert!((p.x - 100.0).abs() <= 1e-9, "{y}: {p:?}");
}
let bulge = warp("bulge");
let map = envelope_mapper(&bulge, Rect::new(0.0, 50.0, 200.0, 50.0));
for x in [0.0, 70.0, 200.0] {
let p = map(Point::new(x, 50.0));
assert!((p.y - 50.0).abs() <= 1e-9, "{x}: {p:?}");
}
}
/// A warp envelope's map version: none in a file reads as 0 (the earlier map), 0 is written
/// as none, so files keep their bytes, and the nets' version is written.
#[test]
fn warp_envelopes_write_their_map_version_only_when_it_has_one() {
let old: EnvelopeKind = serde_json::from_str(r#"{"type":"warp","style":"arc","bend":20}"#).unwrap();
assert!(matches!(old, EnvelopeKind::Warp { version: 0, .. }), "{old:?}");
assert!(!serde_json::to_string(&old).unwrap().contains("version"));
let new = EnvelopeKind::Warp { style: "arc".into(), bend: 20.0, h: 0.0, v: 0.0, horizontal: true, version: WARP_NET };
let text = serde_json::to_string(&new).unwrap();
assert!(text.contains(r#""version":1"#), "{text}");
assert_eq!(serde_json::from_str::<EnvelopeKind>(&text).unwrap(), new);
}
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
@@ -537,7 +731,7 @@ mod tests {
#[test]
fn an_envelope_maps_from_its_box_with_its_margins() {
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let content = [Arc::new(rect)];
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
// Top, bottom, left and right.
+8
View File
@@ -3,6 +3,10 @@ use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
use vectorcraft_geom::Homography;
fn is_zero(n: &u32) -> bool {
*n == 0
}
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
@@ -19,6 +23,10 @@ pub enum EnvelopeKind {
v: f64,
#[serde(default = "yes")]
horizontal: bool,
/// The map the warp draws with: [`WARP_NET`](super::WARP_NET) for its style's Bézier net,
/// 0 (as in files from before the version existed) for [`warp_point`](super::warp_point).
#[serde(default, skip_serializing_if = "is_zero")]
version: u32,
},
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
Mesh {
+36 -14
View File
@@ -119,7 +119,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node {
{
out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps));
}
if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()) {
// A linked opacity mask bends with the object it masks; an unlinked one stays where it is.
if let Some(m) = out.extras_if_any().and_then(|e| e.mask.as_mut()).filter(|m| m.linked) {
m.art = Arc::new(map_node(&m.art, w));
}
match &mut out.kind {
@@ -178,29 +179,46 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
a.is_finite().then_some(a)
}
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
/// seams show.
/// Image `n` (`im`) pushed through the envelope's warp as a raster mesh warp ([`mesh_warp_image`]
/// with `w.cells` cells, Preserve Shape Using Transparency and Anti-Alias as the envelope's
/// options say).
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
// pieces is cut again here, so the pieces count against [`MAX_GENERATED`]. Past it, the image
// is left out.
if !Generating::charge(k.saturating_mul(k).saturating_mul(6)) {
// An image an inner envelope already cut into pieces is cut again here, so the pieces count
// against [`MAX_GENERATED`]. Past it, the image is left out.
if !Generating::charge(mesh_warp_cost(k)) {
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![], clip: false };
return g;
}
mesh_warp_image(n, im, w.f, k, !w.options.anti_alias, w.options.preserve_shape == PreserveShape::Transparency)
}
/// How many objects [`mesh_warp_image`] makes of one image with `cells` rows and columns: two
/// triangles a cell, three objects each.
pub fn mesh_warp_cost(cells: usize) -> usize {
let k = cells.max(1);
k.saturating_mul(k).saturating_mul(6)
}
/// Image `n` (`im`) pushed through point map `f` as a raster mesh warp: its frame cut into
/// `cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where `f` takes its corners (with `masked`, the image masked by the
/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past
/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the
/// color `n` colorizes a grayscale image with. The result is a group with `n`'s id, name,
/// transparency, opacity mask and appearance.
pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node {
let k = cells.max(1);
// A grayscale image's fill color colorizes each piece; the group of pieces takes none.
let colorize = n.extras().colorize.clone();
let (pw, ph) = (im.width as f64, im.height as f64);
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
let mut pieces = Vec::with_capacity(2 * k * k);
for j in 0..k {
for i in 0..k {
let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
let d = s.map(|p| (w.f)(im.xf * p));
let d = s.map(|p| f(im.xf * p));
for t in [[0, 1, 2], [0, 2, 3]] {
let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v]));
let Some(xf) = affine_between(st, dt) else { continue };
@@ -216,8 +234,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
.collect();
let shape = PathData::single(SubPath::polyline(&tri, true));
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
image.extras_mut().pixelated = !w.options.anti_alias;
if w.options.preserve_shape == PreserveShape::Transparency {
image.extras_mut().pixelated = pixelated;
image.extras_mut().colorize = colorize.clone();
if masked {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
@@ -234,6 +253,9 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
}
let mut g = n.clone();
g.kind = NodeKind::Group { children: pieces, clip: false };
if let Some(e) = g.extras_if_any() {
e.colorize = None;
}
g
}
+11 -2
View File
@@ -1,5 +1,5 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
use super::warp::{WarpNet, WarpStyle, flatten_subpath, resample, uses_net, warp_point};
use super::{
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
one,
@@ -1731,10 +1731,19 @@ pub(super) fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64)
pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn Fn(f64, f64) -> Point + 'a> {
let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height());
match kind {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => {
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal, version } => {
let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc);
let b = bend.clamp(-100.0, 100.0) / 100.0;
let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0);
if uses_net(*version) {
let net = WarpNet::new(st, b, dh, dv, src.width(), src.height(), !*horizontal);
// Content with no width or no height takes the middle of the patch along that side.
let (flat_u, flat_v) = (src.width() <= 0.0, src.height() <= 0.0);
return Box::new(move |u: f64, v: f64| {
let (u, v) = (if flat_u { 0.5 } else { u }, if flat_v { 0.5 } else { v });
frame * (src.origin() + net.at(u, v).to_vec2())
});
}
let c = src.center();
let (hw, hh) = (src.width() / 2.0, src.height() / 2.0);
let horizontal = *horizontal;
+331
View File
@@ -109,6 +109,337 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f6
(x2, y2)
}
// =====================================================================================
// Warp nets: each style as one Bézier patch over its box
// =====================================================================================
/// The `version` a Warp effect's params or a warp envelope carries for the [`WarpNet`] map. With
/// none (or 0) a warp draws with [`warp_point`], as every warp did before, so saved documents keep
/// their look; new and imported warps are given this one.
pub const WARP_NET: u32 = 1;
/// Does a warp of map `version` draw with [`WarpNet`]? A version newer than this build knows draws
/// with the newest map it has.
pub fn uses_net(version: u32) -> bool {
version >= WARP_NET
}
/// The map version `v` names when it is one this build draws: a whole number from 0 to
/// [`WARP_NET`], written as an integer or as a float (`1` or `1.0`).
pub fn warp_version(v: &serde_json::Value) -> Option<u32> {
let n = v.as_f64()?;
(n.fract() == 0.0 && (0.0..=f64::from(WARP_NET)).contains(&n)).then_some(n as u32)
}
/// A style's own control net: `rows` × `cols` points (2 to 4 each) of a Bézier patch, row by row
/// from the top, in the box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug)]
struct Net {
rows: usize,
cols: usize,
p: [[Point; 4]; 4],
}
impl Net {
/// The undistorted box `w` × `h` as a `rows` × `cols` net.
fn flat(w: f64, h: f64, rows: usize, cols: usize) -> Self {
let mut p = [[Point::ZERO; 4]; 4];
for (r, row) in p.iter_mut().enumerate().take(rows) {
for (c, q) in row.iter_mut().enumerate().take(cols) {
*q = Point::new(w * (c as f64 / (cols - 1) as f64), h * (r as f64 / (rows - 1) as f64));
}
}
Self { rows, cols, p }
}
/// Upside down within a box `h` high: the rows in reverse order, each point mirrored.
fn mirrored(self, h: f64) -> Self {
let mut out = self;
for (to, from) in out.p[..self.rows].iter_mut().zip(self.p[..self.rows].iter().rev()) {
for (q, f) in to.iter_mut().zip(from).take(self.cols) {
*q = Point::new(f.x, h - f.y);
}
}
out
}
/// Rows and columns exchanged, and x with y: a net made on the box turned on its side.
fn transposed(self) -> Self {
let mut out = Self { rows: self.cols, cols: self.rows, p: [[Point::ZERO; 4]; 4] };
for (r, row) in self.p[..self.rows].iter().enumerate() {
for (c, q) in row[..self.cols].iter().enumerate() {
out.p[c][r] = Point::new(q.y, q.x);
}
}
out
}
/// Each column scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from
/// 0 at the left column to 1 at the right: a horizontal distortion `a` (-1..1).
fn distort_columns(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for c in 0..cols {
let s = 1.0 + a * (2.0 * c as f64 / (cols - 1) as f64 - 1.0);
let mid = half_way(self.p[0][c], self.p[rows - 1][c]);
for row in &mut self.p[..rows] {
row[c] = mid + (row[c] - mid) * s;
}
}
}
/// Each row scaled about the midpoint of its end points by `1 + a (2t - 1)`, `t` running from 0
/// at the top row to 1 at the bottom: a vertical distortion `a` (-1..1).
fn distort_rows(&mut self, a: f64) {
let (rows, cols) = (self.rows, self.cols);
for (r, row) in self.p[..rows].iter_mut().enumerate() {
let s = 1.0 + a * (2.0 * r as f64 / (rows - 1) as f64 - 1.0);
let mid = half_way(row[0], row[cols - 1]);
for q in &mut row[..cols] {
*q = mid + (*q - mid) * s;
}
}
}
/// The same patch as a bicubic net (degree elevation, which leaves the surface as it is).
fn bicubic(self) -> [[Point; 4]; 4] {
let rows: Vec<[Point; 4]> = self.p[..self.rows].iter().map(|row| cubic_line(&row[..self.cols])).collect();
let mut out = [[Point::ZERO; 4]; 4];
for c in 0..4 {
let col: Vec<Point> = rows.iter().map(|row| row[c]).collect();
for (to, q) in out.iter_mut().zip(cubic_line(&col)) {
to[c] = q;
}
}
out
}
}
/// The point half way from `a` to `b`, as halves added: two finite points always give a finite one.
fn half_way(a: Point, b: Point) -> Point {
Point::new(0.5 * a.x + 0.5 * b.x, 0.5 * a.y + 0.5 * b.y)
}
/// A Bézier curve of 2 to 4 control points as the 4 of the same curve's cubic form.
fn cubic_line(p: &[Point]) -> [Point; 4] {
match *p {
[a, b] => [a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b],
[a, b, c] => [a, a.lerp(b, 2.0 / 3.0), c.lerp(b, 2.0 / 3.0), c],
[a, b, c, d] => [a, b, c, d],
_ => [Point::ZERO; 4],
}
}
/// The handle offsets (along the chord, across it) of a cubic circular arc over a chord `chord`
/// long that turns through θ = |`b`| × 180°: each handle is 4/3 tan(θ/4) of the radius long, along
/// the arc's end tangent. With the radius `chord / (2 sin(θ/2))` that length is
/// `chord / (3 cos²(θ/4))`, which a bend near 0 leaves at a third of the chord.
fn arc_handles(chord: f64, b: f64) -> (f64, f64) {
let th = b.abs().min(1.0) * PI;
let len = chord / (3.0 * (th / 4.0).cos().powi(2));
(len * (th / 2.0).cos(), len * (th / 2.0).sin())
}
/// A row from (0, `y`) to (`w`, `y`) bent into a circular arc of |`b`| × 180°: up for a positive
/// `b` when `up`, down when not, the other way for a negative one.
fn arc_row(w: f64, y: f64, b: f64, up: bool) -> [Point; 4] {
let (ax, ay) = arc_handles(w, b);
let dy = if (b > 0.0) == up { -ay } else { ay };
[Point::new(0.0, y), Point::new(ax, y + dy), Point::new(w - ax, y + dy), Point::new(w, y)]
}
/// Arc at bend |`b`| as four rows: the bottom edge bends up through the bottom corners, the top
/// edge is the arc around the same center `h` further out, and the rows between are spaced evenly
/// along the radii. The top corners lie `h` out from the bottom ones along the radii, which lean
/// out by θ/2.
fn arc_rows(w: f64, h: f64, b: f64) -> [[Point; 4]; 4] {
let th = b.abs().min(1.0) * PI;
let (s, c) = (th / 2.0).sin_cos();
let (tl, tr) = (Point::new(-h * s, h * (1.0 - c)), Point::new(w + h * s, h * (1.0 - c)));
let (ax, ay) = arc_handles(tr.x - tl.x, b);
let top = [tl, Point::new(tl.x + ax, tl.y - ay), Point::new(tr.x - ax, tr.y - ay), tr];
let bottom = arc_row(w, h, b.abs(), true);
let mut out = [[Point::ZERO; 4]; 4];
for (r, row) in out.iter_mut().enumerate() {
for (q, (t, u)) in row.iter_mut().zip(top.iter().zip(bottom)) {
*q = t.lerp(u, r as f64 / 3.0);
}
}
out
}
/// Warp style `style`'s own net at bend `b` (-1..1) over the box `w` × `h`, before distortion.
/// `vertical`: the net of the box turned on its side, where Rise moves the other half.
fn style_net(style: WarpStyle, b: f64, w: f64, h: f64, vertical: bool) -> Net {
use WarpStyle::*;
let two = |top: [Point; 4], bottom: [Point; 4]| {
let mut p = [[Point::ZERO; 4]; 4];
(p[0], p[1]) = (top, bottom);
Net { rows: 2, cols: 4, p }
};
let flat_row = |y: f64| [0.0, 1.0, 2.0, 3.0].map(|c| Point::new(w * c / 3.0, y));
match style {
Arc => {
let rows = arc_rows(w, h, b);
let net = two(rows[0], rows[3]);
if b < 0.0 { net.mirrored(h) } else { net }
}
ArcUpper => two(arc_row(w, 0.0, b, true), flat_row(h)),
ArcLower => two(flat_row(0.0), arc_row(w, h, b, false)),
Arch => {
let top = arc_row(w, 0.0, b, true);
two(top, top.map(|q| Point::new(q.x, q.y + h)))
}
Bulge => two(arc_row(w, 0.0, b, true), arc_row(w, h, b, false)),
ShellLower | ShellUpper => {
let a = arc_rows(w, h, b);
let mut net = Net::flat(w, h, 4, 4);
if b >= 0.0 {
// The outer arc and the ends of the row inside it, upside down for Shell Lower.
net.p[0] = a[0];
(net.p[1][0], net.p[1][3]) = (a[1][0], a[1][3]);
if style == ShellLower { net.mirrored(h) } else { net }
} else {
// The inner arc and the ends of the row outside it, upside down for Shell Upper.
net.p[3] = a[3];
(net.p[2][0], net.p[2][3]) = (a[2][0], a[2][3]);
if style == ShellUpper { net.mirrored(h) } else { net }
}
}
Flag | Fish | Rise => {
let mut net = Net::flat(w, h, 2, 4);
let d = 2.0 * b * h;
match style {
Flag => {
for row in &mut net.p[..2] {
(row[1].y, row[2].y) = (row[1].y + d, row[2].y - d);
}
}
Fish => {
(net.p[0][1].y, net.p[0][2].y) = (net.p[0][1].y - d, net.p[0][2].y + d);
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
}
_ => {
for row in &mut net.p[..2] {
if vertical {
(row[2].y, row[3].y) = (row[2].y - d, row[3].y - d);
} else {
(row[0].y, row[1].y) = (row[0].y + d, row[1].y + d);
}
}
}
}
net
}
Wave => {
let mut net = Net::flat(w, h, 3, 4);
let d = 2.0 * b * h;
(net.p[1][1].y, net.p[1][2].y) = (net.p[1][1].y + d, net.p[1][2].y - d);
net
}
Fisheye => {
let mut net = Net::flat(w, h, 4, 4);
let c = Point::new(w / 2.0, h / 2.0);
for row in &mut net.p[1..3] {
for q in &mut row[1..3] {
*q = c + (*q - c) * (1.0 + 4.0 * b);
}
}
net
}
Inflate | Squeeze => {
let mut net = Net::flat(w, h, 3, 3);
let side = if style == Inflate { b * w / 2.0 } else { -b * w / 2.0 };
net.p[0][1].y -= b * h / 2.0;
net.p[2][1].y += b * h / 2.0;
net.p[1][0].x -= side;
net.p[1][2].x += side;
net
}
Twist => {
// The four inner points move round: clockwise for a positive bend.
let mut net = Net::flat(w, h, 4, 4);
let (dx, dy) = (b.abs() * w, b.abs() * h);
if b >= 0.0 {
net.p[1][1].x += dx;
net.p[1][2].y += dy;
net.p[2][2].x -= dx;
net.p[2][1].y -= dy;
} else {
net.p[1][1].y += dy;
net.p[1][2].x -= dx;
net.p[2][2].y -= dy;
net.p[2][1].x += dx;
}
net
}
}
}
/// A warp as one bicubic Bézier patch over its box: the style's own net at the bend, its columns
/// scaled by the horizontal distortion and then its rows by the vertical one. Points are in the
/// box's coordinates (origin at its top left, y down).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WarpNet {
/// The control points over the box divided by `scale`.
net: [[Point; 4]; 4],
/// 1, or a power of two when the net over the box itself does not fit in finite numbers.
scale: f64,
w: f64,
h: f64,
}
impl WarpNet {
/// The net of `style` over a box `w` × `h` at bend `b` and distortions `dh` and `dv` (each
/// -1..1, clamped); `vertical` bends along the box's height.
pub fn new(style: WarpStyle, b: f64, dh: f64, dv: f64, w: f64, h: f64, vertical: bool) -> Self {
let unit = |x: f64| if x.is_finite() { x.clamp(-1.0, 1.0) } else { 0.0 };
let size = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 };
let (b, dh, dv, w, h) = (unit(b), unit(dh), unit(dv), size(w), size(h));
let build = |w: f64, h: f64| {
let mut net = if vertical { style_net(style, b, h, w, true).transposed() } else { style_net(style, b, w, h, false) };
net.distort_columns(dh);
net.distort_rows(dv);
net.bicubic()
};
// A box near the largest finite size can overflow the net. The net is then built on the box
// divided by the smallest power of two that fits (2 to 4096), an exact division for every
// side above the subnormal range, and points are multiplied back as they are read.
let mut out = Self { net: build(w, h), scale: 1.0, w, h };
while out.scale < 4096.0 && !out.net.iter().flatten().all(|q| q.is_finite()) {
let scale = out.scale * 2.0;
out = Self { net: build(w / scale, h / scale), scale, w, h };
}
out
}
/// The point at `(u, v)` of the box (0..1 across and down; outside, the patch goes on). A
/// coordinate the patch would send past the largest finite number is the box's own, held to
/// the finite range.
pub fn at(&self, u: f64, v: f64) -> Point {
let p = self.at_scaled(u, v);
let held = |x: f64| if x.is_nan() { 0.0 } else { x.clamp(-f64::MAX, f64::MAX) };
let coord = |x: f64, flat: f64| if (x * self.scale).is_finite() { x * self.scale } else { held(flat) };
Point::new(coord(p.x, u * self.w), coord(p.y, v * self.h))
}
/// [`Self::at`] divided by the net's scale.
fn at_scaled(&self, u: f64, v: f64) -> Point {
let bern = |t: f64| {
let s = 1.0 - t;
[s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t]
};
let (bu, bv) = (bern(u), bern(v));
let (mut x, mut y) = (0.0, 0.0);
for (row, wv) in self.net.iter().zip(bv) {
for (q, wu) in row.iter().zip(bu) {
x += wv * wu * q.x;
y += wv * wu * q.y;
}
}
Point::new(x, y)
}
}
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
+48
View File
@@ -180,6 +180,24 @@ impl ObjectsOnPath {
});
Some((spacing, start))
}
/// Move the object `member` to the place on the path of the object `target`: the objects
/// between them each move one place toward where `member` was, except the objects for which
/// `fixed` is true, which keep their places and are passed over. Only the order changes, not the
/// Space or the start. Returns `member`'s new index, or `None` when either one is not an object
/// of the group.
pub fn move_member(&mut self, member: NodeId, target: NodeId, fixed: impl Fn(NodeId) -> bool) -> Option<usize> {
let moves = |id: NodeId| id == member || id == target || !fixed(id);
let slots: Vec<usize> = (0..self.members.len()).filter(|&i| moves(self.members[i].id)).collect();
let from = slots.iter().position(|&i| self.members[i].id == member)?;
let to = slots.iter().position(|&i| self.members[i].id == target)?;
let mut moving: Vec<_> = slots.iter().map(|&i| self.members[i]).collect();
let m = moving.remove(from);
moving.insert(to, m);
for (&i, m) in slots.iter().zip(moving) {
self.members[i] = m;
}
Some(slots[to])
}
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
@@ -487,6 +505,36 @@ mod tests {
assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0));
}
#[test]
fn moving_an_object_shifts_the_ones_between_it_and_its_new_place() {
let (a, b, c) =
(rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)), rect(4, Rect::new(40.0, 0.0, 50.0, 10.0)));
let rec = ObjectsOnPath { spacing: Some(30.0), start: 5.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b, &c], CENTER, 15.0) };
let order = |r: &ObjectsOnPath| r.members.iter().map(|m| m.id.0).collect::<Vec<_>>();
let free = |_: NodeId| false;
let mut fwd = rec.clone();
assert_eq!(fwd.move_member(NodeId(2), NodeId(4), free), Some(2));
assert_eq!(order(&fwd), [3, 4, 2], "the first to the last place");
assert_eq!((fwd.spacing, fwd.start, fwd.pivot, fwd.rotate), (rec.spacing, rec.start, rec.pivot, rec.rotate));
let mut back = rec.clone();
assert_eq!(back.move_member(NodeId(4), NodeId(2), free), Some(0));
assert_eq!(order(&back), [4, 2, 3], "the last to the first place");
let mut same = rec.clone();
assert_eq!(same.move_member(NodeId(3), NodeId(3), free), Some(1));
assert_eq!(same, rec, "onto its own place");
let mut other = rec.clone();
assert_eq!(other.move_member(NodeId(1), NodeId(3), free), None, "the path is not one of the objects");
assert_eq!(other.move_member(NodeId(3), NodeId(9), free), None);
assert_eq!(other, rec);
// A fixed object between them keeps its place; the others pass over it.
let mut over = rec.clone();
assert_eq!(over.move_member(NodeId(2), NodeId(4), |id| id == NodeId(3)), Some(2));
assert_eq!(order(&over), [4, 3, 2], "the first and last trade places round the fixed one");
let mut back_over = rec.clone();
assert_eq!(back_over.move_member(NodeId(4), NodeId(2), |id| id == NodeId(3)), Some(0));
assert_eq!(order(&back_over), [4, 3, 2]);
}
#[test]
fn a_group_saved_without_space_reads_as_auto() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
+31
View File
@@ -59,6 +59,11 @@ pub struct Selection {
/// that object ([`crate::symbol_overrides`]). Any other selection change clears it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub symbol_part: Option<SymbolPart>,
/// An object of an Objects on Path group, chosen with its Select/Move widget: the group is the
/// one selected object, and the object is one of its objects on the path
/// ([`crate::objects_on_path::ObjectsOnPath::members`]). Any other selection change clears it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_path_member: Option<NodeId>,
}
/// See [`Selection::symbol_part`].
@@ -94,6 +99,13 @@ impl Selection {
self.guides.clear();
self.key_guide = None;
self.symbol_part = None;
self.on_path_member = None;
}
/// Select only the Objects on Path group `group`, with its object `member` (see
/// [`Selection::on_path_member`]).
pub fn set_on_path_member(&mut self, group: NodeId, member: NodeId) {
self.set([group]);
self.on_path_member = Some(member);
}
/// Select only instance `instance`, with the object at `path` of its symbol's art (see
/// [`Selection::symbol_part`]).
@@ -115,6 +127,7 @@ impl Selection {
self.target = None;
self.slices.clear();
self.symbol_part = None;
self.on_path_member = None;
if !self.objects.contains(&id) {
self.objects.push(id);
}
@@ -123,6 +136,7 @@ impl Selection {
self.target = None;
self.slices.clear();
self.symbol_part = None;
self.on_path_member = None;
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
self.segments.remove(&id);
@@ -152,6 +166,7 @@ impl Selection {
/// Shift-click on a guide).
pub fn toggle_guide(&mut self, i: NodeId) {
self.symbol_part = None;
self.on_path_member = None;
if let Some(k) = self.guides.iter().position(|g| *g == i) {
self.guides.remove(k);
if self.key_guide == Some(i) {
@@ -258,6 +273,22 @@ impl Selection {
if !part_kept {
self.symbol_part = None;
}
// A chosen object on a path needs its Objects on Path group selected alone, with no anchors
// or guides, and still one of the group's objects, neither hidden nor locked.
let member_kept = self.on_path_member.is_some_and(|m| {
let [g] = self.objects.as_slice() else { return false };
self.anchors.is_empty()
&& self.guides.is_empty()
&& doc.is_editable(m)
&& found
.get(g)
.and_then(|p| doc.node_at(p))
.and_then(|n| n.extras().objects_on_path.as_deref().filter(|_| crate::objects_on_path::is_objects_on_path(n)))
.is_some_and(|rec| rec.members.iter().any(|x| x.id == m))
});
if !member_kept {
self.on_path_member = None;
}
}
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
/// around them (and so the key object when it is one): hidden or locked art can't stay
+43
View File
@@ -179,6 +179,49 @@ fn selection_changes_clear_the_target() {
assert_eq!(s.target, None);
}
#[test]
fn a_chosen_object_on_a_path_needs_its_group_selected_alone() {
use vectorcraft_geom::{Point, Rect, shapes};
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (line, a, b, g, other) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
let line = Node::path(line, shapes::line(Point::new(0.0, 100.0), Point::new(200.0, 100.0)), Default::default());
let a = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default());
let b = Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Default::default());
let rec = crate::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], crate::objects_on_path::CENTER, 0.0);
let (line_id, a_id) = (line.id, a.id);
let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false });
group.extras_mut().objects_on_path = Some(Box::new(rec));
d.insert(Some(l), 0, group).unwrap();
d.insert(Some(l), 1, Node::path(other, shapes::rectangle(Rect::new(150.0, 0.0, 160.0, 10.0)), Default::default())).unwrap();
let mut s = Selection::default();
s.set_on_path_member(g, a_id);
s.prune(&d);
assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], Some(a_id)));
s.add(other);
assert_eq!(s.on_path_member, None, "another object joins the selection");
s.set_on_path_member(g, a_id);
s.set([g]);
assert_eq!(s.on_path_member, None, "a new selection");
// The path is not one of the objects, nor is an object outside the group.
for m in [line_id, other] {
s.set_on_path_member(g, m);
s.prune(&d);
assert_eq!(s.on_path_member, None, "{m:?}");
}
// A locked object is not chosen.
s.set_on_path_member(g, a_id);
d.node_mut(a_id).unwrap().locked = true;
s.prune(&d);
assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None), "locked");
d.node_mut(a_id).unwrap().locked = false;
// Once the group is a plain group, no object of it is chosen.
s.set_on_path_member(g, a_id);
d.node_mut(g).unwrap().extras_mut().objects_on_path = None;
s.prune(&d);
assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None));
}
#[test]
fn a_key_object_needs_two_or_more_selected_objects() {
let mut s = Selection::default();
+26 -2
View File
@@ -194,8 +194,14 @@ fn round(h: &str, width: f64) -> StrokeLayer {
StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) }
}
/// Live effect `id` with `params` (the others keep the effect's defaults).
fn fx(id: &str, params: Value) -> Effect {
/// Live effect `id` with `params` (the others keep the effect's defaults). A Warp draws with its
/// style's Bézier net, as a new Warp does.
fn fx(id: &str, mut params: Value) -> Effect {
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::live::WARP_NET));
}
Effect { id: id.into(), params, visible: true }
}
@@ -657,6 +663,24 @@ mod tests {
assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn");
}
/// The built-in styles' Warps draw with the map a new Warp takes.
#[test]
fn builtin_warp_styles_take_the_nets_map() {
let mut warps = 0;
for b in STYLE_LIBRARIES {
let lib = builtin_style_library(b.id).unwrap();
for g in &lib.styles {
let mut effects: Vec<&Effect> = g.appearance.effects.iter().collect();
effects.extend(g.appearance.items.iter().flat_map(|i| i.effects()));
for e in effects.into_iter().filter(|e| e.id.starts_with("warp.")) {
assert_eq!(e.params["version"], json!(crate::live::WARP_NET), "{} › {}: {}", b.id, g.name, e.id);
warps += 1;
}
}
}
assert!(warps >= 3, "{warps}");
}
#[test]
fn painted_and_sketched_follows_textures_with_one_effect_per_style() {
let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect();
+3 -1
View File
@@ -57,7 +57,9 @@ pub struct CharStyle {
#[serde(default)]
pub all_caps: bool,
/// OpenType features that differ from the defaults (OpenType panel), as tags: `"dlig"` turns
/// a feature on, `"-liga"` off.
/// a feature on, `"-liga"` off, and `"salt=2"` turns a feature with alternates on at the font's
/// alternate number 2 (the value the shaper gets; a plain `"salt"` is value 1). Runs that
/// differ only in this list are shaped together, each with its own features.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
/// Character style (Character Styles panel) these attributes come from; None = Normal.
+1 -1
View File
@@ -116,7 +116,7 @@ fn lengths_of(id: &str) -> Lengths {
}
}
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const WARP_DOC: &str = "{bend: % (-100..100, 50; with a bend or a distortion, placed gradients move and scale from the object's bounds to its warped bounds, and images bend as 10 × 10 cells of two triangles unless the pieces would make the art pass 2,000,000 objects or the image's frame keeps its shape past the anchor budget), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\", version: 0|1 (the map: 1 bends each style as one Bézier patch over the bounds, which new and imported warps get; 0, or none, is VectorCraft's earlier map, which warps saved without a version keep)}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
+519
View File
@@ -690,6 +690,525 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
}
}
/// A white 10 pt square with id `id` at the origin.
fn white_square(id: u64) -> Node {
Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0))
}
/// `n` masked by `art`, linked or not.
fn masked_by(mut n: Node, art: Node, linked: bool) -> Node {
let mut mask = vectorcraft_doc::OpacityMask::new(art, true);
mask.linked = linked;
n.extras_mut().mask = Some(Box::new(mask));
n
}
/// The geometry effects that reshape a group's members, one at a time.
fn reshaping_effects() -> Vec<Effect> {
let fx = |id: &str, params: serde_json::Value| Effect { id: id.into(), params, visible: true };
let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]});
vec![
fx("distort.transform", serde_json::json!({"moveH": 30})),
fx("distort.transform", serde_json::json!({"rotate": 90})),
fx("distort.twist", serde_json::json!({"angle": 90})),
fx("distort.freeDistort", corners),
fx("warp.arc", serde_json::json!({})),
fx("distort.roughen", serde_json::json!({"size": 5, "seed": 3})),
fx("distort.zigZag", serde_json::json!({})),
fx("distort.puckerBloat", serde_json::json!({"amount": 50})),
fx("distort.tweak", serde_json::json!({})),
fx("path.offsetPath", serde_json::json!({"offset": 3})),
fx("stylize.roundCorners", serde_json::json!({"radius": 3})),
]
}
/// A group's geometry effect reshapes the art of a member's linked opacity mask as it reshapes the
/// member: a mask drawn over the member still covers it. An unlinked mask stays where it is.
#[test]
fn a_groups_geometry_effects_reshape_a_members_linked_mask_with_it() {
let square = white_square(1);
let original = square.path_data().cloned();
for e in reshaping_effects() {
for linked in [true, false] {
let member = masked_by(square.clone(), white_square(4), linked);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = member.extras().mask.as_deref().unwrap();
assert!(member.path_data().cloned() != original, "{}: the member is reshaped", e.id);
let want = if linked { member.path_data().cloned() } else { original.clone() };
assert_eq!(mask.art.path_data().cloned(), want, "{} (linked: {linked})", e.id);
assert_eq!(mask.linked, linked);
}
}
}
/// The mask of a group inside the group, the mask of a path in it, a mask inside that mask's art and
/// an inverted mask all take the outer group's effect with the paths they mask.
#[test]
fn a_groups_geometry_effects_reshape_nested_and_inverted_masks() {
for e in reshaping_effects() {
let innermost = masked_by(white_square(7), white_square(8), true);
let path = masked_by(white_square(1), masked_by(white_square(6), innermost, true), true);
let mut inner = masked_by(Node::group(NodeId(5), vec![Arc::new(path)]), white_square(4), true);
if let Some(m) = inner.extras_if_any().and_then(|x| x.mask.as_mut()) {
m.invert = true;
}
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let path = &inner.children().unwrap()[0];
let want = path.path_data().cloned();
let mask_of = |n: &Node| n.extras().mask.as_deref().map(|m| (*m.art).clone()).unwrap();
let (outer_mask, path_mask) = (mask_of(inner), mask_of(path));
assert!(inner.extras().mask.as_deref().is_some_and(|m| m.invert));
assert_eq!(outer_mask.path_data().cloned(), want, "{}: the inner group's inverted mask", e.id);
assert_eq!(path_mask.path_data().cloned(), want, "{}: the path's mask", e.id);
assert_eq!(mask_of(&path_mask).path_data().cloned(), want, "{}: the mask inside the mask's art", e.id);
assert_eq!(mask_of(&mask_of(&path_mask)).path_data().cloned(), want, "{}: one level deeper", e.id);
}
}
/// A Transform effect's copies of a path with a linked mask are objects of their own, each with the
/// mask moved with it; the original keeps its mask where it was.
#[test]
fn each_transform_copy_of_a_masked_member_carries_its_mask() {
let member = masked_by(white_square(1), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member)]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
assert_eq!(copies.len(), 3);
for c in copies {
let mask = c.extras().mask.as_deref().unwrap();
assert_eq!(mask.art.geometric_bounds(), c.geometric_bounds());
}
}
/// A Transform effect's copies of the paths of a member group give the group's linked mask the same
/// copies: the mask covers each copy.
#[test]
fn a_member_groups_mask_takes_the_transform_copies_of_its_paths() {
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let want = inner.geometric_bounds();
assert_eq!(want, Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "the path and its copy");
assert_eq!(inner.extras().mask.as_deref().unwrap().art.geometric_bounds(), want);
}
/// A mask's art of several objects takes Transform copies whole, as the art it masks does: each copy
/// of the mask's art is a group of its objects, so the copies do not interleave object by object.
#[test]
fn a_masks_art_of_several_objects_is_copied_whole() {
let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
let copies = mask.children().unwrap();
assert_eq!(copies.len(), 2, "the mask's art and its copy");
for c in copies {
let ids: Vec<NodeId> = c.children().into_iter().flatten().map(|m| m.id).collect();
assert_eq!(ids, [NodeId(4), NodeId(5)], "each copy holds the mask's objects");
}
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)));
}
/// The linked mask of a mask's art that is copied whole takes the same Transform copies.
#[test]
fn the_mask_of_a_masks_art_copied_whole_takes_its_copies() {
let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let mask_art = masked_by(parts, white_square(6), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 1});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 45.0, 10.0)), "the mask's art and its copy");
let own = &mask.extras().mask.as_deref().expect("the mask's art keeps its own mask").art;
assert_eq!(own.geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 10.0)), "its mask covers the copy too");
}
/// The mask of a mask's art copied whole stays in the budget while that art is copied: when the
/// budget has no room for that mask's copy, the art keeps its shape with its masks.
#[test]
fn the_mask_of_a_masks_art_copied_whole_counts_against_the_budget() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true };
let parts = Node::group(NodeId(3), vec![Arc::new(white_square(4)), sq(5, 5.0, Color::WHITE)]);
let mask_art = masked_by(parts, white_square(6), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut art = Node::group(NodeId(10), vec![Arc::new(inner)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the four squares and the copies of three of them, not for the copy of the fourth.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 7 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let inner = &art.children().unwrap()[0];
let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(inner.geometric_bounds(), square, "the path keeps its shape");
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), Some(Rect::new(0.0, 0.0, 15.0, 10.0)), "and so does its mask");
}
/// When a member group's linked mask can't take all the Transform copies its paths take (past the
/// budget), the art keeps its shape under the effect, so the mask still covers the paths.
#[test]
fn art_whose_mask_would_take_fewer_copies_keeps_its_shape() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 1}), visible: true };
let member = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the path, its mask and a copy of the path, not for a copy of the mask.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 3 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let member = &art.children().unwrap()[0];
let square = Some(Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(member.geometric_bounds(), square, "the path keeps its shape");
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), square, "and so does its mask");
}
/// The copies of a path with a linked mask count the art of the mask each copy carries against the
/// budget: they stop where the masks would pass it.
#[test]
fn transform_copies_count_the_art_of_their_masks() {
let transform = Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30, "copies": 2}), visible: true };
let member = masked_by(white_square(1), white_square(4), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
let one = crate::Budget::of(white_square(1).path_data().unwrap()).lines;
// Room for the path, its mask and one copy of both, not for a second copy.
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 4 * one };
crate::reshape::reshape_leaves_within(&mut art, &[&transform], budget, None);
let copies = art.children().unwrap()[0].children().expect("the object and its copy are objects of their own");
assert_eq!(copies.len(), 2);
for c in copies {
assert_eq!(c.extras().mask.as_deref().unwrap().art.geometric_bounds(), c.geometric_bounds());
}
}
/// A Transform effect that scales strokes moves a group's paths as objects, their masks with them.
/// The mask of a member group, whose art holds a path with a mask of its own, moves with the group's
/// paths too.
#[test]
fn a_transform_that_moves_paths_as_objects_moves_a_member_groups_nested_mask() {
let mask_art = masked_by(white_square(4), white_square(5), true);
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), mask_art, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(3, 20.0, Color::BLACK)]);
let params = serde_json::json!({"moveH": 30, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let want = inner.children().unwrap()[0].geometric_bounds();
assert_eq!(want, Some(Rect::new(30.0, 0.0, 40.0, 10.0)));
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), want, "the group's mask");
assert_eq!(mask.extras().mask.as_deref().unwrap().art.geometric_bounds(), want, "the mask inside its art");
}
/// A group's Transform effect moves an image member's linked opacity mask with the image, and its
/// copies' masks with the copies; an unlinked mask stays.
#[test]
fn a_groups_transform_moves_an_image_members_linked_mask() {
for linked in [true, false] {
for copies in [0, 2] {
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), white_square(4), linked);
let mut g = Node::group(NodeId(10), vec![Arc::new(image)]);
let params = serde_json::json!({"moveH": 30, "copies": copies});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let first = &art.children().unwrap()[0];
let images: Vec<&Node> = match first.children() {
Some(ch) => ch.iter().map(|c| &**c).collect(),
None => vec![first],
};
assert_eq!(images.len(), copies + 1);
for im in images {
let mask = im.extras().mask.as_deref().unwrap();
let want = if linked { im.geometric_bounds() } else { Some(Rect::new(0.0, 0.0, 10.0, 10.0)) };
assert_eq!(mask.art.geometric_bounds(), want, "linked {linked}, copies {copies}");
}
}
}
}
/// When a path in a member's mask art would pass the effects' budget, the whole mask stays as it
/// was, rather than part of its art moving with the member, and the member keeps its shape with it.
#[test]
fn a_mask_whose_art_passes_the_budget_stays_whole() {
// Bloat bends a path's sides into curves drawn with more lines the larger they are: many more
// on the mask's large square.
let bloat = Effect { id: "distort.puckerBloat".into(), params: serde_json::json!({"amount": 50}), visible: true };
let large = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)), Appearance::default());
let mask_art = Node::group(NodeId(3), vec![Arc::new(white_square(4)), Arc::new(large.clone())]);
let member = masked_by(white_square(1), mask_art.clone(), true);
let mut art = Node::group(NodeId(10), vec![Arc::new(member)]);
// Room for the member and the mask's small square to take the effect, not for the large one.
let bounds = art.geometric_bounds().unwrap();
let ctx = crate::GeomContext { seed: 10, ..crate::GeomContext::of(&art) };
let square = white_square(1).path_data().cloned().unwrap();
let made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), &square, bounds, &ctx)).lines;
let budget = crate::Budget { anchors: usize::MAX / 4, lines: 2 * made + crate::Budget::of(large.path_data().unwrap()).lines };
let large_made = crate::Budget::of(&crate::apply_geometry_with(std::slice::from_ref(&bloat), large.path_data().unwrap(), bounds, &ctx)).lines;
assert!(large_made > crate::Budget::of(large.path_data().unwrap()).lines, "the large square's curves cost more lines");
crate::reshape::reshape_leaves_within(&mut art, &[&bloat], budget, None);
let member = &art.children().unwrap()[0];
assert_eq!(member.path_data(), Some(&square), "the member keeps its shape with its mask");
assert_eq!(*member.extras().mask.as_deref().unwrap().art, mask_art, "the mask stays whole");
}
/// Live objects in a member's linked mask are evaluated before the group's geometry effects run
/// over the mask's art, so the mask still covers the member: a Repeat in it moves and bends with
/// the member.
#[test]
fn a_groups_effects_reshape_a_live_object_in_a_members_mask() {
let mut grid = vectorcraft_doc::RepeatSpec::grid(vec![Arc::new(white_square(5))], 0.0, 0.0);
if let vectorcraft_doc::RepeatKind::Grid { rows, cols, .. } = &mut grid.kind {
(*rows, *cols) = (1, 1);
}
let repeat = Node::new(NodeId(4), NodeKind::Repeat(grid));
let drawn: Vec<Node> = crate::expand_live(None, &repeat);
let leaf = |n: &Node| {
let mut found = None;
n.walk(&mut |c| found = found.take().or_else(|| c.path_data().cloned()));
found
};
assert_eq!(drawn.iter().find_map(leaf), white_square(1).path_data().cloned(), "the Repeat draws one square over the member");
for e in [reshaping_effects()[0].clone(), reshaping_effects()[4].clone()] {
let member = masked_by(white_square(1), repeat.clone(), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = &member.extras().mask.as_deref().unwrap().art;
assert!(member.path_data() != white_square(1).path_data(), "{}: the member is reshaped", e.id);
assert_eq!(leaf(mask), member.path_data().cloned(), "{}: the mask's square takes the effect with it", e.id);
}
}
/// Type in a member's linked mask is outlined before the group's Transform effect moves the mask's
/// art with the member.
#[test]
fn a_groups_transform_moves_type_in_a_members_mask() {
if skip_without_craft_fonts() {
return;
}
let member = masked_by(white_square(1), type_h(), true);
let before = crate::reshape::outline_text(&type_h()).unwrap().geometric_bounds().unwrap();
let mut g = Node::group(NodeId(10), vec![Arc::new(member)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let mask = &art.children().unwrap()[0].extras().mask.as_deref().unwrap().art;
let mut text = false;
mask.walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_)));
assert!(!text, "the type is outlined");
let after = mask.geometric_bounds().unwrap();
assert!((after.x0 - before.x0 - 30.0).abs() < 0.5 && (after.y0 - before.y0).abs() < 0.5, "{before:?} -> {after:?}");
}
/// The linked mask of a compound path in a group, and of a path in a layer, takes the geometry
/// effects with the art it masks.
#[test]
fn a_compound_paths_and_a_layers_members_masks_take_the_effects() {
let geometry = |n: &Node| crate::reshape::leaf_geometry(n).map(|(p, _)| p);
for e in reshaping_effects() {
let square = white_square(1);
let compound = Node::new(NodeId(1), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: FillRule::NonZero });
let compound = masked_by(compound, white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(compound), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(e.clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert!(geometry(member) != square.path_data().cloned(), "{}: the compound path is reshaped", e.id);
assert_eq!(geometry(&member.extras().mask.as_deref().unwrap().art), geometry(member), "{}: compound path", e.id);
let mut layer = Node::layer(NodeId(10), "Layer", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
ch.extend([Arc::new(masked_by(square.clone(), white_square(4), true)), sq(2, 20.0, Color::BLACK)]);
}
layer.appearance.effects.push(e.clone());
let art = evaluate_container(None, &layer).unwrap();
let member = &art.children().unwrap()[0];
assert!(member.path_data() != square.path_data(), "{}: the layer's path is reshaped", e.id);
assert_eq!(member.extras().mask.as_deref().unwrap().art.path_data(), member.path_data(), "{}: layer", e.id);
}
}
/// A group's Transform effect moves a symbol instance member's linked mask with the instance.
#[test]
fn a_groups_transform_moves_a_symbol_instance_members_linked_mask() {
let instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(instance, white_square(4), true)), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let instance = &art.children().unwrap()[0];
let NodeKind::SymbolInstance { xf, .. } = &instance.kind else { panic!("an instance: {instance:?}") };
assert!((xf.translation().x - 30.0).abs() < 1e-9, "the instance moves: {xf:?}");
let mask = instance.extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap();
assert!((mask.x0 - 30.0).abs() < 1e-9 && (mask.x1 - 40.0).abs() < 1e-9, "its mask moves with it: {mask:?}");
}
/// With Scale Strokes & Effects on, a group's Transform effect scales the stroke of an image
/// member's linked mask as it scales that of a path member's mask.
#[test]
fn a_transform_with_scale_strokes_scales_the_masks_of_images_and_paths_alike() {
let stroked =
Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0));
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let image = masked_by(Node::new(NodeId(1), NodeKind::Image(Box::new(im))), stroked.clone(), true);
let path = masked_by(white_square(2), stroked, true);
let mut g = Node::group(NodeId(10), vec![Arc::new(image), Arc::new(path)]);
let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let width = |n: &Node| n.extras().mask.as_deref().unwrap().art.appearance.stroke().map(|s| s.width);
let ch = art.children().unwrap();
assert_eq!(width(&ch[1]), Some(4.0), "the path's mask");
assert_eq!(width(&ch[0]), width(&ch[1]), "the image's mask");
}
/// With Transform Objects off and Scale Strokes & Effects on, a group's Transform effect scales the
/// strokes of a member's linked mask as it scales the member's, and moves neither.
#[test]
fn a_transform_of_strokes_only_scales_a_members_mask_strokes() {
let stroked = |id: u64| {
Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0))
};
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(stroked(1), stroked(4), true))]);
let params = serde_json::json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
let mask = &member.extras().mask.as_deref().unwrap().art;
assert_eq!(member.appearance.stroke().map(|s| s.width), Some(4.0), "the member's stroke");
assert_eq!(mask.appearance.stroke().map(|s| s.width), Some(4.0), "the mask's stroke");
assert_eq!(mask.geometric_bounds(), member.geometric_bounds());
}
/// The art of a member group's mask is copied as it was before the first effect: a mask of one
/// path takes a second Transform effect's copies path by path, as the path it masks does, though
/// the first effect (with Scale Strokes & Effects) made it a group of copies.
#[test]
fn a_masks_art_is_copied_as_it_was_before_the_first_effect() {
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
for strokes in [true, false] {
let params = serde_json::json!({"moveH": 30, "copies": 2, "strokes": strokes});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
}
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), inner.geometric_bounds());
let kinds = |n: &Node| n.children().unwrap().iter().map(|c| std::mem::discriminant(&c.kind)).collect::<Vec<_>>();
let paths = &inner.children().unwrap()[0];
assert_eq!(kinds(paths).len(), 3, "the first effect's three paths");
assert!(paths.children().unwrap().iter().all(|c| matches!(c.kind, NodeKind::Compound { .. })), "each with its copies");
assert_eq!(kinds(mask), kinds(paths), "the mask's art takes the copies as the paths do");
}
/// A 10 × 10 image with id `id` at the origin.
fn image_node(id: u64) -> Node {
let im = vectorcraft_doc::ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: vectorcraft_geom::Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
Node::new(NodeId(id), NodeKind::Image(Box::new(im)))
}
/// A linked mask whose art is an image moves with the path it masks under a group's Transform
/// effect, and a member group's image mask takes the copies its paths take.
#[test]
fn a_groups_transform_moves_a_linked_mask_whose_art_is_an_image() {
let mut g = Node::group(NodeId(10), vec![Arc::new(masked_by(white_square(1), image_node(4), true)), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[0].clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert_eq!(member.geometric_bounds(), Some(Rect::new(30.0, 0.0, 40.0, 10.0)));
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), member.geometric_bounds());
let inner = masked_by(Node::group(NodeId(2), vec![Arc::new(white_square(1))]), image_node(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(inner)]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(None, &g).unwrap();
let inner = &art.children().unwrap()[0];
assert_eq!(inner.geometric_bounds(), Some(Rect::new(0.0, 0.0, 70.0, 10.0)));
let mask = &inner.extras().mask.as_deref().unwrap().art;
assert_eq!(mask.geometric_bounds(), inner.geometric_bounds(), "the image and its copies");
}
/// An image in a linked mask's art takes no geometry effect but Transform, so under a Warp effect
/// the art keeps its shape with its mask.
#[test]
fn art_whose_mask_holds_an_image_keeps_its_shape_under_a_warp() {
let member = masked_by(white_square(1), Node::group(NodeId(3), vec![Arc::new(image_node(4))]), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(member), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[4].clone());
let art = evaluate_container(None, &g).unwrap();
let member = &art.children().unwrap()[0];
assert_eq!(member.path_data(), white_square(1).path_data(), "the path keeps its shape");
assert_eq!(member.extras().mask.as_deref().unwrap().art.geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0)));
}
/// A Warp effect bends an image's pixels but not the art of its linked mask, so art with such an
/// image keeps its shape under the warp, with the image and its mask.
#[test]
fn art_with_a_masked_image_keeps_its_shape_under_a_warp() {
let image = masked_by(image_node(1), white_square(4), true);
let mut g = Node::group(NodeId(10), vec![Arc::new(image.clone()), sq(2, 20.0, Color::BLACK)]);
g.appearance.effects.push(reshaping_effects()[4].clone());
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert_eq!(*ch[0], image, "the image and its mask");
assert_eq!(ch[1].path_data(), sq(2, 20.0, Color::BLACK).path_data(), "the path keeps its shape");
}
/// A symbol instance in perspective projects the linked masks of the objects in its art with them.
#[test]
fn perspective_projects_the_masks_in_a_symbols_art() {
let art = Node::group(NodeId(20), vec![Arc::new(masked_by(white_square(21), white_square(22), true))]);
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let mut at = vectorcraft_doc::PerspectiveAttachment::new("left", 0.0);
at.projection = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.01, 0.0, 1.0]);
instance.extras_mut().perspective = Some(Box::new(at));
let out = crate::reshape::reshape(None, &instance, Some(&art)).unwrap();
let mut masked = vec![];
out.walk(&mut |c| masked.extend(c.extras().mask.as_deref().map(|m| (c.path_data().cloned(), m.art.path_data().cloned()))));
let [(path, mask)] = masked.as_slice() else { panic!("one masked path: {masked:?}") };
assert!(path != &white_square(21).path_data().cloned(), "the path is projected");
assert_eq!(mask, path, "and so is its mask");
}
/// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit
/// pitch, each crossing every bar of the other direction: n² crossings.
fn crossing_bars(n: usize, effect: &str) -> Node {
+1 -1
View File
@@ -96,7 +96,7 @@ pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
pub use trap::TRAP;
pub use vectorcraft_three_d::Mesh;
pub use warp::{WarpStyle, warp_point};
pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version};
mod catalog;
mod geometry;
+25 -2
View File
@@ -31,7 +31,8 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// (`lightRotation`, `lightHeight`, `lightBehind`). Scribble took one `variation` before its
/// separate `overlapVariation` and `spacingVariation`. Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°).
/// Scribble's angle is also stored above -180° and up to 180° (270° becomes -90°), and a Warp's
/// map `version` given as a whole float is stored as an integer.
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
@@ -39,6 +40,7 @@ pub fn upgrade_params(id: &str, params: &Value) -> Value {
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
upgrade_scribble(id, &mut m);
upgrade_warp_version(id, &mut m);
Value::Object(m)
}
other => other.clone(),
@@ -71,6 +73,19 @@ fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
}
}
/// A Warp's map `version` given as a whole float (`1.0`) is stored as the integer.
fn upgrade_warp_version(id: &str, m: &mut Map<String, Value>) {
if !id.starts_with("warp.") {
return;
}
if let Some(v) = m.get_mut("version")
&& !v.is_u64()
&& let Some(n) = crate::warp::warp_version(v)
{
*v = json!(n);
}
}
fn upgrade_scribble(id: &str, m: &mut Map<String, Value>) {
if id != "stylize.scribble" {
return;
@@ -91,7 +106,15 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
let mut params = merged_params(id, params);
// A new Warp takes its style's Bézier net unless a map is given; the catalog's defaults carry
// no version, so warps saved without one keep the earlier map.
if id.starts_with("warp.")
&& let Value::Object(m) = &mut params
{
m.entry("version").or_insert(json!(crate::warp::WARP_NET));
}
Some(Effect { id: id.to_string(), params, visible: true })
}
/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see
+698 -66
View File
@@ -4,19 +4,22 @@
//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. Type without
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame, and a Warp
//! effect bends its pixels with the frame ([`cut_warped_images`]). Raster effects (shadows, glows, blur,
//! feather) stay on the result for the renderer and exporters. Type without
//! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those
//! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
//! them ([`image_rows`]).
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
@@ -548,7 +551,14 @@ fn as_art_opts(
let mut art = match &n.kind {
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
let image = Node::new(n.id, n.kind.clone());
let mut image = Node::new(n.id, n.kind.clone());
// Its pixels draw as the image's do: colorized by a grayscale image's fill color, and
// smoothed or not.
{
let mut e = image.extras_mut();
e.colorize = n.extras().colorize.clone();
e.pixelated = n.extras().pixelated;
}
let clipped = || {
let mut clip = Node::path(n.id, frame.clone(), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
@@ -580,7 +590,7 @@ fn as_art_opts(
transform_items(&mut art, paint_doc);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
project_leaves(&mut art, &h, paint_doc);
}
// The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip
// group).
@@ -598,9 +608,29 @@ fn as_art_opts(
Some(out)
}
/// Map every path under `art` (and its gradients) through the projective map `h`, curves split
/// finely enough to follow it.
fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) {
/// Map every path under `art` (and its gradients), and every path in the art of the linked opacity
/// masks of the objects under it, through the projective map `h`, curves split finely enough to
/// follow it. The type and live objects in the art of those masks are outlined and evaluated
/// first ([`outlined_members`], with the symbols of `doc`).
fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography, doc: Option<&Document>) {
project_paths(art, h);
each_linked_mask_mut(art, false, 0, &mut |m| {
*m = outlined_mask_art(doc, m);
project_paths(m, h);
});
}
/// `art`, the art of an opacity mask, with type and live objects in it outlined and evaluated
/// ([`outlined_members`]), and as art that keeps its look ([`as_art`]) when it is type itself.
fn outlined_mask_art(doc: Option<&Document>, art: &Node) -> Node {
match art.kind {
NodeKind::Text(_) => as_art(doc, art, None).unwrap_or_else(|| art.clone()),
_ => outlined_members(doc, art),
}
}
/// The paths of [`project_leaves`] outside the art of masks.
fn project_paths(art: &mut Node, h: &vectorcraft_geom::Homography) {
let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p);
let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25));
let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f);
@@ -632,9 +662,9 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box and the art's seed (a random Transform moves the pieces together). Images and
/// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// symbol instances follow a Transform effect the same way ([`transform_placed`]), and images
/// follow a Warp effect as a raster mesh warp ([`cut_warped_images`]); other geometry effects leave them
/// as they are. The paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
@@ -677,57 +707,151 @@ fn hoist_copies(art: &mut Node) {
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
/// `owner` does not hold it.
/// `owner` does not hold it. The art of the linked opacity masks of the objects in `art` (not of
/// `art` itself) takes each effect as the paths it masks do ([`Step::reshape_masks`]), with the
/// type and live objects in it outlined and evaluated first ([`outlined_members`]). When a mask
/// stays as it was, or a Transform effect makes fewer copies than it asks for, art with linked
/// masks keeps its shape under that effect.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
let mut whole = crate::Carry::default();
if fx.is_empty() {
return whole;
}
let mut leaves_safe = true;
for_each_leaf(art, &mut |leaf| {
if !leaves_safe {
return;
}
if let Some((_, rule)) = leaf_geometry(leaf) {
let has_stroke = leaf.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
if rule != FillRule::NonZero || has_stroke {
leaves_safe = false;
}
}
});
let masked = has_linked_mask(art, false);
if masked {
each_linked_mask_mut(art, false, 0, &mut |m| *m = outlined_mask_art(doc, m));
}
let mut masks_safe = true;
each_linked_mask(art, false, 0, &mut |_, m| masks_safe &= leaves_safe(m));
let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
let style_safe = leaves_safe && !art_stroke;
let style_safe = leaves_safe(art) && masks_safe && !art_stroke;
let listed = fx;
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
let fx = opt_fx.as_slice();
let seed = art.id.0;
let mut used = art_cost(art);
each_linked_mask(art, false, 0, &mut |_, m| used = used.plus(art_cost(m)));
let max = budget.at_least(used);
// Read before the effects run: a path's own copies don't make it art of several objects.
// Read before the effects run: a path's own copies don't make it art of several objects. The
// same holds for the art of each mask, listed by the object it masks.
let copy_whole = copies_whole(art);
let mut wholes = HashMap::new();
each_linked_mask(art, false, 0, &mut |id, m| {
wholes.entry(id).or_insert_with(|| copies_whole(m));
});
let mut warped = WarpedImages::new();
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let Some(bounds) = warped.bounds(art) else { break };
let params = merged_params(&e.id, &e.params);
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
let step = Step { e, params: &params, bounds, seed, effect, apart, doc, wholes: &wholes };
// Art with linked masks keeps its shape under an effect that a mask in it could not take
// as the art it masks did, so no mask is left apart from its art.
let before = masked.then(|| (art.clone(), used));
let ran = step.run(art, max, &mut used, copy_whole, Some(&mut warped), false, 0);
if (ran.masks || ran.short)
&& let Some((was, cost)) = before
{
*art = was;
used = cost;
continue;
}
if let Some(carry) = ran.whole {
whole.push(carry);
}
}
cut_warped_images(art, &warped);
whole
}
/// One geometry effect of an object run over its art ([`reshape_leaves_apart`]): the effect, its
/// merged params, the reference box and seed of the object's art, where a deferred live-effect
/// plug-in is listed, whether each Transform copy becomes an object, the document that the
/// swatches and patterns of painted pieces are looked up in, and [`copies_whole`] of the art of
/// each linked mask before the first effect, by the id of the object it masks.
struct Step<'a> {
e: &'a Effect,
params: &'a serde_json::Value,
bounds: Rect,
seed: u64,
effect: Option<usize>,
apart: bool,
doc: Option<&'a Document>,
wholes: &'a HashMap<NodeId, bool>,
}
/// What [`Step::run`] did to a piece of art.
struct Ran {
/// A path in the art kept its shape (past the budget).
kept: bool,
/// The linked opacity mask of an object in the art stayed as it was while the paths it masks
/// took the effect, or could have ([`Step::reshape_masks`]).
masks: bool,
/// A Transform effect made fewer copies of a path, image or symbol instance than it asks for
/// (past the budget or out of reach), where a linked mask could take a different number.
short: bool,
/// How what paints the art as a whole follows the effect, when it does.
whole: Option<crate::Carry>,
}
impl Ran {
/// Nothing in the art kept its shape or made fewer copies, and every linked mask in it moved.
fn complete(&self) -> bool {
!self.kept && !self.masks && !self.short
}
}
impl Step<'_> {
/// Run the effect over `art`, whose paths and those of the linked opacity masks in it keep to
/// `max` together with what they cost so far (`used`). `copy_whole` is [`copies_whole`] of the
/// art, or of a mask's art, before the first effect. `warped` lists the images Warp effects
/// bend in the art ([`WarpedImages`]); a mask's art has none. With `own`, `art` is the art of a
/// mask, and its own linked mask takes the effect too. `depth` counts the masks `art` is in. A
/// live-effect plug-in that paints its pieces leaves a mask's art, and art that holds objects
/// with linked masks, as they are and reports the masks as stayed: a mask can't take the
/// pieces as the art it masks does. So does a Warp effect that bends an image with a linked
/// mask.
#[allow(clippy::too_many_arguments)]
fn run(
&self,
art: &mut Node,
max: Budget,
used: &mut Budget,
copy_whole: bool,
mut warped: Option<&mut WarpedImages>,
own: bool,
depth: usize,
) -> Ran {
let (e, params, bounds, seed) = (self.e, self.params, self.bounds, self.seed);
let stayed = Ran { kept: false, masks: true, short: false, whole: None };
if let Some(plugin) = painted_plugin(e) {
let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
if own || has_linked_mask(art, false) {
return stayed;
}
let at = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
for_each_leaf(art, &mut |leaf| {
let Some((path, _)) = leaf_geometry(leaf) else { return };
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if let Some(made) = paint_leaf(leaf, plugin, &params, bounds, room, doc, at) {
used = used.minus(before).plus(made);
if let Some(made) = paint_leaf(leaf, plugin, params, bounds, room, self.doc, at) {
*used = used.minus(before).plus(made);
}
});
continue;
return Ran { kept: false, masks: false, short: false, whole: None };
}
// A Warp effect that bends images ([`mark_warped_images`]) bends an image's pixels, not the
// art of its linked mask.
let warp = e.id.strip_prefix("warp.").and_then(crate::warp::WarpStyle::from_id).filter(|_| crate::warp::bends(&e.id, params));
if warp.is_some() && has_masked_image(art) {
return stayed;
}
// Placed gradients follow a Transform effect, Free Distort and the Warp effects
// ([`crate::Carry`]): under Free Distort and Warp (`boxes`) each path's move and scale from
// its bounds before the effect to its bounds after it. Under those and the other effects,
// the placed gradients of type's outlines in `art` (`outline_text`) move and scale with
// the outlines' bounds.
let follows = crate::Carry::follows(&e.id, &params, bounds, seed);
let follows = crate::Carry::follows(&e.id, params, bounds, seed);
let boxes = follows && crate::Carry::by_bounds(&e.id);
let mut outlines = vec![];
if !follows || boxes {
@@ -739,24 +863,68 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
}
let mut groups = vec![];
if follows {
group_marks(art, &mut vec![], &HashSet::new(), &mut groups);
group_marks(art, &mut vec![], &HashSet::new(), None, &mut groups);
// The images earlier Warps bend are measured by their pieces only when a group's
// gradient needs the bounds.
if !groups.is_empty()
&& let Some(images) = warped.as_deref().and_then(|w| w.when_cut(art))
{
groups.clear();
group_marks(art, &mut vec![], &HashSet::new(), Some(images), &mut groups);
}
}
// The ids of the paths that kept their shape (past the budget).
// The ids of the paths that kept their shape (past the budget), and their places in the
// order [`for_each_leaf`] visits the paths; and the ids of the paths whose linked masks
// moved with them as they were transformed as objects.
let mut kept_ids = HashSet::new();
let mut kept_leaves = HashSet::new();
let mut leaf_at = 0usize;
let mut moved_ids = HashSet::new();
let transform = (e.id == "distort.transform").then(|| {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
let options = crate::distort::TransformOptions::of(&params);
(m, copies, options, crate::distort::transform_affine(bounds, &params, seed))
let (m, copies) = crate::distort::transform_map(bounds, params, seed);
let options = crate::distort::TransformOptions::of(params);
(m, copies, options, crate::distort::transform_affine(bounds, params, seed))
});
// In a mask's art, images and symbol instances take only a Transform effect
// ([`transform_placed`]): a mask's art that holds one can't take another effect as the art
// it masks does.
if own && transform.is_none() && (is_placed(art) || placed_count(art) > 0) {
return Ran { kept: false, masks: true, short: false, whole: None };
}
// Art of several objects is copied whole, so the copies don't interleave member by member.
// The copies of its members carry their linked masks, moved with them.
if let Some((m, copies, options, _)) = transform
&& copies > 0
&& copy_whole
{
used = copy_art(art, m, copies, copy_scaling(options), max);
continue;
if !own {
*used = copy_art(art, m, copies, copy_scaling(options), max);
return Ran { kept: false, masks: false, short: false, whole: None };
}
// A mask's art: its copies keep to what the rest of the art leaves of `max`, and the
// linked mask of the art itself takes them too. That mask's art stays in `used`, as
// [`copy_art`] counts only the members.
let before = copy_cost(art);
let rest = used.minus(before).plus(art.extras().mask.as_deref().map_or(Budget { anchors: 0, lines: 0 }, |k| copy_cost(&k.art)));
let made = copy_art(art, m, copies, copy_scaling(options), max.minus(rest));
*used = rest.plus(made);
let short = art.children().is_some_and(|c| c.iter().filter(|c| !c.is_guide()).count() <= copies);
let none = HashSet::new();
let masks = self.reshape_own_mask(art, &Skip { moved: &none, kept: &none }, max, used, depth);
return Ran { kept: false, masks, short, whole: None };
}
// A shortfall in the copies matters for the objects a linked mask above them covers, which
// the mask would take a different number of copies than, and anywhere in a mask's art.
let watch = transform.is_some_and(|(_, copies, options, _)| copies > 0 && options.objects) && (own || has_linked_mask(art, false));
let mut covered = HashSet::new();
if watch && !own {
masked_ids(art, false, &mut covered);
}
let counts = |id: NodeId| watch && (own || covered.contains(&id));
let mut short = false;
for_each_leaf(art, &mut |leaf| {
let at = leaf_at;
leaf_at += 1;
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
let before = Budget::of(&path);
@@ -766,15 +934,26 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
// strokes; a gradient paints each copy by itself, not the one piece the copies
// make together; and Expand Appearance (`apart`) makes each copy an object.
// Fills and strokes that stack paint each copy below the one before (#5920, #6316).
// Each copy of a path with a linked mask carries the mask, moved with it.
let separate = copies > 0
&& (apart
&& (self.apart
|| leaf.appearance.has_unplaced_gradient()
|| leaf.appearance.has_placed_gradient()
|| paints_copies_apart(&leaf.appearance, rule));
|| paints_copies_apart(&leaf.appearance, rule)
|| leaf.extras().mask.as_ref().is_some_and(|m| m.linked));
// The copies of a path replace it by a group of them, the path included.
let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len);
if options.reach_appearance() {
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room);
used = used.minus(before).plus(made);
if !moved {
*used = used.minus(before).plus(made);
if moved {
// With Transform Objects off the path stays, and its mask takes the
// effect's stroke and pattern changes as its own art.
if options.objects {
moved_ids.insert(leaf.id);
}
short |= counts(leaf.id) && copied(leaf) <= copies;
} else {
kept_ids.insert(leaf.id);
}
return;
@@ -782,19 +961,28 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
if separate {
let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() };
let made = copy_leaf(leaf, &path, m, copies, scaling, room);
used = used.minus(before).plus(made);
*used = used.minus(before).plus(made);
moved_ids.insert(leaf.id);
short |= counts(leaf.id) && copied(leaf) <= copies;
return;
}
}
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
let stage = self.effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
let Some(out) = apply_one(&e.id, params, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
kept_ids.insert(leaf.id);
kept_leaves.insert(at);
return;
};
used = used.minus(before).plus(Budget::of(&out));
// A Transform effect's copies of a path are subpaths of it, each with the path's anchors.
if counts(leaf.id)
&& let Some((_, copies, _, _)) = transform
{
short |= out.anchor_count() < path.anchor_count().saturating_mul(copies.saturating_add(1));
}
*used = used.minus(before).plus(Budget::of(&out));
if follows && !boxes {
// A gradient fitted to the path fits the path's own bounds.
let carry = crate::Carry::of(&e.id, &params, bounds, path.bounds(), None, seed);
let carry = crate::Carry::of(&e.id, params, bounds, path.bounds(), None, seed);
if carry.moves(&leaf.appearance) {
carry.apply(&mut leaf.appearance);
}
@@ -804,27 +992,233 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget
if boxes {
rebase_leaves(art, &mut leaves.into_iter());
}
// Warp bends images too: each one's pixels follow the warp's map over the same box. The
// images are cut into pieces once, after the last effect ([`cut_warped_images`]). An image
// whose clipping frame kept its shape (past the budget) keeps its pixels as they are.
if let Some(style) = warp
&& let Some(warped) = warped.as_deref_mut()
{
let f: WarpMap = Rc::from(crate::warp::warp_map(bounds, style, params));
warped.reserve(art);
mark_warped_images(art, &f, &kept_leaves, false, &mut 0, warped);
}
if let Some((m, copies, options, _)) = transform
&& options.objects
{
transform_placed(art, m, copies);
// A mask's art that is one image or symbol instance is placed as the only member of a
// group of it.
let root = is_placed(art);
if root {
let id = art.id;
let one = std::mem::replace(art, Node::group(id, vec![]));
*art = Node::group(id, vec![Arc::new(one)]);
}
let placed = if watch { masked_placed(art, own) } else { 0 };
transform_placed(art, m, copies, copy_scaling(options));
short |= watch && masked_placed(art, own) < placed.saturating_mul(copies.saturating_add(1));
if root && let Some([one]) = art.children().map(Vec::as_slice) {
*art = (**one).clone();
}
}
if outlines.iter().any(Option::is_some) {
rebase_type_outlines(art, &mut outlines.into_iter());
}
// Under Transform copies, a mask takes the copies as the paths it masks do; the copies of a
// path with a mask of its own each carry it ([`copy_leaf`]).
let masks = self.reshape_masks(art, own, &Skip { moved: &moved_ids, kept: &kept_ids }, max, used, depth);
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
// follow their bounds under Free Distort and Warp (they stay when no path changed them).
// Each stays when a path under it that its bounds measure kept its shape (past the budget).
let mut after = vec![];
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
let images = if groups.is_empty() { None } else { warped.as_deref().and_then(|w| w.when_cut(art)) };
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, images, &mut after).1;
if !groups.is_empty() {
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, &params, bounds, from, to, seed));
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, params, bounds, from, to, seed));
}
if follows && !held {
whole.push(crate::Carry::of(&e.id, &params, bounds, Some(bounds), art.geometric_bounds(), seed));
let drawn = warped.as_deref().map_or_else(|| art.geometric_bounds(), |w| w.bounds(art));
let whole = (follows && !held).then(|| crate::Carry::of(&e.id, params, bounds, Some(bounds), drawn, seed));
Ran { kept: !kept_ids.is_empty(), masks, short, whole }
}
/// Run the effect over the art of the linked opacity mask of `n` (with `own`) and of each path,
/// compound path, group and layer under it, with the art's reference box, as the paths the mask
/// covers take it: the mask moves and bends with what it masks. The mask of an object
/// transformed as an object moved with it already and is left as it is. A mask stays as it was
/// past [`vectorcraft_doc::depth::MAX_DEPTH`] masks inside masks, and for the reasons
/// [`Step::reshape_own_mask`] gives. Returns whether a mask stayed for one of those reasons.
fn reshape_masks(&self, n: &mut Node, own: bool, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool {
if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) {
return false;
}
let mut stayed = own && self.reshape_own_mask(n, skip, max, used, depth);
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
for c in children.iter_mut() {
if has_linked_mask(c, true) {
stayed |= self.reshape_masks(Arc::make_mut(c), true, skip, max, used, depth);
}
}
}
stayed
}
/// Run the effect over the art of the linked opacity mask of `n` itself, when `n` is a path,
/// compound path, group or layer that `skip` does not list as moved. The mask stays as it was
/// when a path it masks kept its shape, and when its own art could not take the effect whole: a
/// path in it would keep its shape, its copies would stop short, or a mask inside it would stay
/// (no mask is left half moved). Returns whether the mask stayed for one of those reasons.
fn reshape_own_mask(&self, n: &mut Node, skip: &Skip, max: Budget, used: &mut Budget, depth: usize) -> bool {
if depth >= vectorcraft_doc::depth::MAX_DEPTH || !reshapes_mask_of(n) || skip.moved.contains(&n.id) {
return false;
}
let stays = holds_any(n, skip.kept);
let id = n.id;
let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) else { return false };
let before = *used;
let mut art = (*m.art).clone();
let whole = self.wholes.get(&id).copied().unwrap_or_else(|| copies_whole(&art));
let ran = (!stays).then(|| self.run(&mut art, max, used, whole, None, true, depth + 1));
match ran {
Some(ran) if ran.complete() => {
// What paints the mask's art as a whole follows the effect as the art does.
if let Some(carry) = ran.whole
&& carry.moves(&art.appearance)
{
carry.apply(&mut art.appearance);
}
m.art = Arc::new(art);
false
}
_ => {
*used = before;
true
}
}
}
whole
}
/// The objects whose linked masks [`Step::reshape_masks`] leaves as they are: those transformed
/// as objects (`moved`), and those holding a path that kept its shape (`kept`).
struct Skip<'a> {
moved: &'a HashSet<NodeId>,
kept: &'a HashSet<NodeId>,
}
/// Is `n` an object whose linked opacity mask a geometry effect on the art it is in reshapes with
/// it: a path, compound path, group or layer? An image or symbol instance moves its mask only
/// with the map a Transform effect places it by ([`transform_placed`]).
fn reshapes_mask_of(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. })
}
/// Does `n` (with `own`) or a path, compound path, group or layer under it carry a linked opacity
/// mask that [`Step::reshape_masks`] reshapes?
fn has_linked_mask(n: &Node, own: bool) -> bool {
let mine = own && reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked);
mine || match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().any(|c| has_linked_mask(c, true)),
_ => false,
}
}
/// Run `f` on the id of `n` (with `own`) and of each path, compound path, group and layer under it
/// that carries a linked opacity mask, with the art of that mask, and on the masks inside that
/// art, as [`Step::reshape_masks`] reaches them: `depth` masks deep already, up to
/// [`vectorcraft_doc::depth::MAX_DEPTH`].
fn each_linked_mask(n: &Node, own: bool, depth: usize, f: &mut dyn FnMut(NodeId, &Node)) {
if depth >= vectorcraft_doc::depth::MAX_DEPTH {
return;
}
if own
&& reshapes_mask_of(n)
&& let Some(m) = n.extras().mask.as_deref().filter(|m| m.linked)
{
f(n.id, &m.art);
each_linked_mask(&m.art, true, depth + 1, f);
}
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &n.kind {
for c in children {
each_linked_mask(c, true, depth, f);
}
}
}
/// [`each_linked_mask`] that changes the art of each mask: `f` runs on a mask's art before the
/// masks inside it are reached.
fn each_linked_mask_mut(n: &mut Node, own: bool, depth: usize, f: &mut dyn FnMut(&mut Node)) {
if depth >= vectorcraft_doc::depth::MAX_DEPTH || !has_linked_mask(n, own) {
return;
}
if own
&& reshapes_mask_of(n)
&& let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked)
{
let art = Arc::make_mut(&mut m.art);
f(art);
each_linked_mask_mut(art, true, depth + 1, f);
}
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
for c in children.iter_mut() {
if has_linked_mask(c, true) {
each_linked_mask_mut(Arc::make_mut(c), true, depth, f);
}
}
}
}
/// Does [`Step::reshape_masks`] reshape the linked opacity mask of `n` itself?
fn has_own_linked_mask(n: &Node) -> bool {
reshapes_mask_of(n) && n.extras().mask.as_ref().is_some_and(|m| m.linked)
}
/// Add to `out` the ids of the objects under `n` that the linked opacity mask of an object above
/// them covers (every one of them with `under`).
fn masked_ids(n: &Node, under: bool, out: &mut HashSet<NodeId>) {
for c in n.children().into_iter().flatten() {
if under {
out.insert(c.id);
}
masked_ids(c, under || has_own_linked_mask(c), out);
}
}
/// How many images and symbol instances under `n` the linked opacity mask of an object above them
/// covers (every one of them with `under`).
fn masked_placed(n: &Node, under: bool) -> usize {
n.children().into_iter().flatten().map(|c| usize::from(under && is_placed(c)) + masked_placed(c, under || has_own_linked_mask(c))).sum()
}
/// Is `n`, or anything under it, one of `ids`?
fn holds_any(n: &Node, ids: &HashSet<NodeId>) -> bool {
if ids.is_empty() {
return false;
}
let mut any = false;
n.walk(&mut |c| any |= ids.contains(&c.id));
any
}
/// Do the paths under `n` that [`for_each_leaf`] visits all fill by the non-zero rule without a
/// stroke that paints? Effects then reshape their outlines alike in any order that the
/// optimization of [`crate::geometry::optimize_geometry_effects`] takes.
fn leaves_safe(n: &Node) -> bool {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => {
let has_stroke = n.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
leaf_geometry(n).is_none_or(|(_, rule)| rule == FillRule::NonZero && !has_stroke)
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().all(|c| leaves_safe(c)),
_ => true,
}
}
/// Is there an image with a linked opacity mask among the members of `n`, or among its groups'
/// and layers' members, which [`mark_warped_images`] walks?
fn has_masked_image(n: &Node) -> bool {
let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return false };
children.iter().any(|c| match c.kind {
NodeKind::Image(_) => c.extras().mask.as_ref().is_some_and(|m| m.linked),
_ => has_masked_image(c),
})
}
/// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by
@@ -1045,7 +1439,9 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
let mut copy = leaf.clone();
copy.transform(xf, scaling);
// Each copy's own geometry effects make a different amount, as the copy and its effects scale.
let cost = Budget::of(&moved).plus(effects_cost(&copy, &moved));
let mut cost = Budget::of(&moved).plus(effects_cost(&copy, &moved));
// Each copy carries the art of its linked masks, moved with it.
each_linked_mask(&copy, true, 0, &mut |_, m| cost = cost.plus(art_cost(m)));
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
@@ -1445,17 +1841,25 @@ type GroupMark = (Vec<usize>, Option<Rect>, bool);
/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or
/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`.
fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) {
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`. An image that `images`
/// lists is measured by the pieces it will be cut into ([`WarpedImages::image_bounds`]).
fn group_marks(
n: &Node,
at: &mut Vec<usize>,
kept: &HashSet<NodeId>,
images: Option<&WarpedImages>,
out: &mut Vec<GroupMark>,
) -> (Option<Rect>, bool) {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)),
NodeKind::Image(_) => (images.map_or_else(|| n.geometric_bounds(), |w| w.image_bounds(n)), false),
NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => {
let layer = matches!(n.kind, NodeKind::Layer { .. });
let skip = children.len().saturating_sub(n.editing_children().len());
let (mut bounds, mut held) = (None, false);
for (i, c) in children.iter().enumerate() {
at.push(i);
let (b, h) = group_marks(c, at, kept, out);
let (b, h) = group_marks(c, at, kept, images, out);
if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) {
out.push((at.clone(), b, h));
}
@@ -1577,17 +1981,239 @@ fn box_map(from: Rect, to: Rect) -> vectorcraft_geom::Affine {
/// Apply the Transform effect's map `m` to every image and symbol instance under `n`, as
/// [`for_each_leaf`] does to paths: the map goes into the object's placement, and `copies` more
/// follow, each mapped once more than the last. Without copies the object itself takes the map,
/// as a path does.
fn transform_placed(n: &mut Node, m: Affine, copies: usize) {
transform_placed_within(n, m, copies, crate::distort::MAX_COPY_OBJECTS)
/// as a path does. The art of each one's linked opacity mask takes the map with `scaling`, as a
/// path's mask does.
fn transform_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling) {
let mut room = crate::distort::MAX_COPY_OBJECTS.saturating_sub(placed_count(n));
copy_placed(n, m, copies, scaling, &mut room);
}
/// [`transform_placed`] whose copies make at most `max` image and symbol instance nodes of the art,
/// the originals included: each copy adds the object once more, and a Transform after this one
/// copies the lot again, so copies stop where the result would pass `max` (#2042).
#[cfg(test)]
pub(crate) fn transform_placed_within(n: &mut Node, m: Affine, copies: usize, max: usize) {
let mut room = max.saturating_sub(placed_count(n));
copy_placed(n, m, copies, &mut room);
copy_placed(n, m, copies, vectorcraft_doc::Scaling::default(), &mut room);
}
/// Rows and columns of the pieces a Warp effect cuts an image into (VectorCraft's own number).
pub(crate) const WARP_IMAGE_CELLS: usize = 10;
/// A Warp effect's point map, shared by the images it bends.
pub(crate) type WarpMap = Rc<dyn Fn(Point) -> Point>;
/// One image a Warp effect bent: the image's own key, and each warp it took with the placement
/// the image had when it took it, in order.
type WarpedImage = (String, Vec<(WarpMap, Affine)>);
/// The images Warp effects bend while [`reshape_leaves_apart`] runs the effects. Each such image's
/// key is replaced by a key of this table ([`WarpedImages::add`]), which a copy a later Transform
/// effect makes keeps. The images are cut into pieces once, after the last effect
/// ([`cut_warped_images`]): the effects after a Warp reshape the image's frame, not each piece,
/// and a later Warp or Transform moves the one set of pieces.
pub(crate) struct WarpedImages {
/// What this table's keys start with: no image key in the art starts with it
/// ([`WarpedImages::reserve`]).
prefix: String,
/// The prefix was checked against the art's image keys.
reserved: bool,
images: Vec<WarpedImage>,
}
impl WarpedImages {
pub(crate) fn new() -> Self {
static TABLES: AtomicUsize = AtomicUsize::new(0);
Self { prefix: format!("\0warp{}:", TABLES.fetch_add(1, Ordering::Relaxed)), reserved: false, images: vec![] }
}
/// Before the first image of `art` is marked: lengthen the prefix until no image key in `art`
/// starts with it, so no key from the document is read as one of this table's.
pub(crate) fn reserve(&mut self, art: &Node) {
if self.reserved {
return;
}
self.reserved = true;
let mut keys = vec![];
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
keys.push(im.key.clone());
}
});
while keys.iter().any(|k| k.starts_with(self.prefix.as_str())) {
self.prefix.push(':');
}
}
/// What this table's keys start with.
#[cfg(test)]
pub(crate) fn prefix(&self) -> &str {
&self.prefix
}
/// The image that `key`, a key of this table, stands for.
fn get(&self, key: &str) -> Option<&WarpedImage> {
self.images.get(key.strip_prefix(self.prefix.as_str())?.parse::<usize>().ok()?)
}
/// Record that the image with key `key`, placed by `xf`, takes warp `f`; returns the image's
/// new key.
fn add(&mut self, key: &str, xf: Affine, f: &WarpMap) -> String {
let (own, mut warps) = self.get(key).cloned().unwrap_or_else(|| (key.to_string(), vec![]));
warps.push((f.clone(), xf));
self.images.push((own, warps));
format!("{}{}", self.prefix, self.images.len() - 1)
}
/// Does the art `n` have room, within `max` nodes, for the pieces of every image under it that
/// this table lists?
fn fits(&self, n: &Node, max: usize) -> bool {
let mut count = 0usize;
n.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind
&& self.get(&im.key).is_some()
{
count += 1;
}
});
vectorcraft_doc::live::mesh_warp_cost(WARP_IMAGE_CELLS).saturating_mul(count) <= max.saturating_sub(n.count())
}
/// The geometric bounds of `art` as it draws after the effects so far: each image this table
/// lists measured by the pieces the warps and moves it took would cut it into, when the art
/// has room for them ([`cut_warped_images`]).
pub(crate) fn bounds(&self, art: &Node) -> Option<Rect> {
if self.images.is_empty() || !self.fits(art, crate::distort::MAX_COPY_OBJECTS) {
return art.geometric_bounds();
}
// The pieces the images would be cut into now: each group of pieces is shown or hidden as
// its image is, and hidden objects are left out of a layer's bounds.
let mut probe = art.clone();
restore_warped_images(&mut probe, self, true);
probe.geometric_bounds()
}
/// The geometric bounds of image `n`: when this table lists it, measured by the pieces the
/// warps and moves it took would cut it into.
fn image_bounds(&self, n: &Node) -> Option<Rect> {
let NodeKind::Image(im) = &n.kind else { return n.geometric_bounds() };
if self.get(&im.key).is_none() {
return n.geometric_bounds();
}
let mut probe = Node::group(n.id, vec![Arc::new(n.clone())]);
restore_warped_images(&mut probe, self, true);
probe.geometric_bounds()
}
/// This table when the art `art` has room for the pieces of the images it lists, so that a
/// measure of the art takes them bent ([`WarpedImages::bounds`]); `None` otherwise.
fn when_cut(&self, art: &Node) -> Option<&Self> {
(!self.images.is_empty() && self.fits(art, crate::distort::MAX_COPY_OBJECTS)).then_some(self)
}
}
/// Record in `warped` that each image under `n` takes warp `f`, walking the groups and layers
/// [`for_each_leaf`] walks and counting the paths it passes in `at`. `kept` holds the places of
/// the paths that kept their shape (past the budget), in the order [`for_each_leaf`] visits the
/// paths of the art: an image in a clip group whose clipping art holds one (`held`) does not take
/// the warp.
pub(crate) fn mark_warped_images(n: &mut Node, f: &WarpMap, kept: &HashSet<usize>, held: bool, at: &mut usize, warped: &mut WarpedImages) {
let (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }) = &mut n.kind else { return };
let mut held = held;
// A clip group's first member is its clipping art: when a path in it kept its shape, it and
// the members it clips keep theirs.
if *clip && let Some(first) = children.first() {
let start = *at;
held |= (start..start + leaf_count(first)).any(|p| kept.contains(&p));
}
for c in children.iter_mut() {
match &c.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => *at += 1,
NodeKind::Group { .. } | NodeKind::Layer { .. } => mark_warped_images(Arc::make_mut(c), f, kept, held, at, warped),
NodeKind::Image(_) if !held => {
if let NodeKind::Image(im) = &mut Arc::make_mut(c).kind {
im.key = warped.add(&im.key, im.xf, f);
}
}
_ => {}
}
}
}
/// Give each image under `n` that `warped` lists its own key back, and cut it into the pieces of
/// a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`], each piece drawn as the image
/// is, smoothed or not) through the warps it took and the moves between and after them
/// ([`warped_map`]). The art keeps to [`crate::distort::MAX_COPY_OBJECTS`] nodes: when the pieces
/// would pass it, no image is cut. An image whose placement has no inverse is not cut either.
fn cut_warped_images(n: &mut Node, warped: &WarpedImages) {
cut_warped_images_within(n, warped, crate::distort::MAX_COPY_OBJECTS);
}
/// [`cut_warped_images`] with the art keeping to `max` nodes.
pub(crate) fn cut_warped_images_within(n: &mut Node, warped: &WarpedImages, max: usize) {
if warped.images.is_empty() {
return;
}
let cut = warped.fits(n, max);
restore_warped_images(n, warped, cut);
}
/// The walk of [`cut_warped_images`]: each listed image gets its key back, and with `cut` is cut
/// into pieces.
fn restore_warped_images(n: &mut Node, warped: &WarpedImages, cut: bool) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
let NodeKind::Image(im) = &c.kind else {
if c.children().is_some() {
restore_warped_images(Arc::make_mut(c), warped, cut);
}
continue;
};
let Some((key, warps)) = warped.get(&im.key) else { continue };
let mut image = (**c).clone();
let NodeKind::Image(own) = &mut image.kind else { continue };
own.key = key.clone();
// The pieces start from the image as it was placed when it took its first warp.
let first = warps.first().map(|(_, at)| ImageObject { xf: *at, ..(**own).clone() });
let map = warped_map(warps, own.xf);
*c = Arc::new(match (first, map) {
(Some(first), Some(map)) if cut => {
let pixelated = image.extras().pixelated;
vectorcraft_doc::live::mesh_warp_image(&image, &first, &*map, WARP_IMAGE_CELLS, pixelated, false)
}
_ => image,
});
}
}
/// The map that takes a point of an image placed by the first of `warps`' placements to where the
/// warps and the moves between them take it, for an image now placed by `placed`: each warp in
/// turn, then the move from the placement the image had when it took that warp to the one it had
/// at the next, or to `placed` after the last. `None` when a placement has no inverse.
fn warped_map(warps: &[(WarpMap, Affine)], placed: Affine) -> Option<Box<dyn Fn(Point) -> Point>> {
let mut steps: Vec<(WarpMap, Affine)> = Vec::with_capacity(warps.len());
for (i, (f, at)) in warps.iter().enumerate() {
let det = at.determinant();
if !det.is_finite() || det == 0.0 {
return None;
}
let next = warps.get(i + 1).map_or(placed, |(_, at)| *at);
let m = next * at.inverse();
if !m.is_finite() {
return None;
}
steps.push((f.clone(), m));
}
Some(Box::new(move |p| steps.iter().fold(p, |q, (f, m)| *m * f(q))))
}
/// How many paths [`for_each_leaf`] visits in `n`.
fn leaf_count(n: &Node) -> usize {
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => 1,
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| leaf_count(c)).sum(),
_ => 0,
}
}
/// An object that a Transform effect places through its transform rather than reshapes: an image
@@ -1600,11 +2226,11 @@ fn is_placed(n: &Node) -> bool {
/// document order, each copy taking one from `room` (what the art can still grow by), until the
/// budget is full. As with a path, the copies stop before one reaches past
/// [`crate::distort::within_reach`].
fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
fn copy_placed(n: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, room: &mut usize) {
let Some(children) = n.children_mut() else { return };
for c in children.iter_mut() {
if !is_placed(c) {
copy_placed(Arc::make_mut(c), m, copies, room);
copy_placed(Arc::make_mut(c), m, copies, scaling, room);
continue;
}
let src = (**c).clone();
@@ -1623,6 +2249,12 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() {
one.appearance.transform_gradients(xf);
}
// So does the art of its linked opacity mask.
if xf != Affine::IDENTITY
&& let Some(m) = one.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked)
{
Arc::make_mut(&mut m.art).transform(xf, scaling);
}
one
};
if copies == 0 {
+6 -6
View File
@@ -29,7 +29,7 @@ const MAX_PAINT_COPIES: usize = 256 << 20;
/// The most face and box tests a group's transparent members' faces take together to rejoin their
/// members' last runs; past them each starts a run of its own.
const MAX_OVERLAP_TESTS: usize = 1 << 22;
pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22;
/// The width of the stroke around each face that covers the seams between faces.
const SEAM_WIDTH: f64 = 0.75;
@@ -628,14 +628,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc<Node>)>, le
/// What a face can paint, to tell whether two faces overlap: the convex hull of the points and
/// control points of its outline, which holds the outline, and the hull's box grown by half the
/// seam stroke.
struct Coverage {
bounds: Rect,
pub(crate) struct Coverage {
pub(crate) bounds: Rect,
hull: Vec<Point>,
}
impl Coverage {
/// `None` for a face with no outline and no box.
fn of(face: &Node) -> Option<Coverage> {
pub(crate) fn of(face: &Node) -> Option<Coverage> {
let points: Vec<Point> = match face.path_data() {
Some(path) => path
.to_bezpath()
@@ -663,7 +663,7 @@ impl Coverage {
/// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the
/// separating axes of their edges and of the page's axes: `true` whenever they overlap, and
/// sometimes near a corner when they don't.
fn meets(&self, other: &Coverage) -> bool {
pub(crate) fn meets(&self, other: &Coverage) -> bool {
fn normals(hull: &[Point]) -> impl Iterator<Item = Vec2> + '_ {
(0..hull.len()).filter_map(|i| {
let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?);
@@ -710,7 +710,7 @@ fn convex_hull(mut points: Vec<Point>) -> Vec<Point> {
}
/// Do boxes `a` and `b` overlap with some area?
fn overlaps(a: Rect, b: Rect) -> bool {
pub(crate) fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
}
+77 -12
View File
@@ -700,10 +700,11 @@ type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with
/// caps, a shape's fill walls before the faces of its covering strokes that overlap them:
/// [`draw_order`]);
/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as
/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's
/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the
/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask
/// ([`in_runs`]);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it
@@ -856,19 +857,21 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect()));
}
// One order takes a plain group of faces from each solid, one face for each facet. With
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
// opacity mask, and then it stays whole.
let plain = surfaces.iter().all(|(depths, s, _)| {
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
});
// colored lights a surface can instead be one clipped composite of all its facets. A composite
// has no faces to order, so when any surface is one, every surface of the solid, masked ones
// included, draws whole in member order.
let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()));
if !opaque || !plain {
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
}
// A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs
// ([`in_runs`]), and each run carries the mask.
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect();
let (mut placed, mut faces) = (vec![], vec![]);
for (depths, s, _) in surfaces {
for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() {
if let NodeKind::Group { children, .. } = s.kind {
placed.extend(depths);
faces.extend(children.into_iter().map(Some));
faces.extend(children.into_iter().map(|face| Some((surface, face))));
}
}
let sealed = match fx.id.as_str() {
@@ -877,7 +880,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
_ => extrude_options(&p).cap,
};
let order = draw_order(&placed, sealed);
Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))])
let faces: Vec<(usize, Arc<Node>)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect();
let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) };
Some(vec![Arc::new(Node::group(n.id, children))])
}
/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces
/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order
/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a
/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does
/// the surface split, where one group can't be both behind and in front of another face.
fn in_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, masks: &[Option<Box<OpacityMask>>]) -> Vec<Arc<Node>> {
use crate::revolve::{Coverage, overlaps};
struct Run {
surface: Option<usize>,
bounds: Rect,
faces: Vec<Arc<Node>>,
cover: Vec<Coverage>,
}
let mut runs: Vec<Run> = vec![];
let mut last_run = vec![None; masks.len()];
// Past this many tests a face starts a run of its own, which never draws out of order.
let mut budget = crate::revolve::MAX_OVERLAP_TESTS;
for (surface, face) in faces {
let Some(cover) = Coverage::of(&face) else {
// A face with no outline and no box overlaps nothing.
runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] });
continue;
};
if masks.get(surface).is_none_or(Option::is_none) {
runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
continue;
}
let mut clear = |r: &Run| {
budget = budget.saturating_sub(1);
if !overlaps(r.bounds, cover.bounds) {
return budget > 0;
}
budget = budget.saturating_sub(r.cover.len());
budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover))
};
let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear)));
match joins.and_then(|i| runs.get_mut(i)) {
Some(run) => {
run.bounds = run.bounds.union(cover.bounds);
run.faces.push(face);
run.cover.push(cover);
}
None => {
if let Some(last) = last_run.get_mut(surface) {
*last = Some(runs.len());
}
runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
}
}
}
runs.into_iter()
.filter_map(|mut r| {
let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() };
let mut group = Node::group(id, r.faces);
group.extras_mut().mask = Some(mask);
Some(Arc::new(group))
})
.collect()
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
+73 -7
View File
@@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() {
}
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
/// opacity mask: its faces still take their places in the one order of the fill's and the
/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the
/// gradient itself and no mask.
#[test]
fn a_masked_surface_under_a_colored_light_stays_whole() {
fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut half = GradientStop::new(1.0, RED);
half.opacity = 0.5;
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.paint = Paint::Gradient(Box::new(gradient.clone()));
@@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() {
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
let art = solid_art(&n).unwrap();
let drawn = art.children().unwrap();
assert_eq!(drawn.len(), surfaces, "{lights}");
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
assert_eq!(drawn.len(), 1, "{lights}: one order of faces");
let runs: Vec<&Arc<Node>> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect();
assert_eq!(!runs.is_empty(), masked, "{lights}");
assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces");
}
}
@@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() {
}
let art = solid_art(&n).unwrap();
let surface = &art.children().unwrap()[0];
assert_eq!(surface.extras().mask.is_some(), masked, "{end}");
let mut has_mask = false;
surface.walk(&mut |c| has_mask |= c.extras().mask.is_some());
assert_eq!(has_mask, masked, "{end}");
let mut opaque = true;
surface.walk(&mut |c| {
for item in &c.appearance.items {
@@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() {
}
}
/// 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. Here a square
/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square
/// and its front cap is in front of the square's walls.
#[test]
fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| {
let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)];
let mut points = corners.to_vec();
if !clockwise {
points.reverse();
}
b.move_to(points[0]);
for p in &points[1..] {
b.line_to(*p);
}
b.close_path();
};
let art = |id: &str, params: &Value, 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, RED), end]))));
let mut b = BezPath::new();
square(&mut b, 100.0, 300.0, true);
square(&mut b, 150.0, 250.0, false);
let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0));
let mut b = BezPath::new();
square(&mut b, 185.0, 215.0, true);
let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0));
let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]);
g.appearance.effects.push(fx(id, params.clone()));
solid_art(&g).unwrap_or_else(|| panic!("{id}"))
};
// 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
};
for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] {
assert!(!order(&art(id, &params, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask");
let masked = order(&art(id, &params, 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')), "{id}: faces between the member's runs: {masked}");
}
}
#[test]
fn classic_parameters_are_checked() {
let n = classic(json!({}));
+38 -10
View File
@@ -367,9 +367,12 @@ pub struct Slice {
}
impl Slice {
/// The slice's paint: its piece of `g` as a linear gradient placed from `from` to `to`.
pub fn paint(&self, g: &Gradient) -> Paint {
let gradient = g.span(self.span.0 as f32, self.span.1 as f32);
/// The slice's paint: its piece of `unrolled` as a linear gradient placed from `from` to `to`.
/// `unrolled` is the stroke's gradient as [`Gradient::unrolled`] gives it (unrolled once for
/// all the slices), so one that repeats or reflects runs its stops that many times along (or
/// across) the stroke.
pub fn paint(&self, unrolled: &Gradient) -> Paint {
let gradient = unrolled.span(self.span.0 as f32, self.span.1 as f32);
let end = if self.from.distance(self.to) > 1e-9 { self.to } else { self.from + Vec2::new(1.0, 0.0) };
let geom = GradientGeom { start: self.from, end, aspect: 1.0, focal: None };
Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle: geom.angle_deg(), ..GradientPaint::new(gradient) }))
@@ -471,35 +474,60 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines:
_ => vectorcraft_pathops::stroke_region(outlines).to_bezpath(),
};
let grow = if g.stops.iter().all(|s| s.opacity >= 1.0) { SLICE_GROW } else { 0.0 };
let unrolled = g.unrolled();
let slices = gradient_slices(bp, rule, st, SLICE_TOL, SLICE_MARGIN, grow)
.into_iter()
.map(|s| {
let paint = s.paint(g);
let paint = s.paint(&unrolled);
(s.shape, paint)
})
.collect();
Some(WrittenSlices { clip, slices })
}
/// How far before and after a sharp step between two colors (two stops at one offset, as a
/// gradient that repeats has where one run meets the next) [`mesh_marks`] puts a mesh row or
/// column, as a fraction of the gradient's length.
const STEP_GAP: f64 = 1e-4;
/// Where along gradient `g` (0 to 1) Outline Stroke's mesh puts a row or column for its stops
/// ([`Gradient::expanded_stops`]): at each stop, and for two stops at one offset (a sharp step,
/// or where a reflecting gradient turns back) also [`STEP_GAP`] before and after it, so the mesh
/// takes the color on each side.
fn mesh_marks(g: &Gradient) -> Vec<f64> {
let offsets: Vec<f64> = g.expanded_stops().iter().map(|s| f64::from(s.0)).collect();
let shared = |i: usize| {
let t = offsets.get(i).copied().unwrap_or(0.0);
let near = |j: Option<usize>| j.and_then(|j| offsets.get(j)).is_some_and(|&u| (u - t).abs() < 1e-6);
near(i.checked_sub(1)) || near(i.checked_add(1))
};
let mut out = Vec::with_capacity(offsets.len() + 2);
for (i, &t) in offsets.iter().enumerate() {
out.push(t);
if shared(i) {
out.extend([(t - STEP_GAP).max(0.0), (t + STEP_GAP).min(1.0)]);
}
}
out
}
/// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or
/// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the
/// stroke's outline), coloured as [`gradient_slices`] paint it.
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
let g = &*g.unrolled();
let across = st.gradient_mode == StrokeGradientMode::Across;
let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
let at_stops = mesh_marks(g);
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() };
let marks: Vec<f64> = if across { vec![] } else { at_stops.clone() };
let max_dt = match (across, st.profile.is_some()) {
(false, _) => MESH_STEP,
(true, true) => PROFILE_STEP,
(true, false) => 1.0,
};
// Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop.
let mut us: Vec<f64> = if across {
(0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(g.expanded_stops().iter().map(|s| s.0 as f64)).collect()
} else {
vec![0.5]
};
let mut us: Vec<f64> = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] };
us.sort_by(f64::total_cmp);
us.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt)
@@ -286,6 +286,65 @@ fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() {
assert_covered(&hairpin, &st);
}
/// A gradient along or across a stroke that repeats or reflects runs its stops that many times
/// along (or across) it: a slice's paint, and Outline Stroke's meshes, give each point the color
/// the spread gives its place.
#[test]
fn a_spread_gradient_runs_its_stops_along_the_stroke_that_many_times() {
use vectorcraft_color::GradientSpread;
let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false);
for (mode, spread) in [StrokeGradientMode::Along, StrokeGradientMode::Across]
.into_iter()
.flat_map(|m| [GradientSpread::Reflect, GradientSpread::Repeat].map(|s| (m, s)))
{
let mut st = stroke(10.0, mode);
let g = Gradient { spread, repeats: 3, ..Gradient::default() };
st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone())));
for s in slices(&bp, &st) {
let Paint::Gradient(p) = s.paint(&g.unrolled()) else { panic!("a gradient slice") };
let geom = p.geom.unwrap();
for u in [0.1, 0.5, 0.9] {
let at = s.from + (s.to - s.from) * u;
let t = s.span.0 + (s.span.1 - s.span.0) * u;
// Where two runs meet, Repeat's color is the next run's start or the last one's end.
if (t * 3.0 - (t * 3.0).round()).abs() < 1e-9 {
continue;
}
let want = g.sample(g.spread_param(t) as f32).0.to_rgb();
let got = p.gradient.sample(geom.param_at(vectorcraft_color::GradientKind::Linear, at) as f32).0.to_rgb();
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-3), "{mode:?} at {t}: {want:?} vs {got:?}");
}
}
// Outline Stroke: the mesh comes back to white where each run starts again, and reaches
// black where each run ends, on both sides of a sharp step between runs.
let count = |g: &Gradient, hex: &str| {
gradient_meshes(&bp, FillRule::NonZero, &st, g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == hex).count()
};
for hex in ["#ffffff", "#000000"] {
let (spread, pad) = (count(&g, hex), count(&Gradient::default(), hex));
assert!(spread > pad, "{mode:?} {:?} {hex}: {spread} vs {pad}", g.spread);
}
}
}
/// Where a reflecting gradient turns back past a narrow last stop, Outline Stroke's mesh keeps that
/// stop's color.
#[test]
fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() {
use vectorcraft_color::GradientSpread;
let bp = poly(&[(0.0, 0.0), (120.0, 0.0)], false);
let stops = vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.995, Color::BLACK), GradientStop::new(1.0, Color::WHITE)];
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
let mut st = stroke(10.0, mode);
let g = Gradient { spread: GradientSpread::Reflect, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops.clone()) };
st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone())));
let whites =
gradient_meshes(&bp, FillRule::NonZero, &st, &g).iter().flat_map(|m| m.points.iter()).filter(|p| p.color.to_hex() == "#ffffff").count();
// The gradient turns back at white 50 times.
assert!(whites >= 50, "{mode:?}: {whites}");
}
}
/// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along
/// the path, however many points lie that far: not as far as it is wide anywhere when more than
/// 256 do.
+362
View File
@@ -209,6 +209,57 @@ fn warp_arc_bends_the_top_edge() {
assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}");
}
/// A Warp of `version` 1 bends its bounds as its style's Bézier net; one without a version, as
/// saved before the version existed, draws as it always did. Flag at 60 % on a 200 × 100 box: the
/// net moves the top edge up to 6 × 0.6 × 100 × max u(1 - u)(1 - 2u) = 34.64 out, the earlier map
/// moves it 0.6 × 25 = 15.
#[test]
fn a_warp_draws_with_the_map_its_version_names() {
let rect = shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 120.0));
// u(1 - u)(1 - 2u) peaks at √3 / 18.
let peak = 360.0 * 3f64.sqrt() / 18.0;
for (version, out) in [(json!(1), peak), (json!(null), 15.0), (json!(0), 15.0)] {
let b = run("warp.flag", json!({"bend": 60, "version": version}), &rect).bounds().unwrap();
assert!(close(b.y0, 20.0 - out, 0.1) && close(b.y1, 120.0 + out, 0.1), "version {version}: {b:?}");
assert!(close(b.x0, 10.0, 1e-9) && close(b.x1, 210.0, 1e-9), "version {version}: {b:?}");
}
}
/// A straight line has a box with no width or no height, and the net bends it as the middle of
/// the patch along that side: under Arc a vertical line stays vertical, and under Bulge, Fisheye,
/// Inflate and Squeeze a horizontal line stays straight.
#[test]
fn a_warp_bends_a_flat_box_as_the_middle_of_its_patch() {
let anchors = |p: &PathData| p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect::<Vec<_>>();
let vertical = shapes::line(Point::new(100.0, 0.0), Point::new(100.0, 100.0));
let out = run("warp.arc", json!({"bend": 50, "version": 1}), &vertical);
assert!(anchors(&out).iter().all(|a| close(a.p.x, 100.0, 1e-9) && close(a.h_in.x, 100.0, 1e-9) && close(a.h_out.x, 100.0, 1e-9)), "{out:?}");
let horizontal = shapes::line(Point::new(0.0, 50.0), Point::new(200.0, 50.0));
for style in ["bulge", "fisheye", "inflate", "squeeze"] {
let out = run(&format!("warp.{style}"), json!({"bend": 50, "version": 1}), &horizontal);
assert!(
anchors(&out).iter().all(|a| close(a.p.y, 50.0, 1e-9) && close(a.h_in.y, 50.0, 1e-9) && close(a.h_out.y, 50.0, 1e-9)),
"{style}: {out:?}"
);
}
}
/// A new Warp takes the nets' version unless one is given, and the catalog's defaults carry none,
/// so a saved warp's params don't gain one.
#[test]
fn new_warps_take_the_nets_version() {
for (s, _) in WARP_STYLES {
let id = format!("warp.{s}");
let Some(e) = crate::new_effect(&id, &json!({"bend": 20})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(crate::WARP_NET), "{id}");
let Some(e) = crate::new_effect(&id, &json!({"version": 0})) else { panic!("{id}") };
assert_eq!(e.params["version"], json!(0), "{id}");
assert!(crate::merged_params(&id, &json!({"bend": 20})).get("version").is_none(), "{id}");
}
let Some(e) = crate::new_effect("distort.twist", &json!({})) else { panic!("twist") };
assert!(e.params.get("version").is_none());
}
#[test]
fn transform_copies_and_move() {
let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square());
@@ -318,6 +369,317 @@ fn stacked_transform_copies_stay_within_the_image_budget() {
assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001);
}
/// A 40 × 40 px image placed at (0, 0) at one point a pixel, with id `id`.
fn warp_test_image(id: u64) -> std::sync::Arc<Node> {
use vectorcraft_doc::{ImageObject, NodeId};
let im = ImageObject { key: "k".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
std::sync::Arc::new(Node::new(NodeId(id), NodeKind::Image(Box::new(im))))
}
/// How many images `art` holds, and the keys they have.
fn warp_test_images(art: &Node) -> (usize, std::collections::BTreeSet<String>) {
let (mut n, mut keys) = (0, std::collections::BTreeSet::new());
art.walk(&mut |c| {
if let NodeKind::Image(im) = &c.kind {
n += 1;
keys.insert(im.key.clone());
}
});
(n, keys)
}
#[test]
fn warped_images_stay_within_the_object_budget() {
use std::rc::Rc;
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
let cost = vectorcraft_doc::live::mesh_warp_cost(crate::reshape::WARP_IMAGE_CELLS);
let none = std::collections::HashSet::new();
let k = || std::collections::BTreeSet::from(["k".to_string()]);
let warp = |art: &mut Node, warps: usize| {
let mut warped = crate::reshape::WarpedImages::new();
for _ in 0..warps {
crate::reshape::mark_warped_images(art, &lift, &none, false, &mut 0, &mut warped);
}
warped
};
// The art has room for one image's pieces only: neither image is cut, and each gets its own
// key back.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
let warped = warp(&mut art, 1);
let max = art.count() + cost;
crate::reshape::cut_warped_images_within(&mut art, &warped, max);
assert_eq!(warp_test_images(&art), (2, k()));
// With room for both, each is cut into 200 pieces once, however many warps it took.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), warp_test_image(3)]);
let warped = warp(&mut art, 3);
let max = art.count() + 2 * cost;
crate::reshape::cut_warped_images_within(&mut art, &warped, max);
assert_eq!(warp_test_images(&art), (400, k()));
// Images clipped by their frames: the second frame (the second path) kept its shape, so its
// image stays whole, and the first is cut. The third's frame is a group of two paths, the
// second of them kept, with an image after them, and its image sits in a group of its own:
// both stay whole too.
let frame = |id: u64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Default::default()));
let clip_group = |id: u64, children: Vec<Arc<Node>>| Arc::new(Node::new(NodeId(id), NodeKind::Group { children, clip: true }));
let nested = clip_group(
4,
vec![
Arc::new(Node::group(NodeId(4), vec![frame(4), frame(4), warp_test_image(5)])),
Arc::new(Node::group(NodeId(4), vec![warp_test_image(4)])),
],
);
let mut art =
Node::group(NodeId(1), vec![clip_group(2, vec![frame(2), warp_test_image(2)]), clip_group(3, vec![frame(3), warp_test_image(3)]), nested]);
let (mut at, mut warped) = (0, crate::reshape::WarpedImages::new());
crate::reshape::mark_warped_images(&mut art, &lift, &std::collections::HashSet::from([1, 3]), false, &mut at, &mut warped);
assert_eq!(at, 4);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art), (203, k()));
}
/// An image key from the document that looks like a key of the table is not read as one, and a
/// hidden image stays out of the bounds the later effects take, as it does before it is warped.
#[test]
fn warped_images_keep_document_keys_and_hidden_images_apart() {
use std::rc::Rc;
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let lift: crate::reshape::WarpMap = Rc::new(|p: Point| Point::new(p.x, p.y - p.x * 0.1));
let none = std::collections::HashSet::new();
let mut warped = crate::reshape::WarpedImages::new();
let lookalike = format!("{}0", warped.prefix());
let mut b = (*warp_test_image(3)).clone();
if let NodeKind::Image(im) = &mut b.kind {
im.key = lookalike.clone();
}
let mut art = Node::group(NodeId(1), vec![warp_test_image(2), Arc::new(b)]);
warped.reserve(&art);
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art), (400, std::collections::BTreeSet::from(["k".to_string(), lookalike])));
// A layer of a 10 × 10 square and a hidden 40 × 40 image: the warped image is not measured.
let square = Arc::new(Node::path(NodeId(4), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()));
let mut hidden = (*warp_test_image(5)).clone();
hidden.visible = false;
let mut layer = Node::layer(NodeId(6), "Layer 1", vectorcraft_doc::LayerColor::Preset(0));
if let Some(children) = layer.children_mut() {
*children = vec![square, Arc::new(hidden)];
}
let before = layer.geometric_bounds();
let mut warped = crate::reshape::WarpedImages::new();
warped.reserve(&layer);
crate::reshape::mark_warped_images(&mut layer, &lift, &none, false, &mut 0, &mut warped);
assert_eq!(warped.bounds(&layer), before);
// A shown image is measured by the pieces it is cut into.
let mut art = Node::group(NodeId(1), vec![warp_test_image(2)]);
let mut warped = crate::reshape::WarpedImages::new();
warped.reserve(&art);
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
let measured = warped.bounds(&art);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(measured, art.geometric_bounds());
assert!(measured.is_some_and(|b| b.y0 < -3.9), "{measured:?}");
// An image placed at a tiny scale (a placement with a tiny determinant) is cut too; one placed
// with no area is not.
for (scale, pieces) in [(1e-7, 200), (0.0, 1)] {
let mut image = (*warp_test_image(2)).clone();
if let NodeKind::Image(im) = &mut image.kind {
im.xf = Affine::scale(scale);
}
let mut art = Node::group(NodeId(1), vec![Arc::new(image)]);
let mut warped = crate::reshape::WarpedImages::new();
crate::reshape::mark_warped_images(&mut art, &lift, &none, false, &mut 0, &mut warped);
crate::reshape::cut_warped_images_within(&mut art, &warped, usize::MAX);
assert_eq!(warp_test_images(&art).0, pieces, "{scale}");
}
}
/// An image whose frame keeps its shape because the warp would pass the anchor budget keeps its
/// pixels as they are; within the budget, both bend.
#[test]
fn an_image_whose_frame_passes_the_budget_stays_whole() {
use std::sync::Arc;
use vectorcraft_doc::NodeId;
let clipped = || {
let mut frame = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut frame.kind {
*clipping = true;
}
Node::new(NodeId(1), NodeKind::Group { children: vec![Arc::new(frame), warp_test_image(1)], clip: true })
};
let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
let k = || std::collections::BTreeSet::from(["k".to_string()]);
let mut art = clipped();
crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget { anchors: 0, lines: 0 }, None);
assert_eq!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).map(|p| p.anchor_count()), Some(4));
assert_eq!(warp_test_images(&art), (1, k()));
let mut art = clipped();
crate::reshape::reshape_leaves_within(&mut art, &[&arc], Budget::EFFECTS, None);
assert!(art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).is_some_and(|p| p.anchor_count() > 4));
assert_eq!(warp_test_images(&art), (200, k()));
}
/// The first image under `n` (the first piece of a cut image).
fn first_image(n: &Node) -> Option<vectorcraft_doc::ImageObject> {
let mut first = None;
n.walk(&mut |c| {
if first.is_none()
&& let NodeKind::Image(im) = &c.kind
{
first = Some((**im).clone());
}
});
first
}
#[test]
fn effects_after_a_warp_reshape_an_images_frame_and_not_its_pieces() {
let image = |effects: Vec<Effect>| {
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = effects;
n
};
// Each piece's clipping path, and the image's frame (the outer clip group's clipping path).
let clips = |art: &Node| {
let (mut pieces, mut frame) = (vec![], None);
art.walk(&mut |c| {
if let NodeKind::Group { children, clip: true } = &c.kind
&& let Some(path) = children.first().and_then(|p| p.path_data())
{
if matches!(children.get(1).map(|i| &i.kind), Some(NodeKind::Image(_))) && path.anchor_count() == 3 {
pieces.push(path.anchor_count());
} else if frame.is_none() {
frame = Some(path.anchor_count());
}
}
});
(pieces, frame)
};
let arc = fx("warp.arc", json!({"bend": 50, "version": 1}));
for after in [
fx("distort.roughen", json!({"size": 5, "detail": 10})),
fx("stylize.roundCorners", json!({"radius": 5})),
fx("path.offsetPath", json!({"offset": -2})),
] {
let art = crate::reshape::reshape(None, &image(vec![arc.clone(), after.clone()]), None).unwrap();
let (pieces, frame) = clips(&art);
assert_eq!(pieces.len(), 200, "{}", after.id);
assert!(frame.is_some_and(|n| n > 4), "{}: {frame:?}", after.id);
assert_eq!(warp_test_images(&art), (200, std::collections::BTreeSet::from(["k".to_string()])), "{}", after.id);
}
// Two warps cut the image once, through both maps; a Transform after them moves the pieces.
let flag = fx("warp.flag", json!({"bend": 30, "version": 1}));
let mv = fx("distort.transform", json!({"moveH": 100, "moveV": 0}));
let art = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone()]), None).unwrap();
assert_eq!(warp_test_images(&art).0, 200);
let moved = crate::reshape::reshape(None, &image(vec![arc.clone(), flag.clone(), mv]), None).unwrap();
assert_eq!(warp_test_images(&moved).0, 200);
let box1 = Rect::new(0.0, 0.0, 40.0, 40.0);
let f1 = crate::warp::warp_map(box1, crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &arc.params));
// The flag bends the box the arc leaves, which the arc's frame measures.
let box2 = crate::reshape::reshape(None, &image(vec![arc.clone()]), None).unwrap().geometric_bounds().unwrap();
let f2 = crate::warp::warp_map(box2, crate::warp::WarpStyle::Flag, &merged_params("warp.flag", &flag.params));
// The first piece's corners: the pixels (0, 0), (4, 0) and (4, 4).
for (art, by) in [(&art, 0.0), (&moved, 100.0)] {
let piece = first_image(art).unwrap();
for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
let want = f2(f1(p)) + vectorcraft_geom::Vec2::new(by, 0.0);
assert!((piece.xf * p).distance(want) < 1e-6, "{p:?}: {:?} {want:?}", piece.xf * p);
}
}
}
/// A warp with no version (VectorCraft's earlier map) bends an image's pixels with that map, as it
/// bends the frame.
#[test]
fn an_images_pixels_take_the_map_their_warps_version_names() {
for version in [json!(null), json!(0), json!(1)] {
let mut params = json!({"bend": 50});
if !version.is_null() {
params["version"] = version.clone();
}
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = vec![fx("warp.arc", params.clone())];
let art = crate::reshape::reshape(None, &n, None).unwrap();
let f = crate::warp::warp_map(Rect::new(0.0, 0.0, 40.0, 40.0), crate::warp::WarpStyle::Arc, &merged_params("warp.arc", &params));
let piece = first_image(&art).unwrap();
for p in [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0)] {
assert!((piece.xf * p).distance(f(p)) < 1e-6, "{version}: {p:?}");
}
// The frame has an anchor where the map takes each corner.
let frame = art.children().and_then(|c| c.first()).and_then(|c| c.path_data()).unwrap();
for corner in [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 40.0), Point::new(0.0, 40.0)] {
let near = frame.subpaths.iter().flat_map(|s| &s.anchors).any(|a| a.p.distance(f(corner)) < 1e-6);
assert!(near, "{version}: {corner:?}");
}
}
}
/// Under a group's Warp effect, a nested group's own placed gradient follows the nested group's
/// bounds to where its image's pieces draw them.
#[test]
fn a_nested_groups_gradient_follows_its_warped_image() {
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, NodeId};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let geom = GradientGeom { start: (5.0, 20.0).into(), end: (35.0, 20.0).into(), aspect: 1.0, focal: None };
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
let mut inner = Node::group(NodeId(5), vec![warp_test_image(1)]);
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed)));
let square = Node::path(NodeId(2), shapes::rectangle(Rect::new(60.0, 0.0, 100.0, 40.0)), Appearance::default());
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), Arc::new(square)]);
g.appearance.effects.push(fx("warp.arc", json!({"bend": 50, "version": 1})));
let art = crate::group::evaluate_container(None, &g).unwrap();
assert_eq!(warp_test_images(&art).0, 200);
let (from, mut to) = (Rect::new(0.0, 0.0, 40.0, 40.0), None);
art.walk(&mut |c| {
if c.id == NodeId(5) && to.is_none() {
to = c.geometric_bounds();
}
});
let to = to.unwrap();
assert!(to.y0 < from.y0 - 1.0, "the pieces draw the image bent: {to:?}");
let follow = |p: Point| Point::new(to.x0 + (p.x - from.x0) / from.width() * to.width(), to.y0 + (p.y - from.y0) / from.height() * to.height());
let mut checked = 0;
art.walk(&mut |c| {
if c.id != NodeId(5) {
return;
}
for item in &c.appearance.items {
let Paint::Gradient(gp) = item.paint() else { continue };
let got = gp.geom.unwrap();
assert!(got.start.distance(follow(geom.start)) < 1e-6 && got.end.distance(follow(geom.end)) < 1e-6, "{got:?} in {to:?}");
checked += 1;
}
});
assert!(checked > 0);
}
/// An image under a geometry effect other than a Warp draws as the image does: colorized by
/// a grayscale image's fill color, and smoothed or not.
#[test]
fn an_image_under_a_geometry_effect_keeps_its_color_and_smoothing() {
let mut n = (*warp_test_image(1)).clone();
n.appearance.effects = vec![fx("distort.roughen", json!({"size": 5, "detail": 10}))];
n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))));
n.extras_mut().pixelated = true;
let art = crate::reshape::reshape(None, &n, None).unwrap();
let mut seen = vec![];
art.walk(&mut |c| {
if matches!(c.kind, NodeKind::Image(_)) {
seen.push((c.extras().colorize.clone(), c.extras().pixelated));
}
});
assert_eq!(seen, vec![(n.extras().colorize.clone(), true)]);
}
/// What `effects` make of `path` within `budget`.
fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData {
apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget)
+34 -11
View File
@@ -1,25 +1,49 @@
//! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion.
//!
//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is
//! split into short pieces whose control points are mapped (see `map_nonlinear`).
//! A warp with `version` [`WARP_NET`] is its style's Bézier patch over the box ([`WarpNet`]);
//! one without (saved before the version existed) is a map on normalized box coordinates
//! (x, y) ∈ [-1, 1]² (y down), [`warp_point`]. The path is split into short pieces whose control
//! points are mapped (see `map_nonlinear`).
use serde_json::Value;
use vectorcraft_geom::{PathData, Point, Rect};
use crate::util::{map_nonlinear_within, num, text};
pub use vectorcraft_doc::live::{WarpStyle, warp_point};
pub use vectorcraft_doc::live::{WARP_NET, WarpNet, WarpStyle, uses_net, warp_point, warp_version};
/// The map version warp params `p` ask for: their `version`, 0 (the first map) when absent.
pub fn map_version(p: &Value) -> u32 {
let v = num(p, "version", 0.0);
if v.is_finite() && v >= 0.0 { v.min(f64::from(u32::MAX)) as u32 } else { 0 }
}
/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]).
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData {
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, warp_map(b, style, p))
}
/// The point map a warp of `style` with params `p` applies to box `b`: what [`warp`] moves each
/// point of a path to.
pub(crate) fn warp_map(b: Rect, style: WarpStyle, p: &Value) -> Box<dyn Fn(Point) -> Point> {
let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0;
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical");
if uses_net(map_version(p)) {
let net = WarpNet::new(style, bend, dh, dv, b.width(), b.height(), vertical);
let (w, h) = (b.width(), b.height());
// A box with no width or no height takes the middle of the patch along that side.
return Box::new(move |q: Point| {
let (u, v) = (if w > 0.0 { (q.x - b.x0) / w } else { 0.5 }, if h > 0.0 { (q.y - b.y0) / h } else { 0.5 });
b.origin() + net.at(u, v).to_vec2()
});
}
let c = b.center();
let hw = (b.width() / 2.0).max(1e-9);
let hh = (b.height() / 2.0).max(1e-9);
let f = |q: Point| {
Box::new(move |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
@@ -28,18 +52,17 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -
warp_point(style, bend, dh, dv, hh / hw, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear_within(path, piece, max, f)
})
}
/// Does warp effect `id` with params `p` move any point? Every style leaves the box as it is with
/// no bend and no distortion, and Arc (`warp.arc`) takes a bend under 0.0001 % as none: the
/// threshold must match the one in [`warp_point`]'s Arc case (`warp_bends_when_the_warp_moves_the_outline`
/// tests both sides of it).
/// no bend and no distortion. With the first map, Arc (`warp.arc`) takes a bend under 0.0001 % as
/// none: the threshold must match the one in [`warp_point`]'s Arc case
/// (`warp_bends_when_the_warp_moves_the_outline` tests both sides of it). With [`WarpNet`] any
/// bend moves the outline.
pub(crate) fn bends(id: &str, p: &Value) -> bool {
let share = |key: &str, default: f64| num(p, key, default).clamp(-100.0, 100.0) / 100.0;
let bend = share("bend", 50.0);
let bent = if id == "warp.arc" { bend.abs() >= 1e-6 } else { bend != 0.0 };
let bent = if id == "warp.arc" && !uses_net(map_version(p)) { bend.abs() >= 1e-6 } else { bend != 0.0 };
bent || share("horizontal", 0.0) != 0.0 || share("vertical", 0.0) != 0.0
}
+1
View File
@@ -771,6 +771,7 @@ pub use docmenu::guide_indexes;
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use object::bounds_rewrite;
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
pub use plugin_call::{Location, key_location, location_in, location_keys};
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
+7 -2
View File
@@ -1,11 +1,16 @@
//! 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.
//! ([`stencil`]), and Weed Box and Lines adds a weed box and weeding lines around the selected art
//! for vinyl cutting ([`weed`]). Its submenu (Object › Plug-ins › Cutting Tools) and dialogs are
//! the UI's.
mod stencil;
mod weed;
use super::super::CommandSpec;
pub fn specs() -> Vec<CommandSpec> {
stencil::specs()
let mut v = stencil::specs();
v.extend(weed::specs());
v
}
@@ -89,7 +89,12 @@ fn bridges(s: &mut Session, p: &Value) -> Result<Value> {
};
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()))?;
let bridged = stencil_bridges(&refs, spec).map_err(|e| match e {
vectorcraft_pathops::PathOpsError::TooComplex => {
EngineError::Other("Stencil Bridges: the selected art is too large or too detailed to bridge".into())
}
e => EngineError::Other(e.to_string()),
})?;
(bridged, style_of(&top), roots)
};
let summary = |id: Option<NodeId>, original: Option<NodeId>| {
@@ -277,6 +277,34 @@ fn bad_params_are_refused() {
assert!(s.execute("addon.cutting.bridges", &json!({"width": 4})).is_ok(), "good params run");
}
#[test]
fn art_too_detailed_to_bridge_is_refused_with_a_message_that_says_so() {
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("dense", |d, sel| {
// More anchors than Stencil Bridges measures.
let mut zigzag = kurbo::BezPath::new();
zigzag.move_to((100.0, 100.0));
for i in 1..=vectorcraft_pathops::addons::stencil::MAX_STENCIL_SEGMENTS {
zigzag.line_to((100.0 + i as f64 * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
zigzag.close_path();
let huge = vectorcraft_geom::PathData::from_bezpath(&zigzag);
let n = vectorcraft_doc::Node::path(d.alloc_id(), huge, vectorcraft_doc::Appearance::default());
let id = n.id;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let steps = undo_steps(&s);
let e = s.execute("addon.cutting.bridges", &json!({"width": 4})).unwrap_err().to_string();
assert!(e.contains("Stencil Bridges: the selected art is too large or too detailed to bridge"), "{e}");
assert_eq!(undo_steps(&s), steps);
}
#[test]
fn selected_objects_without_outlines_stay_where_they_are() {
for keep in [true, false] {
@@ -0,0 +1,160 @@
//! Weed Box and Lines (`addon.cutting.weed`), a command of the built-in Cutting Tools add-on for
//! vinyl cutting: around the selected art it adds a weed box (a rectangle at a margin around the
//! art) and weeding lines across it that stop at the art, so the waste vinyl around the art lifts
//! off in pieces (<https://help.arlon.com/what-is-a-weeding-border>). The art is measured by the
//! outlines a cutter cuts ([`geometry`]).
mod geometry;
#[cfg(test)]
mod tests;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Rect, shapes};
use super::super::super::edit::selected_roots;
use super::super::super::object::choice;
use super::super::super::paint::swatch_solid;
use super::super::super::pathops::{length_given, number_given};
use super::super::super::{CommandSpec, bad, cmd, has_selection, switch_or, text_param};
use crate::{EngineError, Result, Session};
/// Weed Box and Lines' command.
pub const WEED: &str = "addon.cutting.weed";
/// Which way the weeding lines run.
pub const DIRECTIONS: [&str; 3] = ["horizontal", "vertical", "both"];
/// The largest margin, gap or spacing, in points.
const MAX_DISTANCE: f64 = 100_000.0;
/// The most weeding lines each way.
const MAX_COUNT: usize = 1000;
/// The most pieces of weeding line one run adds.
const MAX_PIECES: usize = 100_000;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.cutting.weed",
"Weed Box and Lines…",
["Object", "Plug-ins", "Cutting Tools"],
None,
"{margin?: pt 0..100000 (9: from the art to the weed box), box?: true (false: the weeding lines only), direction?: \"horizontal\" | \"vertical\" | \"both\" (\"both\"), count?: whole number 0..1000 of lines each way (1), spacing?: pt (instead of count: as few lines as keep them at most this far apart), gap?: pt 0..100000 (0: the lines stop at the art's outlines), weight?: pt 0.01..1000 (0.3), swatch?: a color swatch the lines are stroked in (\"[Registration]\")} add a weed box around the selected art and weeding lines across it that divide it evenly, each running from one side of the box to the other and left out over the art and within `gap` of its outlines, so the waste lifts off in pieces. The art is measured by the outlines a cutter cuts: closed paths enclose their area by their fill rule and open paths are lines, compound paths by all their subpaths, type by its glyph outlines, a clip group by what shows inside its clipping path, images by their frames, placed files by the art they show (by their boxes when the file can't be read), symbol instances by their art and live objects by the art they draw; strokes, effects and hidden objects are left out. One group named Weeding, stroked with no fill, on top of the current layer (outside the clip groups around a group edited in isolation), selected, as one undo step → {id: the group, box: its weed box or null, lines (pieces of weeding line)}",
has_selection,
weed
)]
}
/// The distance `p` gives for `key` (0..[`MAX_DISTANCE`]), or `default`.
fn distance(p: &Value, key: &str, default: f64) -> Result<f64> {
match length_given(p, key, WEED)? {
None => Ok(default),
Some(v) if (0.0..=MAX_DISTANCE).contains(&v) => Ok(v),
Some(_) => Err(bad(WEED, format!("`{key}` must be from 0 to {MAX_DISTANCE} pt"))),
}
}
/// How many lines divide a side `length` long: `count`, or as few as keep them at most `spacing`
/// apart.
fn lines_across(length: f64, count: Option<usize>, spacing: Option<f64>) -> Result<usize> {
let n = match spacing {
// `length / spacing` strips, rounded up, need one line fewer.
Some(s) => ((length / s - 1e-9).ceil() - 1.0).max(0.0),
None => return Ok(count.unwrap_or(1)),
};
if n > MAX_COUNT as f64 {
return Err(bad(WEED, format!("`spacing` makes more than {MAX_COUNT} lines across the box")));
}
Ok(n as usize)
}
/// Where the Weeding group goes: `parent`, or, when `parent` is inside a clip group or clipping
/// sublayer (a group edited in isolation), the container around the outermost of them. A clipping
/// path would hide the lines and the box, which lie outside the art.
fn outside_clips(d: &Document, parent: Option<NodeId>) -> Option<NodeId> {
let clips = |id: NodeId| d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }));
let (mut at, mut out) = (parent, parent);
while let Some(p) = at {
let up = d.parent_of(p);
if clips(p) {
match up {
Some(up) if d.is_editable(up) => out = Some(up),
_ => break,
}
}
at = up;
}
out
}
fn weed(s: &mut Session, p: &Value) -> Result<Value> {
let margin = distance(p, "margin", 9.0)?;
let gap = distance(p, "gap", 0.0)?;
let with_box = switch_or(p, "box", true, WEED)?;
let direction = choice(p, WEED, "direction", &DIRECTIONS)?.unwrap_or("both");
let count = match number_given(p, "count", WEED)? {
None => None,
Some(n) if n.fract() == 0.0 && (0.0..=MAX_COUNT as f64).contains(&n) => Some(n as usize),
Some(_) => return Err(bad(WEED, format!("`count` must be a whole number from 0 to {MAX_COUNT}"))),
};
let spacing = match length_given(p, "spacing", WEED)? {
None => None,
Some(v) if v > 0.0 && v <= MAX_DISTANCE => Some(v),
Some(_) => return Err(bad(WEED, format!("`spacing` must be more than 0 and at most {MAX_DISTANCE} pt"))),
};
if count.is_some() && spacing.is_some() {
return Err(bad(WEED, "give `count` or `spacing`, not both"));
}
let weight = match length_given(p, "weight", WEED)? {
None => 0.3,
Some(v) if (0.01..=1000.0).contains(&v) => v,
Some(_) => return Err(bad(WEED, "`weight` must be from 0.01 to 1000 pt")),
};
let swatch = text_param(p, "swatch", WEED)?.unwrap_or(REGISTRATION);
let stroke = swatch_solid(&s.doc()?.doc, swatch, None).map_err(|e| bad(WEED, e))?;
let roots = selected_roots(s)?;
if roots.is_empty() {
return Err(bad(WEED, "select the art to weed"));
}
let d = &s.doc()?.doc;
let parent = outside_clips(d, s.doc()?.target_parent()?);
// Placed files' art uses their resources (symbols) under names of their own.
let placed = d.with_placed_art();
let region = geometry::Reader::new(&placed).read_ids(&roots).map_err(|e| bad(WEED, e))?;
let region = region.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?;
let outline = region.outline().map_err(|e| bad(WEED, e))?;
let art = outline.iter().fold(None, |r: Option<Rect>, &(a, b)| Some(r.map_or(Rect::from_points(a, b), |r| r.union_pt(a).union_pt(b))));
let frame = art.ok_or_else(|| bad(WEED, "the selection has no outlines to weed around"))?.inflate(margin, margin);
if !(frame.width().is_finite() && frame.height().is_finite()) {
return Err(bad(WEED, "the selected art is too large to measure"));
}
let each = |on: bool, length: f64| if on { lines_across(length, count, spacing) } else { Ok(0) };
let counts =
geometry::Counts { horizontal: each(direction != "vertical", frame.height())?, vertical: each(direction != "horizontal", frame.width())? };
let pieces = geometry::lines(&region, &outline, frame, counts, gap, MAX_PIECES).map_err(|e| bad(WEED, e))?;
if pieces.is_empty() && !with_box {
return Err(EngineError::Other("Weed Box and Lines: no weeding line fits outside the art, and the weed box is off".into()));
}
let ap = Appearance::basic(Paint::None, stroke, weight);
let (group, weed_box, lines) = s.edit("Weed Box and Lines", |d, sel| {
let mut children = vec![];
let mut weed_box = None;
if with_box {
let mut b = Node::path(d.alloc_id(), shapes::rectangle(frame), ap.clone());
b.name = Some("Weed Box".into());
weed_box = Some(b.id);
children.push(Arc::new(b));
}
for (a, b) in &pieces {
children.push(Arc::new(Node::path(d.alloc_id(), shapes::line(*a, *b), ap.clone())));
}
let mut g = Node::group(d.alloc_id(), children);
g.name = Some("Weeding".into());
let id = g.id;
d.insert(parent, usize::MAX, g)?;
sel.set([id]);
Ok((id, weed_box, pieces.len()))
})?;
Ok(json!({ "id": group.0, "box": weed_box.map(|b| b.0), "lines": lines }))
}
@@ -0,0 +1,769 @@
//! The geometry of Weed Box and Lines: the selected art as the outlines a cutter cuts ([`Region`]),
//! and the weeding lines across a box that stop at those outlines or at a gap from them
//! ([`lines`]).
//!
//! A region is measured along one straight line at a time. A closed outline encloses its area by
//! its fill rule, an open one is a line with no area, a group is the union of its members and a
//! clip group is what its members draw inside its clipping path. Along a weeding line the region
//! gives the stretches inside its area ([`Region::spans`]), and its outline pieces
//! ([`Region::outline`]) give the stretches within the gap of an outline.
use std::cell::Cell;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, PathEl, Point, Rect};
use super::super::super::super::pathops::node_path;
/// How closely curves are followed, in points.
const TOLERANCE: f64 = 0.02;
/// The most points the outlines of one selection are measured by, once curves are followed in
/// straight segments (each subpath's points, its first one included).
pub(super) const MAX_POINTS: usize = 200_000;
/// The most tests of a segment against another one measuring a clip group's outlines takes.
const MAX_PAIRS: usize = 40_000_000;
/// The deepest groups, symbol instances and live objects are opened inside one another: as deep
/// as any object is drawn.
pub(super) const MAX_DEPTH: usize = vectorcraft_doc::depth::MAX_DRAWN_DEPTH;
/// The most pairs of an outline segment and a weeding line it comes near that one run measures.
const MAX_NEAR: usize = 10_000_000;
/// Pieces of weeding line shorter than this, in points, are left out.
const MIN_PIECE: f64 = 0.01;
/// A straight segment.
pub(super) type Segment = (Point, Point);
/// One subpath followed in straight segments.
#[derive(Clone, Debug)]
pub(super) struct Poly {
points: Vec<Point>,
closed: bool,
/// The box around `points`.
bounds: Rect,
}
impl Poly {
/// The segments, with the closing one of a closed subpath.
fn segments(&self) -> impl Iterator<Item = Segment> + '_ {
let closing = self.closed.then(|| (self.points.last().copied(), self.points.first().copied()));
self.points.windows(2).map(|w| (w[0], w[1])).chain(closing.and_then(|(a, b)| Some((a?, b?))))
}
}
/// The selected art as the outlines a cutter cuts.
#[derive(Clone, Debug)]
pub(super) enum Region {
/// A path, compound path or type: its subpaths with the rule its closed ones fill by.
Shape { polys: Vec<Poly>, rule: FillRule },
/// Everything its members draw.
Union(Vec<Region>),
/// What `content` draws inside `clip`.
Clip { clip: Box<Region>, content: Box<Region> },
}
/// Reads the selected objects into a [`Region`], keeping count of the outline points.
pub(super) struct Reader<'a> {
doc: &'a Document,
points: usize,
}
impl<'a> Reader<'a> {
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, points: 0 }
}
/// The region of `nodes` together, `None` when they hold no outlines.
pub(super) fn read(&mut self, nodes: &[&Node]) -> Result<Option<Region>, String> {
let mut parts = vec![];
for n in nodes {
parts.extend(self.node(n, 0)?);
}
Ok(union(parts))
}
fn node(&mut self, n: &Node, depth: usize) -> Result<Option<Region>, String> {
if depth > MAX_DEPTH {
return Err(format!("the selected art is nested more than {MAX_DEPTH} levels deep"));
}
if !n.visible {
return Ok(None);
}
match &n.kind {
NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => Ok(None),
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => self.shape(n),
NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } => {
let members = n.editing_children();
if *clip {
let Some((first, rest)) = members.split_first() else { return Ok(None) };
let Some(clip) = self.clip_region(first)? else { return Ok(None) };
let mut parts = vec![];
for c in rest {
parts.extend(self.node(c, depth + 1)?);
}
Ok(union(parts).map(|content| Region::Clip { clip: Box::new(clip), content: Box::new(content) }))
} else {
let mut parts = vec![];
for c in members {
parts.extend(self.node(c, depth + 1)?);
}
Ok(union(parts))
}
}
NodeKind::Image(im) => {
let (w, h) = (f64::from(im.width), f64::from(im.height));
let corners = [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(w, h), Point::new(0.0, h)].map(|p| im.xf * p);
self.polygon(&corners)
}
// An instance of a symbol the document doesn't have draws nothing.
NodeKind::SymbolInstance { .. } => match vectorcraft_render::effects::outline_object(Some(self.doc), n) {
Some(art) => self.node(&art, depth + 1),
None => Ok(None),
},
NodeKind::PlacedDocument(p) => {
// The art of the file it shows; a file that can't be read shows its preview in its
// box, which may be rotated.
if vectorcraft_doc::placed_document::read_cached(p).is_none() {
return self.polygon(&p.corners());
}
let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n);
if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) {
self.polygon(&p.corners())
} else {
self.node(&art, depth + 1)
}
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => {
// The art a live object draws, as the exporters write it; an object that stays
// as it is is measured by its box.
let art = vectorcraft_render::effects::expand_live_deep(Some(self.doc), n);
if std::mem::discriminant(&art.kind) == std::mem::discriminant(&n.kind) { self.frame(n) } else { self.node(&art, depth + 1) }
}
}
}
/// The region a clipping path `first` clips to, whatever its own visibility: the region the
/// renderer clips to (a path, a compound path, type by its glyphs, a group's visible members,
/// an image's frame). `None` when it clips to nothing, and the clipped art is hidden.
fn clip_region(&mut self, first: &Node) -> Result<Option<Region>, String> {
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(first) else { return Ok(None) };
let mut polys = self.polys(outline)?;
// A region is filled: an open subpath clips as if closed.
polys.iter_mut().for_each(|p| p.closed = true);
Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule }))
}
/// The region of the objects `ids` together, each within the clipping paths of the clip groups
/// and layers around it (an object selected inside a clip group shows only within its clipping
/// path, and not at all inside a hidden group or layer; a clipping path is measured as its clip
/// group), `None` when they show no outlines.
pub(super) fn read_ids(&mut self, ids: &[NodeId]) -> Result<Option<Region>, String> {
let doc = self.doc;
let clips = |n: &Node| matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
// Every object's place in the tree, found in one walk.
let places = doc.index_paths(ids.iter().copied());
let mut paths = vec![];
for id in ids {
let Some(mut path) = places.get(id).cloned() else { continue };
// A clipping path selected on its own stands for the art it shows: its clip group.
while let (Some(n), Some(up)) = (doc.node_at(&path), path.split_last().map(|(_, up)| up).and_then(|up| doc.node_at(up))) {
if !(clips(up) && up.editing_children().first().is_some_and(|f| f.id == n.id)) {
break;
}
path.pop();
}
paths.push(path);
}
// An object selected twice, or inside another selected object, is measured once, with it.
paths.sort();
paths.dedup();
let mut kept: Vec<Vec<usize>> = vec![];
for path in paths {
if !kept.last().is_some_and(|k| path.starts_with(k)) {
kept.push(path);
}
}
let mut parts = vec![];
'ids: for path in &kept {
let Some(n) = doc.node_at(path) else { continue };
// Art in a hidden group or layer isn't shown, and isn't measured.
if (1..path.len()).any(|depth| path.get(..depth).and_then(|p| doc.node_at(p)).is_some_and(|c| !c.visible)) {
continue;
}
let Some(mut region) = self.read(&[n])? else { continue };
// The containers around the object, innermost first.
let mut child = n.id;
for depth in (1..path.len()).rev() {
let Some(container) = path.get(..depth).and_then(|p| doc.node_at(p)) else { break };
if clips(container)
&& let Some(first) = container.editing_children().first().filter(|f| f.id != child)
{
let Some(clip) = self.clip_region(first)? else { continue 'ids };
region = Region::Clip { clip: Box::new(clip), content: Box::new(region) };
}
child = container.id;
}
parts.push(region);
}
Ok(union(parts))
}
/// A path, compound path or type as its outlines.
fn shape(&mut self, n: &Node) -> Result<Option<Region>, String> {
// Each anchor is at least one point, so a path past the budget errs before it is copied.
if anchors(n) > MAX_POINTS.saturating_sub(self.points) {
return Err(too_many());
}
let Some((path, rule)) = node_path(n) else { return Ok(None) };
let polys = self.polys(path.to_bezpath())?;
Ok((!polys.is_empty()).then_some(Region::Shape { polys, rule }))
}
/// The box of an object whose outlines can't be read.
fn frame(&mut self, n: &Node) -> Result<Option<Region>, String> {
match n.geometric_bounds_with(&self.doc.symbol_boxes()) {
Some(r) => self.polygon(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]),
None => Ok(None),
}
}
/// A closed polygon through `points`.
fn polygon(&mut self, points: &[Point]) -> Result<Option<Region>, String> {
let poly = poly(points.to_vec(), true);
self.count(poly.iter().map(|p| p.points.len()).sum())?;
Ok(poly.map(|p| Region::Shape { polys: vec![p], rule: FillRule::NonZero }))
}
fn count(&mut self, n: usize) -> Result<(), String> {
self.points = self.points.saturating_add(n);
if self.points > MAX_POINTS { Err(too_many()) } else { Ok(()) }
}
/// The subpaths of `path` followed in straight segments.
fn polys(&mut self, path: BezPath) -> Result<Vec<Poly>, String> {
let (mut out, mut current, points) = (vec![], vec![], Cell::new(0usize));
let left = MAX_POINTS.saturating_sub(self.points);
// Past the budget no further curve is followed (one curve gives at most
// `MAX_FLATTEN_PER_CURVE` points), and the read fails below.
let elements = path.into_iter().take_while(|_| points.get() <= left);
vectorcraft_geom::flatten(elements, TOLERANCE, |el| {
points.set(points.get() + 1);
if points.get() > left {
return;
}
match el {
PathEl::MoveTo(p) => {
out.extend(poly(std::mem::take(&mut current), false));
current.push(p);
}
PathEl::LineTo(p) => current.push(p),
PathEl::ClosePath => out.extend(poly(std::mem::take(&mut current), true)),
PathEl::QuadTo(..) | PathEl::CurveTo(..) => {}
}
});
if points.get() > left {
return Err(too_many());
}
out.extend(poly(current, false));
self.count(out.iter().map(|p: &Poly| p.points.len()).sum())?;
Ok(out)
}
}
fn too_many() -> String {
format!("the selected art has too many outline points to measure (more than {MAX_POINTS})")
}
/// The anchors of a path or compound path node, read without copying its path; 0 for type.
fn anchors(n: &Node) -> usize {
match &n.kind {
NodeKind::Path { path, .. } => path.anchor_count(),
NodeKind::Compound { children, .. } => children.iter().fold(0, |sum, c| sum.saturating_add(anchors(c))),
_ => 0,
}
}
/// A subpath through `points`, `None` for fewer than two points or one that isn't finite.
fn poly(points: Vec<Point>, closed: bool) -> Option<Poly> {
if points.len() < 2 || points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
return None;
}
let first = *points.first()?;
let bounds = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
Some(Poly { points, closed, bounds })
}
/// One region of `parts`, `None` when there are none.
fn union(mut parts: Vec<Region>) -> Option<Region> {
match parts.len() {
0 => None,
1 => parts.pop(),
_ => Some(Region::Union(parts)),
}
}
/// `p` as (along, across) a weeding line: horizontal lines run along x, vertical ones along y.
fn uv(p: Point, vertical: bool) -> (f64, f64) {
if vertical { (p.y, p.x) } else { (p.x, p.y) }
}
/// Sorts `spans` and joins those that overlap or touch.
fn merged(mut spans: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out: Vec<(f64, f64)> = Vec::with_capacity(spans.len());
for (a, b) in spans {
match out.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => out.push((a, b)),
}
}
out
}
/// Where sorted, joined spans `a` and `b` overlap.
fn intersected(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
let (mut i, mut j, mut out) = (0, 0, vec![]);
while let (Some(&(a0, a1)), Some(&(b0, b1))) = (a.get(i), b.get(j)) {
let (lo, hi) = (a0.max(b0), a1.min(b1));
if lo <= hi {
out.push((lo, hi));
}
if a1 < b1 {
i += 1;
} else {
j += 1;
}
}
out
}
/// Where segment a→b crosses the line `across` = `c`, along it, and which way (1 when `across`
/// grows from a to b), `None` when it doesn't. Each crossing counts once: an end exactly on the
/// line counts as past it.
fn crosses(a: Point, b: Point, vertical: bool, c: f64) -> Option<(f64, i32)> {
let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical));
((v0 <= c && v1 > c) || (v1 <= c && v0 > c)).then(|| (u0 + (c - v0) * (u1 - u0) / (v1 - v0), if v1 > v0 { 1 } else { -1 }))
}
/// The stretches inside an area filled by `rule`, from where its outlines cross a line, sorted
/// along it.
fn filled(crossings: impl IntoIterator<Item = (f64, i32)>, rule: FillRule) -> Vec<(f64, f64)> {
let (mut winding, mut start, mut out) = (0, 0.0, vec![]);
let inside = |w: i32| match rule {
FillRule::NonZero => w != 0,
FillRule::EvenOdd => w % 2 != 0,
};
for (u, dir) in crossings {
let was = inside(winding);
winding += dir;
match (was, inside(winding)) {
(false, true) => start = u,
(true, false) => out.push((start, u)),
_ => {}
}
}
merged(out)
}
/// Where the closed outlines of a region's shapes cross a set of weeding lines: for each line, the
/// crossings as (shape, place along the line, direction), sorted by shape and then by place. Shapes
/// are numbered in the order [`Region::shapes`] lists them.
pub(super) type Crossings = Vec<Vec<(usize, f64, i32)>>;
/// Where the shapes of each part of a region are numbered among its [`Crossings`].
#[derive(Debug)]
pub(super) enum Layout {
Shape,
/// The number of each part's first shape, from the union's first, and the parts' layouts.
Union {
starts: Vec<usize>,
parts: Vec<Layout>,
},
/// The clipping path's shapes come first.
Clip {
clip_shapes: usize,
clip: Box<Layout>,
content: Box<Layout>,
},
}
impl Layout {
/// The layout of `region` and how many shapes it has.
pub(super) fn of(region: &Region) -> (Layout, usize) {
match region {
Region::Shape { .. } => (Layout::Shape, 1),
Region::Union(parts) => {
let (mut starts, mut layouts, mut n) = (vec![], vec![], 0);
for r in parts {
let (l, k) = Layout::of(r);
starts.push(n);
layouts.push(l);
n += k;
}
(Layout::Union { starts, parts: layouts }, n)
}
Region::Clip { clip, content } => {
let ((lc, a), (lt, b)) = (Layout::of(clip), Layout::of(content));
(Layout::Clip { clip_shapes: a, clip: Box::new(lc), content: Box::new(lt) }, a + b)
}
}
}
}
/// The [`Crossings`] of `region` with the lines `across` = v0 + (k + 1)·step, k < `count`. Errs
/// past [`MAX_NEAR`] crossings in all.
pub(super) fn crossings(region: &Region, vertical: bool, v0: f64, step: f64, count: usize) -> Result<Crossings, String> {
let mut shapes = vec![];
region.shapes(&mut shapes);
let at = |k: usize| v0 + step * (k as f64 + 1.0);
// The lines a segment may cross: those from just below its lower end to its higher end.
let candidates = |(a, b): Segment| {
let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1);
if step <= 0.0 || va == vb {
return 0..0;
}
let first = (((va.min(vb) - v0) / step).floor() - 1.0).clamp(0.0, count as f64);
let end = ((va.max(vb) - v0) / step).ceil().clamp(0.0, count as f64);
(first as usize)..(end as usize)
};
let closed = |polys: &[Poly]| polys.iter().filter(|p| p.closed).flat_map(Poly::segments).collect::<Vec<_>>();
let total = shapes.iter().flat_map(|(polys, _)| closed(polys)).fold(0usize, |n, s| n.saturating_add(candidates(s).len()));
if total > MAX_NEAR {
return Err("the weeding lines cross the art's outlines too many times to measure; use fewer lines".into());
}
let mut out: Crossings = vec![vec![]; count];
for (i, (polys, _)) in shapes.iter().enumerate() {
for (a, b) in closed(polys) {
for k in candidates((a, b)) {
if let (Some((u, dir)), Some(line)) = (crosses(a, b, vertical, at(k)), out.get_mut(k)) {
line.push((i, u, dir));
}
}
}
}
for line in &mut out {
line.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
}
Ok(out)
}
impl Region {
/// The stretches of the line `across` = `c` (horizontal: y = c, along x; vertical: x = c,
/// along y) inside the region's area, sorted and joined.
pub(super) fn spans(&self, vertical: bool, c: f64) -> Vec<(f64, f64)> {
match self {
Region::Shape { polys, rule } => {
let mut crossings: Vec<(f64, i32)> = vec![];
for p in polys.iter().filter(|p| p.closed) {
let (lo, hi) = if vertical { (p.bounds.x0, p.bounds.x1) } else { (p.bounds.y0, p.bounds.y1) };
if c >= lo && c <= hi {
crossings.extend(p.segments().filter_map(|(a, b)| crosses(a, b, vertical, c)));
}
}
crossings.sort_by(|a, b| a.0.total_cmp(&b.0));
filled(crossings, *rule)
}
Region::Union(parts) => merged(parts.iter().flat_map(|r| r.spans(vertical, c)).collect()),
Region::Clip { clip, content } => intersected(&clip.spans(vertical, c), &content.spans(vertical, c)),
}
}
/// The region's shapes with their fill rules, in a fixed order.
fn shapes<'a>(&'a self, out: &mut Vec<(&'a [Poly], FillRule)>) {
match self {
Region::Shape { polys, rule } => out.push((polys, *rule)),
Region::Union(parts) => parts.iter().for_each(|r| r.shapes(out)),
Region::Clip { clip, content } => {
clip.shapes(out);
content.shapes(out);
}
}
}
/// [`Region::spans`] on a weeding line, from `line`, the line's [`Crossings`] with the
/// region's shapes, numbered from `base` (`layout` is the region's [`Layout`]). Only the parts
/// the line crosses are visited: a part it doesn't cross has no stretch inside its area.
pub(super) fn spans_on(&self, layout: &Layout, line: &[(usize, f64, i32)], base: usize) -> Vec<(f64, f64)> {
if line.is_empty() {
return vec![];
}
match (self, layout) {
(Region::Shape { rule, .. }, Layout::Shape) => filled(line.iter().map(|&(_, u, dir)| (u, dir)), *rule),
(Region::Union(parts), Layout::Union { starts, parts: layouts }) => {
let (mut out, mut rest) = (vec![], line);
while let Some(&(id, ..)) = rest.first() {
// The part whose shapes the next crossing is on, and its crossings.
let j = starts.partition_point(|&s| base + s <= id).saturating_sub(1);
let (Some(r), Some(l), Some(&start)) = (parts.get(j), layouts.get(j), starts.get(j)) else { break };
let end = starts.get(j + 1).map_or(usize::MAX, |&s| base + s);
let n = rest.partition_point(|c| c.0 < end);
if n == 0 {
break;
}
let (mine, after) = rest.split_at(n);
out.extend(r.spans_on(l, mine, base + start));
rest = after;
}
merged(out)
}
(Region::Clip { clip, content }, Layout::Clip { clip_shapes, clip: lc, content: lt }) => {
let (a, b) = line.split_at(line.partition_point(|c| c.0 < base + clip_shapes));
if a.is_empty() || b.is_empty() {
return vec![];
}
intersected(&clip.spans_on(lc, a, base), &content.spans_on(lt, b, base + clip_shapes))
}
_ => vec![],
}
}
/// Is `p` inside the region's area or on one of `segments`, the outlines it shows?
fn contains(&self, p: Point, segments: &[Segment]) -> bool {
const EPS: f64 = 1e-7;
self.spans(false, p.y).iter().any(|&(a, b)| p.x >= a - EPS && p.x <= b + EPS) || segments.iter().any(|&(a, b)| distance(p, a, b) <= EPS)
}
/// The pieces of outline the region shows: every outline of a shape and of a group's members,
/// and in a clip group the members' outlines inside the clipping path and the clipping path's
/// outline where the members' area or outlines are. Errs past [`MAX_PAIRS`], counted over
/// every clip group in the region together.
pub(super) fn outline(&self) -> Result<Vec<Segment>, String> {
self.outline_counted(&mut 0)
}
/// [`Region::outline`], adding the measure's work to `work`.
fn outline_counted(&self, work: &mut usize) -> Result<Vec<Segment>, String> {
match self {
Region::Shape { polys, .. } => Ok(polys.iter().flat_map(Poly::segments).collect()),
Region::Union(parts) => {
let mut out = vec![];
for r in parts {
out.extend(r.outline_counted(work)?);
}
Ok(out)
}
Region::Clip { clip, content } => {
// Each side's area is bounded by the outlines it shows, so the pieces are cut and
// tested against those, never against art a nested clip hides.
let (clip_shown, content_shown) = (clip.outline_counted(work)?, content.outline_counted(work)?);
let mut out = within(&content_shown, clip, &clip_shown, work)?;
out.extend(within(&clip_shown, content, &content_shown, work)?);
Ok(out)
}
}
}
/// The points of the region's closed outlines, those a nested clip hides included: what one
/// [`Region::spans`] looks at, at most.
fn closed_points(&self) -> usize {
match self {
Region::Shape { polys, .. } => polys.iter().filter(|p| p.closed).map(|p| p.points.len()).sum(),
Region::Union(parts) => parts.iter().map(Region::closed_points).sum(),
Region::Clip { clip, content } => clip.closed_points().saturating_add(content.closed_points()),
}
}
}
fn too_complex_clip() -> String {
"a clip group in the selection has too many outline segments to measure".into()
}
/// The pieces of `segments` inside `region` (or on its outlines), cut where they cross
/// `region_segments`, the outlines the region shows. Each segment, and each piece of one tested
/// for being inside, adds the region's closed outline points (hidden ones included) and its
/// shown segment count to `work`, and the measure errs past [`MAX_PAIRS`].
fn within(segments: &[Segment], region: &Region, region_segments: &[Segment], work: &mut usize) -> Result<Vec<Segment>, String> {
let mut out = vec![];
let cost = region.closed_points().saturating_add(region_segments.len()).max(1);
let charge = |work: &mut usize| {
*work = work.saturating_add(cost);
if *work > MAX_PAIRS { Err(too_complex_clip()) } else { Ok(()) }
};
for &(a, b) in segments {
charge(work)?;
let mut cuts = vec![0.0, 1.0];
let seg_box = Rect::from_points(a, b);
for &(c, d) in region_segments {
if touches(seg_box, Rect::from_points(c, d)) {
cuts.extend(crossing(a, b, c, d));
}
}
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
let at = |t: f64| a + (b - a) * t;
for w in cuts.windows(2) {
let (t0, t1) = (w[0], w[1]);
if t1 - t0 < 1e-12 {
continue;
}
charge(work)?;
if region.contains(at((t0 + t1) / 2.0), region_segments) {
out.push((at(t0), at(t1)));
}
}
}
Ok(out)
}
/// Do boxes `a` and `b` overlap or touch?
fn touches(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// Where along a→b (0 to 1) segment c→d crosses or touches it: one place, or the ends of the
/// shared stretch when the two lie on one line.
fn crossing(a: Point, b: Point, c: Point, d: Point) -> Vec<f64> {
let (r, s) = (b - a, d - c);
let denom = r.cross(s);
let qp = c - a;
let len2 = r.hypot2();
if len2 == 0.0 {
return vec![];
}
if denom.abs() <= 1e-12 * len2.sqrt() * s.hypot().max(1e-300) {
// Parallel: the ends of c→d on a→b when the two lie on one line.
if qp.cross(r).abs() > 1e-9 * len2.sqrt() {
return vec![];
}
return [c, d].iter().map(|p| (*p - a).dot(r) / len2).filter(|t| (0.0..=1.0).contains(t)).collect();
}
let t = qp.cross(s) / denom;
let u = qp.cross(r) / denom;
if (0.0..=1.0).contains(&t) && (0.0..=1.0).contains(&u) { vec![t] } else { vec![] }
}
/// The distance from `p` to segment a→b.
fn distance(p: Point, a: Point, b: Point) -> f64 {
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 };
(p - (a + ab * t)).hypot()
}
/// The stretch of the line `across` = `c` within `gap` of segment a→b, `None` when the line
/// passes farther away. With no gap it is where the segment meets the line.
fn near(a: Point, b: Point, vertical: bool, c: f64, gap: f64) -> Option<(f64, f64)> {
let ((u0, v0), (u1, v1)) = (uv(a, vertical), uv(b, vertical));
if gap <= 0.0 {
if v0 == v1 {
return (v0 == c).then_some((u0.min(u1), u0.max(u1)));
}
if c < v0.min(v1) || c > v0.max(v1) {
return None;
}
let u = u0 + (c - v0) * (u1 - u0) / (v1 - v0);
return Some((u, u));
}
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
let mut take = |x: f64, y: f64| {
lo = lo.min(x);
hi = hi.max(y);
};
// The round ends.
for (u, v) in [(u0, v0), (u1, v1)] {
let dv = (c - v).abs();
if dv <= gap {
let h = (gap * gap - dv * dv).max(0.0).sqrt();
take(u - h, u + h);
}
}
// The band along the segment: points whose foot falls on it, within the gap of it.
let (du, dv) = (u1 - u0, v1 - v0);
let len = du.hypot(dv);
if len > 0.0 {
// Each condition is linear in the position x along the line: k·x + m ≥ 0.
let mut range = (f64::NEG_INFINITY, f64::INFINITY);
let mut keep = |k: f64, m: f64| {
if k == 0.0 {
if m < 0.0 {
range = (1.0, 0.0);
}
} else if k > 0.0 {
range.0 = range.0.max(-m / k);
} else {
range.1 = range.1.min(-m / k);
}
};
// The foot at or after a: (x - u0)·du + (c - v0)·dv ≥ 0.
keep(du, -u0 * du + (c - v0) * dv);
// The foot at or before b.
keep(-du, u0 * du - (c - v0) * dv + len * len);
// Within the gap on either side: |(x - u0)·dv - (c - v0)·du| ≤ gap·len.
let cross0 = -u0 * dv - (c - v0) * du;
keep(-dv, -cross0 + gap * len);
keep(dv, cross0 + gap * len);
if range.0 <= range.1 {
take(range.0, range.1);
}
}
(lo <= hi).then_some((lo, hi))
}
/// How many weeding lines run each way.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Counts {
pub horizontal: usize,
pub vertical: usize,
}
/// The weeding lines across `frame`: `n.horizontal` horizontal and `n.vertical` vertical lines that
/// divide it evenly, each from one side of the frame to the other, left out where it is inside the
/// region's area or within `gap` of one of its outlines (`outline`). Pieces are in order along each
/// line, the horizontal lines first, top to bottom. Errs past [`MAX_NEAR`] pairs of an outline
/// segment and a line it comes within `gap` of, past [`MAX_NEAR`] crossings of the region's
/// outlines with the lines one way, or past `max_pieces` pieces.
pub(super) fn lines(region: &Region, outline: &[Segment], frame: Rect, n: Counts, gap: f64, max_pieces: usize) -> Result<Vec<Segment>, String> {
let mut out = vec![];
let (layout, _) = Layout::of(region);
for (vertical, count) in [(false, n.horizontal), (true, n.vertical)] {
let ((u0, v0), (u1, v1)) = (uv(Point::new(frame.x0, frame.y0), vertical), uv(Point::new(frame.x1, frame.y1), vertical));
let step = (v1 - v0) / (count as f64 + 1.0);
// A frame with no height (for horizontal lines) or no width has no waste to divide.
if step <= 0.0 {
continue;
}
// The lines (0-based) each outline segment comes within `gap` of: lines k (1-based) lie at
// v0 + k·step.
let reach = |&(a, b): &Segment| {
let (va, vb) = (uv(a, vertical).1, uv(b, vertical).1);
let (lo, hi) = (va.min(vb) - gap, va.max(vb) + gap);
let first = ((lo - v0) / step).ceil().max(1.0);
let last = ((hi - v0) / step).floor().min(count as f64);
if step > 0.0 && first <= last { (first as usize - 1)..(last as usize) } else { 0..0 }
};
// The outlines near each line, found once for all of them, by index into `outline`.
let pairs = outline.iter().fold(0usize, |sum, s| sum.saturating_add(reach(s).len()));
if pairs > MAX_NEAR {
return Err("the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap".into());
}
let mut near_line: Vec<Vec<usize>> = vec![vec![]; count];
for (j, s) in outline.iter().enumerate() {
for k in reach(s) {
if let Some(list) = near_line.get_mut(k) {
list.push(j);
}
}
}
let crossed = crossings(region, vertical, v0, step, count)?;
for (i, segments) in near_line.iter().enumerate() {
let c = v0 + step * (i as f64 + 1.0);
let mut blocked = region.spans_on(&layout, crossed.get(i).map(Vec::as_slice).unwrap_or_default(), 0);
blocked.extend(segments.iter().filter_map(|&j| outline.get(j)).filter_map(|&(a, b)| near(a, b, vertical, c, gap)));
let mut from = u0;
let point = |u: f64| if vertical { Point::new(c, u) } else { Point::new(u, c) };
for (a, b) in merged(blocked) {
if a - from >= MIN_PIECE && a <= u1 {
out.push((point(from), point(a.min(u1))));
}
from = from.max(b);
}
if u1 - from >= MIN_PIECE {
out.push((point(from), point(u1)));
}
if out.len() > max_pieces {
return Err(format!("the weeding lines would be more than {max_pieces} pieces"));
}
}
}
Ok(out)
}
@@ -0,0 +1,915 @@
//! Tests for `addon.cutting.weed`: a weed box around the selected art and weeding lines across it
//! that stop at the art's outlines.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape, shapes};
use super::super::super::super::pathops::text_outline;
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
/// Runs Weed Box and Lines with `p` and gives the group it made.
fn weed(s: &mut Session, p: Value) -> (Value, Node) {
let r = s.execute("addon.cutting.weed", &p).unwrap();
let g = node(s, id_of(&r));
(r, g)
}
/// The weed box's rectangle in group `g`, if it has one.
fn weed_box(g: &Node) -> Option<Rect> {
g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).map(|c| c.geometric_bounds().unwrap())
}
/// The weeding lines in group `g` as their two ends, rounded to 1/1000 pt.
fn lines(g: &Node) -> Vec<((f64, f64), (f64, f64))> {
let r = |v: f64| (v * 1000.0).round() / 1000.0;
g.children()
.unwrap()
.iter()
.filter(|c| c.name.is_none())
.map(|c| match &c.kind {
NodeKind::Path { path, .. } => {
let a: Vec<Point> = path.anchors().map(|(_, _, a)| a.p).collect();
assert_eq!(a.len(), 2, "a weeding line is one straight segment");
((r(a[0].x), r(a[0].y)), (r(a[1].x), r(a[1].y)))
}
other => panic!("not a path: {other:?}"),
})
.collect()
}
#[test]
fn a_weed_box_and_lines_go_around_a_rectangle() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let undo = s.doc().unwrap().history.undo.len();
let (r, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(g.name.as_deref(), Some("Weeding"));
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0)));
// One line each way through the middle, left out over the art.
assert_eq!(
lines(&g),
[((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (160.0, 120.0)), ((125.0, 90.0), (125.0, 100.0)), ((125.0, 140.0), (125.0, 150.0))]
);
assert_eq!(r["lines"], json!(4));
assert_eq!(r["box"], json!(weed_box_id(&g)));
// Stroked in Registration with no fill, selected, as one undo step that takes it all away.
for c in g.children().unwrap() {
assert_eq!(c.appearance.fill_paint(), Paint::None, "no fill");
assert!(c.appearance.stroke_paint().is_registration());
}
assert_eq!(s.doc().unwrap().selection.objects, [g.id]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g.id).is_none());
}
fn weed_box_id(g: &Node) -> u64 {
g.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Weed Box")).unwrap().id.0
}
#[test]
fn lines_stop_at_the_outline_and_cross_the_hole_of_a_compound_path() {
let mut s = session();
let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0);
select(&mut s, &[outer, inner]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
// Both subpaths wind the same way, so the fill rule alone decides whether the inner one is a
// hole.
let same_way = |rule: FillRule| {
move |d: &mut vectorcraft_doc::Document, _: &mut vectorcraft_doc::Selection| -> Result<()> {
let Some(NodeKind::Compound { children, rule: r }) = d.node_mut(c).map(|n| &mut n.kind) else { panic!("not a compound path") };
*r = rule;
let area = |n: &Node| match &n.kind {
NodeKind::Path { path, .. } => path.subpaths[0].signed_area(),
_ => panic!("not a path"),
};
let outer = area(&children[0]).signum();
for child in children.iter_mut() {
if area(child).signum() != outer
&& let NodeKind::Path { path, .. } = &mut std::sync::Arc::make_mut(child).kind
{
path.reverse();
}
}
Ok(())
}
};
s.edit("even-odd", same_way(FillRule::EvenOdd)).unwrap();
select(&mut s, &[c]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The waste in the hole is cut too; the ring itself is not.
assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((130.0, 150.0), (170.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]);
// With the non-zero rule the inner subpath fills, and no line crosses it.
s.edit("non-zero", same_way(FillRule::NonZero)).unwrap();
select(&mut s, &[c]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(lines(&g), [((90.0, 150.0), (100.0, 150.0)), ((200.0, 150.0), (210.0, 150.0))]);
}
#[test]
fn a_gap_keeps_the_lines_away_from_the_outline() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let (_, g) = weed(&mut s, json!({"margin": 20, "gap": 5, "direction": "horizontal"}));
assert_eq!(weed_box(&g), Some(Rect::new(80.0, 80.0, 170.0, 160.0)));
assert_eq!(lines(&g), [((80.0, 120.0), (95.0, 120.0)), ((155.0, 120.0), (170.0, 120.0))]);
// The gap is a distance from the outline: a line passing 5 pt beside a corner, with a gap of
// 6, stops √11 pt short of the corner's height.
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let b = rect(&mut s, 200.0, 60.0, 10.0, 120.0);
select(&mut s, &[a, b]);
let (_, g) = weed(&mut s, json!({"margin": 0, "gap": 6, "direction": "vertical"}));
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 60.0, 210.0, 180.0)));
assert_eq!(lines(&g), [((155.0, 60.0), (155.0, 96.683)), ((155.0, 143.317), (155.0, 180.0))]);
}
#[test]
fn a_box_with_no_height_gets_no_lines_across_it() {
// A horizontal line with no margin: the box is as flat as the line, and no weeding line runs
// along it.
let mut s = session();
let l = line(&mut s, &[(100.0, 150.0), (200.0, 150.0)]);
let n = s.doc().unwrap().doc.node_count();
assert!(s.execute("addon.cutting.weed", &json!({"margin": 0, "direction": "horizontal", "box": false})).is_err());
assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing to add");
select(&mut s, &[l]);
let (_, g) = weed(&mut s, json!({"margin": 0, "direction": "horizontal"}));
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 150.0, 200.0, 150.0)));
assert_eq!(lines(&g), []);
}
#[test]
fn an_open_path_stops_a_line_where_it_crosses() {
let mut s = session();
line(&mut s, &[(100.0, 100.0), (100.0, 200.0)]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal", "gap": 2}));
assert_eq!(lines(&g), [((90.0, 150.0), (98.0, 150.0)), ((102.0, 150.0), (110.0, 150.0))]);
}
#[test]
fn type_is_measured_by_its_glyph_outlines() {
let mut s = session();
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "OHO", "size": 72})).unwrap());
let glyphs = match &node(&s, t).kind {
NodeKind::Text(t) => text_outline(t),
_ => panic!("not type"),
};
let ink = glyphs.bounds().unwrap();
let (_, g) = weed(&mut s, json!({"margin": 10, "count": 3, "direction": "horizontal"}));
let b = weed_box(&g).unwrap();
for (got, want) in [(b.x0, ink.x0 - 10.0), (b.y0, ink.y0 - 10.0), (b.x1, ink.x1 + 10.0), (b.y1, ink.y1 + 10.0)] {
assert!((got - want).abs() < 0.05, "the box is 10 pt around the glyphs: {b:?} vs {ink:?}");
}
// No piece of line runs over a glyph: each piece's middle is outside the ink.
let shape = glyphs.to_bezpath();
let pieces = lines(&g);
assert!(pieces.len() > 3, "the letters break the lines: {pieces:?}");
for ((x0, y0), (x1, y1)) in pieces {
let mid = Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0);
assert_eq!(shape.winding(mid), 0, "{mid:?} is outside the glyphs");
}
}
#[test]
fn a_clip_group_is_measured_by_what_shows() {
let mut s = session();
let big = rect(&mut s, 0.0, 0.0, 400.0, 300.0);
let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
select(&mut s, &[big, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let (_, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 160.0, 150.0)));
assert_eq!(lines(&g).len(), 4);
// Art that shows only partly: the clip path's edge where the art is, and the art's edge
// inside the clip.
let mut s = session();
let art = rect(&mut s, 120.0, 0.0, 100.0, 300.0);
let clip = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
select(&mut s, &[art, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(weed_box(&g), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
assert_eq!(lines(&g), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]);
}
#[test]
fn a_group_as_the_clipping_path_clips_by_its_members() {
// Make Clipping Mask takes a path, a compound path or type; an opened file can clip by a
// group, as this one does.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("clip group", |d, sel| {
let mut r = |x: f64, w: f64, y: f64, h: f64| {
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + w, y + h)), Appearance::default()))
};
let (a, b, art) = (r(100.0, 50.0, 100.0, 40.0), r(250.0, 50.0, 100.0, 40.0), r(0.0, 400.0, 0.0, 300.0));
let clip = Arc::new(Node::group(d.alloc_id(), vec![a, b]));
let mut g = Node::group(d.alloc_id(), vec![clip, art]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
let id = g.id;
d.insert(parent, usize::MAX, g)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The art shows through both rectangles, and the line is cut between them too.
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 310.0, 150.0)));
assert_eq!(lines(&g), [((90.0, 120.0), (100.0, 120.0)), ((150.0, 120.0), (250.0, 120.0)), ((300.0, 120.0), (310.0, 120.0))]);
}
#[test]
fn lines_near_too_many_outline_segments_are_refused() {
let mut s = session();
// A zigzag of 12,000 segments; with a gap as tall as the box, each of 1,000 lines comes near
// every segment.
let mut zigzag = BezPath::new();
zigzag.move_to((100.0, 100.0));
for i in 1..=12_000 {
zigzag.line_to((100.0 + f64::from(i) * 0.01, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("zigzag", |d, sel| {
let n = Node::path(d.alloc_id(), PathData::from_bezpath(&zigzag), Appearance::default());
let id = n.id;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({"count": 1000, "gap": 50, "direction": "horizontal"})).unwrap_err();
assert!(e.to_string().contains("too many outline segments"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
// Fewer lines measure.
s.execute("addon.cutting.weed", &json!({"count": 10, "gap": 50, "direction": "horizontal"})).unwrap();
}
/// Adds a clip group, clipping path `clip` over `art` (both unpainted), and selects it.
fn clip_group(s: &mut Session, clip: PathData, art: PathData) -> NodeId {
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("clip group", |d, sel| {
let clip = Arc::new(Node::path(d.alloc_id(), clip, Appearance::default()));
let art = Arc::new(Node::path(d.alloc_id(), art, Appearance::default()));
let mut g = Node::group(d.alloc_id(), vec![clip, art]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
let id = g.id;
d.insert(parent, usize::MAX, g)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(s, &[id]);
id
}
#[test]
fn an_open_clipping_path_clips_as_if_closed() {
let mut s = session();
let mut v = BezPath::new();
v.move_to((100.0, 100.0));
v.line_to((150.0, 200.0));
v.line_to((200.0, 100.0));
clip_group(&mut s, PathData::from_bezpath(&v), shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 300.0)));
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The art shows inside the triangle the V closes; the line stops at its sides.
assert_eq!(weed_box(&g), Some(Rect::new(90.0, 90.0, 210.0, 210.0)));
assert_eq!(lines(&g), [((90.0, 150.0), (125.0, 150.0)), ((175.0, 150.0), (210.0, 150.0))]);
}
#[test]
fn art_a_nested_clip_hides_adds_no_outline() {
// A clip group, clipping path A, holding a clip group whose clipping path B shows a small
// rectangle and hides a line that runs along A's bottom edge.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("nested clips", |d, sel| {
let mut path = |p: PathData| Arc::new(Node::path(d.alloc_id(), p, Appearance::default()));
let mut l = BezPath::new();
l.move_to((0.0, 200.0));
l.line_to((400.0, 200.0));
let (b, shown, hidden) = (
path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0))),
path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0))),
path(PathData::from_bezpath(&l)),
);
let a = path(shapes::rectangle(Rect::new(50.0, 50.0, 300.0, 200.0)));
let clipped = |mut g: Node| {
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
g
};
let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, hidden])));
let outer = clipped(Node::group(d.alloc_id(), vec![a, inner]));
let id = outer.id;
d.insert(parent, usize::MAX, outer)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let (_, g) = weed(&mut s, json!({"margin": 10, "count": 0}));
assert_eq!(weed_box(&g), Some(Rect::new(100.0, 100.0, 150.0, 140.0)), "only the shown rectangle is measured");
}
#[test]
fn art_a_nested_clip_hides_counts_toward_the_clip_limit() {
// A clip group, clipping path A of 20,000 segments, holding a clip group whose small clipping
// path B shows a rectangle and hides a closed zigzag C of 20,000 points across every row of A.
// The inner group shows four outline pieces, but testing each piece of A for being inside it
// looks at all of C.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("nested clips", |d, sel| {
let mut path = |p: BezPath| Arc::new(Node::path(d.alloc_id(), PathData::from_bezpath(&p), Appearance::default()));
let mut a = BezPath::new();
a.move_to((0.0, 0.0));
for i in 1..=20_000 {
a.line_to((f64::from(i) * 0.02, if i % 2 == 0 { 0.0 } else { 0.5 }));
}
a.line_to((400.0, 300.0));
a.line_to((0.0, 300.0));
a.close_path();
let mut c = BezPath::new();
c.move_to((300.0, 0.0));
for i in 1..=20_000 {
c.line_to((300.0 + f64::from(i) * 0.002, if i % 2 == 0 { 0.0 } else { 300.0 }));
}
c.close_path();
let b = path(shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)).to_bezpath());
let shown = path(shapes::rectangle(Rect::new(110.0, 110.0, 140.0, 130.0)).to_bezpath());
let (a, c) = (path(a), path(c));
let clipped = |mut g: Node| {
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
g
};
let inner = Arc::new(clipped(Node::group(d.alloc_id(), vec![b, shown, c])));
let outer = clipped(Node::group(d.alloc_id(), vec![a, inner]));
let id = outer.id;
d.insert(parent, usize::MAX, outer)?;
sel.set([id]);
Ok(id)
})
.unwrap();
select(&mut s, &[id]);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({"margin": 10, "count": 0})).unwrap_err();
assert!(e.to_string().contains("too many outline segments"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
}
#[test]
fn art_selected_inside_a_clip_group_is_measured_by_what_shows() {
// The rectangle runs far outside the clipping path; selected on its own, it still shows only
// inside it.
let mut s = session();
let clip = shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0));
let g = clip_group(&mut s, clip, shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)));
let art = node(&s, g).children().unwrap()[1].id;
select(&mut s, &[art]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
assert_eq!(lines(&w), [((110.0, 120.0), (120.0, 120.0)), ((150.0, 120.0), (160.0, 120.0))]);
// The clipping path selected on its own is measured by the art it shows.
let clip_path = node(&s, g).children().unwrap()[0].id;
select(&mut s, &[clip_path]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
// With the art selected too, or the group, the group is measured once.
let d = &s.doc().unwrap().doc;
let outline = |ids: &[NodeId]| super::geometry::Reader::new(d).read_ids(ids).unwrap().unwrap().outline().unwrap().len();
assert_eq!(outline(&[clip_path, art]), outline(&[g]));
assert_eq!(outline(&[g, art, g]), outline(&[g]));
}
#[test]
fn art_in_a_hidden_group_is_left_out() {
let mut s = session();
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let b = rect(&mut s, 200.0, 100.0, 50.0, 40.0);
select(&mut s, &[a, b]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(g) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[a]);
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "nothing selected shows");
select(&mut s, &[a, shown]);
let (_, w) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
}
#[test]
fn art_in_a_hidden_group_is_not_measured() {
// Selected art in a hidden group with more outline points than the limit doesn't count toward
// it.
let mut s = session();
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
let mut p = BezPath::new();
p.move_to((100.0, 100.0));
for i in 1..=super::geometry::MAX_POINTS as u32 {
p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
let dense = insert(&mut s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()));
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(g) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[dense, shown]);
let (_, w) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&w), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
}
#[test]
fn clip_outlines_crossing_too_often_are_refused() {
// A clipping path whose 700 edges each run across the art, and open art whose 700 segments
// each run down across the clipping path: every pair crosses, and each of the 490,000 pieces
// of art between crossings would be tested against all 700 edges.
let zigzag = |across: bool| {
let mut p = BezPath::new();
for i in 0..=700 {
let (along, side) = (100.0 + f64::from(i) * 0.25, if i % 2 == 0 { 100.0 } else { 300.0 });
let pt = if across { (side, along) } else { (along, side) };
if i == 0 { p.move_to(pt) } else { p.line_to(pt) }
}
p
};
let mut clip = zigzag(true);
clip.close_path();
let mut s = session();
clip_group(&mut s, PathData::from_bezpath(&clip), PathData::from_bezpath(&zigzag(false)));
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("too many outline segments"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
}
/// Each weeding line's stretches inside the art, found from the crossings indexed once for all the
/// lines, match those found by measuring the whole art along that line.
#[test]
fn indexed_crossings_find_the_same_stretches() {
let mut s = session();
let mut ids = vec![];
let outer = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let inner = rect(&mut s, 130.0, 130.0, 40.0, 40.0);
select(&mut s, &[outer, inner]);
ids.push(id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()));
let big = rect(&mut s, 220.0, 50.0, 200.0, 200.0);
let window = id_of(&s.execute("shape.ellipse", &json!({"x": 260, "y": 80, "width": 90, "height": 120})).unwrap());
select(&mut s, &[big, window]);
ids.push(id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()));
ids.push(id_of(&s.execute("text.create", &json!({"x": 100, "y": 320, "text": "WEED 8", "size": 60})).unwrap()));
ids.push(line(&mut s, &[(90.0, 40.0), (450.0, 380.0)]));
let a = rect(&mut s, 300.0, 300.0, 30.0, 30.0);
let b = id_of(&s.execute("shape.ellipse", &json!({"x": 320, "y": 310, "width": 60, "height": 40})).unwrap());
select(&mut s, &[a, b]);
ids.push(id_of(&s.execute("object.group", &json!({})).unwrap()));
let d = &s.doc().unwrap().doc;
let nodes: Vec<&Node> = ids.iter().filter_map(|id| d.node(*id)).collect();
let region = super::geometry::Reader::new(d).read(&nodes).unwrap().unwrap();
let (layout, _) = super::geometry::Layout::of(&region);
let count = 97;
for vertical in [false, true] {
let (v0, v1) = (30.0, 400.0);
let step = (v1 - v0) / (count as f64 + 1.0);
let crossed = super::geometry::crossings(&region, vertical, v0, step, count).unwrap();
assert_eq!(crossed.len(), count);
let mut hit = 0;
for (k, line) in crossed.iter().enumerate() {
let c = v0 + step * (k as f64 + 1.0);
let want = region.spans(vertical, c);
hit += usize::from(!want.is_empty());
assert_eq!(region.spans_on(&layout, line, 0), want, "line {k}, vertical {vertical}");
}
assert!(hit > count / 2, "most lines cross the art ({hit})");
}
}
#[test]
fn art_nested_too_deep_is_refused() {
// A rectangle inside `levels` groups, each holding the next. A document keeps none this deep,
// but the art symbol instances and live objects draw nests further.
let doc = vectorcraft_doc::Document::new(800.0, 600.0);
let nested = |levels: usize| {
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default());
for i in 0..levels {
n = Node::group(NodeId(i as u64 + 2), vec![Arc::new(n)]);
}
n
};
let read = |n: &Node| super::geometry::Reader::new(&doc).read(&[n]);
assert!(read(&nested(super::geometry::MAX_DEPTH)).unwrap().is_some());
let e = read(&nested(super::geometry::MAX_DEPTH + 1)).unwrap_err();
assert!(e.contains("nested more than"), "{e}");
}
#[test]
fn spacing_divides_the_box_into_strips_no_wider() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 10.0, 40.0);
// The box is 60 pt tall: three strips of 20 pt.
let (_, g) = weed(&mut s, json!({"margin": 10, "spacing": 25, "direction": "horizontal", "box": false}));
assert_eq!(weed_box(&g), None);
let ys: Vec<f64> = lines(&g).iter().map(|l| l.0.1).collect();
assert_eq!(ys, [110.0, 110.0, 130.0, 130.0]);
let mut s = session();
rect(&mut s, 100.0, 100.0, 10.0, 40.0);
assert!(s.execute("addon.cutting.weed", &json!({"spacing": 0.01})).is_err(), "too many lines");
}
#[test]
fn the_lines_take_a_swatch() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Cut", "color": "cmyk 0 100 0 0", "spot": true})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let (_, g) = weed(&mut s, json!({"swatch": "Cut", "weight": 1}));
for c in g.children().unwrap() {
match c.appearance.stroke_paint() {
Paint::Solid { swatch, .. } => assert_eq!(swatch.as_deref(), Some("Cut")),
other => panic!("stroke {other:?}"),
}
assert_eq!(c.appearance.stroke_width(), 1.0);
}
}
/// Adds the object `make` makes with a new id to the current layer, selects it and gives its id.
fn insert(s: &mut Session, make: impl FnOnce(NodeId) -> Node) -> NodeId {
let parent = s.doc().unwrap().target_parent().unwrap();
let id = s
.edit("insert", |d, _| {
let n = make(d.alloc_id());
let id = n.id;
d.insert(parent, usize::MAX, n)?;
Ok(id)
})
.unwrap();
select(s, &[id]);
id
}
/// A 100 pt square turned 45° about (300, 300): a diamond 141.42 pt across.
fn diamond_xf() -> Affine {
Affine::translate((300.0, 300.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::translate((-50.0, -50.0))
}
/// The weeding lines of `g` at height `y` (to 1/1000 pt).
fn at(g: &Node, y: f64) -> Vec<(f64, f64)> {
lines(g).into_iter().filter(|((_, y0), (_, y1))| *y0 == y && *y1 == y).map(|((x0, _), (x1, _))| (x0, x1)).collect()
}
/// The diamond's weeding with three horizontal lines and a margin of 10: the box, and where the
/// first line's left piece ends.
fn diamond_weeding(s: &mut Session) -> (Rect, f64) {
let (_, g) = weed(s, json!({"margin": 10, "count": 3, "direction": "horizontal"}));
let b = weed_box(&g).unwrap();
let y = ((b.y0 + b.height() / 4.0) * 1000.0).round() / 1000.0;
let first = at(&g, y);
assert_eq!(first.len(), 2, "{first:?}");
(b, first[0].1)
}
#[test]
fn an_image_is_measured_by_its_frame_as_turned() {
let mut s = session();
let im = vectorcraft_doc::ImageObject {
key: "image".into(),
width: 100,
height: 100,
xf: diamond_xf(),
link: None,
placement: Default::default(),
pixels: None,
};
insert(&mut s, |id| Node::new(id, NodeKind::Image(Box::new(im))));
let (b, end) = diamond_weeding(&mut s);
let half = 50.0 * std::f64::consts::SQRT_2;
assert!((b.x0 - (290.0 - half)).abs() < 1e-3 && (b.y1 - (310.0 + half)).abs() < 1e-3, "{b:?}");
// A quarter of the box down, the line meets the diamond's upper left edge, not its bounds.
let y = b.y0 + b.height() / 4.0;
assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}");
}
#[test]
fn a_placed_file_is_measured_by_the_art_it_shows() {
let dir = crate::tests_links::Folder::new("weed-placed");
// A file with a 60 × 40 artboard and a 20 pt wide rectangle at each side (hidden when
// `hidden`), placed centered on (200, 150) and selected.
let place = |s: &mut Session, name: &str, hidden: bool| {
let src = dir.file(name);
let mut d = vectorcraft_doc::Document::new(60.0, 40.0);
let layer = d.layers[0].id;
for x in [0.0, 40.0] {
let id = d.alloc_id();
let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
let mut n = Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 40.0)), look);
n.visible = !hidden;
d.insert(Some(layer), 0, n).unwrap();
}
crate::tests_links::write(&src, &vectorcraft_format::save_file(&d));
let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap();
select(s, &[NodeId(r["ids"][0].as_u64().unwrap())]);
};
let mut s = session();
place(&mut s, "pair.vectorcraft", false);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The rectangles lie at 170..190 and 210..230; the line runs between them too.
assert_eq!(at(&g, 150.0), [(160.0, 170.0), (190.0, 210.0), (230.0, 240.0)]);
// A file read but shown in a box with no size shows nothing, and its box isn't measured.
let id = NodeId(s.execute("file.place", &json!({"path": dir.file("pair.vectorcraft"), "at": [400, 150]})).unwrap()["ids"][0].as_u64().unwrap());
s.edit("no size", |d, _| {
if let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) {
(p.width, p.height) = (0.0, 0.0);
}
Ok(())
})
.unwrap();
select(&mut s, &[id]);
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("has no outlines"), "{e}");
// A file read with nothing showing has no outlines, and its box isn't measured instead.
let mut s = session();
place(&mut s, "hidden.vectorcraft", true);
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("has no outlines"), "{e}");
// A file whose art is an instance of a symbol of its own: the instance's art is measured.
let src = dir.file("symbol.vectorcraft");
let mut d = vectorcraft_doc::Document::new(60.0, 40.0);
let look = Appearance::basic(Paint::solid(vectorcraft_color::Color::BLACK), Paint::None, 0.0);
let art = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 40.0)), look);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY })).unwrap();
crate::tests_links::write(&src, &vectorcraft_format::save_file(&d));
let mut s = session();
let r = s.execute("file.place", &json!({"path": src, "at": [200, 150]})).unwrap();
select(&mut s, &[NodeId(r["ids"][0].as_u64().unwrap())]);
let (_, g) = weed(&mut s, json!({"margin": 10}));
let b = weed_box(&g).unwrap();
assert!(b.x0 >= 160.0 - 1e-6 && b.x1 <= 240.0 + 1e-6 && b.width() < 41.0, "the bar, not the whole box: {b:?}");
}
#[test]
fn a_placed_file_that_cant_be_read_is_measured_by_its_box_as_turned() {
let mut s = session();
let p = vectorcraft_doc::PlacedDocument {
link: vectorcraft_doc::LinkInfo::new("/no/such/file.pdf"),
key: "missing".into(),
bounding: false,
page_box: Default::default(),
width: 100.0,
height: 100.0,
xf: diamond_xf(),
placement: Default::default(),
};
insert(&mut s, |id| Node::new(id, NodeKind::PlacedDocument(Box::new(p))));
let (b, end) = diamond_weeding(&mut s);
let half = 50.0 * std::f64::consts::SQRT_2;
let y = b.y0 + b.height() / 4.0;
assert!((end - (300.0 - (y - (300.0 - half)))).abs() < 0.01, "{end} at {y}");
}
#[test]
fn a_symbol_instance_is_measured_by_its_art() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0);
let b = rect(&mut s, 140.0, 100.0, 20.0, 40.0);
select(&mut s, &[a, b]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
select(&mut s, &[inst]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The line runs between the two rectangles too.
assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 140.0), (160.0, 170.0)]);
// An instance of a symbol the document doesn't have draws nothing, and has no outlines.
s.edit("lose the symbol", |d, _| {
if let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node_mut(inst).map(|n| &mut n.kind) {
*symbol = "Missing".into();
}
Ok(())
})
.unwrap();
select(&mut s, &[inst]);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({})).unwrap_err();
assert!(e.to_string().contains("has no outlines"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
select(&mut s, &[inst, shown]);
let (_, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
}
#[test]
fn a_blend_is_measured_by_the_art_it_draws() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 20.0, 40.0);
let b = rect(&mut s, 200.0, 100.0, 20.0, 40.0);
let blend = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap());
select(&mut s, &[blend]);
let (_, g) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The step halfway between the two rectangles stops the line too.
assert_eq!(at(&g, 120.0), [(90.0, 100.0), (120.0, 150.0), (170.0, 200.0), (220.0, 230.0)]);
}
#[test]
fn the_group_goes_on_top_of_the_current_layer_and_hidden_layers_are_left_out() {
let mut s = session();
let first = s.doc().unwrap().doc.layers[0].id;
let hidden = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap());
let shown = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
let below = rect(&mut s, 400.0, 400.0, 20.0, 20.0);
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(first) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[hidden]);
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "art in a hidden layer doesn't show");
select(&mut s, &[hidden, shown]);
let (_, g) = weed(&mut s, json!({"margin": 10}));
assert_eq!(weed_box(&g), Some(Rect::new(290.0, 290.0, 330.0, 330.0)));
let d = &s.doc().unwrap().doc;
assert_eq!(d.parent_of(g.id), Some(art));
assert_eq!(d.node(art).unwrap().children().unwrap().last().map(|c| c.id), Some(g.id), "on top");
// A hidden object selected on its own shows nothing.
s.edit("hide", |d, _| {
if let Some(n) = d.node_mut(below) {
n.visible = false;
}
Ok(())
})
.unwrap();
select(&mut s, &[below]);
assert!(s.execute("addon.cutting.weed", &json!({})).is_err(), "a hidden object doesn't show");
}
#[test]
fn the_group_goes_outside_a_clip_group_edited_in_isolation() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let g = clip_group(&mut s, shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)));
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
let art = node(&s, g).children().unwrap()[1].id;
select(&mut s, &[art]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
// The clipping path would hide the lines and the box around the art inside it.
let d = &s.doc().unwrap().doc;
assert_eq!(d.parent_of(w.id), Some(layer));
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
// A plain group edited in isolation inside a clip group: the group goes outside the clip group.
let mut s = session();
let parent = s.doc().unwrap().target_parent().unwrap();
let (inner, art) = s
.edit("clip group around a group", |d, _| {
let clip = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 140.0)), Appearance::default()));
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 0.0, 220.0, 300.0)), Appearance::default());
let art_id = art.id;
let inner = Node::group(d.alloc_id(), vec![Arc::new(art)]);
let inner_id = inner.id;
let mut g = Node::group(d.alloc_id(), vec![clip, Arc::new(inner)]);
if let NodeKind::Group { clip, .. } = &mut g.kind {
*clip = true;
}
d.insert(parent, usize::MAX, g)?;
Ok((inner_id, art_id))
})
.unwrap();
s.execute("object.isolate", &json!({"id": inner.0})).unwrap();
select(&mut s, &[art]);
let (_, w) = weed(&mut s, json!({"margin": 10, "direction": "horizontal"}));
assert_eq!(s.doc().unwrap().doc.parent_of(w.id), parent);
assert_eq!(weed_box(&w), Some(Rect::new(110.0, 90.0, 160.0, 150.0)));
}
/// Adds one path of `count` open vertical subpaths 2 pt apart, from (100, 100) down to y = 200.
fn bars(s: &mut Session, count: u32) -> NodeId {
let mut p = BezPath::new();
for i in 0..count {
let x = 100.0 + f64::from(i) * 2.0;
p.move_to((x, 100.0));
p.line_to((x, 200.0));
}
insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()))
}
#[test]
fn more_than_the_most_pieces_of_line_is_refused() {
// 1,000 lines each cut into 100 pieces by 101 bars: as many as one run adds.
let mut s = session();
bars(&mut s, 101);
let r = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap();
assert_eq!(r["lines"], json!(super::MAX_PIECES));
// One bar more makes 1,000 pieces more.
let mut s = session();
bars(&mut s, 102);
let n = s.doc().unwrap().doc.node_count();
let e = s.execute("addon.cutting.weed", &json!({"margin": 0, "count": 1000, "direction": "horizontal"})).unwrap_err();
assert!(e.to_string().contains("more than 100000 pieces"), "{e}");
assert_eq!(s.doc().unwrap().doc.node_count(), n);
}
#[test]
fn art_with_more_than_the_most_outline_points_is_refused() {
// An open zigzag of `points` points: one subpath, measured by its points.
let zigzag = |s: &mut Session, points: u32| {
let mut p = BezPath::new();
p.move_to((100.0, 100.0));
for i in 1..points {
p.line_to((100.0 + f64::from(i) * 0.001, if i % 2 == 0 { 100.0 } else { 101.0 }));
}
insert(s, |id| Node::path(id, PathData::from_bezpath(&p), Appearance::default()));
};
let mut s = session();
zigzag(&mut s, super::geometry::MAX_POINTS as u32);
s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap();
let mut s = session();
zigzag(&mut s, super::geometry::MAX_POINTS as u32 + 1);
let e = s.execute("addon.cutting.weed", &json!({"count": 0})).unwrap_err();
assert!(e.to_string().contains("too many outline points"), "{e}");
}
#[test]
fn bad_params_and_an_empty_selection_are_refused() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
for p in [
json!({"margin": -1}),
json!({"gap": "x"}),
json!({"count": 1.5}),
json!({"count": 1001}),
json!({"count": 2, "spacing": 10}),
json!({"spacing": 0}),
json!({"direction": "diagonal"}),
json!({"box": "yes"}),
json!({"weight": 0}),
json!({"swatch": "no such swatch"}),
json!({"swatch": 3}),
json!({"box": false, "count": 0}),
] {
let n = s.doc().unwrap().doc.node_count();
assert!(s.execute("addon.cutting.weed", &p).is_err(), "{p}");
assert_eq!(s.doc().unwrap().doc.node_count(), n, "nothing added for {p}");
}
// The same art with good params is weeded; with nothing selected it is refused.
s.execute("addon.cutting.weed", &json!({"count": 2, "gap": "1 mm"})).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
assert!(s.execute("addon.cutting.weed", &json!({})).is_err());
}
+165
View File
@@ -176,6 +176,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams },
BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media },
];
/// Built-in library `id`, made once.
@@ -474,6 +475,170 @@ fn energy_beams() -> BrushLibrary {
built("Energy Beams", Document::new(100.0, 100.0), brushes)
}
/// The fixed-seed number source of the Dry Media grain: integer steps and plain arithmetic, so the
/// art is the same on every run and every platform.
struct Grain(u64);
impl Grain {
/// The next number in 0..1.
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
/// The next number in `a`..`b`.
fn range(&mut self, a: f64, b: f64) -> f64 {
a + (b - a) * self.next()
}
}
/// `floor` at x = `x0` and `x1`, rising smoothly to 1 within `ramp` pt of each.
fn ends(x: f64, x0: f64, x1: f64, ramp: f64, floor: f64) -> f64 {
let t = ((x - x0).min(x1 - x) / ramp).clamp(0.0, 1.0);
floor + (1.0 - floor) * t * t * (3.0 - 2.0 * t)
}
/// A closed band from x = `x0` to `x1` in `n` steps, at most `half` pt either side of y = 0 times
/// `taper(x)`; each edge point is pulled in by up to `rough` of that.
fn rough_band(g: &mut Grain, (x0, x1): (f64, f64), n: usize, half: f64, rough: f64, taper: impl Fn(f64) -> f64) -> PathData {
let xs: Vec<f64> = (0..=n).map(|i| x0 + (x1 - x0) * i as f64 / n as f64).collect();
let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, -half * taper(x) * (1.0 - rough * g.next()))).collect();
let bottom: Vec<Point> = xs.iter().rev().map(|&x| Point::new(x, half * taper(x) * (1.0 - rough * g.next()))).collect();
pts.extend(bottom);
PathData::single(SubPath::polyline(&pts, true))
}
/// A small four-cornered fleck around (`cx`, `cy`), at most `w` × `h` pt either side; one corner
/// in each direction, so its outline never crosses itself.
fn fleck(g: &mut Grain, cx: f64, cy: f64, w: f64, h: f64) -> PathData {
let mut side = |a: f64, b: f64| g.range(a, b);
let pts = [
(cx - w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)),
(cx + w * side(-0.3, 0.3), cy - h * side(0.6, 1.0)),
(cx + w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)),
(cx + w * side(-0.3, 0.3), cy + h * side(0.6, 1.0)),
];
polygon(&pts)
}
/// `body` with `holes` (inside it, apart from each other) cut out, even-odd, in gray `k`: what
/// lies under the stroke shows through the holes.
fn holed(body: PathData, holes: Vec<PathData>, k: f32) -> Node {
let children = std::iter::once(body).chain(holes).map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::gray(k))))], ..Default::default() }.into();
n
}
/// Path `p` filled with gray `k` (1 is black).
fn ink(p: PathData, k: f32) -> Node {
filled(p, Paint::solid(Color::gray(k)))
}
/// Slivers `w` × `h` pt (ranges) in `count` slots along x = 6..94 inside a band `half` pt wide
/// times `taper`. Each stays within `reach` of the band's half width from y = 0, taken where the
/// band is narrowest over the sliver and the 3 pt either side of it, so with `reach` below the
/// band's roughness margin no sliver touches the band's edge or another sliver.
fn slivers(g: &mut Grain, count: usize, half: f64, reach: f64, (w, h): ((f64, f64), (f64, f64)), taper: impl Fn(f64) -> f64) -> Vec<PathData> {
let slot = 88.0 / count as f64;
(0..count)
.map(|i| {
let cx = 6.0 + slot * (i as f64 + 0.5);
let (w, h) = (g.range(w.0, w.1), g.range(h.0, h.1));
let room = reach * half * taper(cx - w - 3.0).min(taper(cx + w + 3.0));
let cy = g.range(-1.0, 1.0) * (room - h).max(0.0);
fleck(g, cx, cy, w, h)
})
.collect()
}
/// Dry Media: art brushes of grainy dry drawing marks, chalk, pencil, crayon and charcoal (original
/// vector grain from fixed seeds; every brush 100 pt long, black and grays that take the stroke
/// color as tints, gaps cut out so the paper shows).
pub(crate) fn dry_media() -> BrushLibrary {
// Chalk Grain, 100 × 8 pt: a soft band with narrow scratches and chips along its edges.
let chalk = {
let mut g = Grain(0x0c4a_1c0a);
let taper = |x: f64| ends(x, 0.0, 100.0, 10.0, 0.35);
let body = rough_band(&mut g, (0.0, 100.0), 36, 4.0, 0.25, taper);
let scratches = slivers(&mut g, 14, 4.0, 0.7, ((1.2, 2.4), (0.15, 0.3)), taper);
let mut parts = vec![holed(body, scratches, 0.8)];
for i in 0..18 {
let cx = 3.0 + 94.0 * (i as f64 + g.range(0.2, 0.8)) / 18.0;
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
let cy = side * 4.0 * taper(cx) * g.range(0.7, 0.95);
let (w, h) = (g.range(0.3, 0.7), g.range(0.25, 0.5));
let f = fleck(&mut g, cx, cy, w, h);
parts.push(ink(f, 1.0));
}
group(parts)
};
// Graphite Pencil, 100 × 2.5 pt: a solid tapered core with fine slanted hatching at its edges.
let pencil = {
let mut g = Grain(0x009e_4c11);
let core = rough_band(&mut g, (0.0, 100.0), 40, 0.8, 0.12, |x| ends(x, 0.0, 100.0, 18.0, 0.25));
let mut parts = vec![ink(core, 1.0)];
for i in 0..24 {
let x0 = 2.0 + 92.0 * (i as f64 + g.range(0.0, 0.4)) / 24.0;
let len = g.range(1.5, 3.0);
let t = g.range(0.12, 0.2);
let slant = g.range(0.1, 0.3);
let y0 = if i % 2 == 0 { -g.range(0.85, 1.05) } else { g.range(0.85, 1.25) - t - slant };
parts.push(ink(polygon(&[(x0, y0 + slant), (x0 + len, y0), (x0 + len, y0 + t), (x0, y0 + slant + t)]), 0.6));
}
group(parts)
};
// Wax Crayon, 100 × 6 pt: a dense band with small gaps in its middle and short overlapping
// marks along both edges.
let crayon = {
let mut g = Grain(0x00c0_a7e5);
let taper = |x: f64| ends(x, 0.0, 100.0, 6.0, 0.5);
let body = rough_band(&mut g, (0.0, 100.0), 30, 2.6, 0.2, taper);
let gaps = slivers(&mut g, 16, 2.6, 0.34, ((0.6, 1.6), (0.15, 0.3)), taper);
let mut parts = vec![holed(body, gaps, 0.9)];
for i in 0..36 {
let w = g.range(1.0, 2.2);
let cx = (1.0 + w).max(98.0 * (i as f64 + g.range(0.0, 1.0)) / 36.0).min(99.0 - w);
let side = if i % 2 == 0 { -1.0 } else { 1.0 };
let h = g.range(0.4, 0.6);
// Clear of the gaps, which stay within 0.9 pt of the middle.
let inner = 0.9 + h;
let cy = side * g.range(inner, (2.4 * taper(cx)).max(inner));
let f = fleck(&mut g, cx, cy, w, h);
parts.push(ink(f, 1.0));
}
group(parts)
};
// Charcoal Dust, 100 × 7 pt: a dark band broken into pieces, with flecks of dust either side.
let dust = {
let mut g = Grain(0xc4a2_c0a1);
let mut parts = vec![];
let mut x0 = 0.0;
while x0 < 100.0 {
let x1 = (x0 + g.range(14.0, 24.0)).min(100.0);
let x1 = if 100.0 - x1 < 8.0 { 100.0 } else { x1 };
let piece = rough_band(&mut g, (x0, x1), 10, 2.0, 0.3, |x| ends(x, 0.0, 100.0, 8.0, 0.4) * ends(x, x0, x1, 2.0, 0.3));
parts.push(ink(piece, 1.0));
x0 = x1 + g.range(1.5, 3.0);
}
for i in 0..30 {
let cx = 2.0 + 96.0 * (i as f64 + g.range(0.2, 0.8)) / 30.0;
let side = if g.next() < 0.5 { -1.0 } else { 1.0 };
let s = g.range(0.15, 0.3);
let cy = side * g.range(2.3, 3.5 - s);
let f = fleck(&mut g, cx, cy, s, s);
parts.push(ink(f, if i % 3 == 0 { 0.7 } else { 1.0 }));
}
group(parts)
};
let brushes = vec![
art("Chalk Grain", chalk, Colorization::Tints),
art("Graphite Pencil", pencil, Colorization::Tints),
art("Wax Crayon", crayon, Colorization::Tints),
art("Charcoal Dust", dust, Colorization::Tints),
];
built("Dry Media", Document::new(100.0, 100.0), brushes)
}
// ---------- commands ----------
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
+1 -1
View File
@@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure|tilt|bearing|rotation, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest; tilt: width with the pen lying flat to width_max upright; bearing and rotation: width at −180° to width_max at 180°; half way on a stroke drawn without that input), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
+281 -50
View File
@@ -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)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
"{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), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, 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
),
@@ -40,7 +40,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [
("objects", "Objects"),
("graphicStyles", "Graphic Styles"),
("brushes", "Brushes"),
("spotColors", "Spot Colors"),
("spotColors", "Spot Color Objects"),
("patterns", "Pattern Objects"),
("gradients", "Gradient Objects"),
("symbols", "Symbols"),
@@ -52,7 +52,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [
];
/// The object counts of `document.info` and their labels.
const OBJECT_LABELS: [(&str, &str); 18] = [
const OBJECT_LABELS: [(&str, &str); 19] = [
("paths", "Paths"),
("compoundPaths", "Compound Paths"),
("groups", "Groups"),
@@ -63,6 +63,7 @@ const OBJECT_LABELS: [(&str, &str); 18] = [
("symbolInstances", "Symbol Instances"),
("gradients", "Gradient Objects"),
("patterns", "Pattern Objects"),
("spots", "Spot Color Objects"),
("meshes", "Gradient Meshes"),
("blends", "Blends"),
("envelopes", "Envelopes"),
@@ -131,7 +132,7 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S
/// The Document Info categories of `d` (only `category` when given) from `info` (what
/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the
/// rows of its objects filled or stroked with a pattern and with a gradient.
/// rows of its objects filled or stroked with a spot color, with a pattern and with a gradient.
#[allow(clippy::too_many_arguments)]
fn sections(
d: &Document,
@@ -139,6 +140,7 @@ fn sections(
fonts: &BTreeSet<super::fonts::UsedFont>,
images: &[ImageRow],
repeats: &[(String, String)],
spot_objects: &[(String, String)],
pattern_objects: &[(String, String)],
gradient_objects: &[(String, String)],
category: Option<&str>,
@@ -183,7 +185,7 @@ fn sections(
}
"graphicStyles" => names("graphicStyleNames"),
"brushes" => names("brushNames"),
"spotColors" => names("spotColors"),
"spotColors" => spot_objects.to_vec(),
"patterns" => pattern_objects.to_vec(),
"gradients" => gradient_objects.to_vec(),
"symbols" => names("symbols"),
@@ -273,15 +275,50 @@ fn gradient_label(p: &Paint) -> Option<String> {
}
}
/// The type object `n` holds and the runs its layout draws, when it uses a pattern or a gradient:
/// the runs with a glyph outline (list markers and ruby included) or an underline or strikethrough
/// bar. Spaces and line breaks draw no outline. A type object's characters carry their own fill and
/// stroke, and the type object's own fills and strokes paint on its characters, so only those runs
/// paint. The type is laid out with its inline graphics' bounds resolved from `d`'s symbols, as the
/// renderer lays it out.
fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet<usize>)> {
/// The names of `d`'s spot color swatches: the swatches whose prints go to a plate of their own
/// ([`vectorcraft_doc::inks::inks`]). A name held by several swatches is the first one's, as
/// [`Document::swatch`] finds it, and the Registration swatch prints on every plate, so it is not
/// one.
fn spot_swatches(d: &Document) -> HashSet<&str> {
let mut first: BTreeMap<&str, bool> = BTreeMap::new();
for s in d.swatches_iter() {
first.entry(s.name.as_str()).or_insert(s.spot && s.paint.color().is_some());
}
first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect()
}
/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is
/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A
/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws
/// nothing, and a color that only looks like a spot color is not linked to one.
fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
let mut out: Vec<String> = vec![];
let mut add = |swatch: &Option<String>, tint: f32| {
if let Some(name) = swatch
&& tint > 0.0
&& spots.contains(name.as_str())
&& !out.contains(name)
{
out.push(name.clone());
}
};
match p {
Paint::Solid { swatch, tint, .. } => add(swatch, *tint),
Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)),
_ => {}
}
out
}
/// The type object `n` holds and the runs its layout draws, when it uses a spot color, a pattern or
/// a gradient (`spots`: [`spot_swatches`]): the runs with a glyph outline (list markers and ruby
/// included) or an underline or strikethrough bar. Spaces and line breaks draw no outline. A type
/// object's characters carry their own fill and stroke, and the type object's own fills and strokes
/// paint on its characters, so only those runs paint. The type is laid out with its inline
/// graphics' bounds resolved from `d`'s symbols, as the renderer lays it out.
fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option<(&'a vectorcraft_doc::TextObject, BTreeSet<usize>)> {
let NodeKind::Text(t) = &n.kind else { return None };
let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_));
let is_listed = |p: &Paint| matches!(p, Paint::Pattern { .. } | Paint::Gradient(_)) || !spot_labels(spots, p).is_empty();
let own = n.appearance.items.iter().any(|i| match i {
vectorcraft_doc::AppearanceItem::Fill(f) => is_listed(&f.paint),
vectorcraft_doc::AppearanceItem::Stroke(s) => is_listed(&s.paint),
@@ -297,43 +334,53 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T
Some((t, painted))
}
/// The patterns or gradients (`label`: [`pattern_label`] or [`gradient_label`]) that `n`'s own
/// fills and strokes paint, each once. A hidden fill or stroke and a stroke with no width paint
/// nothing, and neither does any fill or stroke of a guide (drawn in the guide color), of a compound
/// path's member (`members`: the compound path's own fills and strokes paint it) or of type whose
/// layout draws nothing (`painted_runs`). An image's fills and strokes paint only where an effect
/// gives them a shape.
/// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its
/// gradient.
type PaintNames<'a> = dyn Fn(&Paint) -> Vec<String> + 'a;
/// The spot colors, patterns or gradients (`label`: [`spot_labels`], [`pattern_label`] or
/// [`gradient_label`], each paint's names) that `n`'s own fills and strokes paint, each once. A
/// hidden fill or stroke and a stroke with no width paint nothing, and neither does any fill or
/// stroke of a guide (drawn in the guide color), of a compound path's member (`members`: the
/// compound path's own fills and strokes paint it) or of type whose layout draws nothing
/// (`painted_runs`). An image's fills and strokes paint only where an effect gives them a shape.
fn own_paints(
d: &Document,
n: &Node,
members: &HashSet<*const Node>,
painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet<usize>)>,
label: fn(&Paint) -> Option<String>,
label: &PaintNames<'_>,
) -> Vec<String> {
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|| members.contains(&std::ptr::from_ref(n))
|| painted_runs.is_some_and(|(_, r)| r.is_empty())
|| !n.appearance.items.iter().any(|i| label(i.paint()).is_some())
|| n.appearance.items.iter().all(|i| label(i.paint()).is_empty())
{
return vec![];
}
// The patterns or gradients an image's shaped fills and strokes paint (their effects can
// transform the paint, so they are matched by label).
// The names an image's shaped fills and strokes paint (their effects can transform the paint,
// so they are matched by name).
let shaped: Option<Vec<String>> = matches!(n.kind, NodeKind::Image(_))
.then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().filter_map(|(_, _, item)| label(item.paint())).collect());
.then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect());
let mut out: Vec<String> = vec![];
for item in &n.appearance.items {
let Some(name) = label(item.paint()) else { continue };
let paints = item.visible()
&& !matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0)
&& shaped.as_ref().is_none_or(|s| s.contains(&name));
if paints && !out.contains(&name) {
out.push(name);
if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) {
continue;
}
for name in label(item.paint()) {
if shaped.as_ref().is_none_or(|s| s.contains(&name)) && !out.contains(&name) {
out.push(name);
}
}
}
out
}
/// [`pattern_label`] or [`gradient_label`] as the names [`own_paints`] takes.
fn one_label(label: fn(&Paint) -> Option<String>) -> impl Fn(&Paint) -> Vec<String> {
move |p| label(p).into_iter().collect()
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.info";
let category = str_param(p, "category");
@@ -370,7 +417,13 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut patterns = BTreeSet::new();
let mut symbols = BTreeSet::new();
// `spot_objects`, `spot_rows` and `spot` hold the `spotObjects` entries, the rows of the objects filled or stroked with a spot
// color, in walk order, and the spot colors they use. `spot_swatches` names the document's spot colors once, for every paint.
let spot_swatches = spot_swatches(d);
let mut spot_objects: Vec<Value> = vec![];
let mut spot_rows: Vec<(String, String)> = vec![];
let mut spot = BTreeSet::new();
let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label));
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
// `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use.
let mut brushes: Vec<String> = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() };
@@ -446,17 +499,11 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(k) = k {
*counts.entry(k).or_default() += 1;
}
for item in &n.appearance.items {
if let Paint::Solid { swatch: Some(name), .. } = item.paint()
&& d.swatch(name).is_some_and(|s| s.spot)
{
spot.insert(name.clone());
}
}
// The patterns and the gradients this object's fills and strokes paint, each once.
let painted_runs = painted_runs(d, n);
let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), pattern_label);
let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), gradient_label);
// The spot colors, the patterns and the gradients this object's fills and strokes paint, each once.
let painted_runs = painted_runs(d, &spot_swatches, n);
let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label);
let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern);
let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient);
// The characters' own paints: an inline graphic draws no outline and does not take its
// run's paint (a list marker set in its run does), and a stroke with no width paints
// nothing.
@@ -464,19 +511,38 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
for run in painted.iter().filter_map(|i| t.runs.get(*i)) {
let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke);
for paint in std::iter::once(&run.style.fill).chain(stroke) {
for (used, label) in
[(&mut used_patterns, pattern_label as fn(&Paint) -> Option<String>), (&mut used_gradients, gradient_label)]
{
if let Some(name) = label(paint)
&& !used.contains(&name)
{
used.push(name);
let labels: [(&mut Vec<String>, &PaintNames<'_>); 3] =
[(&mut used_spots, &spot_label), (&mut used_patterns, &pattern), (&mut used_gradients, &gradient)];
for (used, label) in labels {
for name in label(paint) {
if !used.contains(&name) {
used.push(name);
}
}
}
}
}
}
// Objects counts the objects that Pattern Objects and Gradient Objects list.
// A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take
// the color, as the renderer draws it.
if let NodeKind::Image(im) = &n.kind
&& let Some(colorize) = n.extras().colorize.as_deref()
&& d.image_blob(im).is_some_and(|b| b.takes_colorize())
{
for name in spot_label(colorize) {
if !used_spots.contains(&name) {
used_spots.insert(0, name);
}
}
}
// Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list.
if !used_spots.is_empty() {
let name = n.display_name();
spot.extend(used_spots.iter().cloned());
spot_rows.push((name.clone(), used_spots.join(", ")));
spot_objects.push(json!({ "id": n.id.0, "name": name, "spotColors": used_spots }));
*counts.entry("spots").or_default() += 1;
}
if !used_patterns.is_empty() {
let name = n.display_name();
patterns.extend(used_patterns.iter().cloned());
@@ -529,6 +595,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
"images": images,
"symbols": symbols,
"spotColors": spot,
"spotColorNames": spot,
"spotObjects": spot_objects,
"swatches": d.swatches_iter().count(),
"graphicStyles": d.graphic_styles.len(),
"graphicStyleNames": styles,
@@ -544,7 +612,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
});
// The document's name: its file's, else its title.
out["document"]["name"] = json!(st.title());
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &pattern_rows, &gradient_rows, category);
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
if text {
return Ok(json!({ "text": report_text(&st.title(), selection_only, &sections) }));
}
@@ -997,6 +1065,169 @@ mod tests {
assert!(!text.contains("Spare"), "{text}");
}
/// Document Info › Spot Color Objects lists each object filled or stroked with a spot color once,
/// with the spot colors it uses, as a solid color or in a gradient's stops, one in a group and a
/// type object whose characters use one included. A spot swatch no art uses adds no row; a
/// process color that looks the same, Registration and a tint of 0 are not spot colors; paint
/// that draws nothing is left out by the rules Pattern Objects follows; and Objects counts the
/// listed objects. With Selection Only it lists the objects in the selection, each once, and the
/// report writes the same rows.
#[test]
fn info_lists_the_spot_color_objects() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
// An empty document lists none.
let i = s.execute("document.info", &json!({"category": "spotColors"})).unwrap();
assert_eq!((i["spotObjects"].clone(), i["spotColorNames"].clone(), i["spotColors"].clone()), (json!([]), json!([]), json!([])));
assert!(i["objects"]["spots"].is_null() && i["sections"][0]["rows"] == json!([]), "{i}");
let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("\nSPOT COLOR OBJECTS\nNone\n"), "{text}");
let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0});
s.execute("swatch.new", &json!({"name": "Ink", "color": ink(1.0), "spot": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Gold", "color": ink(0.2), "spot": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Spare", "color": ink(0.4), "spot": true})).unwrap();
// A global process color of the same color as Ink.
s.execute("swatch.new", &json!({"name": "Look", "color": ink(1.0), "global": true})).unwrap();
let square =
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
let ramp = |a: &str, b: &str| json!({"kind": "linear", "stops": [{"offset": 0, "color": "#000", "swatch": a}, {"offset": 1, "color": "#fff", "swatch": b}]});
let (a, b, c, f, g, h) =
(square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0), square(&mut s, 80.0), square(&mut s, 100.0));
s.execute("paint.setFill", &json!({"ids": [a], "swatch": "Ink"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [a], "swatch": "Gold", "tint": 40})).unwrap();
s.execute("layer.setProps", &json!({"id": a, "name": "Both"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [b], "swatch": "Look"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [b], "swatch": "Ink", "tint": 50})).unwrap();
s.execute("paint.setFill", &json!({"ids": [c], "swatch": "Ink", "tint": 0})).unwrap();
s.execute("paint.setFill", &json!({"ids": [f], "gradient": ramp("Gold", "Look")})).unwrap();
s.execute("paint.setFill", &json!({"ids": [g], "gradient": ramp("Look", "Look")})).unwrap();
s.execute("paint.setFill", &json!({"ids": [h], "swatch": "[Registration]"})).unwrap();
for id in [&c, &g, &h] {
s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
}
s.execute("select.set", &json!({"ids": [b, c]})).unwrap();
let group = s.execute("object.group", &json!({})).unwrap()["id"].clone();
// A type object whose characters are filled with a spot color.
let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [t], "swatch": "Ink"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [t], "none": true})).unwrap();
s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap();
// A hidden spot fill and a spot stroke with no width paint nothing, so neither object is listed.
let (d, e) = (square(&mut s, 120.0), square(&mut s, 140.0));
s.execute("paint.setStroke", &json!({"ids": [d], "none": true})).unwrap();
s.execute("paint.setFill", &json!({"ids": [d], "swatch": "Ink"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [d], "index": 0, "visible": false})).unwrap();
s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Ink"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap();
// Type with no characters, or of only spaces and a line break, paints nothing, and neither
// does a guide made from a path with a spot color.
let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Ink"})).unwrap();
s.execute("appearance.addFill", &json!({"ids": [empty]})).unwrap();
let blank = s.execute("text.create", &json!({"x": 0, "y": 320, "text": " \n"})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [blank], "swatch": "Ink"})).unwrap();
let guide = square(&mut s, 300.0);
s.execute("paint.setFill", &json!({"ids": [guide], "swatch": "Ink"})).unwrap();
s.execute("select.set", &json!({"ids": [guide]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
// Nor does a compound path's member's own spot fill, or an image's spot fill without an
// effect that gives it a shape.
let (m1, m2) = (square(&mut s, 320.0), square(&mut s, 340.0));
s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap();
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].clone();
s.execute("paint.setFill", &json!({"ids": [compound], "color": "#000000"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [compound], "none": true})).unwrap();
let node = |s: &Session, id: &Value| s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().clone();
let (member, paint) = (node(&s, &compound).children().unwrap()[0].id, node(&s, &a).appearance);
let pic = square(&mut s, 360.0);
s.execute("select.set", &json!({"ids": [pic]})).unwrap();
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let image = s.doc().unwrap().selection.objects[0];
s.edit("own paint", |d, _| {
d.node_mut(member).unwrap().appearance = paint.clone();
d.node_mut(image).unwrap().appearance = paint;
Ok(())
})
.unwrap();
let i = s.execute("document.info", &json!({})).unwrap();
assert_eq!(
i["spotObjects"],
json!([
{"id": a, "name": "Both", "spotColors": ["Ink", "Gold"]},
{"id": b, "name": "<Rectangle>", "spotColors": ["Ink"]},
{"id": f, "name": "<Rectangle>", "spotColors": ["Gold"]},
{"id": t, "name": "Type", "spotColors": ["Ink"]},
])
);
assert_eq!((i["spotColorNames"].clone(), i["spotColors"].clone()), (json!(["Gold", "Ink"]), json!(["Gold", "Ink"])));
// Objects counts the objects that Spot Color Objects lists.
assert_eq!(i["objects"]["spots"].as_u64(), Some(4), "{}", i["objects"]);
let objects = s.execute("document.info", &json!({"category": "objects"})).unwrap()["sections"][0]["rows"].clone();
assert!(objects.as_array().unwrap().contains(&json!(["Spot Color Objects", "4"])), "{objects}");
s.execute("select.set", &json!({"ids": [c, g, h, d, e, empty, blank, guide, compound, image.0]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap();
assert!(i["objects"]["spots"].is_null() && i["spotObjects"] == json!([]) && i["spotColors"] == json!([]), "{i}");
let i = s.execute("document.info", &json!({"category": "spotColors"})).unwrap();
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("spotColors"), Some("Spot Color Objects")));
assert_eq!(i["sections"][0]["rows"], json!([["Both", "Ink, Gold"], ["<Rectangle>", "Ink"], ["<Rectangle>", "Gold"], ["Type", "Ink"]]));
// A selection that holds the group and the object inside it lists that object once.
for ids in [json!([group, b]), json!([b, group])] {
s.execute("select.set", &json!({"ids": ids})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap();
assert_eq!(i["sections"][0]["rows"], json!([["<Rectangle>", "Ink"]]), "{ids}");
assert_eq!((i["objects"]["spots"].as_u64(), i["spotColorNames"].clone()), (Some(1), json!(["Ink"])), "{ids}");
}
s.execute("select.set", &json!({"ids": [t]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "spotColors"})).unwrap();
assert_eq!((i["sections"][0]["rows"].clone(), i["spotColors"].clone()), (json!([["Type", "Ink"]]), json!(["Ink"])));
let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("\nSPOT COLOR OBJECTS\nBoth: Ink, Gold\n<Rectangle>: Ink\n<Rectangle>: Gold\nType: Ink\n"), "{text}");
assert!(!text.contains("Spare") && !text.contains("Look"), "{text}");
}
/// Spot Color Objects lists a grayscale image colorized with a spot color, and leaves out a
/// gradient whose stops are all at an opacity of 0, which draws nothing.
#[test]
fn spot_color_objects_take_colorized_images_and_skip_clear_gradients() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0, "m": 1, "y": 1, "k": 0}, "spot": true})).unwrap();
let mut bytes = Vec::new();
image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap())
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
.unwrap();
let image = s
.edit("Place", |d, sel| {
d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(bytes));
let id = d.alloc_id();
let im = vectorcraft_doc::ImageObject {
key: "gray".into(),
width: 2,
height: 1,
xf: Default::default(),
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
})
.unwrap();
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
assert!(s.doc().unwrap().doc.node(image).unwrap().extras().colorize.is_some());
let clear = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 10, "height": 10})).unwrap()["id"].clone();
let stops =
json!([{"offset": 0, "color": "#000", "swatch": "Ink", "opacity": 0}, {"offset": 1, "color": "#fff", "swatch": "Ink", "opacity": 0}]);
s.execute("paint.setFill", &json!({"ids": [clear], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap();
let i = s.execute("document.info", &json!({})).unwrap();
let name = s.doc().unwrap().doc.node(image).unwrap().display_name();
assert_eq!(i["spotObjects"], json!([{"id": image.0, "name": name, "spotColors": ["Ink"]}]), "{i}");
assert_eq!(i["objects"]["spots"].as_u64(), Some(1), "{}", i["objects"]);
}
/// Document Info › Objects counts an object with a gradient or a pattern on both its fill and
/// its stroke once, and with Selection Only a selection that holds a group and an object inside
/// it counts that object once.
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New…",
["File"],
None,
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792 (each more than 0 and at most 3,999,856 pt, as artboard.new takes them), units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
always,
super::newdoc::file_new
),
+87 -20
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
has_selection,
expand
),
@@ -476,11 +476,14 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
} else {
// A gradient that repeats or reflects expands as its stops repeated over the box.
let (g, runs) = padded_on(g, bounds);
let g = &*g;
let frame = Frame::new(g, bounds);
match to {
GradientTo::Mesh => vec![Node::new(d.alloc_id(), NodeKind::Mesh(frame.mesh(&g.gradient)))],
GradientTo::Objects(n) => frame
.pieces(&g.gradient, n as usize)
.pieces(&g.gradient, n as usize, runs)
.into_iter()
.map(|(shape, (color, opacity))| {
let fill = FillLayer { opacity, overprint: f.overprint, ..FillLayer::new(Paint::solid(color)) };
@@ -1234,8 +1237,64 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
(super::paint::stored_color(store_in, c), a)
});
}
let (geom, spread) = (g.resolve(bounds), g.gradient.spreader());
Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32))
}
/// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond
/// gradient that repeats or reflects with its stops repeated as many times as the box needs
/// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the
/// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated
/// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from
/// there to 1.
fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) {
use std::borrow::Cow;
if !g.gradient.is_spread() {
return (Cow::Borrowed(g), None);
}
let geom = g.resolve(bounds);
Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32))
match g.gradient.padded_over(&geom, bounds) {
Some(p) => {
let runs = Some((p.first, p.runs));
(Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)
}
None => {
let runs = Some((0.0, g.gradient.repeat_count() as usize));
(Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)
}
}
}
/// The edges (gradient parameters, from `lo` to `hi`) of about `n` bands [`Frame::pieces`] cuts:
/// evenly spaced, or for stops repeated in `runs` ([`padded_on`]: where the first run starts and
/// how many runs lie evenly from there to 1) the same whole number of bands in each run between
/// `lo` and `hi` (at least one, about `n` in all), so every run starts on an edge and no band
/// holds two runs' colors.
fn band_edges(lo: f64, hi: f64, n: usize, runs: Option<(f64, usize)>) -> Vec<f64> {
let n = n.max(1);
let even = || {
let step = (hi - lo) / n as f64;
(0..=n).map(|k| lo + k as f64 * step).collect()
};
let Some((first, count)) = runs.filter(|&(first, count)| count > 0 && (0.0..1.0).contains(&first)) else { return even() };
let run = (1.0 - first) / count as f64;
let (from, to) = (lo.max(first), hi.min(1.0));
let shown = (to - from) / run;
if !(shown > 0.0 && shown.is_finite()) {
return even();
}
let step = run / (n as f64 / shown).round().max(1.0);
// Both whole numbers of steps from `first`, at most `n` plus the runs shown, plus one.
let (k0, k1) = (((from - first) / step).ceil(), ((to - first) / step).floor());
let mut edges = vec![lo];
for k in (k0 as usize)..(k1 as usize).saturating_add(1) {
let t = first + k as f64 * step;
if t > edges.last().copied().unwrap_or(lo) + 1e-12 && t < hi - 1e-12 {
edges.push(t);
}
}
edges.push(hi);
edges
}
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
@@ -1404,10 +1463,17 @@ impl Frame {
/// end, for a radial one, nested ellipses (largest first), for a conic one, wedges around the
/// center from each band's start to the seam, for a diamond one, nested squares (largest
/// first). Each paints over the ones below except its band, so no seam shows between bands.
/// With translucent stops each shape is just its band, so nothing doubles up.
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
/// With translucent stops each shape is just its band, so nothing doubles up. Stops repeated
/// in `runs` ([`padded_on`]) get the same whole number of bands in each run ([`band_edges`]).
fn pieces(&self, g: &Gradient, n: usize, runs: Option<(f64, usize)>) -> Vec<(PathData, (Color, f32))> {
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
let color = |e: &[f64], k: usize| {
let (a, b) = (e.get(k).copied().unwrap_or(0.0), e.get(k + 1).copied().unwrap_or(1.0));
g.sample_keep_model(((a + b) / 2.0) as f32)
};
if let Some((geom, b, _)) = &self.conic {
// Evenly spaced around the turn, as the wedges are.
let n = band_edges(0.0, 1.0, n, runs).len().saturating_sub(1);
return geom
.conic_wedges(*b, n, opaque)
.into_iter()
@@ -1419,51 +1485,52 @@ impl Frame {
return vec![(shapes::rectangle(*b), g.sample_keep_model(1.0))];
};
// Nested squares about the center, largest first, as a radial gradient's ellipses.
let step = reach / n as f64;
let edges = band_edges(0.0, *reach, n, runs);
let square = |t: f64| -> SubPath {
let pts: Vec<Point> = [0.0, 0.25, 0.5, 0.75].iter().map(|&u| *frame * (diamond_perimeter(u).to_vec2() * t).to_point()).collect();
SubPath::polyline(&pts, true)
};
return (0..n)
return (0..edges.len().saturating_sub(1))
.rev()
.map(|k| {
let mut shape = PathData::single(square((k + 1) as f64 * step));
let mut shape = PathData::single(square(edges.get(k + 1).copied().unwrap_or(*reach)));
if !opaque && k > 0 {
let mut hole = square(k as f64 * step);
let mut hole = square(edges.get(k).copied().unwrap_or(0.0));
hole.reverse();
shape.subpaths.push(hole);
}
(shape, g.sample_keep_model(((k as f64 + 0.5) * step) as f32))
(shape, color(&edges, k))
})
.collect();
}
let step = (self.t1 - self.t0) / n as f64;
let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32);
let edges = band_edges(self.t0, self.t1, n, runs);
let bands = edges.len().saturating_sub(1);
let edge = |k: usize| edges.get(k).copied().unwrap_or(self.t1);
if self.radial {
let ellipse = |t: f64| {
let c = self.focal + (self.start - self.focal) * t;
let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, c.x, c.y]);
shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m)
};
return (0..n)
return (0..bands)
.rev()
.map(|k| {
let mut shape = ellipse((k + 1) as f64 * step);
let mut shape = ellipse(edge(k + 1));
if !opaque && k > 0 {
let mut hole = ellipse(k as f64 * step).subpaths.remove(0);
let mut hole = ellipse(edge(k)).subpaths.remove(0);
hole.reverse();
shape.subpaths.push(hole);
}
(shape, color(k))
(shape, color(&edges, k))
})
.collect();
}
(0..n)
(0..bands)
.map(|k| {
let a = self.t0 + k as f64 * step;
let b = if opaque { self.t1 } else { a + step };
let a = edge(k);
let b = if opaque { self.t1 } else { edge(k + 1) };
let pts = [self.at(a, self.s0), self.at(b, self.s0), self.at(b, self.s1), self.at(a, self.s1)];
(PathData::single(SubPath::polyline(&pts, true)), color(k))
(PathData::single(SubPath::polyline(&pts, true)), color(&edges, k))
})
.collect()
}
+7 -4
View File
@@ -92,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, 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,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_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,
@@ -123,7 +123,10 @@ mod formats;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use access::list_dir;
pub use access::write_file_with;
pub use access::{write_file_with, write_save_file, write_save_file_with};
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests_readonly;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use access::{Placed, copy_new, file_metadata};
pub(crate) use access::{
+25
View File
@@ -112,6 +112,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write)
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other
/// writers keep their existing errors; an automation-root refusal is never reclassified.
pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> {
write_save_file_with(path, &mut |w| w.write_all(bytes))
}
/// [`write_save_file`] for a streamed save.
#[cfg(not(target_arch = "wasm32"))]
pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
vectorcraft_doc::cancel::check()?;
may_write(path)?;
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e))
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError {
let reason = format!("{path}: {error}");
if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) }
}
#[cfg(target_arch = "wasm32")]
pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
write_file_with(path, fill)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn create_dir(path: &str) -> Result<()> {
may_write(path)?;
+27 -12
View File
@@ -825,6 +825,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are
/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in.
/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is
/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG
/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]).
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
@@ -863,7 +866,16 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
// A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is
// seen, so it places the way it was taken (#4284); the others keep their own bytes.
let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some();
let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten();
let turned = orientation.is_some();
// The resolution describes the stored pixels; a quarter turn swaps its axes.
let quarter = orientation.is_some_and(|o| o.to_exif() >= 5);
let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) });
if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff"))
&& let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi)
{
return stored(tiff, "image/tiff", size, shown_ppi);
}
let kept = match f.id {
_ if turned => None,
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
@@ -879,36 +891,39 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
};
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
Some(img) => img,
Some(img) => image::DynamicImage::ImageRgba8(img),
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
Some(read) => read.map_err(err)?,
Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?),
None => {
use image::ImageDecoder as _;
let mut decoder = reader.into_decoder().map_err(err)?;
let decoder = reader.into_decoder().map_err(err)?;
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
let (w, h) = decoder.dimensions();
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
}
let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms);
let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?;
img.apply_orientation(orientation);
img.to_rgba8()
image::DynamicImage::from_decoder(decoder).map_err(err)?
}
},
};
img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms));
let mut img = img.into_rgba8();
if f.id == "tga" {
super::tgaread::fix_alpha(bytes, &mut img);
}
as_png(img, None, ppi)
as_png(img, None, shown_ppi)
}
/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when
/// it leaves them as stored, or the image has none.
/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with
/// extra samples) has its Orientation tag read from its first image
/// ([`vectorcraft_doc::cmyk::tiff_orientation`]).
pub(crate) fn exif_orientation(bytes: &[u8]) -> Option<image::metadata::Orientation> {
use image::ImageDecoder as _;
let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms)
use image::metadata::Orientation;
let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok();
let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?));
decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms)
}
/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its
+7 -2
View File
@@ -157,6 +157,11 @@ fn documents_folder() -> Option<std::path::PathBuf> {
Some(home.join("Documents"))
}
/// A Save As after a refused Save starts in Documents, else home, never the refused folder.
pub fn documents_dialog_folder() -> Option<String> {
documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder)
}
/// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't
/// set, is relative, or is the home folder itself (the spec's way of turning it off).
fn xdg_documents(text: &str, home: &std::path::Path) -> Option<std::path::PathBuf> {
@@ -1043,7 +1048,7 @@ 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 mut out = job.write_streamed(&mut super::write_file_with)?;
let mut out = job.write_streamed(&mut super::write_save_file_with)?;
job.complete(s, uid);
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
Ok(out)
@@ -1170,7 +1175,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. 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",
"{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. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+49 -8
View File
@@ -653,6 +653,11 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
None => e,
})?;
remember(s, &p);
// A prepared export is written later, maybe on another thread: it names the folder as this
// request may see it.
if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) {
export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned()));
}
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
leave_out_unreadable_folder(&mut r, folder);
}
@@ -663,10 +668,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
/// 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);
leave_out_folder_named(e, folder, &super::access::shown_path(folder))
}
/// [`leave_out_folder_from_error`] with `folder` named `name`.
fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError {
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) },
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,
}
}
@@ -699,9 +708,14 @@ fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String
/// `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;
if !super::access::shown(folder) {
leave_out_folder(r, folder);
}
}
/// The reply `r` of an export into `folder` with the ZIP's `path` left out and `files` named
/// relative to `folder` ([`leave_out_unreadable_folder`]).
fn leave_out_folder(r: &mut Value, folder: &str) {
let Some(o) = r.as_object_mut() else { return };
o.remove("path");
let inside = format!("{folder}/");
@@ -1074,7 +1088,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
// Include Artboard Fill off: the encoders leave the background colors out.
let fill = if o.include_artboard_fill == Some(false) { json!({ "includeArtboardFill": false }) } else { json!({}) };
let rows = rows.into_iter().map(|r| Row { options: merge(with_black_output(s, &r.options), fill.clone()), ..r }).collect();
let export = ScreenExport { cmd: C, o, rows, naming, items };
let export = ScreenExport { cmd: C, o, rows, naming, items, hidden: None };
match &mut s.screens_hand_off {
Some(slot) => {
*slot = Some(export);
@@ -1095,9 +1109,27 @@ pub struct ScreenExport {
rows: Vec<Row>,
naming: Naming,
items: Vec<Item>,
/// The export folder and the name the request that prepared the export may see it by, when the
/// folder came from the `exportPreset` and is outside that request's read roots: the reply and
/// the errors of [`ScreenExport::write`] name it as [`export_for_screens`] does.
hidden: Option<(String, String)>,
}
impl Session {
/// The folder `document.exportForScreens` (`id`) with `params` writes into, the client's
/// `folder` or else the one its `exportPreset` keeps, with the name the request being served may
/// see it by when the reply leaves it out: a preset's folder outside the read roots, named by
/// its own name ([`super::access::shown_path`]). `assets.export` takes no preset, so for it the
/// folder is the client's. → `None` without a folder (the files come back in the reply) or for
/// an unknown preset, which the export itself refuses.
pub fn screen_export_folder(&self, id: &str, params: &Value) -> Option<(String, Option<String>)> {
let given = params.get("folder").is_some();
let p = if given || id == ASSETS { params.clone() } else { with_preset(self, params).ok()? };
let folder = str_param(&p, "folder").filter(|f| !f.is_empty())?.to_string();
let hidden = (!given && !super::access::shown(&folder)).then(|| super::access::shown_path(&folder).into_owned());
Some((folder, hidden))
}
/// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as
/// a command (checked and journaled as any other; Export for Screens remembers its settings)
/// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`]
@@ -1123,10 +1155,19 @@ impl ScreenExport {
/// return → the reply of `document.exportForScreens`. It reports one progress step per file
/// ([`vectorcraft_doc::cancel::progress`]), and a cancel stops it before it encodes or writes
/// another file.
pub fn write(self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
pub fn write(mut self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let cmd = self.cmd;
let hidden = self.hidden.take();
// A refusal names `assets.export` when that command prepared the export, as its reply does.
encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) })
let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) });
match hidden {
Some((folder, name)) => {
let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?;
leave_out_folder(&mut r, &folder);
Ok(r)
}
None => r,
}
}
}
+248
View File
@@ -907,6 +907,254 @@ fn a_photo_places_the_way_its_exif_orientation_says() {
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top");
}
/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as
/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right,
/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it.
fn first_pixel_at(orientation: u16) -> (bool, bool) {
match orientation {
2 | 6 => (true, false),
3 | 7 => (true, true),
4 | 8 => (false, true),
_ => (false, false),
}
}
/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP
/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution
/// of `x` and `y` pixels per inch.
fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec<u8> {
let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat();
// Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow.
[
b"II".as_slice(),
&[0x2a, 0, 8, 0, 0, 0],
&4u16.to_le_bytes(),
&entry(0x0112, 3, u32::from(orientation)),
&entry(0x011a, 5, 62),
&entry(0x011b, 5, 70),
&entry(0x0128, 3, 2),
&[0, 0, 0, 0],
&[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(),
]
.concat()
}
/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's
/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation
/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned
/// a second time. Values outside 1 to 8 leave the photo as stored.
#[test]
fn a_quarter_turn_swaps_the_resolution_axes() {
// 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white.
let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) });
let mut jpg = Vec::new();
image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap();
for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat();
// Without a JFIF density the resolution comes from the EXIF block.
let mut turned = jpg[..2].to_vec();
turned.extend([0xff, 0xe1]);
turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
turned.extend(&exif);
turned.extend(&jpg[2..]);
let what = format!("orientation {orientation}");
assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored");
let quarter = (5..=8).contains(&orientation);
let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) };
let img = raster_image(&turned).unwrap();
assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}");
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8();
let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80;
let (w, h) = size;
let corners = [(false, false), (true, false), (true, true), (false, true)];
let reds: Vec<_> =
corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect();
assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner");
let again = raster_image(&img.blob.bytes).unwrap();
assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again");
}
}
/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a
/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many
/// pixels per inch: its first directory copied to the end of the file with the tag added in order,
/// and the header pointing there, so that every other offset in the file still holds.
fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option<u32>) -> Vec<u8> {
let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap();
let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]]));
let mut entries: Vec<Vec<u8>> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect();
let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n);
let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) };
entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat());
let mut out = tiff.to_vec();
if out.len() % 2 == 1 {
out.push(0);
}
if let Some(y) = y_ppi {
let rational = u32::try_from(out.len()).unwrap();
out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat());
let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap();
e[8..12].copy_from_slice(&rational.to_le_bytes());
}
let ifd = u32::try_from(out.len()).unwrap();
out[4..8].copy_from_slice(&ifd.to_le_bytes());
out.extend(u16::try_from(n + 1).unwrap().to_le_bytes());
out.extend(entries.concat());
out.extend([0; 4]);
out
}
/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed
/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284),
/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes.
#[test]
fn tiffs_read_without_the_image_decoder_follow_their_orientation() {
// 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque.
let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]);
let rows: Vec<Vec<bool>> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect();
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression: 2,
t4_options: 0,
photometric: 0,
fill_order: 1,
rows_per_strip: 2,
tile: None,
rtc: false,
};
let fax = vectorcraft_testkit::tiff::group3(&g);
// The Group 3 file declares no resolution.
for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] {
assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now");
for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) {
let what = format!("{kind}, orientation {orientation}, LONG {long}");
let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}"));
let quarter = orientation >= 5;
let size = if quarter { (2, 3) } else { (3, 2) };
let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) });
assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}");
assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image");
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8();
let (right, bottom) = first_pixel_at(orientation);
let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect();
assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner");
}
}
}
/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores
/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes.
/// Read again, the stored image is not turned a second time.
#[test]
fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() {
// 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black.
let first: [u8; 4] = [255, 0, 0, 0];
let other: [u8; 4] = [0, 0, 0, 77];
let channel = |i: usize| -> Vec<u16> { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() };
for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] {
let channels: Vec<Vec<u16>> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
let file = hand_tiff(5, bits, &[], planar, &channels);
for orientation in 1..=8 {
let what = format!("{bits} bits, planar {planar}, orientation {orientation}");
let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}"));
let quarter = orientation >= 5;
let size = if quarter { (2, 3) } else { (3, 2) };
let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) };
assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}");
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost"));
assert_eq!((inks.width, inks.height), size, "{what}: the inks' size");
let (right, bottom) = first_pixel_at(orientation);
let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
for (i, px) in inks.data.chunks(4).enumerate() {
let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0);
assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})");
}
let again = raster_image(&img.blob.bytes).unwrap();
assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again");
}
}
}
/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK
/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and
/// the stored image, read again, is not turned a second time.
#[test]
fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() {
use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions};
// 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black.
let inks: Vec<u8> =
(0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect();
let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() };
let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap();
for orientation in 1..=8u16 {
let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat();
let mut file = jpg[..2].to_vec();
file.extend([0xff, 0xe1]);
file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes());
file.extend(&exif);
file.extend(&jpg[2..]);
let what = format!("orientation {orientation}");
let quarter = orientation >= 5;
let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) };
let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" };
let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}");
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost"));
assert_eq!((inks.width, inks.height), size, "{what}: the inks' size");
let (w, h) = size;
let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec();
let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40;
let corners = [(false, false), (true, false), (true, true), (false, true)];
let cyans: Vec<_> =
corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect();
assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner");
let black = at(w / 2, h / 2);
assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}");
let again = raster_image(&img.blob.bytes).unwrap();
assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again");
}
}
/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read
/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a
/// half turn keeps them.
#[test]
fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() {
use image::ImageEncoder as _;
let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] }));
for orientation in [3u16, 6, 8] {
let mut png = Vec::new();
let mut enc = image::codecs::png::PngEncoder::new(&mut png);
enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap();
enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap();
let png = super::ppi::with_png_resolution(&png, (150.0, 300.0));
let mut webp = Vec::new();
let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp);
enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap();
enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap();
let channel = |c: usize| -> Vec<u16> { rgb.pixels().map(|p| u16::from(p[c])).collect() };
let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300));
for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] {
let what = format!("{kind}, orientation {orientation}");
assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored");
let quarter = orientation >= 5;
let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) };
let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}");
assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image");
let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8();
let (right, bottom) = first_pixel_at(orientation);
let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 });
let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect();
assert_eq!(black, [at], "{what}: the first stored pixel's corner");
}
}
}
/// With `importer` named, a file up to the importer's limit is read whole and a larger one is an
/// error, whatever its content.
#[test]
@@ -0,0 +1,52 @@
//! Save errors retain their type without changing the other filesystem writers (#6146).
use std::io::{self, ErrorKind};
use serde_json::json;
use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with};
use crate::{EngineError, READ_ONLY_LOCATION, Session};
#[test]
fn readonly_error_is_typed_and_other_writers_keep_their_error() {
let dir = vectorcraft_testkit::temp_dir("save-refusal");
let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned();
for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] {
let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err();
let reason = format!("{path}: injected");
assert!(matches!(&ordinary, EngineError::Other(_)));
assert_eq!(ordinary.to_string(), reason);
if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) {
assert!(matches!(&typed, EngineError::ReadOnlyLocation(_)));
assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}"));
} else {
assert!(matches!(&typed, EngineError::Other(_)));
assert_eq!(typed.to_string(), reason);
}
assert!(!std::path::Path::new(&path).exists());
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn readonly_failed_save_keeps_document_and_options() {
let mut session = Session::new();
session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap();
session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
let st = session.doc_mut().unwrap();
st.path = Some("Poster.svg".into());
st.format = "svg";
st.save_options = json!({"decimals": 3}).as_object().unwrap().clone();
let before = serde_json::to_value(&*st.doc).unwrap();
let options = st.save_options.clone();
let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap();
let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err();
assert!(matches!(e, EngineError::ReadOnlyLocation(_)));
let st = session.doc().unwrap();
assert_eq!(st.path.as_deref(), Some("Poster.svg"));
assert_eq!(st.format, "svg");
assert_eq!(st.save_options, options);
assert!(st.is_dirty());
assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before);
}
+76 -1
View File
@@ -730,11 +730,86 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_
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 root = crate::cmd::findfiles::plain(inside.canonicalize().unwrap()).display().to_string();
let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned();
assert!(e.contains(&format!("root {root}: {name}")), "{e}");
}
/// A prepared export into the folder an export preset keeps, prepared for a client whose read roots
/// don't hold that folder and written later outside that client's scope (as on the background
/// thread): its reply leaves out the ZIP's path and names the files relative to the folder, and a
/// refused write names the folder by its name. A folder inside the read roots, and one the client
/// gives, stay whole.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out() {
use crate::file_access::{AutomationRoots, confine};
let mut s = session(1);
let tmp = Folder::new("prepared-roots");
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": "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 prepare = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, p: Value| {
confine(roots.as_ref(), || s.prepare_screen_export("document.exportForScreens", &p)).unwrap()
};
let (r, files) = write_prepared(prepare(&mut s, &write_only, json!({"exportPreset": "Kept"})));
assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}");
assert!(files.contains_key(&format!("{folder}/1x/Artboard-1.png")), "the files are written into the folder");
let (r, _) = write_prepared(prepare(&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}");
// A refused write names the folder by its name.
let refused = prepare(&mut s, &write_only, json!({"exportPreset": "Kept"}));
let e = refused.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string();
assert!(e.contains("kept-out/1x/Artboard-1.png: access denied") && !e.contains(&folder), "{e}");
// Inside the read roots, or given by the client: whole.
let (r, _) = write_prepared(prepare(&mut s, &both, json!({"exportPreset": "Kept"})));
assert_eq!(r["files"], json!([format!("{folder}/1x/Artboard-1.png")]), "{r}");
let (r, _) = write_prepared(prepare(&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}");
let given = prepare(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder}));
let e = given.write(&mut |path, _| Err(crate::EngineError::Other(format!("{path}: access denied")))).unwrap_err().to_string();
assert!(e.contains(&folder), "{e}");
}
/// [`Session::screen_export_folder`]: the client's folder, else the one the export preset keeps,
/// with its own name when the request's read roots don't hold it; none without a folder, for an
/// unknown preset or for `assets.export`, which takes no preset.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_export_folder_is_the_clients_else_the_presets() {
use crate::file_access::{AutomationRoots, confine};
let mut s = session(1);
let tmp = Folder::new("export-folder");
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 ask = |id: &str, p: Value| confine(confined.as_ref(), || s.screen_export_folder(id, &p));
let screens = "document.exportForScreens";
assert_eq!(ask(screens, json!({"exportPreset": "Kept"})), Some((folder.clone(), Some("kept-out".to_string()))));
assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": "/x/y"})), Some(("/x/y".to_string(), None)));
assert_eq!(ask(screens, json!({"exportPreset": "Kept", "folder": ""})), None);
assert_eq!(ask(screens, json!({"exportPreset": "Nope"})), None);
assert_eq!(ask(screens, json!({})), None);
assert_eq!(ask("assets.export", json!({"exportPreset": "Kept"})), None);
assert_eq!(s.screen_export_folder(screens, &json!({"exportPreset": "Kept"})), Some((folder, None)), "unconfined: whole");
}
/// 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.
+11 -1
View File
@@ -183,6 +183,16 @@ fn a_picked_folder_inside_other_apps_folders_is_refused() {
assert!(p.error().is_some_and(|e| e.ends_with("is not a folder")), "{p:?}");
let p = search(&root, "missing", Limits::default(), 1, &[]);
assert!(p.error().is_some_and(|e| e.starts_with(&root.join("missing").display().to_string())), "{p:?}");
// Each failure has the same reason without the folder's path.
let briefs = [
("missing", "the folder can't be read"),
("file.ttf", "the path isn't a folder"),
("refused", "VectorCraft doesn't search other apps' or the system's folders"),
("Users/Public/x", "VectorCraft doesn't search the Shared or Public folder in Users"),
];
for (picked, why) in briefs {
assert_eq!(search(&root, picked, Limits::default(), 1, &[]).failure(), Some(why), "{picked}");
}
}
#[test]
@@ -362,7 +372,7 @@ fn a_picked_folder_that_cant_be_listed_fails_the_search() {
let p = search(&root, "locked", Limits::default(), 2, &["y.ttf"]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
if enforced {
assert!(matches!(&p.end, Some(End::Failed(e)) if e.contains("locked")), "{p:?}");
assert!(matches!(&p.end, Some(End::Failed(e, "the folder can't be listed")) if e.contains("locked")), "{p:?}");
} else {
assert_eq!(found(&root, &p), ["locked/y.ttf"]);
}
+51 -15
View File
@@ -23,6 +23,13 @@ const CHECK_EVERY: u64 = 1024;
/// How a search ends when every walker thread stopped on a bug.
const INTERNAL: &str = "every search thread stopped on an internal error (please report this bug)";
/// Why a search couldn't start, without the folder's path ([`Progress::failure`]).
const UNREADABLE: &str = "the folder can't be read";
const NOT_A_FOLDER: &str = "the path isn't a folder";
const SHARED: &str = "VectorCraft doesn't search the Shared or Public folder in Users";
const APPS: &str = "VectorCraft doesn't search other apps' or the system's folders";
const UNLISTED: &str = "the folder can't be listed";
/// What a search looks for.
pub trait Visitor: Send + Sync {
/// Whether the regular file named `name` is to be read: asked of the files a folder lists until
@@ -77,8 +84,9 @@ pub enum End {
/// reached their [`Limits`]. Folders deeper than [`Limits::depth`] are skipped and an item
/// keeps at most [`Limits::per_item`] files, without ending the search.
Limit,
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search.
Failed(String),
/// It couldn't start: the folder can't be read, isn't a folder or isn't one to search. Why,
/// and the same without the folder's path.
Failed(String, &'static str),
}
/// Where a search stands ([`Search::progress`]).
@@ -114,7 +122,7 @@ impl Progress {
None => "searching",
Some(End::Done | End::Found) => "done",
Some(End::Stopped | End::Time | End::Limit) => "stopped",
Some(End::Failed(_)) => "failed",
Some(End::Failed(..)) => "failed",
}
}
@@ -131,10 +139,38 @@ impl Progress {
/// Why a failed search failed.
pub fn error(&self) -> Option<&str> {
match &self.end {
Some(End::Failed(e)) => Some(e),
Some(End::Failed(e, _)) => Some(e),
_ => None,
}
}
/// Why a failed search failed, without the folder's path.
pub fn failure(&self) -> Option<&'static str> {
match &self.end {
Some(End::Failed(_, why)) => Some(why),
_ => None,
}
}
/// Keep what the caller asking now may read under its read roots ([`crate::file_access::current`]),
/// for a reply to it: the files found inside them. Returns the folder searched (`given` until a
/// walker checked it) and why the search failed, when that folder lies inside them too;
/// otherwise no folder and why without its path ([`Self::failure`]). Without read roots in
/// force, everything is kept.
pub fn keep_readable(&mut self, given: &str) -> (Option<String>, Option<String>) {
let folder = if self.folder.as_os_str().is_empty() { given.to_string() } else { self.folder.to_string_lossy().into_owned() };
if crate::file_access::current().is_none() {
return (Some(folder), self.error().map(str::to_string));
}
// A path that isn't valid text is never shown: its lossy form names another file.
let readable = |p: &Path| p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
self.hits.retain(|(path, _)| readable(path));
if readable(Path::new(given)) && (self.folder.as_os_str().is_empty() || readable(&self.folder)) {
(Some(folder), self.error().map(str::to_string))
} else {
(None, self.failure().map(str::to_string))
}
}
}
/// Walker threads shared out among searches: all of them together run at most `cap` threads.
@@ -306,7 +342,7 @@ pub(crate) fn start_in(
return Err("the search couldn't start: try again in a moment".into());
}
if w.alive == 0 && w.end.is_none() {
shared.finish(&mut w, End::Failed(INTERNAL.into()));
shared.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
}
Ok(Search { shared })
@@ -401,13 +437,13 @@ impl Shared {
fn prepare(&self, root: &Path) {
let rules = self.rules.get_or_init(|| self.given.clone().unwrap_or_else(Rules::system));
let path = root.display();
let checked = match std::fs::canonicalize(root) {
Err(e) => Err(format!("{path}: {e}")),
Ok(folder) if !folder.is_dir() => Err(format!("{path} is not a folder")),
let (checked, brief) = match std::fs::canonicalize(root) {
Err(e) => (Err(format!("{path}: {e}")), UNREADABLE),
Ok(folder) if !folder.is_dir() => (Err(format!("{path} is not a folder")), NOT_A_FOLDER),
Ok(folder) => match rules.refusal(&folder) {
None => Ok(folder),
Some(Refusal::Shared) => Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")),
Some(Refusal::Apps) => Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")),
None => (Ok(folder), INTERNAL),
Some(Refusal::Shared) => (Err(format!("VectorCraft doesn't search the Shared or Public folder in Users: {path}")), SHARED),
Some(Refusal::Apps) => (Err(format!("VectorCraft doesn't search other apps' or the system's folders: {path}")), APPS),
},
};
let mut w = self.lock();
@@ -416,7 +452,7 @@ impl Shared {
w.folder = folder.clone();
w.stack.push((folder, 0));
}
Err(e) => self.finish(&mut w, End::Failed(e)),
Err(e) => self.finish(&mut w, End::Failed(e, brief)),
}
}
@@ -430,7 +466,7 @@ impl Shared {
let mut w = self.lock();
// The picked folder itself: the search can't run, and says why.
if depth == 0 {
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display())));
self.finish(&mut w, End::Failed(format!("{}: {e}", plain(dir.to_path_buf()).display()), UNLISTED));
} else {
w.unreadable += 1;
w.folders += 1;
@@ -610,7 +646,7 @@ fn walker(s: &Shared) {
if done.is_err() {
let mut w = s.lock();
match job {
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into())),
Job::Prepare(_) => s.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL)),
Job::List(..) => w.unreadable += 1,
}
}
@@ -640,7 +676,7 @@ impl Drop for Alive {
let mut w = self.0.lock();
w.alive = w.alive.saturating_sub(1);
if w.alive == 0 && w.end.is_none() {
self.0.finish(&mut w, End::Failed(INTERNAL.into()));
self.0.finish(&mut w, End::Failed(INTERNAL.into(), INTERNAL));
}
drop(w);
self.0.pool.release(1);
+18 -10
View File
@@ -76,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Font Files",
[],
None,
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
"{folder?: an absolute path, fonts?: [{family, style?}] (1 to 1000; default: the active document's missing fonts, as text.missingFonts lists them), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders for font files that make the fonts available, on separate threads, and answer at once (state: searching); without folder: the last search's state, with the files found so far. A search reads the table directories and the name, fvar and OS/2 tables of .ttf, .otf, .ttc and .otc files of at most 256 MB and, on macOS, of suitcase fonts (files without an extension or with .suit, whose fonts are in a resource fork of at most 8 MB), and lists a file for a font when the file, added with text.addFontFiles, makes the font resolve exactly as text.fonts resolves it; fonts that resolve exactly already are left out. It doesn't enter app packages, folders named Program Files (also x86 and Arm), ProgramData, $Recycle.Bin or System Volume Information, the system's folders at the root of a volume, the Shared and Public folders in Users, a home folder's Library, AppData, Applications, snap and dot folders, a Library folder's app data folders or the folders the environment names for apps' data, and a search of a folder inside one of them fails; the walk also skips other hidden folders, folders whose contents are in the cloud and folders more than 64 levels deep, and follows no links (a picked path is resolved through its links). With automation read roots, a reply leaves out the files found outside the caller's roots, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, fonts: [{family, style, status, files: [paths]}], searched?: {folders, files, fontFiles}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
),
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Font Files",
[],
None,
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
"{files: [paths the last text.findFontFiles search found] (1 to 1000)} copy the font files into VectorCraft's own Fonts folder (next to the preferences; the desktop app and vectorcraft-cli read it), never replacing a file there: the names Name.otf, Name 2.otf … Name 99.otf are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is kept; then scan the fonts again, as text.rescanFonts does, and type set in them redraws. Only regular .ttf, .otf, .ttc, .otc, .woff and .woff2 files and suitcase fonts (with their resource fork) are copied, at most 256 MB a file and 1 GB in all; any other file is skipped. Add only fonts you own or are licensed to install. With automation read roots, folder is null when the Fonts folder is outside them, and a to or file path outside them is given as its file name; none on the web → {folder, copied: [{from, to}], kept: [{from, to}], skipped: [{file, reason}], families?, faces?}",
always,
add
),
@@ -275,7 +275,9 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
/// Where the session's last search stands (`text.findFontFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(f) = &s.font_search else { return json!({ "state": "idle", "fonts": [] }) };
let p = f.search.progress();
let mut p = f.search.progress();
// Only what the caller asking now may read: a search another caller started can hold more.
let (folder, error) = p.keep_readable(&f.folder);
let mut files: Vec<Vec<String>> = vec![vec![]; f.fonts.len()];
for (path, items) in &p.hits {
for i in items {
@@ -293,16 +295,18 @@ fn state(s: &Session) -> Value {
json!({ "family": font.family, "style": font.style, "status": font.status.as_str(), "files": files })
})
.collect();
let folder = if p.folder.as_os_str().is_empty() { f.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
"id": f.id, "state": p.state(), "folder": folder, "fonts": fonts,
"id": f.id, "state": p.state(), "fonts": fonts,
"searched": { "folders": p.folders, "files": p.files, "fontFiles": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
if let Some(folder) = folder {
out["folder"] = json!(folder);
}
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
if let Some(e) = p.error() {
if let Some(e) = error {
out["error"] = json!(e);
}
out
@@ -348,12 +352,16 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
// VectorCraft's own Fonts folder: no automation root applies to it.
crate::file_access::unconfined(|| super::fileio::create_dir(&dir.to_string_lossy()))?;
let report = copy_into(&files, dir);
let pairs = |v: &[(PathBuf, PathBuf)]| {
v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": to.to_string_lossy() })).collect::<Vec<_>>()
// The caller sees the Fonts folder and the copies in it only where its read roots reach.
let readable = |p: &Path| crate::file_access::current().is_none() || p.to_str().is_some_and(|p| crate::file_access::check_read(p).is_ok());
let shown = |p: &Path| -> String {
let shown = if readable(p) { Some(p.as_os_str()) } else { p.file_name() };
shown.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()
};
let pairs = |v: &[(PathBuf, PathBuf)]| v.iter().map(|(from, to)| json!({ "from": from.to_string_lossy(), "to": shown(to) })).collect::<Vec<_>>();
let mut out = json!({
"folder": dir.to_string_lossy(), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": file.to_string_lossy(), "reason": why })).collect::<Vec<_>>(),
"folder": readable(dir).then(|| dir.to_string_lossy()), "copied": pairs(&report.copied), "kept": pairs(&report.kept),
"skipped": report.skipped.iter().map(|(file, why)| json!({ "file": shown(file), "reason": why })).collect::<Vec<_>>(),
});
if !report.copied.is_empty() || !report.kept.is_empty() {
// The fonts are cataloged and type set in them redraws, as Refresh Font List does it.
+45 -7
View File
@@ -20,6 +20,9 @@
//! and they lie the same way relative to each other: the group as a whole is moved or scaled.
//! With Appearance on, each of those objects must have the same fill and stroke too; with Size,
//! the groups the same width and height.
//! - Selecting one of the repeated objects with the Selection tool highlights the similar objects
//! at once, before Global Edit starts. Here they are the objects Start Global Edit would take
//! with the current Global Edit Options ([`similar`]).
//! - While Global Edit is on, an edit made to one of the found objects is made to all of them,
//! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt
//! match to 10 pt. Stop Global Edit (or Escape) ends it.
@@ -780,18 +783,55 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se
/// The outlines of the members of the active document's Global Edit that are not selected, hold
/// no selected object and sit inside no selected object, in their layer's colour: the canvas
/// highlights them while Global Edit is on.
/// highlights them while Global Edit is on. While it is off, with one supported object selected,
/// they are the outlines of the objects Start Global Edit would take with the Global Edit Options
/// ([`similar`]).
pub fn highlights(s: &Session) -> Outlines {
let Some(st) = s.active() else { return vec![] };
let Some(ge) = &st.global_edit else { return vec![] };
let Some(ge) = &st.global_edit else { return similar(s) };
if let Some((rev, out)) = &*ge.outlines.borrow()
&& *rev == st.revision
{
return out.clone();
}
let out = outlines(st, &ge.members);
*ge.outlines.borrow_mut() = Some((st.revision, out.clone()));
out
}
/// [`similar`]'s outlines as of a document revision and the options they were found with.
pub type SimilarCache = std::cell::RefCell<Option<(u64, Options, Outlines)>>;
/// Before Global Edit starts: with one supported object selected, the outlines of the other
/// objects Start Global Edit would take now with the session's Global Edit Options, so the canvas
/// shows them as soon as the object is selected. None while an interaction (a drag) is in
/// progress, or when fewer than two objects would be taken. Kept per document revision and
/// options, as finding them walks the whole document.
pub fn similar(s: &Session) -> Outlines {
let Some(st) = s.active() else { return vec![] };
if st.global_edit.is_some() || st.interaction.is_some() || st.selection.objects.len() != 1 {
return vec![];
}
let o = &s.global_edit_options;
if let Some((rev, opts, out)) = &*st.similar_outlines.borrow()
&& *rev == st.revision
&& opts == o
{
return out.clone();
}
let out = match matches(s, o) {
Ok(m) if m.len() >= 2 => outlines(st, &m),
_ => vec![],
};
*st.similar_outlines.borrow_mut() = Some((st.revision, o.clone(), out.clone()));
out
}
/// The outlines of `members` that are not selected, hold no selected object and sit inside no
/// selected object, in their layer's color.
fn outlines(st: &crate::DocState, members: &[(NodeId, Affine)]) -> Outlines {
let symbols = st.doc.symbol_boxes();
let out: Vec<_> = ge
.members
members
.iter()
.filter_map(|(id, _)| {
let n = st.doc.node(*id).filter(|n| !holds(n, &st.selection.objects))?;
@@ -804,9 +844,7 @@ pub fn highlights(s: &Session) -> Outlines {
};
Some((path, st.doc.layer_color(*id)))
})
.collect();
*ge.outlines.borrow_mut() = Some((st.revision, out.clone()));
out
.collect()
}
#[cfg(test)]
+55 -2
View File
@@ -1,7 +1,8 @@
//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector.
use serde_json::{Value, json};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint};
use vectorcraft_color::gradient::MAX_REPEATS;
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientSpread, GradientStop, Paint};
use vectorcraft_doc::appearance::stroke_paint_bounds;
use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode};
use vectorcraft_geom::{Affine, Point, Rect};
@@ -23,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
@@ -129,6 +130,36 @@ fn method_param(p: &Value) -> Parsed<Option<GradientMethod>> {
}
}
/// The `spread` param of `p`, if given: what the gradient paints past the ends of its stops.
fn spread_param(p: &Value) -> Parsed<Option<GradientSpread>> {
match p.get("spread") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(GradientSpread::parse).map(Some).ok_or_else(|| "`spread` must be pad, repeat or reflect".into()),
}
}
/// The `repeats` param of `p`, if given: how many times the stops run along the vector with
/// Repeat and Reflect, a whole number from 1 to [`MAX_REPEATS`].
fn repeats_param(p: &Value) -> Parsed<Option<u32>> {
match p.get("repeats") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|n| n.fract() == 0.0 && (1.0..=f64::from(MAX_REPEATS)).contains(n))
.map(|n| Some(n as u32))
.ok_or_else(|| format!("`repeats` must be a whole number from 1 to {MAX_REPEATS}")),
}
}
/// An error when `p` gives a spread other than pad for a gradient of `kind` that has no ends to
/// spread past (freeform).
fn spread_for(kind: GradientKind, p: &Value) -> Parsed<()> {
match spread_param(p)? {
Some(s) if !s.is_pad() && kind == GradientKind::Freeform => Err("`spread` applies to linear, radial, conic and diamond gradients".into()),
_ => Ok(()),
}
}
/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d`
/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and
/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps
@@ -271,6 +302,9 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
Some(v) => v.as_bool().ok_or("`dither` must be true or false")?,
};
gp.gradient.method = method_param(g)?.unwrap_or_default();
spread_for(kind, g)?;
gp.gradient.spread = spread_param(g)?.unwrap_or_default();
gp.gradient.repeats = repeats_param(g)?.unwrap_or(1);
gp.angle = f64_or(g, "angle", 0.0);
gp.swatch = str_param(g, "swatch").map(str::to_string);
let geom = GeomEdit::parse(g)?;
@@ -422,6 +456,13 @@ fn apply_gradient_edit_in_model(
if let Some(m) = method_param(p)? {
gp.gradient.method = m;
}
spread_for(gp.gradient.kind, p)?;
if let Some(sp) = spread_param(p)? {
gp.gradient.spread = sp;
}
if let Some(n) = repeats_param(p)? {
gp.gradient.repeats = n;
}
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
let before = gp.geom;
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
@@ -516,6 +557,18 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => std::borrow::Cow::Borrowed(p),
};
// A spread for selected gradients leaves a freeform default (which has no ends) as it is.
let spreads = matches!(spread_param(p), Ok(Some(sp)) if !sp.is_pad());
let for_default = match for_default {
q if spreads && !ids.is_empty() && default_kind == Some(GradientKind::Freeform) => {
let mut q = q.into_owned();
if let Some(o) = q.as_object_mut() {
o.remove("spread");
}
std::borrow::Cow::Owned(q)
}
q => q,
};
let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model)
.map_err(|e| bad(C, e))?;
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
+12 -6
View File
@@ -4,7 +4,7 @@
//! The built-in presets are the gradient swatches of the built-in swatch libraries
//! ([`vectorcraft_color::libraries`]: the app's own computed palettes), each listed under its
//! library's name. Saved presets are kept with the preferences ([`crate::Prefs::gradient_presets`]),
//! so every document has them. A preset is the colour ramp (type, stops, method, dither), not a
//! so every document has them. A preset is the color ramp (type, stops, method, dither, spread), not a
//! placement: applying one edits the gradient in place, as `paint.editGradient` does.
use serde::{Deserialize, Serialize};
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Presets",
[],
None,
"{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique",
"{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?, spread?, repeats?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique",
always,
list
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Gradient Preset",
[],
None,
"{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops and method, and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}",
"{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops, method, spread and repeats (a freeform preset's: the first three), and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}",
has_doc,
apply
),
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Gradient Preset",
[],
None,
"{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method and dither (stops linked to a swatch keep their colour, unlinked) → {name}",
"{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method, dither, spread and repeats (stops linked to a swatch keep their colour, unlinked) → {name}",
can_save,
save
),
@@ -90,12 +90,13 @@ fn builtin_presets() -> impl Iterator<Item = GradientPresetEntry> {
})
}
/// Do `a` and `b` have the same colour ramp (type, method, dither and stops), swatch links aside?
/// Do `a` and `b` have the same color ramp (type, method, dither, spread as it paints and stops),
/// swatch links aside?
fn same_ramp(a: &Gradient, b: &Gradient) -> bool {
let stop = |x: &vectorcraft_color::GradientStop, y: &vectorcraft_color::GradientStop| {
(x.offset, x.color, x.opacity, x.midpoint) == (y.offset, y.color, y.opacity, y.midpoint)
};
(a.kind, a.method, a.dither) == (b.kind, b.method, b.dither)
(a.kind, a.method, a.dither, a.painted_spread()) == (b.kind, b.method, b.dither, b.painted_spread())
&& a.stops.len() == b.stops.len()
&& a.stops.iter().zip(&b.stops).all(|(x, y)| stop(x, y))
}
@@ -215,6 +216,11 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
o.insert("kind".into(), json!(g.kind.label().to_lowercase()));
o.insert("stops".into(), stops_json(&g.stops));
o.insert("method".into(), json!(g.method.label().to_lowercase()));
// A freeform preset takes no spread (paint.editGradient refuses one).
if g.kind != vectorcraft_color::GradientKind::Freeform {
o.insert("spread".into(), json!(g.spread.label().to_lowercase()));
o.insert("repeats".into(), json!(g.repeat_count()));
}
// Dither is a fill's (paint.editGradient refuses it on a stroke).
if !stroke {
o.insert("dither".into(), json!(g.dither));
+8 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Artboard",
["Window", "Artboards"],
None,
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)",
"{x?, y?, width?, height? (each more than 0 and at most 3,999,856 pt, as file.new takes them), name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), guideLabel?: name | \"#rrggbb\" | null (its guide label, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)",
has_doc,
artboard_new
),
@@ -926,6 +926,9 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let guide_label = artboard_guide_label(p, "artboard.new")?.flatten();
check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?;
check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?;
if ["width", "height"].iter().filter_map(|k| p.get(*k).and_then(Value::as_f64)).any(|side| !super::newdoc::side_in_range(side)) {
return Err(super::newdoc::side_error("artboard.new", d0.units));
}
let r = match artboard_side(p, "artboard.new")? {
Some(side) => {
if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
@@ -941,6 +944,10 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")),
None => rect_from(p, def),
};
// A size taken from another artboard (`side`, or width or height left out) keeps to the limit too.
if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) {
return Err(super::newdoc::side_error("artboard.new", d0.units));
}
let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty());
let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false));
let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false);
+16 -7
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Linked Files",
[],
None,
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits; none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
"{folder?: an absolute path, ids?: [image or placed document ids] (default: the active document's missing links), maxSeconds?: 60 (1 to 600), stop?: false (true: stop the last search)} with folder: look in it and its subfolders, on separate threads, for files with the names of the linked files (in any letter case), and answer at once with the search's state (searching while it runs); without folder: the last search's state, with the files found so far. Each linked file's files come best first: same (the size and hash saved with the link), size (the saved size, where there is no saved hash or the file is larger than 1 GiB), then name. Within each kind, a file whose name has the link's letter case comes first. Then each file's folder names are compared with the saved path's, from the innermost outward up to the first one that differs, and a file with more matching names comes first; files that still tie are listed by path. Before the comparison, the saved path's parent-folder steps (.., or the empty components of a classic Mac :: run) are resolved, and . components in a slash path are skipped. A search looks for at most 1000 linked files and keeps the first 10 files of each one's name, and up to 10 that are the same file; it ends once every linked file has its same file, and searches only the folders text.findFontFiles may search, with the same maxSeconds, depth and count limits. With automation read roots, a reply leaves out the files found outside the caller's roots, a link's path outside them, and the folder when it is outside them (error then gives the reason without the path); none on the web → {id (absent while idle), state: idle|searching|done|stopped|failed, folder?, links: [{name, path?, ids, files: [{path, size, match: same|size|name, caseDiffers?: true}]}], searched?: {folders, files, read}, skipped?, unreadable?, stopped?: stop|time|limit, error?, seconds?}",
always,
find
)]
@@ -52,6 +52,8 @@ pub(crate) struct LinkSearch {
pub(crate) id: u64,
/// The folder as given.
pub(crate) folder: String,
/// The path of the document the links were taken from, which a relative link path starts from.
pub(crate) doc: Option<String>,
pub(crate) links: Vec<Sought>,
/// The size of each file read, by the path the search reports ([`LinkFiles::sizes`]).
pub(crate) sizes: Arc<Mutex<HashMap<PathBuf, u64>>>,
@@ -163,6 +165,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
if links.is_empty() {
return Err(bad(C, if ids.is_some() { "these objects show no linked files" } else { "no linked files are missing" }));
}
let doc = super::links::LinkBase::of(st).doc.map(str::to_string);
// A new search stops the last one.
s.link_search = None;
let threads = s.search_threads.unwrap_or_else(findfiles::threads);
@@ -171,7 +174,7 @@ fn find(s: &mut Session, p: &Value) -> Result<Value> {
let visitor = Arc::new(LinkFiles::new(&links, sizes.clone()));
let search =
findfiles::start(PathBuf::from(&folder), s.search_rules.clone(), limits, threads, 2 * links.len(), visitor).map_err(EngineError::Other)?;
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, links, sizes, search });
s.link_search = Some(LinkSearch { id: NEXT.fetch_add(1, Ordering::Relaxed), folder, doc, links, sizes, search });
Ok(state(s))
}
@@ -209,7 +212,9 @@ fn shared_folders(saved: &str, found: &Path) -> usize {
/// Where the session's last search stands (`links.findFiles` without `folder`).
fn state(s: &Session) -> Value {
let Some(l) = &s.link_search else { return json!({ "state": "idle", "links": [] }) };
let p = l.search.progress();
let mut p = l.search.progress();
// Only what the caller asking now may read: a search another caller started can hold more.
let (folder, error) = p.keep_readable(&l.folder);
let n = l.links.len();
let sizes = lock(&l.sizes).clone();
// Per linked file: the files found, each with whether it is the same file.
@@ -267,19 +272,23 @@ fn state(s: &Session) -> Value {
f
})
.collect();
json!({ "name": link.name, "path": link.path, "ids": link.ids, "files": files })
let mut row = json!({ "name": link.name, "ids": link.ids, "files": files });
super::links::note_path(&mut row, "path", &link.path, l.doc.as_deref());
row
})
.collect();
let folder = if p.folder.as_os_str().is_empty() { l.folder.clone() } else { p.folder.to_string_lossy().into_owned() };
let mut out = json!({
"id": l.id, "state": p.state(), "folder": folder, "links": links,
"id": l.id, "state": p.state(), "links": links,
"searched": { "folders": p.folders, "files": p.files, "read": p.read },
"skipped": p.skipped, "unreadable": p.unreadable, "seconds": (p.seconds * 10.0).round() / 10.0,
});
if let Some(folder) = folder {
out["folder"] = json!(folder);
}
if let Some(why) = p.stopped() {
out["stopped"] = json!(why);
}
if let Some(e) = p.error() {
if let Some(e) = error {
out["error"] = json!(e);
}
out
+3 -3
View File
@@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a
continue;
};
let preview = d.images.get(&key).map(|b| b.bytes.clone());
// A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became.
// A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became.
let mime = match fileio::format_for_name(g.link.name()) {
Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(),
_ => img.blob.mime.clone(),
@@ -898,12 +898,12 @@ fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
/// relative path taken from the document's folder) lies inside them.
fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
row[key] = json!(path);
} else if let Some(o) = row.as_object_mut() {
+4 -4
View File
@@ -145,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make with Warp…",
["Object", "Envelope Distort"],
Some("Cmd+Alt+Shift+W"),
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
"{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v, version?: 0|1 (1: each style bends the bounds as one Bézier patch; 0: VectorCraft's earlier map)} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}",
has_selection,
env_make_warp
),
@@ -172,7 +172,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset with Warp…",
["Object", "Envelope Distort"],
None,
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v} make the selected envelopes warp envelopes, keeping their content",
"{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %), horizontalDistortion?: as h, verticalDistortion?: as v, version?: as makeWithWarp (unset: a warp envelope's own, else 1)} make the selected envelopes warp envelopes, keeping their content",
has_envelope,
env_reset_warp
),
@@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Info",
[],
None,
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
"{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, version (a warp's map: 1, each style as one Bézier patch; 0, the earlier map), rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, boundsMargin?: [top, bottom, left, right] (shares of the content's height and width, reported only when not zero: with distortAppearance, how far the box the content maps from reaches past its geometric bounds), editing}",
has_doc,
env_info
),
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Envelope Options…",
["Object", "Envelope Distort"],
None,
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
"{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance; on an envelope with boundsMargin, distortAppearance also switches the content between the box with the margins and its geometric box), style?, bend?, h?, v?, horizontal? (warp envelopes), horizontalDistortion?: as h, verticalDistortion?: as v, orientation?: horizontal|vertical, version?: 0|1 (warp envelopes' map, as makeWithWarp)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)",
has_doc,
env_options
),
+11 -6
View File
@@ -13,9 +13,14 @@ const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verti
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
check_numbers(p, WARP_NUMBERS, cmd)?;
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
// A new warp takes its style's Bézier net; an edited one keeps the map it has unless `version`
// names one.
let (mut style, mut bend, mut h, mut v, mut horizontal, mut version) = ("arc".to_string(), 50.0, 0.0, 0.0, true, live::WARP_NET);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0, version: n0 }) = cur {
(style, bend, h, v, horizontal, version) = (s0.clone(), *b0, *h0, *v0, *z0, *n0);
}
if let Some(n) = p.get("version") {
version = live::warp_version(n).ok_or_else(|| bad(cmd, format!("`version` must be 0 or {}", live::WARP_NET)))?;
}
if let Some(s) = text_param(p, "style", cmd)? {
let s = s.strip_prefix("warp.").unwrap_or(s);
@@ -32,7 +37,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<Envelop
Some(o) => o == "horizontal",
None => given,
};
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal })
Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal, version })
}
/// The options and fidelity new envelopes get (Envelope Options with no envelope selected).
@@ -206,8 +211,8 @@ pub(super) fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
};
let v = match &**kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal})
EnvelopeKind::Warp { style, bend, h, v, horizontal, version } => {
json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal, "version": version})
}
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
+25 -4
View File
@@ -241,8 +241,8 @@ impl DocSettings {
if let Some(h) = length("height", self.units)? {
self.height = h;
}
if !(self.width > 0.0 && self.height > 0.0) || self.width > MAX_SIDE || self.height > MAX_SIDE {
return Err(bad(cmd, "width/height must be positive and within the canvas"));
if !side_in_range(self.width) || !side_in_range(self.height) {
return Err(side_error(cmd, self.units));
}
match p.get("orientation").and_then(Value::as_str) {
None => {}
@@ -359,8 +359,29 @@ impl DocSettings {
}
}
/// Largest artboard side `file.new` accepts, points.
const MAX_SIDE: f64 = 16383.0 * 10.0;
/// Largest artboard side `file.new` and `artboard.new` accept, points: the span of the canvas
/// ([`crate::MAX_COORD`] from the origin) less the largest bleed on both sides, 3,999,856 pt.
pub(crate) const MAX_SIDE: f64 = crate::MAX_COORD - 2.0 * vectorcraft_doc::setup::MAX_BLEED;
/// Is `side` an artboard side `file.new` and `artboard.new` accept: more than 0 and at most [`MAX_SIDE`]?
pub(crate) fn side_in_range(side: f64) -> bool {
side > 0.0 && side <= MAX_SIDE
}
/// The error of an artboard side outside [`side_in_range`]: the limit in points, and in `units` as
/// well when they aren't points, rounded down to the decimals [`Unit::number`] shows so the value
/// shown is one the commands take.
pub(crate) fn side_error(cmd: &str, units: Unit) -> crate::EngineError {
let limit = match units {
Unit::Points => format!("{MAX_SIDE} pt"),
u => {
let step = if matches!(u, Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 1e3 } else { 1e4 };
let shown = (u.from_pt(MAX_SIDE) * step).floor() / step;
format!("{MAX_SIDE} pt ({} {})", u.number(u.to_pt(shown)), u.suffix())
}
};
bad(cmd, format!("width and height must be more than 0 and at most {limit}"))
}
/// Largest artboard spacing, points.
const MAX_SPACING: f64 = 1.0e5;
+89 -2
View File
@@ -9,6 +9,10 @@
//! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the
//! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the
//! first one attaches), and `object.objectsOnPath.expand` makes it a plain group.
//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move
//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the
//! path, the objects between it and that place each moving one place toward where it was; hidden
//! and locked objects keep their places.
//!
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
@@ -19,7 +23,7 @@
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use super::edit::{roots_of, selected_roots};
use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection};
use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection};
/// The id of the Objects on Path tool.
pub const TOOL: &str = "objectsOnPath";
@@ -53,6 +57,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_objects_on_path,
options
),
cmd!(
"object.objectsOnPath.selectMember",
"Select Object on Path",
[],
None,
"{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}",
has_doc,
select_member
),
cmd!(
"object.objectsOnPath.moveMember",
"Move Object on Path",
[],
None,
"{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}",
has_doc,
move_member
),
cmd!(
"object.objectsOnPath.expand",
"Expand",
@@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
}
// ---------- Select/Move ----------
/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the
/// group's record (not its path), in a group that is not hidden or locked.
fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> {
let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
let id = checked_id(s, v, cmd)?;
let d = &s.doc()?.doc;
let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group"));
let g =
d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?;
let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id));
if !listed {
return Err(not_on_path());
}
if !d.is_editable(g) {
return Err(bad(cmd, "the Objects on Path group is hidden or locked"));
}
if !d.is_editable(id) {
return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked")));
}
Ok((g, id))
}
fn select_member(s: &mut Session, p: &Value) -> Result<Value> {
let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?;
s.select(|_, sel| sel.set_on_path_member(g, member))?;
Ok(json!({ "group": g.0, "member": member.0 }))
}
fn move_member(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.objectsOnPath.moveMember";
let (g, member) = member_param(s, p, "member", C)?;
let (target_group, target) = member_param(s, p, "target", C)?;
if target_group != g {
return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`"));
}
if let Some(v) = p.get("group").filter(|v| !v.is_null())
&& checked_id(s, v, C)? != g
{
return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`"));
}
let at = |s: &Session| -> Result<usize> {
let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?;
rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member))
};
// Onto its own place: nothing changes, and no undo step is recorded.
if target == member {
return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? }));
}
// Hidden and locked objects between them keep their places.
let index = s.edit("Move Object on Path", |d, _| {
let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?;
let fixed: HashSet<NodeId> = {
let editable = d.editable();
rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect()
};
let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?;
let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?;
n.extras_mut().objects_on_path = Some(Box::new(rec));
Ok(index)
})?;
Ok(json!({ "group": g.0, "member": member.0, "index": index }))
}
// ---------- Expand ----------
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
+8
View File
@@ -317,6 +317,14 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
if effect == vectorcraft_render::effects::SVG_FILTER && given.contains_key("name") && !given.contains_key("xml") {
return Err(bad(id, "an SVG filter is named by its id: give `xml` with `name`"));
}
// A Warp's map is one this build draws: 0, the earlier map, or its style's Bézier net.
let newest = vectorcraft_render::effects::WARP_NET;
if effect.starts_with("warp.")
&& let Some(v) = given.get("version")
&& vectorcraft_render::effects::warp_version(v).is_none()
{
return Err(bad(id, format!("`version` must be 0 or {newest}")));
}
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
+254
View File
@@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary {
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 },
BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects },
];
/// The library category of the built-in pattern libraries.
@@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) {
});
}
/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced.
fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> {
(0..=n)
.map(|i| {
let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n);
(t, f(t))
})
.collect()
}
/// The closed band between two curves drawn from the same side to the same side: `upper` forward,
/// then `lower` back.
fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData {
let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect();
polygon(&points)
}
/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`.
fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath {
const CORNERS: u32 = 72;
let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU };
let points: Vec<Point> = (0..CORNERS)
.map(|i| {
let a = turn * f64::from(i) / f64::from(CORNERS);
Point::new(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
SubPath::polyline(&points, true)
}
/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one
/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives
/// a disk.
fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData {
let mut subpaths = vec![circle(cx, cy, outer, false)];
if inner > 0.0 {
subpaths.push(circle(cx, cy, inner, true));
}
PathData::new(subpaths)
}
/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that
/// meet at its tips, turning the other way with `reverse`.
fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath {
const STEPS: u32 = 32;
let at = |i: u32, side: f64| {
let t = f64::from(i) / f64::from(STEPS);
Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin())
};
let mut points: Vec<Point> = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect();
if reverse {
points.reverse();
}
SubPath::polyline(&points, true)
}
/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a
/// clear background. Each tile is one period of its design, so a band that leaves one edge comes
/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the
/// tiling draws the part outside the tile on the next tile.
fn optical_effects(d: &mut Document) {
use std::f64::consts::TAU;
// Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile.
let bands = (0..3u32)
.map(|k| {
let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin();
band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5))
})
.collect();
add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands);
// Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one
// above it, so the ninth leads into the first wave of the next tile down.
let lines = (0..9u32)
.map(|k| {
let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0);
let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin();
band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6))
})
.collect();
add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines);
// Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its
// right. The two sets cross, and the outer rings reach into the next tile across.
let rings = (1..=7u32)
.map(|k| (24.0, 3.0 * f64::from(k)))
.chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k))))
.map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6))
.collect();
add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings);
// Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's
// starting with the narrower one. The corner's rings are the same rings at all four corners.
let mut rings = vec![];
for (c, first) in [(0.0, 0), (20.0, 1)] {
let mut r = 0.0;
for k in 0..5 {
let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 };
rings.push(ring(c, c, r, r + width));
r += width + 1.2;
}
}
add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings);
// An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and
// whose rows by one across it, each edge drawn through five points.
let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin());
let cells = (0..8u32)
.flat_map(|row| (0..8u32).map(move |col| (col, row)))
.filter(|(col, row)| (col + row) % 2 == 0)
.map(|(col, row)| {
let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row));
let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)];
let points: Vec<(f64, f64)> = corners
.windows(2)
.flat_map(|e| {
let (a, b) = (e[0], e[1]);
(0..4u32).map(move |i| {
let t = f64::from(i) / 4.0;
warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
})
})
.collect();
polygon(&points)
})
.collect();
add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells);
// Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile,
// each a sixth of a wave on from the one to its left.
let stripes = (0..6u32)
.map(|k| {
let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0);
let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos();
let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() };
band(&side(-1.0), &side(1.0))
})
.collect();
add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes);
// Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands
// rise and fall from band to band.
let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)]
.into_iter()
.map(|(y, bend, width)| {
let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect();
let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect();
band(&upper, &lower)
})
.collect();
add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons);
// Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones
// reach across the side edges and cross the next tiles' lenses.
let lenses = (0..5u32)
.map(|k| {
let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k));
PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)])
})
.collect();
add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -326,4 +483,101 @@ mod tests {
}
assert!(crossing > 0, "no mark reaches past an edge");
}
const OPTICAL: [&str; 8] =
["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"];
fn optical_doc() -> Document {
let mut d = Document::new(100.0, 100.0);
optical_effects(&mut d);
d
}
/// The paths of pattern `p`'s art, each with its fill rule.
fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> {
p.art
.iter()
.map(|n| match &n.kind {
vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule),
_ => panic!("{}: art that is not a path", p.name),
})
.collect()
}
#[test]
fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() {
let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed");
assert_eq!(b.name, "Optical Effects");
let names: Vec<String> = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect();
assert_eq!(names, OPTICAL);
// Made twice, the art is the same.
let (a, again) = (optical_doc(), optical_doc());
assert_eq!(a.patterns, again.patterns);
for p in &a.patterns {
let (w, h) = (p.width(), p.height());
assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
assert_eq!(p.tile_type, TileType::Grid, "{}", p.name);
let paths = paths(p);
let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum();
assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len());
for (path, _) in &paths {
let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name));
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && 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);
}
}
}
/// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band
/// cut off at an edge, or one that comes back at another height or width, paints one side only.
/// No path is another moved by a whole tile, which the tiling would draw twice in one place.
#[test]
fn optical_tiles_join_without_a_seam() {
const SAMPLES: u32 = 400;
const STEP: f64 = 1e-4;
for p in &optical_doc().patterns {
let (w, h) = (p.width(), p.height());
let shapes: Vec<(BezPathFill, Rect)> =
paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect();
let paints = |q: Point| {
let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP);
p.offsets_covering(near).into_iter().any(|o| {
let at = q - o;
shapes.iter().any(|((bp, rule), b)| {
let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1;
let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 };
match rule {
vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0,
vectorcraft_geom::FillRule::NonZero => turns != 0,
}
})
})
};
let (mut differ, mut painted) = (0, 0);
for i in 0..SAMPLES {
let t = (f64::from(i) + 0.37) / f64::from(SAMPLES);
let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h));
let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP));
for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] {
let (pa, pb) = (paints(a), paints(b));
differ += usize::from(pa != pb);
painted += usize::from(pa);
}
}
assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES);
assert!(painted > 0, "{}: nothing crosses an edge", p.name);
let bounds: Vec<Rect> = shapes.iter().map(|(_, b)| *b).collect();
let same = |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
};
for b in &bounds {
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| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
}
}
}
}
type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule);
}
+4 -2
View File
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Image Info",
[],
None,
"{id?} an image object (default: the one selected) → {id, name, linked, link, colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
"{id?} an image object (default: the one selected) → {id, name, linked, link (the linked file's path; null when embedded, or when automation read roots are in force and the file is outside them), colorMode: RGB|Grayscale|CMYK, pixelWidth, pixelHeight, ppi: [x, y] at its placed size, width, height}",
has_doc,
image_info
),
@@ -891,11 +891,13 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
let [a, b, c, d, ..] = im.xf.as_coeffs();
let ppi = |x: f64, y: f64| Some(x.hypot(y)).filter(|l| *l > 1e-12).map_or(0.0, |l| 72.0 / l);
let size = n.geometric_bounds().unwrap_or_default();
// A linked file outside the read roots of the caller asking now is left unnamed, as in Link Info.
let doc_path = super::links::LinkBase::of(st).doc;
Ok(json!({
"id": id.0,
"name": n.display_name(),
"linked": im.link.is_some(),
"link": im.link.as_ref().map(|l| &l.path),
"link": im.link.as_ref().map(|l| &l.path).filter(|path| super::links::path_shown(path, doc_path)),
"colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)),
"pixelWidth": im.width,
"pixelHeight": im.height,
+84 -10
View File
@@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError {
}
}
/// Under automation read roots, a reply names a file or folder outside them by its name alone.
fn shown_file(path: &str) -> String {
super::fileio::shown_path(path).into_owned()
}
/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a
/// file outside the automation read roots is named by its file name.
fn shown_item(item: &safe_mode::Item) -> Value {
let mut v = item.to_json();
if let Some(file) = item.file.as_deref() {
v["file"] = json!(shown_file(file));
}
v
}
/// JSON description of an installed plug-in.
pub fn describe(p: &Plugin) -> Value {
let m = p.manifest();
let mut v = json!({
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
"size": p.size(), "source": p.source(),
"size": p.size(), "source": p.source().map(shown_file),
"permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api,
});
match m.kind {
@@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value {
v
}
/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them
/// is `null` and a failed file outside them is named by its file name, in the errors too.
fn shown_report(mut report: Value) -> Value {
let mut hidden: Vec<(String, String)> = vec![];
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
for row in failed {
let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue };
let shown = shown_file(&file);
if shown != file {
row["file"] = json!(shown);
hidden.push((file, shown));
}
}
}
if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) {
let shown = shown_file(&folder);
if shown != folder {
report["folder"] = Value::Null;
hidden.push((folder, shown));
}
}
// A failed file's path holds its folder's: the longer paths are put back first.
hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len()));
let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name));
if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) {
report["error"] = json!(e);
}
if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) {
for row in failed {
if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) {
row["error"] = json!(e);
}
}
}
report
}
fn list(_: &mut Session, _: &Value) -> Result<Value> {
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect();
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone()));
let disabled: Vec<Value> = safe_mode::disabled().iter().map(shown_item).collect();
Ok(
json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
)
@@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({
"active": safe_mode::active(),
"source": safe_mode::source().map(safe_mode::Source::name),
"disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>(),
"pendingItem": safe_mode::previous().map(|i| i.to_json()),
"disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>(),
"pendingItem": safe_mode::previous().map(|i| shown_item(&i)),
}))
}
/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name
/// by its file name (outside the automation read roots) is found by that name, when no other listed
/// file has it.
fn listed_file(file: String) -> String {
let mut listed: Vec<String> = safe_mode::disabled()
.into_iter()
.chain(safe_mode::previous())
.filter_map(|i| i.file)
.chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string)))
.collect();
listed.sort();
listed.dedup();
if listed.contains(&file) {
return file;
}
let mut named = listed.into_iter().filter(|f| shown_file(f) == file);
match (named.next(), named.next()) {
(Some(f), None) => f,
_ => file,
}
}
/// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a
/// plug-in that stopped a launch while loading may be known by its file alone). It neither loads
/// nor unloads anything now, and uninstalls nothing.
@@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None };
(registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored)
}
(None, Some(file)) => (safe_mode::Item::file(file), None),
(None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None),
(None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")),
};
let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect();
@@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
// Without a plug-in folder (a session confined to automation roots), or when the list there
// can't take the change, the change stays in this process and goes with the next change that saves.
let saved = !changed || s.store_disabled_plugins();
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>()}))
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::<Vec<_>>()}))
}
fn info(_: &mut Session, p: &Value) -> Result<Value> {
@@ -1055,7 +1129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's or importer's format id, plugin.<id>, for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.<id>.<command id>, which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
always,
list
),
@@ -1127,7 +1201,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Safe Mode",
[],
None,
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
always,
safe_mode_state
),
@@ -1136,7 +1210,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Enable on Re-launch",
[],
None,
"{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
"{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
always,
set_enabled
),
+85 -2
View File
@@ -102,7 +102,53 @@ const FILE_WORDS: &[&str] = &[
];
/// Param keys that name a file or a folder.
const FILE_KEYS: &[&str] = &["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url"];
const FILE_KEYS: &[&str] =
&["path", "paths", "file", "files", "folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder", "url", "diffPath"];
/// The keys in [`FILE_KEYS`] that name a folder.
const FOLDER_KEYS: &[&str] = &["folder", "folders", "dir", "directory", "destinationFolder", "sourceFolder"];
/// What a param key that names a place on disk names there.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Location {
/// A file (or a list of files).
File,
/// A folder (or a list of folders).
Folder,
}
/// What `key` names on disk when a command takes it ([`FILE_KEYS`]), or `None` when it never
/// names a file or a folder.
pub fn key_location(key: &str) -> Option<Location> {
if FOLDER_KEYS.contains(&key) {
Some(Location::Folder)
} else if FILE_KEYS.contains(&key) {
Some(Location::File)
} else {
None
}
}
/// What `key` names on disk in command `spec`: [`key_location`], or `None` when [`NOT_FILES`]
/// names it for `spec` (it names part of the document there).
pub fn location_in(spec: &CommandSpec, key: &str) -> Option<Location> {
if NOT_FILES.contains(&(spec.id, key)) { None } else { key_location(key) }
}
/// The param keys of `spec` that name a file or a folder, with what they name: the keys in
/// [`FILE_KEYS`] that its params description names ([`super::params::doc_keys`]), with those of the
/// commands its `…` entries name ([`super::params::takes_key`]), and its schema's params, less those
/// [`NOT_FILES`] names for it. Sorted, each key once.
pub fn location_keys(spec: &CommandSpec) -> Vec<(&'static str, Location)> {
let described = super::params::doc_keys(spec.params).map(|p| p.keys).unwrap_or_default();
let in_schema = spec.schema.map(|s| s.params.iter().map(|p| p.name)).into_iter().flatten();
let taken = FILE_KEYS.iter().copied().filter(|k| super::params::takes_key(spec.params, k));
let mut keys: Vec<(&'static str, Location)> =
described.into_iter().chain(in_schema).chain(taken).filter_map(|k| Some((k, location_in(spec, k)?))).collect();
keys.sort_unstable_by_key(|(k, _)| *k);
keys.dedup();
keys
}
/// Commands and the param key in [`FILE_KEYS`] that, in them, names part of the document (a path of
/// its art, a setting, an attribute it stores or a result field), not a file.
@@ -119,6 +165,8 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[
("attributes.set", "url"),
("svgInteractivity.info", "files"),
("addon.cleanup.rejoin", "paths"),
("object.slice.options", "url"),
("document.exportForScreens", "folders"),
];
/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`],
@@ -466,7 +514,8 @@ mod tests {
}
for (id, key) in NOT_FILES {
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
assert!(names_key(spec.params, key), "{id} has no `{key}` param");
let described = crate::cmd::params::doc_keys(spec.params).is_ok_and(|p| p.keys.contains(key));
assert!(names_key(spec.params, key) || described, "{id} has no `{key}` param");
}
let leaks: Vec<&str> = specs
.iter()
@@ -565,6 +614,40 @@ mod tests {
read.unwrap();
}
/// [`location_keys`] names every key in [`FILE_KEYS`] that a command's params description or
/// schema names, in every way a description writes one (`path | name`, `folder? (…)`), less
/// those [`NOT_FILES`] names for it; folder keys name folders. No command a plug-in may call
/// has one.
#[test]
fn location_keys_come_from_the_command_catalog() {
let find = |id: &str| crate::cmd::find_command(id).unwrap_or_else(|| panic!("no command {id}"));
let keys = |id: &str| location_keys(find(id));
assert!(keys("document.open").contains(&("path", Location::File)), "{:?}", keys("document.open"));
assert!(keys("document.compare").contains(&("diffPath", Location::File)), "{:?}", keys("document.compare"));
assert!(keys("addon.document.saveTimestampedCopy").contains(&("folder", Location::Folder)));
let relink = keys("links.relink");
for k in [("path", Location::File), ("paths", Location::File), ("folder", Location::Folder)] {
assert!(relink.contains(&k), "{relink:?}");
}
assert!(keys("action.batch").contains(&("destinationFolder", Location::Folder)));
for (id, key) in NOT_FILES {
assert!(!keys(id).iter().any(|(k, _)| k == key), "{id} `{key}` names a file");
}
for spec in crate::cmd::command_specs() {
let found = location_keys(spec);
let described = crate::cmd::params::doc_keys(spec.params).map(|p| p.keys).unwrap_or_default();
let in_schema = spec.schema.map(|s| s.params.iter().map(|p| p.name).collect::<Vec<_>>()).unwrap_or_default();
for k in described.into_iter().chain(in_schema).filter(|k| FILE_KEYS.contains(k) && !NOT_FILES.contains(&(spec.id, *k))) {
assert!(found.iter().any(|(f, _)| *f == k), "{}: `{k}` is missing from {found:?}", spec.id);
}
assert!(found.iter().all(|(k, l)| (*l == Location::Folder) == FOLDER_KEYS.contains(k)), "{}: {found:?}", spec.id);
assert!(denied(spec).is_some() || found.is_empty(), "{} names {found:?} but a plug-in may call it", spec.id);
}
assert_eq!(key_location("url"), Some(Location::File));
assert_eq!(key_location("sourceFolder"), Some(Location::Folder));
assert_eq!(key_location("pathData"), None);
}
#[test]
fn keys_are_found_as_whole_keys() {
assert!(names_key("{path: a .wasm file}", "path"));
@@ -33,6 +33,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.color.swatchLegend",
"addon.contour.set",
"addon.cutting.bridges",
"addon.cutting.weed",
"addon.image.removeBackground",
"addon.pathtools.addPoints",
"addon.pathtools.close",
@@ -307,7 +308,9 @@ pub(super) const ALLOWED: &[&str] = &[
"object.nudge",
"object.objectsOnPath.attach",
"object.objectsOnPath.expand",
"object.objectsOnPath.moveMember",
"object.objectsOnPath.options",
"object.objectsOnPath.selectMember",
"object.path.addAnchorPoints",
"object.path.cleanUp",
"object.path.divideObjectsBelow",
@@ -645,6 +648,8 @@ pub(super) const ALLOWED: &[&str] = &[
"type.opticalMarginAlignment",
"type.orientation.horizontal",
"type.orientation.vertical",
"type.pathCenter.bottom",
"type.pathCenter.top",
"type.pathOptions",
"type.resetHorizontalScale",
"type.resetTracking",
+32 -7
View File
@@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync {
fn remove(&self, name: &str) -> std::result::Result<(), String>;
/// Where the entries are, as the user knows it (a folder, "browser storage").
fn location(&self) -> String;
/// Is [`RecoveryStore::location`] a folder on this computer (default: no)?
fn in_folder(&self) -> bool {
false
}
/// The areas in the store (default: those with entries).
fn areas(&self) -> std::result::Result<Vec<String>, String> {
let names = self.list()?;
@@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore {
fn location(&self) -> String {
self.0.to_string_lossy().into_owned()
}
fn in_folder(&self) -> bool {
true
}
fn areas(&self) -> std::result::Result<Vec<String>, String> {
Self::names(&self.0, true)
}
@@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) {
}
}
/// The document's file `path` as a reply names it: under automation read roots, only inside them.
fn shown_path(path: Option<String>) -> Option<String> {
path.filter(|p| crate::file_access::check_read(p).is_ok())
}
/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation
/// read roots is `null`.
fn shown_location(store: &dyn RecoveryStore) -> Value {
let location = store.location();
if store.in_folder() && crate::file_access::check_read(&location).is_err() {
return Value::Null;
}
json!(location)
}
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
@@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result<Value> {
}
forget_clean(s);
heartbeat(s);
Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() }))
Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) }))
}
/// Who an area of the store belongs to.
@@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
let meta = read_meta(store.as_ref(), f);
let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title };
rows.push(json!({
"file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved,
"file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved,
"open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running,
}));
}
@@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
tidy(store.as_ref(), &area, hold);
}
}
Ok(json!({ "copies": rows, "location": store.location() }))
Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) }))
}
/// A gone app's area to act on: its name, the copies to act on, and its lock.
@@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
// The copy stays until the document is saved or closed.
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
let title = st.title();
Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json()))
Ok(super::fileio::merge(
json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }),
links.to_json(),
))
}
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1527,7 +1552,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Recovery Data",
[],
None,
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)",
"{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)",
has_store,
save
),
@@ -1536,7 +1561,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recovery Data",
[],
None,
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash",
"{} the recovery copies in the store → {copies: [{file (\"<area>/<name>\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is",
has_store,
list
),
@@ -1545,7 +1570,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Restore Recovered Documents",
[],
None,
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed",
"{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \"<name> [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed",
has_store,
restore
),
+44
View File
@@ -233,6 +233,43 @@ impl Session {
self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32"))
}
/// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read
/// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in
/// (`file`, also in the run's error line in `console`) are named by their file name. What the
/// script wrote itself (`log`, its output lines) stays as written.
pub fn shown_script_report(&self) -> Option<Value> {
let mut report = self.script_report()?.clone();
if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) {
for path in written.iter_mut() {
if let Some(name) = path.as_str().and_then(hidden_name) {
*path = json!(name);
}
}
}
if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string)
&& let Some(name) = hidden_name(&file)
{
// The run's error lines come after every line the script wrote. They are written again
// from the report with the file's name, which also covers a line the console cut inside
// the path. A console that dropped its oldest lines may hold only the last of them.
let before = script::error_lines(&report);
report["file"] = json!(name);
if let Some(message) = report.get("message").and_then(Value::as_str) {
report["message"] = json!(message.replace(&file, &name));
}
let after = script::error_lines(&report);
if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) {
let kept = (1..=before.len().min(lines.len()))
.rev()
.find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str()))))
.unwrap_or(0);
lines.truncate(lines.len() - kept);
lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l)));
}
}
Some(report)
}
/// The text of the script at `path`, which a listing of the Scripts folder gave
/// ([`script::folder::read`]), checked as `script.run` checks a file's.
pub fn installed_script(&self, path: &str) -> Result<String> {
@@ -248,6 +285,13 @@ impl Session {
}
}
/// The file name a reply names `path` by when the file is outside the automation read roots; `None`
/// when a reply may name it whole.
fn hidden_name(path: &str) -> Option<String> {
let shown = super::fileio::shown_path(path);
(shown != path).then(|| shown.into_owned())
}
fn list_installed(s: &mut Session, _: &Value) -> Result<Value> {
let Some(root) = s.scripts_dir() else {
return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}));
+1 -1
View File
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Not Aligned to Pixel Grid",
["Select", "Object"],
None,
"{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}",
"{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups, clip groups, blends, envelopes, repeats and Intertwine groups but not into compound paths → {count}",
has_doc,
not_pixel_aligned
),
+6 -5
View File
@@ -393,11 +393,12 @@ fn float_key(v: f32) -> Option<u32> {
/// keys.
type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>);
/// A gradient's kind, method, dither and stops, as keys.
type GradientKey<'a> = (u8, u8, bool, Vec<StopKey<'a>>);
/// A gradient's kind, method, dither, spread and count as it paints them, and stops, as keys.
type GradientKey<'a> = (u8, u8, bool, (vectorcraft_color::GradientSpread, u32), Vec<StopKey<'a>>);
/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when
/// any of its numbers is NaN, so the gradient equals no gradient.
/// A key that is equal for two gradients exactly when the gradients are equal (`==`), apart from
/// a count that a gradient which pads does not use ([`Gradient::painted_spread`]); `None` when any
/// of its numbers is NaN, so the gradient equals no gradient.
fn gradient_key(g: &Gradient) -> Option<GradientKey<'_>> {
let kind = match g.kind {
GradientKind::Linear => 0,
@@ -410,7 +411,7 @@ fn gradient_key(g: &Gradient) -> Option<GradientKey<'_>> {
GradientMethod::Classic => 0,
GradientMethod::Perceptual => 1,
};
Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::<Option<_>>()?))
Some((kind, method, g.dither, g.painted_spread(), g.stops.iter().map(stop_key).collect::<Option<_>>()?))
}
/// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN.
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -309,12 +309,12 @@ fn character_style_color_and_features_reach_text_and_follow_swatch_edits() {
// The command takes the color of the link at the tint, whatever color is sent.
tinted["tint"] = json!(0.4);
let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false,
"features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]});
"features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]});
s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap();
let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap();
assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false));
assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]);
assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01", "salt=2"]);
let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone();
assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap());
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
@@ -355,6 +355,8 @@ fn character_style_color_and_features_are_checked() {
(json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"),
(json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"),
(json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"),
(json!({"features": ["salt=x"]}), "unknown OpenType feature `salt=x`"),
(json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"),
(json!({"features": many}), "features lists at most 256 tags"),
(json!({"fill": {"type": "solid"}}), "missing field"),
] {
+2 -1
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), 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_style
),
@@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod area_tests;
mod block;
pub(crate) use block::needed_room;
#[cfg(test)]
mod block_tests;
+34 -1
View File
@@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection
out
}
/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as
/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`]
/// measures them, and a margin of under half a size step so that the line is not made a step
/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative
/// indents take more than its width needs its own width. None when no line has a width.
pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> {
let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?;
let text = t.plain_text();
let lines = typed_lines(&text);
let widths = line_widths(vectorcraft_text::FontDb::global(), t);
let sizes = first_sizes(&t.runs, &lines);
lines
.iter()
.zip(&sizes)
.filter_map(|(l, size)| {
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
let size = size * points;
if l.content == l.start || !(size.is_finite() && size > 0.0) {
return None;
}
let para = t.para_at(l.para);
let first = if l.para_start { para.first_line_indent } else { 0.0 };
let indents = para.laid_out_left_indent() + para.right_indent + first;
// Negative indents that take more than the line's width leave the line no room at its
// size; the frame is then as wide as the line itself, and Block makes it larger.
let room = if width + indents > 1e-9 { width + indents } else { width };
// A tenth of a point more size widens the line by 0.1 * width / size.
Some((room + 0.04 * width / size, l.start, l.para))
})
.filter(|r| r.0.is_finite() && r.0 > 1e-9)
.reduce(|a, b| if b.0 > a.0 { b } else { a })
}
/// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing
/// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down
/// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its
@@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool {
let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?;
let para = t.para_at(l.para);
let first = if l.para_start { para.first_line_indent } else { 0.0 };
let avail = cell - para.left_indent - para.right_indent - first;
let avail = cell - para.laid_out_left_indent() - para.right_indent - first;
let size = size * points;
if !(size.is_finite() && size > 0.0) {
return None;
+249 -3
View File
@@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() {
assert!(!text(&s, id).dynamic_text);
let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [point]})).unwrap();
// Off leaves point type as it is, with no undo step, and a gap needs it turned on.
let n = undo_len(&s);
assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point]));
let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string();
assert!(e.contains("need area type"), "{e}");
assert_eq!(undo_len(&s), n);
assert!(matches!(text(&s, point).kind, TextKind::Point));
let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [shape]})).unwrap();
let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string();
assert!(e.contains("select type on a path or area type"), "{e}");
// With point type selected too, only the area type changes.
// With point type selected too, both turn on, the point type as area type with Block on.
s.execute("select.set", &json!({"ids": [id, point]})).unwrap();
let n = undo_len(&s);
let r = s.execute("type.dynamicText", &json!({})).unwrap();
assert_eq!(r["ids"], json!([id]));
assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text);
assert_eq!(r["ids"], json!([id, point]));
assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text);
assert!(matches!(text(&s, point).kind, TextKind::Area { .. }));
assert_eq!(undo_len(&s), n + 1);
// A gap with only type on a path selected is an error, and nothing changes.
let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap();
let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap();
@@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() {
assert!(matches!(text(&s, a).kind, TextKind::Area { .. }));
}
/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected.
fn point(s: &mut Session, text: &str, extra: Value) -> u64 {
let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
id
}
/// Where the first glyph of the laid-out line starting at byte `start` is in the document.
fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there");
t.xf * lay.glyphs[l.glyph_start].origin
}
#[test]
fn point_type_becomes_area_type_with_block_at_its_widest_line() {
for justify in ["left", "center", "right"] {
let mut s = session();
let id = point(&mut s, "Short\nA much longer line", json!({}));
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
let before = line_start(&text(&s, id), 6);
let n = undo_len(&s);
let r = s.execute("type.dynamicText", &json!({})).unwrap();
assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id])));
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps");
assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step");
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text");
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}");
assert_eq!(t.area.fit, AreaFit::None);
assert_block(&t);
assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size");
assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0));
// Across, the widest line stays where it was; the short line above it grew, so it moved down.
let after = line_start(&t, 6);
assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}");
assert!(after.y > before.y + 12.0, "{justify}");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line");
// Edits fit it again as area type; undo brings the point type back as it was.
s.execute("edit.undo", &json!({})).unwrap();
let back = text(&s, id);
assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}");
}
}
#[test]
fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() {
let mut s = session();
// The gaps apply to the frame point type becomes.
let id = point(&mut s, "one\ntwo words here", json!({}));
let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap();
assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0)));
let t = text(&s, id);
assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0));
assert_block(&t);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Indents: the frame takes them in, so the widest line keeps its size.
let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300}));
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap();
let before = line_start(&text(&s, id), 5);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_block(&t);
assert_eq!(size_at(&t, 5), 12.0);
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
// A nested list item: the frame takes in the level's indent too.
let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200}));
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_eq!(size_at(&t, 5), 12.0, "nested list");
assert!(size_at(&t, 0) > 12.0);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Mixed alignments: the widest line keeps its size and its place.
let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400}));
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) {
let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() };
t.set_paragraph_styles(vec![t.para.clone(), right]);
}
let before = line_start(&text(&s, id), 5);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_eq!(size_at(&t, 5), 12.0, "mixed alignments");
assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5));
// A right-to-left list, whose indents the layout mirrors.
let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450}));
s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap();
let widest = "קצר\n".len();
let before = line_start(&text(&s, id), widest);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert_eq!(size_at(&t, widest), 12.0, "right-to-left list");
assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest));
// Point type that was area type with its own options: the new frame takes the defaults but the
// gaps.
let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500}));
s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap();
s.execute(
"text.areaOptions",
&json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}),
)
.unwrap();
s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap();
let before = line_start(&text(&s, id), 4);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() };
assert_eq!(t.area, defaults);
assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame");
assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4));
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
// Vertical type: the frame is as tall as the longest column and grows to the left.
let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true}));
let before = line_start(&text(&s, id), "縦書き\n".len());
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
assert_block(&t);
assert_eq!(size_at(&t, "縦書き\n".len()), 12.0);
let after = line_start(&t, "縦書き\n".len());
assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}");
assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
}
#[test]
fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() {
// Block makes the line larger; it keeps its start, center or end by its alignment.
let ends = |t: &TextObject| {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = &lay.lines[0];
((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x)
};
for justify in ["left", "center", "right"] {
let mut s = session();
let id = point(&mut s, "Short", json!({}));
s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap();
let (b0, b1) = ends(&text(&s, id));
let n = undo_len(&s);
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "{justify}");
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}");
assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger");
let (a0, a1) = ends(&t);
let kept = match justify {
"left" => (a0, b0),
"center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0),
_ => (a1, b1),
};
assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}");
}
}
#[test]
fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() {
let mut s = session();
let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({}));
// A wrap object above the type, across the top of the frame it becomes.
let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
assert!(!t.wrap.is_empty(), "the new area type takes the wrap object");
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap");
// A wrap object deeper than any size Block sets, across the whole frame: the lines go below it.
let id = point(&mut s, "Below", json!({"x": 1000, "y": 100}));
let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text && !t.wrap.is_empty());
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object");
// An inverted wrap object far below the type holds the lines inside it: the frame reaches it.
let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100}));
let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
s.execute("object.textWrap.options", &json!({"invert": true})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text && !t.wrap.is_empty());
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object");
}
#[test]
fn point_type_with_nothing_to_fit_is_left_as_it_is() {
let mut s = session();
for blank in ["", " ", "\n\n"] {
let id = point(&mut s, blank, json!({}));
let before = text(&s, id);
let n = undo_len(&s);
let r = s.execute("type.dynamicText", &json!({})).unwrap();
assert_eq!(r["on"], json!(true));
assert_eq!(undo_len(&s), n, "{blank:?}: no undo step");
assert_eq!(text(&s, id), before, "{blank:?}");
// A gap needs type it can apply to.
let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string();
assert!(e.contains("need area type"), "{blank:?}: {e}");
}
// A line far shorter than the widest one stops at the size limit, and the frame still holds it.
let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6}));
s.execute("type.dynamicText", &json!({})).unwrap();
let t = text(&s, id);
assert!(t.dynamic_text);
assert_eq!(size_at(&t, 0), 1296.0);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
}
#[test]
fn long_text_fits_in_a_bounded_number_of_layouts() {
let mut s = session();
@@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() {
// pass and the caches), plus the reply's own.
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4;
assert!(used <= bound, "{used} layouts");
// Long point type: measured, fitted in its frame and fitted again at its depth, with the
// layouts that place and measure the frame.
let id = point(&mut s, &story, json!({}));
let before = vectorcraft_text::test_fonts::layouts_on_this_thread();
s.execute("type.dynamicText", &json!({"on": true})).unwrap();
let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before;
let t = text(&s, id);
assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }));
assert_block(&t);
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow);
let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8;
assert!(used <= bound, "point type: {used} layouts");
}
+300 -21
View File
@@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No
use vectorcraft_geom::{Affine, Rect, shapes};
use super::object::choice;
use super::typecmd::{area_text, block_gaps, refresh_bounds};
use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds};
use super::{
CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or,
switch_or, targets, text_param,
@@ -60,12 +60,30 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
path_options
),
cmd!(
"type.pathCenter.top",
"Center on Top",
["Type", "Type on a Path"],
None,
"{ids?|id?} slide the selected type on a closed path along it so that the middle of the span between its start and end brackets lands on the path's topmost point as the object is drawn (after its transform, so a rotated, scaled or skewed path is measured as it appears; on a level stretch such as a rectangle's top edge, its middle; with several separate topmost points, the middle of the shortest part of the path that holds them all), keeping the span's length (a full turn stays a full turn), and set the first paragraph's alignment to Center unless it is Center or Justify Center, so the type is centered there. Point type and area type in the selection are left as they are. Type on an open or compound path, type in a thread, and type on a path with no length or no height are refused with an error that names them, and nothing changes. When every object is already centered there, nothing changes and no undo step is recorded → {ids, start, end} (the first object's brackets)",
has_closed_path_type,
path_center_top
),
cmd!(
"type.pathCenter.bottom",
"Center on Bottom",
["Type", "Type on a Path"],
None,
"{ids?|id?} as type.pathCenter.top, on the path's bottommost point as drawn. The type is not flipped: on the bottom of a path that runs clockwise it reads upside down until Type on a Path Options › Flip turns it → {ids, start, end} (the first object's brackets)",
has_closed_path_type,
path_center_bottom
),
cmd!(
"type.dynamicText",
"Dynamic Text",
["Type"],
None,
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
"{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}",
has_dynamic_type,
dynamic_text
),
@@ -328,13 +346,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
// A little slack on the right so the text doesn't rewrap.
let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0);
let slack = size * 0.5 + trailing;
// Slack goes where the alignment leaves room, so the text doesn't move or rewrap.
let (sl, sr) = match t.para_at(0).justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
_ => (0.0, slack),
};
let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack);
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);
@@ -344,6 +356,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
Ok(ids_json(&ids))
}
/// How `slack` added to point type's lines in a frame is shared before and after them: where the
/// alignment leaves room, so the text doesn't move or rewrap.
fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) {
match justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if rtl => (slack, 0.0),
_ => (0.0, slack),
}
}
/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type
/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as
/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own
/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows.
/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the
/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type
/// takes, in the type's own space, which the fit and the depth follow. When negative indents take
/// more than the widest line's width, the frame is as wide as that line and placed so that the
/// line keeps its aligned point. None when no line has a
/// width (empty text, or only spaces and breaks), which Block leaves at its size.
fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option<TextObject> {
let db = vectorcraft_text::FontDb::global();
// Options point type kept from a frame it had before (columns, inset, alignment, first
// baseline) do not carry over to the new frame.
let mut t = t.clone();
let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap);
t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() };
t.wrap = wrap.to_vec();
let t = &t;
// The room is measured as Block sets the lines in a frame, with the word gap.
let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() };
let (room, widest, para_index) = needed_room(&as_block)?;
let lay = vectorcraft_text::layout(db, t);
let first = lay.lines.first()?;
let line = lay.lines.iter().find(|l| l.start == widest)?;
// Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds.
let para = t.para_at(para_index);
let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n');
let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 };
// A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them.
let mirror = line.rtl && !t.vertical && para.list.is_some();
let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) };
let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right);
let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min);
let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max);
if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) {
return None;
}
// Negative indents can take more than the line's width (hi below lo); the frame is then wider
// than the line with its indents, and the same split of the difference keeps the line's
// aligned point (its start, center or end) where it was when Block makes the line fill it.
let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo));
let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0)));
let start = frame(bottom - top);
let mut out = t.clone();
out.kind = TextKind::Area { frame: shapes::rectangle(start) };
out.dynamic_text = true;
refresh_bounds(&mut out);
// The lines fitted in one cell from the frame's top deep enough for all of them (every line at
// the largest size Block sets), around any wrap shapes, give the depth the frame takes.
let mut probe = out.clone();
let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12);
let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points;
// Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also
// reaches as far again as the farthest one reaches past the frame's top.
let inv = lay.line_xf.inverse();
let deep = deep
+ wrap
.iter()
.filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs()))
.filter(|d| d.is_finite())
.fold(0.0, f64::max);
probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) };
if !wrap.is_empty() {
refresh_bounds(&mut probe);
}
let laid = vectorcraft_text::layout(db, &probe);
if laid.overflow {
return Some(out);
}
let mut grown = start;
for l in &laid.lines {
for y in [l.baseline - l.ascent, l.baseline + l.descent] {
grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y));
}
}
// Only the depth grows (for vertical type, the columns run on to the left), with a hundredth
// of a point to spare.
if t.vertical {
(grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01);
} else {
(grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01);
}
if grown.is_finite() && grown != start {
out.kind = TextKind::Area { frame: shapes::rectangle(grown) };
refresh_bounds(&mut out);
}
Some(out)
}
/// (run, byte within the run) for a byte offset into the plain text.
fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> {
let mut off = 0;
@@ -430,6 +543,136 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
Ok(ids_json(&ids))
}
/// Whether type on a path `t` runs along one closed path (no open or compound path).
fn on_one_closed_path(t: &TextObject) -> bool {
matches!(&t.kind, TextKind::OnPath { path, .. } if path.subpaths.len() == 1 && path.is_closed())
}
/// Why `type.pathCenter.top` and `.bottom` refuse type on a path `ids`: those on an open or
/// compound path, then those in a thread; None when they take every one.
fn path_center_refusal(d: &Document, ids: &[NodeId]) -> Option<String> {
let open = ids.iter().filter(|id| !path_text(d.node(**id)).is_some_and(on_one_closed_path)).map(|id| id.0.to_string()).collect::<Vec<_>>();
if !open.is_empty() {
let open = open.join(", ");
return Some(format!("type on an open or compound path cannot be centered on its top or bottom: {open}"));
}
let frames = threaded_ids(d, ids);
(!frames.is_empty()).then(|| format!("type on a path in a thread cannot be centered on its top or bottom: {frames}"))
}
/// `type.pathCenter.top` and `.bottom` are enabled for a selection holding type on a path, all of
/// it on one closed path and none of it in a thread.
fn has_closed_path_type(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
let d = &st.doc;
let selected: Vec<NodeId> = st.selection.objects.to_vec();
let paths: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| path_text(d.node(*id)).is_some()).collect();
if paths.is_empty() {
return Err("select type on a closed path".into());
}
path_center_refusal(d, &paths).map_or(Ok(()), Err)
}
/// The brackets that center type on a path `t` on its path's topmost point (`bottom`: its
/// bottommost) as `t.xf` draws it, its span keeping its length; None for a path with no length or
/// no height.
fn centered_brackets(t: &TextObject, bottom: bool) -> Option<(f64, Option<f64>)> {
let TextKind::OnPath { path, end, .. } = &t.kind else { return None };
let (from, to) = t.kind.path_span()?;
let arc = vectorcraft_geom::ArcPath::new(path);
if arc.is_empty() {
return None;
}
let flip = if bottom { Affine::FLIP_Y } else { Affine::IDENTITY };
let middle = arc.topmost(flip * t.xf)? / arc.len();
let run = to - from;
let start = (middle - run * 0.5).rem_euclid(1.0).clamp(0.0, 1.0);
// Without an end bracket the type runs once round; a full turn's end stays on its start.
let end = end.map(|_| if run >= 1.0 - 1e-12 { start } else { (start + run).rem_euclid(1.0).clamp(0.0, 1.0) });
Some((start, end))
}
/// Whether brackets `a` and `b`, fractions of a closed path's length, are at one place within
/// rounding (0 and 1 are one place).
fn same_bracket(a: f64, b: f64) -> bool {
let d = (a - b).rem_euclid(1.0);
d.min(1.0 - d) <= 1e-9
}
fn path_center_top(s: &mut Session, p: &Value) -> Result<Value> {
path_center(s, p, false)
}
fn path_center_bottom(s: &mut Session, p: &Value) -> Result<Value> {
path_center(s, p, true)
}
/// Type › Type on a Path › Center on Top and Center on Bottom: slide the selected type on closed
/// paths along them so the middle of each span lands on the path's top (`bottom`: its bottom) as
/// drawn, and center the first paragraph, in one undo step; none when nothing changes.
fn path_center(s: &mut Session, p: &Value, bottom: bool) -> Result<Value> {
let c = if bottom { "type.pathCenter.bottom" } else { "type.pathCenter.top" };
let centered = |j: Justify| matches!(j, Justify::Center | Justify::JustifyCenter);
// Each object the command changes, with its new brackets when they move and whether its first
// paragraph is set to Center.
let (ids, changes) = {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = texts(s, p, c)?.into_iter().filter(|i| path_text(d.node(*i)).is_some()).collect();
if ids.is_empty() {
return Err(bad(c, "select type on a closed path"));
}
if let Some(why) = path_center_refusal(d, &ids) {
return Err(bad(c, why));
}
let (mut changes, mut flat) = (vec![], vec![]);
for id in &ids {
let Some(t) = path_text(d.node(*id)) else { continue };
let TextKind::OnPath { start, end, .. } = &t.kind else { continue };
let Some((to_start, to_end)) = centered_brackets(t, bottom) else {
flat.push(id.0.to_string());
continue;
};
let stays = same_bracket(*start, to_start)
&& match (end, to_end) {
(None, None) => true,
(Some(a), Some(b)) => same_bracket(*a, b),
_ => false,
};
let align = !centered(t.para_at(0).justify);
if !stays || align {
changes.push((*id, (!stays).then_some((to_start, to_end)), align));
}
}
if !flat.is_empty() {
let flat = flat.join(", ");
return Err(bad(c, format!("type on a path with no length or no height cannot be centered: {flat}")));
}
(ids, changes)
};
if !changes.is_empty() {
s.edit(if bottom { "Center on Bottom" } else { "Center on Top" }, |d, _| {
for (id, brackets, align) in &changes {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
if let (Some(b), TextKind::OnPath { start, end, .. }) = (brackets, &mut t.kind) {
(*start, *end) = *b;
}
if *align {
t.edit_paras(Some(0..1), |para| para.justify = Justify::Center);
}
refresh_bounds(t);
}
Ok(())
})?;
}
let d = &s.doc()?.doc;
let (start, end) = ids.first().and_then(|id| path_text(d.node(*id))).map_or((0.0, None), |t| match &t.kind {
TextKind::OnPath { start, end, .. } => (*start, *end),
_ => (0.0, None),
});
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "start": start, "end": end}))
}
/// The options `type.pathOptions` sets; with none of them given it queries.
const PATH_OPTIONS: [&str; 6] = ["start", "end", "flip", "effect", "alignToPath", "spacing"];
@@ -550,14 +793,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> {
path_text(n).or_else(|| area_text(n))
}
/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it
/// in a thread.
/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into
/// area type with Block on.
fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> {
match n.map(|n| &n.kind) {
Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t),
_ => dynamic_type(n),
}
}
/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type,
/// none of it in a thread.
fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
let d = &st.doc;
let selected: Vec<NodeId> = st.selection.objects.to_vec();
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect();
let types: Vec<NodeId> = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect();
if types.is_empty() {
return Err("select type on a path or area type".into());
}
@@ -583,32 +835,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String {
/// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on
/// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in
/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn
/// at ([`bake_dynamic_text`]).
/// one undo step, and none when nothing changes. Turning it on also turns the selected point type
/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was
/// drawn at ([`bake_dynamic_text`]).
fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "type.dynamicText";
let (word_gap, line_gap) = block_gaps(p, C)?;
let (ids, on, planned) = {
let (ids, on, planned, converted) = {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect();
let ids: Vec<NodeId> = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect();
let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?;
if let Some(why) = threaded_refusal(d, &ids) {
return Err(bad(C, why));
}
let gap_given = word_gap.is_some() || line_gap.is_some();
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) {
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
// Point type with a line to fit, which turning it on makes area type.
let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some());
if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) {
return Err(bad(C, "wordGap and lineGap need area type in the selection"));
}
let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?;
// Each object as the command leaves it, and only those it changes.
let mut planned: Vec<(NodeId, TextObject)> = vec![];
// The point type that becomes area type, with the gaps applied, by id.
let mut converted: Vec<(NodeId, TextObject)> = vec![];
for id in &ids {
let Some(t) = dynamic_type(d.node(*id)) else { continue };
let Some(t) = dynamic_target(d.node(*id)) else { continue };
let mut after = t.clone();
if matches!(t.kind, TextKind::Area { .. }) {
if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) {
after.area.word_gap = word_gap.unwrap_or(t.area.word_gap);
after.area.line_gap = line_gap.unwrap_or(t.area.line_gap);
}
if matches!(t.kind, TextKind::Point) {
// Point type has no Dynamic Text of its own: on, it becomes area type with Block
// on; off, or with no line to fit, it stays as it is.
if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() {
planned.push((*id, block));
converted.push((*id, after));
}
continue;
}
match (on, t.dynamic_text) {
(true, true) => refresh_bounds(&mut after),
(true, false) => {
@@ -628,7 +894,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
planned.push((*id, after));
}
}
(ids, on, planned)
(ids, on, planned, converted)
};
if !planned.is_empty() {
s.edit("Dynamic Text", |d, _| {
@@ -637,6 +903,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result<Value> {
**t = after.clone();
}
}
if converted.is_empty() {
return Ok(());
}
// The new area type takes the Text Wrap shapes above it; one that has any is fitted
// again around them, with the depth that holds every line.
for id in vectorcraft_doc::text::resolve_wrap(None, d) {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap));
match block {
Some(block) => **t = block,
None => refresh_bounds(t),
}
}
Ok(())
})?;
}
+6 -2
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Free Distort",
[],
None,
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles",
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
has_doc,
distort
),
@@ -104,7 +104,8 @@ impl Projective {
}
/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine
/// approximation at their centre.
/// approximation at their center. The art of a linked opacity mask is warped with the object it
/// masks; an unlinked one stays where it is.
fn warp_node(n: &mut Node, pr: &Projective) {
n.pin_gradients();
match &mut n.kind {
@@ -131,6 +132,9 @@ fn warp_node(n: &mut Node, pr: &Projective) {
return;
}
}
if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) {
warp_node(Arc::make_mut(&mut m.art), pr);
}
n.appearance.warp_gradients(&|p| pr.affine_near(p));
}
+4
View File
@@ -34,6 +34,9 @@ pub struct DocState {
/// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape
/// ([`cmd::globaledit`]). Editing state: not saved, not undoable.
pub global_edit: Option<cmd::globaledit::GlobalEdit>,
/// The objects Start Global Edit would take, highlighted before it starts
/// ([`cmd::globaledit::similar`]), as of a revision. Not saved, not undoable.
pub(crate) similar_outlines: cmd::globaledit::SimilarCache,
/// What leaving symbol editing mode goes back to ([`cmd::symboledit`]).
pub(crate) symbol_edit_return: cmd::symboledit::Return,
pub interaction: Option<Interaction>,
@@ -302,6 +305,7 @@ impl DocState {
variables_highlight: None,
isolation: None,
global_edit: None,
similar_outlines: Default::default(),
symbol_edit_return: Default::default(),
interaction: None,
undo_group: None,
+7
View File
@@ -3,6 +3,9 @@ use super::{NodeId, cancel};
/// The text of [`EngineError::Busy`], which ends every error a frontend reports for it.
pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish";
/// Stable prefix for a save refused by the filesystem: choose another destination.
pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION";
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
@@ -20,6 +23,10 @@ pub enum EngineError {
NoNode(NodeId),
#[error("{0}")]
Other(String),
/// The save's terminal filesystem error refuses writes here. The reason keeps the path and
/// system message. This never means an automation-root refusal or a recovery-copy failure.
#[error("{code}: {0}", code = READ_ONLY_LOCATION)]
ReadOnlyLocation(String),
/// The caller asked for the command to stop ([`cancel`]). It changed nothing.
#[error("cancelled; nothing was changed")]
Cancelled,
+1 -1
View File
@@ -382,7 +382,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS;
use command_sets::given_ids;
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
pub use doc_state::{DocState, OpenRows, RecalledSelection};
pub use error::{EngineError, Result, SCRIPT_BUSY};
pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY};
#[cfg(test)]
use history::history_budget_for;
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
+22 -8
View File
@@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) {
host.console.borrow_mut().push(line, cut);
}
/// The error of the failed run `report` describes, as its console lines give it; `name` is the
/// run's name in those lines ([`console::line_name`]).
fn error_text(name: &str, report: &Value) -> String {
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
let message = report["message"].as_str().unwrap_or_default();
match report["file"].as_str() {
Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"),
_ if line > 0 => format!("{name}:{line}:{column}: {message}"),
_ => format!("{name}: location unavailable: {message}"),
}
}
/// The console lines of the error of the failed run `report` describes, each as the console keeps
/// it, cut to its limit.
pub(crate) fn error_lines(report: &Value) -> Vec<String> {
let name = console::line_name(report["name"].as_str().unwrap_or_default());
let mut lines = vec![];
each_line(&error_text(&name, report), |line, _| lines.push(line));
lines
}
/// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run
/// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report
/// line in the session's console. Adds the run's time, host calls by kind, slowest commands,
@@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console::
}
}
if report["ok"] == false {
let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0));
let message = report["message"].as_str().unwrap_or_default();
let text = match report["file"].as_str() {
Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name),
_ if line > 0 => format!("{}:{line}:{column}: {message}", host.name),
_ => format!("{}: location unavailable: {message}", host.name),
};
each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut));
each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut));
}
let timing = clock().is_some();
let ms = millis(host.run_nanos.get());

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