Merge main into feat/smooth-cursor-without-path

This commit is contained in:
dotCooCoo
2026-11-13 13:50:39 -08:00
205 changed files with 12745 additions and 670 deletions
+6 -2
View File
@@ -58,7 +58,10 @@ pollster = "1"
# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which
# src/caps_lock.rs uses to read whether Caps Lock is on. Win32_System_Registry has `RegGetValueW`,
# which src/host_services.rs uses to read the path PhotoCraft registers under App Paths.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# Win32_Devices_Display has `QueryDisplayConfig`, Win32_UI_ColorSystem the display's ICC profile and
# Win32_System_LibraryLoader `GetProcAddress`, which src/display_profile.rs uses to read the
# display's color profile for the canvas.
windows = { version = "0.62", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_ColorSystem", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# The renderer's worker count follows those cores (src/windows_cores.rs).
vectorcraft-render = { workspace = true }
@@ -93,7 +96,8 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a
# A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a
# second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build
# through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs).
x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] }
# RandR reads a display's `_ICC_PROFILE` for the canvas (src/display_profile.rs).
x11rb = { version = "0.13", default-features = false, features = ["randr", "xinput", "xkb"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one, and copies and pastes text, SVG and PNG through the compositor's
+3
View File
@@ -219,6 +219,9 @@ impl App {
self.app.services.dock_monitors = window::dock_monitors(w, zoom);
self.monitors_listed = Some((now, zoom));
}
if let Some(w) = &self.winit_window {
self.display_profile.tick(w, now, self.app.session.prefs.monitor_profile_display, &self.app.display_profile, ctx);
}
self.app.tick(ctx);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
+629
View File
@@ -0,0 +1,629 @@
//! Reads the ICC profile of the display that shows most of the main window, for Preferences ›
//! Performance › Use Monitor Profile for Canvas, and hands it to [`DisplayProfile::report`].
//!
//! - Windows: the display's default profile for its standard color mode. A display with Advanced
//! Color on (HDR or Automatic Color Management) is converted by Windows, and nothing is read.
//! - X11: colord's profile for the RandR output, then the output's `_ICC_PROFILE` property, then
//! the screen's `_ICC_PROFILE` when only one output is active.
//! - Wayland, XWayland included: the compositor gives applications no display profile, and the
//! canvas stays sRGB.
//! - macOS: the window's layer is tagged sRGB and macOS converts it (`mac_color_space`).
//!
//! While the preference is on, a background thread reads the display every [`EVERY`] seconds,
//! and at once when the window moves to another display.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use vectorcraft_ui_egui::display_profile::{DisplayProfile, Found, Reading};
/// Seconds between two readings of the same display.
const EVERY: f64 = 10.0;
/// Seconds between two checks of which display shows the window.
const LOOK_EVERY: f64 = 1.0;
/// Starts the readings from the main window's frames.
#[derive(Default)]
pub(crate) struct Reader {
/// When the last reading started, and the display it was for.
last: Option<(f64, Option<String>)>,
/// When the window's display was last looked up.
looked: Option<f64>,
/// A reading is running.
busy: Arc<AtomicBool>,
}
/// Clears [`Reader::busy`] when the reading thread ends, also by a panic.
struct Done(Arc<AtomicBool>);
impl Drop for Done {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
impl Reader {
/// Starts a reading when one is due and none is running. `now` is the frame's time in seconds.
pub(crate) fn tick(&mut self, window: &winit::window::Window, now: f64, enabled: bool, profile: &DisplayProfile, ctx: &egui::Context) {
if !enabled || self.busy.load(Ordering::Acquire) || self.looked.is_some_and(|at| (at..at + LOOK_EVERY).contains(&now)) {
return;
}
self.looked = Some(now);
let display = window.current_monitor().and_then(|m| m.name());
if !due(self.last.as_ref(), now, display.as_deref()) {
return;
}
self.last = Some((now, display.clone()));
profile.pending();
self.busy.store(true, Ordering::Release);
let done = Done(self.busy.clone());
let (profile, ctx) = (profile.clone(), ctx.clone());
let spawned = std::thread::Builder::new().name("display profile".into()).spawn(move || {
let _done = done;
if profile.report(read(display)) {
ctx.request_repaint();
}
});
if let Err(e) = spawned {
log::warn!("couldn't start reading the display profile: {e}");
}
}
}
/// Whether a reading of `display` is due at `now`, after the last one (`last`): none yet, another
/// display, [`EVERY`] seconds later, or a clock that went back.
fn due(last: Option<&(f64, Option<String>)>, now: f64, display: Option<&str>) -> bool {
last.is_none_or(|(at, shown)| shown.as_deref() != display || !(*at..at + EVERY).contains(&now))
}
#[cfg(windows)]
fn read(display: Option<String>) -> Reading {
let found = win::read(display.as_deref());
Reading { display, found }
}
#[cfg(target_os = "linux")]
fn read(display: Option<String>) -> Reading {
let found = if wayland_session(|k| std::env::var_os(k)) {
Found::NoProfile("a Wayland session doesn't give applications the display's profile".into())
} else {
x11::read(display.as_deref())
};
Reading { display, found }
}
#[cfg(all(target_os = "macos", feature = "wgpu"))]
fn read(display: Option<String>) -> Reading {
Reading { display, found: Found::System("macOS converts the window to the display's profile".into()) }
}
#[cfg(not(any(windows, target_os = "linux", all(target_os = "macos", feature = "wgpu"))))]
fn read(display: Option<String>) -> Reading {
Reading { display, found: Found::NoProfile("this build doesn't read display profiles on this system".into()) }
}
/// Whether the app runs in a Wayland session, read from the environment `var` gives. XWayland
/// windows count: the variables stay set for them.
#[cfg(any(target_os = "linux", test))]
fn wayland_session(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|k| var(k).is_some_and(|v| !v.is_empty()))
}
/// The X11 reading from colord's answer `colord` (the profile file it names, read) and the X11
/// properties `properties`: colord's profile when its file can be read, otherwise the
/// properties' profile. When neither has one, the reason colord gave is kept.
#[cfg(any(target_os = "linux", test))]
fn x11_choice(colord: Option<Result<Option<Found>, String>>, properties: impl FnOnce() -> Result<Option<Found>, String>) -> Found {
let colord_reason = match colord {
Some(Ok(Some(found @ Found::Profile { .. }))) => return found,
Some(Ok(Some(Found::NoProfile(e) | Found::System(e)))) | Some(Err(e)) => Some(e),
Some(Ok(None)) | None => None,
};
match properties() {
Ok(Some(found)) => found,
Ok(None) => Found::NoProfile(match colord_reason {
Some(e) => format!("no profile is set for this display ({e})"),
None => "no profile is set for this display".into(),
}),
Err(e) => Found::NoProfile(e),
}
}
/// `found` when it is not a profile or the profile can be used, otherwise why it can't, so the
/// next source is tried.
#[cfg(any(target_os = "linux", test))]
fn usable(found: Found) -> Found {
match found {
Found::Profile { icc, from } => match vectorcraft_ui_egui::display_profile::check_profile(&icc) {
Ok(()) => Found::Profile { icc, from },
Err(e) => Found::NoProfile(format!("{from}: {e}")),
},
other => other,
}
}
/// The ICC file at `path`, refused before it is read when it is larger than [`MAX_PROFILE_BYTES`].
///
/// [`MAX_PROFILE_BYTES`]: vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES
#[cfg(any(windows, target_os = "linux", test))]
fn read_icc(path: &std::path::Path) -> Result<Vec<u8>, String> {
use std::io::Read as _;
let max = vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES;
let too_large = || format!("{} is larger than {} MiB", path.display(), max >> 20);
let file = std::fs::File::open(path).map_err(|e| format!("couldn't open {}: {e}", path.display()))?;
let len = file.metadata().map_err(|e| format!("couldn't read {}: {e}", path.display()))?.len();
if len > max as u64 {
return Err(too_large());
}
let mut bytes = Vec::with_capacity(len as usize);
file.take(max as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("couldn't read {}: {e}", path.display()))?;
if bytes.len() > max { Err(too_large()) } else { Ok(bytes) }
}
/// The file a profile `name` stands for: `name` itself when it is a path, otherwise the file of
/// that name in the system's color folder `dir`.
#[cfg(any(windows, test))]
fn profile_path(dir: Option<&std::path::Path>, name: &str) -> std::path::PathBuf {
match dir {
Some(dir) if !name.contains(['\\', '/']) => dir.join(name),
_ => std::path::PathBuf::from(name),
}
}
/// `path`'s profile, or why there is none.
#[cfg(any(windows, target_os = "linux"))]
fn profile_file(path: &std::path::Path) -> Found {
let from = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned());
match read_icc(path) {
Ok(icc) => Found::Profile { icc, from },
Err(e) => Found::NoProfile(e),
}
}
#[cfg(windows)]
#[allow(unsafe_code)]
mod win {
use std::path::PathBuf;
use std::sync::OnceLock;
use vectorcraft_ui_egui::display_profile::Found;
use windows::Win32::Devices::Display::{
DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_HEADER,
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
DisplayConfigGetDeviceInfo, GetDisplayConfigBufferSizes, QDC_ONLY_ACTIVE_PATHS, QueryDisplayConfig,
};
use windows::Win32::Foundation::{ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, HLOCAL, LUID, LocalFree};
use windows::Win32::Graphics::Gdi::{CreateDCW, DeleteDC};
use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW};
use windows::Win32::UI::ColorSystem::{
CPST_STANDARD_DISPLAY_COLOR_MODE, CPT_ICC, GetColorDirectoryW, GetICMProfileW, WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER,
};
use windows::core::{HRESULT, PCWSTR, PWSTR, s, w};
/// `ColorProfileGetDisplayDefault` and `ColorProfileGetDisplayUserScope` (mscms.dll), which
/// older Windows versions don't have.
type GetDefault = unsafe extern "system" fn(i32, LUID, u32, i32, i32, *mut PWSTR) -> HRESULT;
type GetScope = unsafe extern "system" fn(LUID, u32, *mut i32) -> HRESULT;
/// The display path that shows a GDI device.
struct Path {
source: u32,
target_adapter: LUID,
target: u32,
}
pub(super) fn read(display: Option<&str>) -> Found {
let Some(device) = display else { return Found::NoProfile("Windows didn't name the window's display".into()) };
let path = match find_path(device) {
Ok(path) => path,
Err(e) => return Found::NoProfile(e),
};
if path.as_ref().is_some_and(advanced_color) {
return Found::System("Windows manages this display's colors (Advanced Color)".into());
}
let name = match path.as_ref().and_then(default_profile) {
Some(Ok(name)) => name,
Some(Err(e)) => return Found::NoProfile(e),
// Older Windows, or a display without an active path: GDI's profile for the device.
None => match icm_profile(device) {
Ok(name) => name,
Err(e) => return Found::NoProfile(e),
},
};
match name {
Some(name) => super::profile_file(&super::profile_path(color_directory().as_deref(), &name)),
None => Found::NoProfile("no color profile is set for this display".into()),
}
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain([0]).collect()
}
fn until_nul(w: &[u16]) -> String {
String::from_utf16_lossy(&w[..w.iter().position(|&c| c == 0).unwrap_or(w.len())])
}
/// The active display paths.
fn paths() -> Result<Vec<DISPLAYCONFIG_PATH_INFO>, String> {
for _ in 0..4 {
let (mut paths_len, mut modes_len) = (0u32, 0u32);
// SAFETY: both pointers are to live `u32`s.
let e = unsafe { GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, &mut modes_len) };
if e != ERROR_SUCCESS {
return Err(format!("Windows couldn't list the displays (error {})", e.0));
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); paths_len as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); modes_len as usize];
// SAFETY: the arrays hold as many elements as the lengths passed with them.
let e =
unsafe { QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, &mut paths_len, paths.as_mut_ptr(), &mut modes_len, modes.as_mut_ptr(), None) };
if e == ERROR_INSUFFICIENT_BUFFER {
// The displays changed between the two calls.
continue;
}
if e != ERROR_SUCCESS {
return Err(format!("Windows couldn't list the displays (error {})", e.0));
}
paths.truncate(paths_len as usize);
return Ok(paths);
}
Err("the displays kept changing while Windows listed them".into())
}
/// The path whose source is the GDI device `device` (`\\.\DISPLAY1`), if one is active.
fn find_path(device: &str) -> Result<Option<Path>, String> {
for p in paths()? {
let mut name = DISPLAYCONFIG_SOURCE_DEVICE_NAME {
header: DISPLAYCONFIG_DEVICE_INFO_HEADER {
r#type: DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME,
size: size_of::<DISPLAYCONFIG_SOURCE_DEVICE_NAME>() as u32,
adapterId: p.sourceInfo.adapterId,
id: p.sourceInfo.id,
},
..Default::default()
};
// SAFETY: the header starts a `DISPLAYCONFIG_SOURCE_DEVICE_NAME` of the size it states.
if unsafe { DisplayConfigGetDeviceInfo(&mut name.header) } == 0 && until_nul(&name.viewGdiDeviceName).eq_ignore_ascii_case(device) {
return Ok(Some(Path { source: p.sourceInfo.id, target_adapter: p.targetInfo.adapterId, target: p.targetInfo.id }));
}
}
Ok(None)
}
/// Whether Windows color-manages the path's display itself: Advanced Color is on (HDR), or
/// wide color is enforced (Automatic Color Management on an SDR display).
fn advanced_color(path: &Path) -> bool {
let mut info = DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO {
header: DISPLAYCONFIG_DEVICE_INFO_HEADER {
r#type: DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
size: size_of::<DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO>() as u32,
adapterId: path.target_adapter,
id: path.target,
},
..Default::default()
};
// SAFETY: the header starts a `DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO` of the size it states.
if unsafe { DisplayConfigGetDeviceInfo(&mut info.header) } != 0 {
return false;
}
// SAFETY: every bit pattern is a valid `u32`.
let bits = unsafe { info.Anonymous.value };
// advancedColorEnabled is bit 1 and wideColorEnforced bit 2.
bits & 0b110 != 0
}
fn api() -> Option<(GetDefault, GetScope)> {
static API: OnceLock<Option<(GetDefault, GetScope)>> = OnceLock::new();
*API.get_or_init(|| {
// SAFETY: mscms.dll is loaded from System32 only, and stays loaded for the process.
let module = unsafe { LoadLibraryExW(w!("mscms.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.ok()?;
// SAFETY: the names are NUL-terminated, and the module is loaded.
let (default, scope) = unsafe {
(GetProcAddress(module, s!("ColorProfileGetDisplayDefault"))?, GetProcAddress(module, s!("ColorProfileGetDisplayUserScope"))?)
};
// SAFETY: both exports have these signatures (icm.h).
unsafe {
Some((
std::mem::transmute::<unsafe extern "system" fn() -> isize, GetDefault>(default),
std::mem::transmute::<unsafe extern "system" fn() -> isize, GetScope>(scope),
))
}
})
}
/// The display's default profile name for its standard color mode, `None` without the API.
fn default_profile(path: &Path) -> Option<Result<Option<String>, String>> {
let (get_default, get_scope) = api()?;
let mut scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0;
// SAFETY: `scope` is a live `i32`.
if unsafe { get_scope(path.target_adapter, path.source, &mut scope) }.is_err() {
scope = WCS_PROFILE_MANAGEMENT_SCOPE_CURRENT_USER.0;
}
let mut name = PWSTR::null();
// SAFETY: `name` is a live pointer the function fills with a string it allocates.
let hr = unsafe { get_default(scope, path.target_adapter, path.source, CPT_ICC.0, CPST_STANDARD_DISPLAY_COLOR_MODE.0, &mut name) };
if hr.is_err() {
return Some(Err(format!("Windows couldn't give the display's profile (error {:#010x})", hr.0)));
}
if name.is_null() {
return Some(Ok(None));
}
// SAFETY: `name` is a NUL-terminated string from the call above, freed once with LocalFree.
let text = unsafe { name.to_string() };
// SAFETY: as above.
let _ = unsafe { LocalFree(Some(HLOCAL(name.0.cast()))) };
Some(text.map(|t| Some(t).filter(|t| !t.is_empty())).map_err(|e| format!("the display's profile name isn't readable: {e}")))
}
/// GDI's profile file for the display device `device`.
fn icm_profile(device: &str) -> Result<Option<String>, String> {
let device = wide(device);
// SAFETY: both strings are NUL-terminated and outlive the call.
let dc = unsafe { CreateDCW(w!("DISPLAY"), PCWSTR(device.as_ptr()), PCWSTR::null(), None) };
if dc.is_invalid() {
return Err("Windows couldn't open the display".into());
}
let mut len = 0u32;
// SAFETY: a null buffer asks for the length only.
let _ = unsafe { GetICMProfileW(dc, &mut len, None) };
let mut buf = vec![0u16; len.clamp(1, 32 * 1024) as usize];
len = buf.len() as u32;
// SAFETY: `buf` holds `len` characters.
let ok = unsafe { GetICMProfileW(dc, &mut len, Some(PWSTR(buf.as_mut_ptr()))) }.as_bool();
// SAFETY: `dc` came from CreateDCW above.
let _ = unsafe { DeleteDC(dc) };
Ok(ok.then(|| until_nul(&buf)).filter(|n| !n.is_empty()))
}
/// The system's color folder (`…\spool\drivers\color`).
fn color_directory() -> Option<PathBuf> {
let mut bytes = 0u32;
// SAFETY: a null buffer asks for the size only, in bytes.
let _ = unsafe { GetColorDirectoryW(PCWSTR::null(), None, &mut bytes) };
let mut buf = vec![0u16; (bytes as usize / 2).clamp(1, 32 * 1024)];
bytes = (buf.len() * 2) as u32;
// SAFETY: `buf` holds `bytes` bytes.
unsafe { GetColorDirectoryW(PCWSTR::null(), Some(PWSTR(buf.as_mut_ptr())), &mut bytes) }.as_bool().then(|| PathBuf::from(until_nul(&buf)))
}
#[cfg(test)]
mod tests {
use windows::Win32::Graphics::Gdi::{DISPLAY_DEVICE_ATTACHED_TO_DESKTOP, DISPLAY_DEVICEW, EnumDisplayDevicesW};
use super::*;
/// The GDI names (`\\.\DISPLAY1`) of the displays attached to the desktop.
fn attached_displays() -> Vec<String> {
let mut names = Vec::new();
for i in 0.. {
let mut device = DISPLAY_DEVICEW { cb: size_of::<DISPLAY_DEVICEW>() as u32, ..Default::default() };
// SAFETY: `device` is a live `DISPLAY_DEVICEW` with `cb` set to its size.
if !unsafe { EnumDisplayDevicesW(PCWSTR::null(), i, &mut device, 0) }.as_bool() {
break;
}
if device.StateFlags.contains(DISPLAY_DEVICE_ATTACHED_TO_DESKTOP) {
names.push(until_nul(&device.DeviceName));
}
}
names
}
#[test]
fn windows_reads_each_attached_display_and_refuses_an_unknown_one() {
assert_eq!(read(None), Found::NoProfile("Windows didn't name the window's display".into()));
assert!(color_directory().is_none_or(|dir| dir.is_absolute()));
match find_path(r"\\.\DISPLAY999") {
Ok(path) => {
assert!(path.is_none());
assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile("Windows couldn't open the display".into()));
}
// A session without displays (a service): the reading gives Windows' error.
Err(e) => {
eprintln!("the displays couldn't be listed: {e}");
assert_eq!(read(Some(r"\\.\DISPLAY999")), Found::NoProfile(e));
return;
}
}
for name in attached_displays() {
// A display without an active path is read through GDI (`icm_profile`).
let path = find_path(&name).unwrap();
// winit names the display in GDI's spelling; the match ignores letter case.
let lower = find_path(&name.to_lowercase()).unwrap();
assert_eq!(lower.map(|p| (p.source, p.target)), path.as_ref().map(|p| (p.source, p.target)), "{name} in lowercase");
match read(Some(&name)) {
Found::Profile { icc, from } => {
eprintln!("{name}: profile {from}, {} bytes", icc.len());
assert!(!icc.is_empty() && !from.is_empty());
assert!(!path.as_ref().is_some_and(advanced_color));
}
Found::System(e) => {
eprintln!("{name}: system, {e}");
assert!(path.as_ref().is_some_and(advanced_color));
}
Found::NoProfile(e) => {
eprintln!("{name}: no profile, {e}");
assert!(!e.is_empty());
}
}
}
}
}
}
#[cfg(target_os = "linux")]
mod x11 {
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use vectorcraft_ui_egui::display_profile::{Found, MAX_PROFILE_BYTES};
use x11rb::connection::Connection as _;
use x11rb::protocol::randr;
use x11rb::protocol::xproto::{self, AtomEnum};
use zbus::blocking::{connection::Builder, proxy::Proxy};
use zbus::zvariant::OwnedObjectPath;
const COLORD: &str = "org.freedesktop.ColorManager";
const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
pub(super) fn read(output: Option<&str>) -> Found {
let colord = output.map(|o| colord_profile(o).map(|path| path.map(|path| super::usable(super::profile_file(Path::new(&path))))));
super::x11_choice(colord, || properties(output))
}
/// The file colord assigns the RandR output `output`.
fn colord_profile(output: &str) -> Result<Option<String>, String> {
let connection = Builder::system()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?
.method_timeout(Duration::from_secs(2))
.build()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't reach colord: {e}"))?;
let devices: Vec<OwnedObjectPath> =
manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list the displays: {e}"))?;
for path in devices {
let Ok(device) = Proxy::new(&connection, COLORD, &path, COLORD_DEVICE) else { continue };
let Ok(metadata) = device.get_property::<HashMap<String, String>>("Metadata") else { continue };
if metadata.get("XRANDR_name").map(String::as_str) != Some(output) {
continue;
}
// `*` asks for the device's default profile.
let Ok(profile_path) = device.call::<_, _, OwnedObjectPath>("GetProfileForQualifiers", &(vec!["*"],)) else { return Ok(None) };
let profile =
Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't reach colord's profile: {e}"))?;
return Ok(profile.get_property::<String>("Filename").ok().filter(|f| !f.is_empty()));
}
Ok(None)
}
/// The `_ICC_PROFILE` property of the RandR output `output`, or of the screen when only one
/// output is active.
fn properties(output: Option<&str>) -> Result<Option<Found>, String> {
let (conn, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
let root = conn.setup().roots.get(screen).ok_or_else(|| "the X11 display has no screen".to_string())?.root;
let atom = xproto::intern_atom(&conn, true, b"_ICC_PROFILE")
.map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))?
.reply()
.map_err(|e| format!("couldn't ask the X11 display for profiles: {e}"))?
.atom;
if atom == 0 {
return Ok(None);
}
let resources = randr::get_screen_resources_current(&conn, root)
.map_err(|e| format!("couldn't list the X11 outputs: {e}"))?
.reply()
.map_err(|e| format!("couldn't list the X11 outputs: {e}"))?;
let (mut active, mut shown) = (0usize, None);
for o in resources.outputs {
let Ok(Ok(info)) = randr::get_output_info(&conn, o, resources.config_timestamp).map(|c| c.reply()) else { continue };
if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
continue;
}
active += 1;
if output.is_some_and(|name| info.name == name.as_bytes()) {
shown = Some(o);
}
}
let longs = (MAX_PROFILE_BYTES / 4) as u32;
let too_large = || Found::NoProfile(format!("the display's profile is larger than {} MiB", MAX_PROFILE_BYTES >> 20));
if let Some(o) = shown
&& let Ok(Ok(p)) = randr::get_output_property(&conn, o, atom, AtomEnum::ANY, 0, longs, false, false).map(|c| c.reply())
&& !p.data.is_empty()
{
if p.bytes_after > 0 {
return Ok(Some(too_large()));
}
return Ok(Some(Found::Profile { icc: p.data, from: "the X11 output's _ICC_PROFILE".into() }));
}
if active != 1 {
return Ok(None);
}
let Ok(Ok(p)) = xproto::get_property(&conn, false, root, atom, AtomEnum::ANY, 0, longs).map(|c| c.reply()) else { return Ok(None) };
if p.value.is_empty() {
return Ok(None);
}
if p.bytes_after > 0 {
return Ok(Some(too_large()));
}
Ok(Some(Found::Profile { icc: p.value, from: "the X11 screen's _ICC_PROFILE".into() }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reading_is_due_first_after_ten_seconds_and_when_the_window_changes_display() {
assert!(due(None, 0.0, None));
let last = (100.0, Some("DP-1".to_string()));
assert!(!due(Some(&last), 100.0, Some("DP-1")));
assert!(!due(Some(&last), 109.9, Some("DP-1")));
assert!(due(Some(&last), 110.0, Some("DP-1")));
assert!(due(Some(&last), 101.0, Some("HDMI-1")));
assert!(due(Some(&last), 101.0, None));
// A clock that went back.
assert!(due(Some(&last), 50.0, Some("DP-1")));
}
#[test]
fn wayland_sessions_are_told_apart_from_x11_by_the_environment() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |k: &str| pairs.iter().find(|(n, _)| *n == k).map(|(_, v)| std::ffi::OsString::from(v))
};
assert!(wayland_session(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])));
assert!(wayland_session(env(&[("WAYLAND_SOCKET", "5")])));
assert!(!wayland_session(env(&[("DISPLAY", ":0")])));
assert!(!wayland_session(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":0")])));
}
#[test]
fn profile_files_larger_than_the_limit_are_refused_before_they_are_read() {
let dir = tempfile::tempdir().unwrap();
let small = dir.path().join("small.icc");
std::fs::write(&small, [1u8, 2, 3]).unwrap();
assert_eq!(read_icc(&small).unwrap(), [1, 2, 3]);
let big = dir.path().join("big.icc");
let f = std::fs::File::create(&big).unwrap();
f.set_len(vectorcraft_ui_egui::display_profile::MAX_PROFILE_BYTES as u64 + 1).unwrap();
assert!(read_icc(&big).unwrap_err().contains("larger than 1 MiB"));
assert!(read_icc(&dir.path().join("missing.icc")).unwrap_err().starts_with("couldn't open"));
}
#[test]
fn x11_takes_the_output_property_when_colord_s_file_cannot_be_read() {
let icc = |b: u8| Found::Profile { icc: vec![b], from: "test".into() };
let property = || Ok(Some(icc(2)));
// colord's profile, read: the property is not asked for.
assert_eq!(x11_choice(Some(Ok(Some(icc(1)))), || panic!("not asked")), icc(1));
// colord names a file that can't be read, or has no profile, or isn't running.
assert_eq!(x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), property), icc(2));
assert_eq!(x11_choice(Some(Ok(None)), property), icc(2));
assert_eq!(x11_choice(Some(Err("no colord".into())), property), icc(2));
assert_eq!(x11_choice(None, property), icc(2));
// Neither has a profile: colord's reason is kept.
assert_eq!(
x11_choice(Some(Ok(Some(Found::NoProfile("couldn't open a.icc".into())))), || Ok(None)),
Found::NoProfile("no profile is set for this display (couldn't open a.icc)".into())
);
assert_eq!(x11_choice(None, || Ok(None)), Found::NoProfile("no profile is set for this display".into()));
assert_eq!(x11_choice(None, || Err("no X11".into())), Found::NoProfile("no X11".into()));
// colord's file holds no usable profile: the property is used.
let broken = usable(Found::Profile { icc: vec![1, 2, 3], from: "a.icc".into() });
assert_eq!(broken, Found::NoProfile("a.icc: the file is not an ICC profile".into()));
assert_eq!(x11_choice(Some(Ok(Some(broken))), property), icc(2));
assert_eq!(usable(Found::NoProfile("none".into())), Found::NoProfile("none".into()));
}
#[test]
fn a_profile_name_is_found_in_the_color_folder_and_a_path_is_kept() {
let dir = std::path::Path::new("C:/Windows/System32/spool/drivers/color");
assert_eq!(profile_path(Some(dir), "Display.icm"), dir.join("Display.icm"));
assert_eq!(profile_path(Some(dir), "D:\\profiles\\Display.icm"), std::path::PathBuf::from("D:\\profiles\\Display.icm"));
assert_eq!(profile_path(None, "Display.icm"), std::path::PathBuf::from("Display.icm"));
}
}
+4
View File
@@ -28,6 +28,7 @@ mod clipboard;
mod compositor_gpu;
mod control_server;
mod cursor_theme;
mod display_profile;
#[cfg(feature = "wgpu")]
mod gpu;
mod hang_watch;
@@ -131,6 +132,8 @@ struct App {
held_files: Vec<String>,
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
monitors_listed: Option<(f64, f32)>,
/// Reads the profile of the display that shows the main window, for the canvas.
display_profile: display_profile::Reader,
/// When this frame's input hook started, for the hang report's frame times (`hang_watch`).
frame_start: Option<std::time::Instant>,
}
@@ -634,6 +637,7 @@ fn main() -> std::process::ExitCode {
maximize_tries: 8,
held_files,
monitors_listed: None,
display_profile: Default::default(),
frame_start: None,
}))
}),
+33 -2
View File
@@ -762,9 +762,40 @@ pub fn library(doc: &Document) -> Vec<Brush> {
}
/// Parse a stored library value (malformed entries are skipped, values kept within their
/// ranges; see [`Brush::from_json`]).
/// ranges; see [`Brush::from_json`]). A name that repeats is kept by its first brush, and each
/// later brush of that name is numbered ([`number_repeated_names`]).
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default()
let mut lib: Vec<Brush> = v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default();
number_repeated_names(&mut lib);
lib
}
/// Renames the second and later brushes of each repeated name in `lib` to the first "Name 2",
/// "Name 3"… that no brush in `lib` has. The first brush of a name keeps it, so a stroke that names
/// it draws the brush [`find`] gave it before.
fn number_repeated_names(lib: &mut [Brush]) {
let mut seen = std::collections::HashSet::new();
if lib.iter().all(|b| seen.insert(b.name.as_str())) {
return;
}
let mut taken: std::collections::HashSet<String> = lib.iter().map(|b| b.name.clone()).collect();
let mut kept = std::collections::HashSet::new();
// The next number to try for each repeated name, so the brushes of one name try each number
// once between them.
let mut next: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
for b in lib.iter_mut() {
if kept.insert(b.name.clone()) {
continue;
}
let from = next.entry(b.name.clone()).or_insert(2);
// `taken` is finite, so one of the numbers is free.
if let Some((i, name)) = (*from..).map(|i| (i, format!("{} {i}", b.name))).find(|(_, n)| !taken.contains(n)) {
*from = i.saturating_add(1);
taken.insert(name.clone());
kept.insert(name.clone());
b.name = name;
}
}
}
/// Store `lib` as the document's brush library.
+28
View File
@@ -945,6 +945,34 @@ fn library_defaults_store_and_find() {
assert_eq!(unique_name(&lib, "Arrow"), "Arrow 2");
}
/// A stored library that repeats a name reads with the first brush of that name as stored and each
/// later one numbered, past every name the library already has; strokes of the name keep drawing
/// the first brush.
#[test]
fn repeated_brush_names_are_numbered_when_read() {
let mut d = Document::new(100.0, 100.0);
let named = |name: &str, size: f64| Brush { name: name.into(), ..calli(0.0, 100.0, size) };
store(&mut d, &[named("Nib", 1.0), named("Nib 2", 2.0), named("Nib", 3.0), named("Other", 4.0), named("Nib", 5.0), named("Nib 2", 6.0)]);
let lib = library(&d);
let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).collect();
assert_eq!(names, ["Nib", "Nib 2", "Nib 3", "Other", "Nib 4", "Nib 2 2"]);
let size = |b: &Brush| if let BrushKind::Calligraphic(c) = &b.kind { c.size } else { 0.0 };
assert_eq!(lib.iter().map(size).collect::<Vec<_>>(), [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
assert_eq!(find(&d, "Nib").map(|b| size(&b)), Some(1.0));
assert_eq!(find(&d, "Nib 3").map(|b| size(&b)), Some(3.0));
// Reading again gives the same names, and the numbered library stored again reads as stored.
assert_eq!(library(&d), lib);
store(&mut d, &lib);
assert_eq!(library(&d), lib);
// Many brushes of one name take the numbers in order, past the numbered names the file has.
let many: Vec<Brush> = (0..5000).map(|i| named(if i == 1 { "Nib 3" } else { "Nib" }, 1.0)).collect();
store(&mut d, &many);
let names: Vec<String> = library(&d).into_iter().map(|b| b.name).collect();
assert_eq!(names[..5], ["Nib", "Nib 3", "Nib 2", "Nib 4", "Nib 5"]);
assert_eq!(names.last().map(String::as_str), Some("Nib 5000"));
assert_eq!(names.iter().collect::<std::collections::HashSet<_>>().len(), names.len());
}
#[test]
fn brushes_serde_round_trip() {
let lib = defaults().to_vec();
+3 -1
View File
@@ -2,7 +2,8 @@
//! gamut checks and soft-proof transforms.
//!
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB
//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB;
//! the desktop canvas converts it into the display's own profile last ([`display`]).
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
@@ -10,6 +11,7 @@
//! the profiles [`use_document_profiles`] last put in place of the working spaces;
//! [`crate::Color::to_rgb`] uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
pub mod display;
pub mod generic;
pub mod icc;
pub mod lab;
+327
View File
@@ -0,0 +1,327 @@
//! The canvas's last step before the screen: the sRGB the canvas draws, converted into a display's
//! ICC profile (Preferences › Performance › Use Monitor Profile for Canvas).
//!
//! The display profile is a destination only. Document colors, proofs, exports and the web build
//! keep their sRGB output; a soft proof is applied first, then this conversion.
//!
//! [`DisplayTransform::from_icc`] accepts an RGB display-class (`mntr`) profile of at most
//! [`MAX_PROFILE_BYTES`] whose declared length fits the data and from which a transform can be built.
//! The conversion is relative colorimetric, sampled once into a [`LUT_N`]³ table, so drawing a frame
//! costs a table lookup per pixel and never builds a transform.
use std::hash::{Hash, Hasher};
use super::{Intent, ProfileKind, icc::IccProfile};
/// The largest display profile read: VectorCraft's own limit. Display profiles are a few kilobytes
/// (matrix/shaper) to a few hundred (table-based).
pub const MAX_PROFILE_BYTES: usize = 1 << 20;
/// Grid points per channel of the conversion table.
const LUT_N: usize = 33;
/// Why a display profile can't be used. The canvas then stays sRGB.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProfileError {
/// More bytes than [`MAX_PROFILE_BYTES`].
TooLarge(usize),
/// No ICC header (`acsp` signature) or a header cut short.
NotIcc,
/// The header's length is shorter than a header or longer than the data.
BadLength { declared: usize, len: usize },
/// The profile class isn't a display's (`mntr`): an input, output or other profile.
NotDisplay(String),
/// The profile's data color space isn't RGB.
NotRgb(String),
/// The profile doesn't parse.
Unreadable(String),
/// No conversion from sRGB into the profile can be built.
NoTransform,
}
impl std::fmt::Display for ProfileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooLarge(n) => write!(f, "the profile is {n} bytes, more than the {MAX_PROFILE_BYTES} a display profile may have"),
Self::NotIcc => f.write_str("the file is not an ICC profile"),
Self::BadLength { declared, len } => write!(f, "the profile's header gives a length of {declared} bytes for {len} bytes of data"),
Self::NotDisplay(class) => write!(f, "the profile's class is `{class}`, not a display profile (`mntr`)"),
Self::NotRgb(space) => write!(f, "the profile's color space is `{space}`, not RGB"),
Self::Unreadable(e) => write!(f, "the profile can't be read: {e}"),
Self::NoTransform => f.write_str("no conversion into the profile can be built"),
}
}
}
/// Four header bytes as text, for an error message.
fn signature(b: &[u8]) -> String {
String::from_utf8_lossy(b).trim_end().to_string()
}
/// sRGB → a display profile, as a table.
pub struct DisplayTransform {
name: String,
fingerprint: u64,
/// `LUT_N`³ display values, red slowest; `None` when the profile shows sRGB unchanged (every
/// grid point within half an 8-bit step of itself).
table: Option<Vec<[f32; 3]>>,
}
impl std::fmt::Debug for DisplayTransform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DisplayTransform").field("name", &self.name).field("identity", &self.is_identity()).finish()
}
}
impl DisplayTransform {
/// Checks `bytes` as a display profile, as [`Self::from_icc`] does, without building the
/// conversion table.
pub fn check(bytes: &[u8]) -> Result<(), ProfileError> {
Self::parse(bytes).map(|_| ())
}
/// Checks `bytes` as a display profile and builds the conversion into it.
pub fn from_icc(bytes: &[u8]) -> Result<Self, ProfileError> {
let (profile, bytes) = Self::parse(bytes)?;
let to_display =
|rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?]));
let s = (LUT_N - 1) as f32;
let mut table = Vec::with_capacity(LUT_N.pow(3));
for r in 0..LUT_N {
for g in 0..LUT_N {
for b in 0..LUT_N {
let rgb = [r as f32 / s, g as f32 / s, b as f32 / s];
table.push(to_display(rgb).ok_or(ProfileError::NoTransform)?);
}
}
}
let grid = |i: usize| [i / (LUT_N * LUT_N), i / LUT_N % LUT_N, i % LUT_N].map(|v| v as f32 / s);
let identity = table.iter().enumerate().all(|(i, v)| v.iter().zip(grid(i)).all(|(a, b)| (a - b).abs() <= 0.5 / 255.0));
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
Ok(Self { name: profile.name.clone(), fingerprint: h.finish(), table: (!identity).then_some(table) })
}
/// The parsed profile and its bytes up to its declared length, when `bytes` is an RGB display
/// profile from which a conversion can be built.
fn parse(bytes: &[u8]) -> Result<(IccProfile, &[u8]), ProfileError> {
if bytes.len() > MAX_PROFILE_BYTES {
return Err(ProfileError::TooLarge(bytes.len()));
}
if bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
return Err(ProfileError::NotIcc);
}
let declared = bytes.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize);
if declared < 132 || declared > bytes.len() {
return Err(ProfileError::BadLength { declared, len: bytes.len() });
}
let bytes = &bytes[..declared];
if &bytes[12..16] != b"mntr" {
return Err(ProfileError::NotDisplay(signature(&bytes[12..16])));
}
if &bytes[16..20] != b"RGB " {
return Err(ProfileError::NotRgb(signature(&bytes[16..20])));
}
let profile = IccProfile::from_bytes(None, bytes).map_err(|e| ProfileError::Unreadable(e.to_string()))?;
if profile.kind != ProfileKind::Rgb {
return Err(ProfileError::NotRgb(format!("{:?}", profile.kind)));
}
let to_display =
|rgb: [f32; 3]| profile.from_srgb(rgb, Intent::RelativeColorimetric).and_then(|v| Some([*v.first()?, *v.get(1)?, *v.get(2)?]));
if to_display([0.5; 3]).is_none() {
return Err(ProfileError::NoTransform);
}
Ok((profile, bytes))
}
/// The profile's description.
pub fn name(&self) -> &str {
&self.name
}
/// A hash of the profile's bytes, which tells two profiles with the same name apart.
pub fn fingerprint(&self) -> u64 {
self.fingerprint
}
/// The profile shows sRGB unchanged, so nothing is converted.
pub fn is_identity(&self) -> bool {
self.table.is_none()
}
/// One sRGB color (0..1) in the display profile.
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
let Some(table) = &self.table else { return rgb.map(|v| v.clamp(0.0, 1.0)) };
let n = LUT_N;
let s = (n - 1) as f32;
let p = rgb.map(|v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) * s });
let i = p.map(|v| (v.floor() as usize).min(n - 2));
let t = [p[0] - i[0] as f32, p[1] - i[1] as f32, p[2] - i[2] as f32];
let mut out = [0.0f32; 3];
for (dr, wr) in [(0, 1.0 - t[0]), (1, t[0])] {
for (dg, wg) in [(0, 1.0 - t[1]), (1, t[1])] {
for (db, wb) in [(0, 1.0 - t[2]), (1, t[2])] {
let w = wr * wg * wb;
if w == 0.0 {
continue;
}
let v = table[((i[0] + dr) * n + i[1] + dg) * n + i[2] + db];
for c in 0..3 {
out[c] += w * v[c];
}
}
}
}
out.map(|v| v.clamp(0.0, 1.0))
}
/// One 8-bit sRGB color in the display profile.
pub fn apply8(&self, rgb: [u8; 3]) -> [u8; 3] {
if self.table.is_none() {
return rgb;
}
self.apply(rgb.map(|v| f32::from(v) / 255.0)).map(|v| (v * 255.0).round() as u8)
}
/// Premultiplied RGBA8 pixels, converted in place. Alpha is kept, and fully transparent
/// pixels are left as they are.
pub fn apply_premultiplied(&self, pixels: &mut [u8]) {
if self.table.is_none() {
return;
}
let mut last_in = [0u8; 4];
let mut last_out = [0u8; 4];
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3];
if a == 0 {
continue;
}
if *px == last_in {
*px = last_out;
continue;
}
last_in = *px;
let un = |c: u8| if a == 255 { c } else { ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 };
let out = self.apply8([un(px[0]), un(px[1]), un(px[2])]);
for (c, o) in px.iter_mut().zip(out) {
*c = if a == 255 { o } else { ((u32::from(o) * u32::from(a) + 127) / 255) as u8 };
}
last_out = *px;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cms::{DISPLAY_P3, GENERIC_CMYK, SRGB, icc_bytes};
/// `name`'s ICC file, marked as a display profile.
fn display(name: &str) -> Vec<u8> {
let mut b = icc_bytes(name).unwrap().to_vec();
b[12..16].copy_from_slice(b"mntr");
b
}
#[test]
fn a_wide_gamut_display_shows_srgb_red_less_saturated_and_srgb_is_left_alone() {
let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap();
assert!(!p3.is_identity());
assert_eq!(p3.name(), DISPLAY_P3);
// sRGB red is inside Display P3: its display values have green and blue above 0 and less red.
let [r, g, b] = p3.apply8([255, 0, 0]);
assert!(r < 245 && g > 40 && b > 10, "{:?}", [r, g, b]);
// White and black stay where they are (relative colorimetric).
assert_eq!(p3.apply8([255; 3]), [255; 3]);
assert_eq!(p3.apply8([0; 3]), [0; 3]);
let srgb = DisplayTransform::from_icc(&display(SRGB)).unwrap();
assert!(srgb.is_identity());
assert_eq!(srgb.apply8([255, 0, 0]), [255, 0, 0]);
assert_ne!(srgb.fingerprint(), p3.fingerprint());
}
#[test]
fn the_table_matches_the_profile_between_its_grid_points() {
let bytes = display(DISPLAY_P3);
let p3 = DisplayTransform::from_icc(&bytes).unwrap();
let profile = IccProfile::from_bytes(None, &bytes).unwrap();
for rgb in [[0.1, 0.7, 0.33], [0.9, 0.05, 0.6], [0.47, 0.47, 0.47], [0.0, 0.2, 1.0]] {
let want = profile.from_srgb(rgb, Intent::RelativeColorimetric).unwrap();
let got = p3.apply(rgb);
for c in 0..3 {
assert!((got[c] - want[c]).abs() < 1.0 / 255.0, "{rgb:?}: {got:?} vs {want:?}");
}
}
}
#[test]
fn premultiplied_pixels_keep_their_alpha_and_transparent_ones_stay_empty() {
let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap();
let mut px = vec![255, 0, 0, 255, 128, 0, 0, 128, 0, 0, 0, 0, 255, 0, 0, 255];
p3.apply_premultiplied(&mut px);
let opaque = p3.apply8([255, 0, 0]);
assert_eq!(&px[0..4], &[opaque[0], opaque[1], opaque[2], 255]);
assert_eq!(&px[12..16], &px[0..4], "a repeated pixel converts the same");
assert_eq!(px[7], 128);
// Half-covered red: the same color at half coverage, within rounding.
for c in 0..3 {
assert!((i32::from(px[4 + c]) - i32::from(opaque[c]) / 2).abs() <= 1, "{:?}", &px[4..8]);
}
assert_eq!(&px[8..12], &[0, 0, 0, 0]);
}
#[test]
fn profiles_that_are_not_rgb_display_profiles_are_refused() {
let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec();
assert!(matches!(DisplayTransform::from_icc(&cmyk), Err(ProfileError::NotDisplay(_))));
let mut cmyk_display = cmyk.clone();
cmyk_display[12..16].copy_from_slice(b"mntr");
assert!(matches!(DisplayTransform::from_icc(&cmyk_display), Err(ProfileError::NotRgb(s)) if s == "CMYK"));
let mut input = display(DISPLAY_P3);
input[12..16].copy_from_slice(b"scnr");
assert_eq!(DisplayTransform::from_icc(&input).unwrap_err(), ProfileError::NotDisplay("scnr".into()));
}
#[test]
fn malformed_short_and_oversized_data_is_refused() {
assert_eq!(DisplayTransform::from_icc(&[]).unwrap_err(), ProfileError::NotIcc);
assert_eq!(DisplayTransform::from_icc(&[0; 132]).unwrap_err(), ProfileError::NotIcc);
let good = display(DISPLAY_P3);
assert_eq!(DisplayTransform::from_icc(&good[..100]).unwrap_err(), ProfileError::NotIcc);
let mut long = good.clone();
long[0..4].copy_from_slice(&(good.len() as u32 + 1).to_be_bytes());
assert_eq!(DisplayTransform::from_icc(&long).unwrap_err(), ProfileError::BadLength { declared: good.len() + 1, len: good.len() });
let mut short = good.clone();
short[0..4].copy_from_slice(&64u32.to_be_bytes());
assert!(matches!(DisplayTransform::from_icc(&short), Err(ProfileError::BadLength { declared: 64, .. })));
// Trailing bytes past the declared length are not part of the profile.
let mut padded = good.clone();
padded.extend([0xAB; 16]);
let a = DisplayTransform::from_icc(&padded).unwrap();
assert_eq!(a.fingerprint(), DisplayTransform::from_icc(&good).unwrap().fingerprint());
let mut garbage_tags = good.clone();
for b in &mut garbage_tags[128..] {
*b = 0xFF;
}
assert!(matches!(DisplayTransform::from_icc(&garbage_tags), Err(ProfileError::Unreadable(_) | ProfileError::NoTransform)));
let huge = vec![0u8; MAX_PROFILE_BYTES + 1];
assert_eq!(DisplayTransform::from_icc(&huge).unwrap_err(), ProfileError::TooLarge(MAX_PROFILE_BYTES + 1));
}
#[test]
fn check_accepts_and_refuses_what_from_icc_does() {
assert_eq!(DisplayTransform::check(&display(DISPLAY_P3)), Ok(()));
assert_eq!(DisplayTransform::check(&display(SRGB)), Ok(()));
let cmyk = icc_bytes(GENERIC_CMYK).unwrap().to_vec();
assert_eq!(DisplayTransform::check(&cmyk), Err(DisplayTransform::from_icc(&cmyk).unwrap_err()));
assert_eq!(DisplayTransform::check(&[1, 2, 3]), Err(ProfileError::NotIcc));
}
#[test]
fn nan_and_out_of_range_values_stay_in_range() {
let p3 = DisplayTransform::from_icc(&display(DISPLAY_P3)).unwrap();
for v in p3.apply([f32::NAN, -1.0, 9.0]) {
assert!((0.0..=1.0).contains(&v));
}
}
}
+345 -28
View File
@@ -1205,6 +1205,13 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) ->
// Spine
// =====================================================================================
#[cfg(test)]
thread_local! {
/// How many times [`Spine::new`] has run on this thread, for tests that count how often a path
/// is flattened.
pub(crate) static SPINES_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the
/// arc length at each of its anchors.
pub struct Spine {
@@ -1223,6 +1230,8 @@ impl Spine {
/// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when
/// the first one flattens to fewer than two points (so it has no length to walk).
pub fn new(path: &PathData) -> Option<Self> {
#[cfg(test)]
SPINES_BUILT.with(|n| n.set(n.get() + 1));
let sp = path.subpaths.first()?;
let mut pts: Vec<Point> = Vec::new();
let mut cum: Vec<f64> = Vec::new();
@@ -1455,7 +1464,7 @@ pub fn pin_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Vec<
}
/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length
/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)?
/// (within a hundredth of a point, as [`SpreadLengths::anchor_at_length`] matches anchors)?
fn keys_spread_along(keys: &[Arc<Node>], spine: &Spine) -> bool {
let k = keys.len();
keys.iter().enumerate().all(|(i, key)| {
@@ -1480,40 +1489,204 @@ fn open_at_start(sp: &mut SubPath, ka: &mut [usize]) {
}
/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit
/// on, splitting segments where none is.
/// on, splitting segments where none is. `None`, leaving `path` as it was, when the subpath has no
/// length to spread along, when its length is not finite (a path so large its length overflows),
/// or when an arc length it would split at is not finite.
fn spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> {
let total = Spine::new(path)?.length();
let closed = path.subpaths.first().is_some_and(|s| s.closed);
spread_anchors_with(path, k, |_| {})
}
/// [`spread_anchors`], calling `work` for each step of work it does.
fn spread_anchors_with(path: &mut PathData, k: usize, mut work: impl FnMut(SpreadWork)) -> Option<Vec<usize>> {
let sp = path.subpaths.first_mut()?;
let lengths = SpreadLengths::new(sp, &mut work);
let total = lengths.total().filter(|t| t.is_finite())?;
let targets = (1..k.saturating_sub(1)).map(|i| total * i as f64 / (k - 1) as f64);
let mut out = vec![0usize];
for i in 1..k.saturating_sub(1) {
let s = total * i as f64 / (k - 1) as f64;
out.push(anchor_at_length(path, s)?);
}
let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) });
out.push(last);
out.extend(anchors_at_lengths(sp, lengths, targets, &mut work)?);
out.push(if sp.closed { 0 } else { sp.anchors.len().saturating_sub(1) });
Some(out)
}
/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a
/// point, else a new one splitting the segment there.
fn anchor_at_length(path: &mut PathData, s: f64) -> Option<usize> {
let spine = Spine::new(path)?;
let sp = path.subpaths.first_mut()?;
let n = sp.segment_count();
let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0);
let from = spine.anchor_length(seg).unwrap_or(0.0);
let to = spine.anchor_length(seg + 1).unwrap_or(spine.length());
if s - from < 0.01 {
return Some(seg);
/// The anchors of `sp` at each arc length in `targets`, in order, splitting segments where none is
/// (see [`SpreadLengths::anchor_at_length`]); `lengths` measures `sp`. The splits are made on a copy
/// of `sp`, which replaces it only when every target has its anchor: `None` leaves `sp` as it was.
fn anchors_at_lengths(
sp: &mut SubPath,
mut lengths: SpreadLengths,
targets: impl IntoIterator<Item = f64>,
work: &mut impl FnMut(SpreadWork),
) -> Option<Vec<usize>> {
let mut split = sp.clone();
let mut out = Vec::new();
for s in targets {
out.push(lengths.anchor_at_length(&mut split, s, work)?);
}
if to - s < 0.01 {
return Some((seg + 1) % sp.anchors.len().max(1));
*sp = split;
Some(out)
}
/// A step of the work [`spread_anchors_with`] does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SpreadWork {
/// Flattening every segment of the subpath.
WholePath,
/// Flattening one segment after a split.
Segment,
/// Adding one segment's flattened points to the arc lengths.
Measure,
/// Comparing an anchor's arc length with the one looked for.
Compare,
}
/// Where the measurement of a subpath's flattened points stands at the start of a segment: the last
/// point kept, the arc length there (0 before any point), and how many points were kept.
#[derive(Clone, Copy)]
struct Measured {
last: Option<Point>,
len: f64,
kept: usize,
}
impl Measured {
const START: Self = Self { last: None, len: 0.0, kept: 0 };
/// The measurement after `pts`, keeping each point more than 1e-9 from the last one kept, as
/// [`Spine::new`] does.
fn then(mut self, pts: &[Point]) -> Self {
for &p in pts {
if self.last.is_none_or(|q| q.distance(p) > 1e-9) {
self.len = match self.last {
Some(q) => self.len + q.distance(p),
None => 0.0,
};
self.last = Some(p);
self.kept += 1;
}
}
self
}
}
/// The arc lengths of a subpath's anchors, as [`Spine::new`] measures them, kept up to date while
/// [`spread_anchors`] splits its segments. Each segment is flattened once, and again only when it
/// is split. A split leaves the arc lengths of the anchors before it as they are; those after it
/// are measured again, in order from the split, only as far as the next arc length looked for, so
/// the sums are the same as [`Spine::new`]'s on the split subpath.
struct SpreadLengths {
/// The finite points the segments flatten to, each segment's in one run.
pts: Vec<Point>,
/// Where each segment's run is in `pts`. A split segment's halves get runs at the end, and its
/// old run is not used again.
flat: Vec<std::ops::Range<usize>>,
/// The measurement at the start of each segment, up to the first one not measured since the
/// last split; after the last segment too once every segment is measured.
marks: Vec<Measured>,
}
impl SpreadLengths {
/// Flatten and measure every segment of `sp`.
fn new(sp: &SubPath, work: &mut impl FnMut(SpreadWork)) -> Self {
work(SpreadWork::WholePath);
let n = sp.segment_count();
let mut lengths = Self { pts: Vec::new(), flat: Vec::with_capacity(n), marks: Vec::with_capacity(n + 1) };
for i in 0..n {
let run = lengths.flatten(sp, i);
lengths.flat.push(run);
}
lengths.marks.push(Measured::START);
lengths.measure_to(f64::INFINITY, work);
lengths
}
/// Flatten segment `i` of `sp` as [`Spine::new`] does, adding its finite points to `pts`, and
/// return where they are.
fn flatten(&mut self, sp: &SubPath, i: usize) -> std::ops::Range<usize> {
let start = self.pts.len();
let c = sp.segment(i);
let mut bp = BezPath::new();
bp.move_to(c.p0);
if sp.segment_is_line(i) {
bp.line_to(c.p3);
} else {
bp.curve_to(c.p1, c.p2, c.p3);
}
vectorcraft_geom::kurbo::flatten(bp.iter(), 0.1, |el| {
if let vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) = el
&& p.x.is_finite()
&& p.y.is_finite()
{
self.pts.push(p);
}
});
start..self.pts.len()
}
/// The subpath's flattened length, `None` when it flattens to fewer than two points.
fn total(&self) -> Option<f64> {
self.marks.last().filter(|m| self.marks.len() > self.flat.len() && m.kept >= 2).map(|m| m.len)
}
/// Measure the segments that are not measured, from the first, until one starts past `s` (with
/// two points kept before it) or every one is measured.
fn measure_to(&mut self, s: f64, work: &mut impl FnMut(SpreadWork)) {
while let Some(run) = self.flat.get(self.marks.len() - 1) {
let Some(&m) = self.marks.last() else { return };
if self.marks.len() >= 2 && m.kept >= 2 && m.len > s {
return;
}
work(SpreadWork::Measure);
self.marks.push(m.then(self.pts.get(run.clone()).unwrap_or_default()));
}
}
/// The anchor of `sp` at arc length `s`: an existing one within a hundredth of a point, else a
/// new one splitting the segment there. The segment is the last one starting at or before `s`
/// (the first one when none does), found by a binary search. `None`, leaving `sp` as it was,
/// when `s` or the arc length at the end of the segment is not finite, or when `sp` flattens
/// to fewer than two points.
fn anchor_at_length(&mut self, sp: &mut SubPath, s: f64, work: &mut impl FnMut(SpreadWork)) -> Option<usize> {
if !s.is_finite() {
return None;
}
self.measure_to(s, work);
let n = self.flat.len();
if self.marks.last().is_none_or(|m| m.kept < 2) {
return None;
}
let starts = &self.marks[..self.marks.len().min(n)];
let seg = starts
.partition_point(|m| {
work(SpreadWork::Compare);
m.len <= s
})
.saturating_sub(1);
let from = self.marks.get(seg)?.len;
let to = self.marks.get(seg + 1)?.len;
if s - from < 0.01 {
return Some(seg);
}
if to - s < 0.01 {
return Some((seg + 1) % sp.anchors.len().max(1));
}
if !from.is_finite() || !to.is_finite() {
return None;
}
let c = sp.segment(seg);
// The flattened length and the curve's own differ slightly: scale into the curve's.
let arc = c.arclen(1e-4).max(1e-12);
let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4);
if !t.is_finite() {
return None;
}
let at = sp.insert_anchor(seg, t);
work(SpreadWork::Segment);
work(SpreadWork::Segment);
let halves = [self.flatten(sp, seg), self.flatten(sp, seg + 1)];
self.flat.splice(seg..=seg, halves);
self.marks.truncate(seg + 1);
Some(at)
}
let c = sp.segment(seg);
// The flattened length and the curve's own differ slightly: scale into the curve's.
let arc = c.arclen(1e-4).max(1e-12);
let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4);
Some(sp.insert_anchor(seg, t))
}
/// Where a blend's keys and steps go along its spine (see [`blend_spine`]).
@@ -2003,6 +2176,150 @@ mod tests {
}
}
/// [`spread_anchors`] by measuring the whole spine again for each key and walking its segments
/// from the last, as a reference for the measurement kept up to date between keys.
fn rebuilt_spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> {
let total = Spine::new(path)?.length();
let closed = path.subpaths.first().is_some_and(|s| s.closed);
let mut out = vec![0usize];
for i in 1..k.saturating_sub(1) {
let s = total * i as f64 / (k - 1) as f64;
let spine = Spine::new(path)?;
let sp = path.subpaths.first_mut()?;
let n = sp.segment_count();
let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0);
let from = spine.anchor_length(seg).unwrap_or(0.0);
let to = spine.anchor_length(seg + 1).unwrap_or(spine.length());
out.push(if s - from < 0.01 {
seg
} else if to - s < 0.01 {
(seg + 1) % sp.anchors.len().max(1)
} else {
let c = sp.segment(seg);
let arc = c.arclen(1e-4).max(1e-12);
let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4);
sp.insert_anchor(seg, t)
});
}
let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) });
out.push(last);
Some(out)
}
/// Spreading keys along a spine measured once and kept up to date gives the same anchors and
/// the same spine as measuring it again for each key: on open and closed polylines and curves,
/// zero-length segments, keys exactly on anchors, keys within, at and just past a hundredth of
/// a point from an anchor, a closed curve whose keys go round past its first anchor, many keys
/// in one segment, and paths with no segment. The closed spines open as [`pin_spine`] opens
/// them.
#[test]
fn spreading_keys_matches_measuring_the_spine_for_each_key() {
let p = Point::new;
let bow = SubPath::new(
vec![Anchor::smooth(p(0.0, 0.0), p(50.0, 50.0)), Anchor::smooth(p(100.0, 0.0), p(150.0, -50.0)), Anchor::corner(p(200.0, 0.0))],
false,
);
let mut spines = vec![
PathData::default(),
PathData::single(SubPath::default()),
PathData::single(SubPath::polyline(&[p(5.0, 5.0)], false)),
PathData::single(SubPath::polyline(&[p(5.0, 5.0), p(5.0, 5.0)], false)),
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], false)),
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(100.0, 0.0)], true)),
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(20.0, 0.0), p(30.0, 0.0)], false)),
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(10.0, 0.0), p(30.0, 5.0), p(30.0, 5.0)], false)),
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0)], true)),
PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(40.0, 0.0), p(40.0, 30.0), p(0.0, 0.0)], true)),
PathData::single(bow.clone()),
PathData::single(SubPath { closed: true, ..bow }),
shapes::ellipse(Rect::new(0.0, 0.0, 120.0, 80.0)),
];
// An anchor near where the middle of three keys goes, 10 along a spine 20 long.
for d in [0.0, 0.005, 0.0099, 0.01, 0.0101, 0.02] {
for x in [10.0 - d, 10.0 + d] {
spines.push(PathData::single(SubPath::polyline(&[p(0.0, 0.0), p(x, 0.0), p(20.0, 0.0)], false)));
}
}
let mut compared = 0;
for spine in &spines {
for k in (0..=12).chain([17, 50, 200]) {
let (mut a, mut b) = (spine.clone(), spine.clone());
let (ka, kb) = (spread_anchors(&mut a, k), rebuilt_spread_anchors(&mut b, k));
assert_eq!(ka, kb, "{k} keys on {spine:?}");
assert_eq!(a, b, "{k} keys on {spine:?}");
if let (Some(mut ka), Some(mut kb)) = (ka, kb) {
let (mut sa, mut sb) = (a.subpaths[0].clone(), b.subpaths[0].clone());
open_at_start(&mut sa, &mut ka);
open_at_start(&mut sb, &mut kb);
assert_eq!((sa, ka), (sb, kb), "{k} keys on {spine:?}, opened");
compared += 1;
}
}
}
assert!(compared > 200, "{compared} spreads compared");
}
/// Spreading 1,000 keys along a spine of 4,096 segments flattens the spine once and then only
/// the two halves of each split segment, finds each key's segment in a few comparisons, and
/// measures about as many segments as the spine ends up with, not the whole spine per key.
#[test]
fn spreading_keys_measures_the_spine_once() {
let pts: Vec<Point> = (0..=4096).map(|i| Point::new(i as f64, if i % 2 == 0 { 0.0 } else { 1.0 })).collect();
let mut path = PathData::single(SubPath::polyline(&pts, false));
let (mut whole, mut segments, mut measured, mut compared) = (0, 0, 0, 0);
let ka = spread_anchors_with(&mut path, 1000, |w| match w {
SpreadWork::WholePath => whole += 1,
SpreadWork::Segment => segments += 1,
SpreadWork::Measure => measured += 1,
SpreadWork::Compare => compared += 1,
})
.unwrap();
assert_eq!(ka.len(), 1000);
let n = path.subpaths[0].segment_count();
assert!(n > 4096 + 900, "{n} segments");
assert_eq!(whole, 1);
assert!(segments <= 2 * 998, "{segments} segments flattened");
assert!(compared <= 998 * 14, "{compared} comparisons");
assert!(measured <= 4096 + n + 3 * 1000, "{measured} segments measured for {n}");
let mut reference = PathData::single(SubPath::polyline(&pts, false));
assert_eq!(rebuilt_spread_anchors(&mut reference, 1000), Some(ka));
assert_eq!(reference, path);
}
/// A spine so large that its length overflows spreads no keys, and is left as it was, rather
/// than getting an anchor split at a parameter that is not a number. Two keys, which split
/// nothing, get no anchors either.
#[test]
fn spreading_keys_along_a_spine_whose_length_overflows_changes_nothing() {
let p = Point::new;
for closed in [false, true] {
for k in [2, 3, 5] {
let spine = PathData::single(SubPath::polyline(&[p(-1e308, 0.0), p(0.0, 0.0), p(1e308, 0.0)], closed));
let mut path = spine.clone();
assert_eq!(spread_anchors(&mut path, k), None, "closed {closed}, {k} keys");
assert_eq!(path, spine);
assert!(path.subpaths[0].anchors.iter().all(|a| a.p.x.is_finite() && a.h_in.x.is_finite() && a.h_out.x.is_finite()));
}
}
}
/// When a later arc length has no anchor (here one that is not finite, as when a spine's length
/// overflows only after earlier splits), the splits already made are dropped with it.
#[test]
fn a_failed_anchor_keeps_none_of_the_earlier_splits() {
let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false);
for bad in [f64::NAN, f64::INFINITY] {
let mut split = sp.clone();
let lengths = SpreadLengths::new(&split, &mut |_| {});
assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0, bad], &mut |_| {}), None);
assert_eq!(split, sp);
}
let mut split = sp.clone();
let lengths = SpreadLengths::new(&split, &mut |_| {});
assert_eq!(anchors_at_lengths(&mut split, lengths, [25.0, 50.0], &mut |_| {}), Some(vec![1, 2]));
assert_eq!(split.anchors.len(), 4);
}
#[test]
fn easing_keeps_the_ends_and_stays_monotone() {
use crate::live::{BlendEase, BlendEaseType as K};
+2
View File
@@ -78,6 +78,7 @@ impl Document {
units_from_file: _,
index: _,
guide_cache: _,
inline_cycles: _,
} = self;
*version == other.version
&& *title == other.title
@@ -262,6 +263,7 @@ impl Document {
units_from_file: false,
index: Default::default(),
guide_cache: Default::default(),
inline_cycles: Default::default(),
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
+431 -43
View File
@@ -2,10 +2,13 @@
//! from the document's symbols, which the text layout needs to size and place it.
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock, Weak};
use vectorcraft_geom::{Affine, Rect};
use crate::{Document, NodeId, NodeKind, TextObject};
use crate::{Document, Node, NodeId, NodeKind, Symbol, TextObject};
/// The document key under which the app records each symbol's natural size (`{name: [w, h]}`).
pub const SYMBOL_SIZES: &str = "symbolSizes";
@@ -39,9 +42,27 @@ impl Document {
usable(Some(self.symbol_natural_xf(name).transform_rect_bbox(b)))
}
/// The art bounds an inline graphic of the symbol named `name` takes: [`Self::symbol_art_bounds`],
/// or `None` (an empty slot) when the symbol's art shows the symbol itself inline, directly or
/// through the inline graphics in other symbols' art, so that its size would depend on itself.
pub fn inline_art_bounds(&self, name: &str) -> Option<Rect> {
if self.inline_cycles().contains(name) { None } else { self.symbol_art_bounds(name) }
}
/// The names of the symbols that reach themselves through the inline graphics in symbol art,
/// found once while the symbols stay as they are.
fn inline_cycles(&self) -> Arc<HashSet<String>> {
if let Some(found) = self.inline_cycles.get(&self.symbols) {
return found;
}
let found = Arc::new(InlineGraph::of(self).cyclic);
self.inline_cycles.set(&self.symbols, &found);
found
}
/// Does `t` have inline graphics whose resolved bounds differ from the document's symbols?
fn inline_stale(&self, t: &TextObject) -> bool {
t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != self.symbol_art_bounds(&a.symbol))
stale_by(t, &|n| self.inline_art_bounds(n))
}
/// `t` with its inline graphics' art bounds resolved from this document's symbols (borrowed
@@ -56,54 +77,421 @@ impl Document {
Cow::Owned(t)
}
/// Fill in the art bounds of `t`'s inline graphics; returns whether any changed (the text's
/// cached layout bounds are then cleared).
/// Fill in the art bounds of `t`'s inline graphics ([`Self::inline_art_bounds`]); returns
/// whether any changed (the text's cached layout is then cleared).
pub fn resolve_text_inline(&self, t: &mut TextObject) -> bool {
let mut changed = false;
for r in &mut t.runs {
if let Some(a) = &mut r.inline {
let b = self.symbol_art_bounds(&a.symbol);
if a.bounds != b {
a.bounds = b;
changed = true;
}
}
}
if changed {
t.cached_bounds = None;
t.cached_ink = None;
}
changed
resolve_runs(t, &|n| self.inline_art_bounds(n))
}
/// Resolve the inline graphics of every text object in the layers (after opening a document
/// or changing its symbols). Returns the ids of the texts that changed: their cached layout
/// bounds were cleared and need recomputing.
/// Resolve the inline graphics of every text object in the document (after opening a document
/// or changing its symbols), without laying the type out again: see
/// [`Self::resolve_inline_art_with`].
pub fn resolve_inline_art(&mut self) -> Vec<NodeId> {
let mut stale = vec![];
self.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| r.inline.is_some())
&& self.inline_stale(t)
{
stale.push(n.id);
}
});
self.resolve_inline_art_with(&mut |_| {})
}
/// Resolve the inline graphics of every text object in the document: in the layers, the art
/// of symbols, graph designs and pattern tiles, and the opacity masks and live objects in
/// them. Each text whose inline graphics changed has its cached layout cleared and is passed
/// to `layout` to be laid out again. The symbols are resolved first, each after the symbols its
/// inline graphics show, so a symbol's art is measured with its type already laid out; a
/// symbol that shows itself inline ([`Self::inline_art_bounds`]) is shown as an empty slot.
/// Returns the ids of the texts in the layers that changed.
pub fn resolve_inline_art_with(&mut self, layout: &mut dyn FnMut(&mut TextObject)) -> Vec<NodeId> {
let mut out = vec![];
for id in stale {
let Some(mut t) = self.node(id).and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.clone()),
_ => None,
}) else {
continue;
};
if self.resolve_text_inline(&mut t)
&& let Some(n) = self.node_mut(id)
{
n.kind = NodeKind::Text(t);
out.push(id);
let mut unused = vec![];
let graph = InlineGraph::of(self);
let measured = Measured::default();
for &i in &graph.order {
let mut art = self.symbols[i].art.clone();
if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) {
self.symbols[i].art = art;
}
}
for i in 0..self.layers.len() {
let mut layer = self.layers[i].clone();
if resolve_tree(&mut layer, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut out) {
self.layers[i] = layer;
}
}
for i in 0..self.graph_designs.len() {
let mut art = self.graph_designs[i].art.clone();
if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) {
self.graph_designs[i].art = art;
}
}
for i in 0..self.patterns.len() {
for j in 0..self.patterns[i].art.len() {
let mut art = self.patterns[i].art[j].clone();
if resolve_tree(&mut art, &|n| measured.get(n, || graph.bounds(self, n)), layout, &mut unused) {
self.patterns[i].art[j] = art;
}
}
}
out
}
}
/// Whether `t` has inline graphics whose bounds differ from what `bounds` gives their symbols.
fn stale_by(t: &TextObject, bounds: &dyn Fn(&str) -> Option<Rect>) -> bool {
t.runs.iter().filter_map(|r| r.inline.as_ref()).any(|a| a.bounds != bounds(&a.symbol))
}
/// Gives `t`'s inline graphics the bounds `bounds` gives their symbols; returns whether any changed
/// (the text's cached layout is then cleared).
fn resolve_runs(t: &mut TextObject, bounds: &dyn Fn(&str) -> Option<Rect>) -> bool {
let mut changed = false;
for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) {
let b = bounds(&a.symbol);
if a.bounds != b {
a.bounds = b;
changed = true;
}
}
if changed {
t.cached_bounds = None;
t.cached_baselines.clear();
t.cached_ink = None;
}
changed
}
/// Resolves the inline graphics of the type in tree `n` (opacity mask art included) with
/// `bounds`, passing each text that changed to `layout` and adding its id to `changed`; returns
/// whether the tree changed. Each node is visited once, and only the nodes on the way to changed
/// type are copied.
fn resolve_tree(
n: &mut Arc<Node>,
bounds: &dyn Fn(&str) -> Option<Rect>,
layout: &mut dyn FnMut(&mut TextObject),
changed: &mut Vec<NodeId>,
) -> bool {
let mut copied = false;
if matches!(&n.kind, NodeKind::Text(t) if stale_by(t, bounds)) {
let node = Arc::make_mut(n);
if let NodeKind::Text(t) = &mut node.kind
&& resolve_runs(t, bounds)
{
layout(t);
changed.push(node.id);
}
copied = true;
}
let mask = n.extras().mask.as_deref().and_then(|m| {
let mut art = m.art.clone();
resolve_tree(&mut art, bounds, layout, changed).then_some(art)
});
let mut kids = vec![];
for (i, c) in n.children().into_iter().flatten().enumerate() {
let mut c = c.clone();
if resolve_tree(&mut c, bounds, layout, changed) {
kids.push((i, c));
}
}
if mask.is_none() && kids.is_empty() {
return copied;
}
let node = Arc::make_mut(n);
if let Some(art) = mask
&& let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_deref_mut())
{
m.art = art;
}
if let Some(ch) = node.children_mut() {
for (i, c) in kids {
ch[i] = c;
}
}
true
}
/// The art bounds of each symbol, measured once in a pass of [`Document::resolve_inline_art_with`].
/// A symbol is first asked for after its own type is resolved (the pass goes in
/// [`InlineGraph::order`]), so its measurement stays current for the rest of the pass.
#[derive(Default)]
struct Measured(RefCell<HashMap<String, Option<Rect>>>);
impl Measured {
/// The bounds kept for `name`, or `measure`'s, kept for later.
fn get(&self, name: &str, measure: impl FnOnce() -> Option<Rect>) -> Option<Rect> {
if let Some(&b) = self.0.borrow().get(name) {
return b;
}
let b = measure();
self.0.borrow_mut().insert(name.to_owned(), b);
b
}
}
/// The names of the symbols the inline graphics in tree `n` show (opacity mask art included).
fn inline_symbols(n: &Node) -> Vec<String> {
fn collect(n: &Node, out: &mut Vec<String>) {
if let NodeKind::Text(t) = &n.kind {
out.extend(t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone())));
}
if let Some(m) = n.extras().mask.as_deref() {
collect(&m.art, out);
}
for c in n.children().into_iter().flatten() {
collect(c, out);
}
}
let mut out = vec![];
collect(n, &mut out);
out
}
/// Each symbol name of `d` → the index of the first symbol of that name (the one a name finds).
fn symbol_index(d: &Document) -> HashMap<&str, usize> {
let mut index = HashMap::with_capacity(d.symbols.len());
for (i, s) in d.symbols.iter().enumerate() {
index.entry(s.name.as_str()).or_insert(i);
}
index
}
/// The symbols of a document as their inline graphics depend on each other.
struct InlineGraph {
/// Every symbol index, each after the symbols its art shows inline (except along a cycle).
order: Vec<usize>,
/// The names of the symbols that reach themselves through inline graphics.
cyclic: HashSet<String>,
}
impl InlineGraph {
/// The graph of `d`'s symbols: each symbol's art is walked once, and the order and cycles are
/// found in time linear in the symbols and their inline references (Tarjan's strongly
/// connected components, without recursion).
fn of(d: &Document) -> Self {
let index = symbol_index(d);
let edges: Vec<Vec<usize>> = d
.symbols
.iter()
.map(|s| {
let mut e: Vec<usize> = inline_symbols(&s.art).iter().filter_map(|n| index.get(n.as_str()).copied()).collect();
e.sort_unstable();
e.dedup();
e
})
.collect();
let (order, cyclic) = components(&edges);
let cyclic = order.iter().filter(|&&i| cyclic[i]).map(|&i| d.symbols[i].name.clone()).collect();
Self { order, cyclic }
}
/// [`Document::inline_art_bounds`] with the cycles found in this graph.
fn bounds(&self, d: &Document, name: &str) -> Option<Rect> {
if self.cyclic.contains(name) { None } else { d.symbol_art_bounds(name) }
}
}
/// The nodes of the graph `edges` (node → the nodes it depends on), each after the nodes it
/// depends on except within a cycle, and whether each node is on a cycle (a component of more than
/// one node, or an edge to itself).
fn components(edges: &[Vec<usize>]) -> (Vec<usize>, Vec<bool>) {
const UNSEEN: usize = usize::MAX;
let n = edges.len();
let (mut index, mut low, mut on_stack) = (vec![UNSEEN; n], vec![0; n], vec![false; n]);
let (mut stack, mut order, mut cyclic) = (vec![], Vec::with_capacity(n), vec![false; n]);
let mut next = 0;
// The nodes being visited, each with the position of its next edge.
let mut calls: Vec<(usize, usize)> = vec![];
for root in 0..n {
if index[root] != UNSEEN {
continue;
}
index[root] = next;
low[root] = next;
next += 1;
stack.push(root);
on_stack[root] = true;
calls.push((root, 0));
while let Some(top) = calls.last_mut() {
let v = top.0;
if let Some(&w) = edges[v].get(top.1) {
top.1 += 1;
if index[w] == UNSEEN {
index[w] = next;
low[w] = next;
next += 1;
stack.push(w);
on_stack[w] = true;
calls.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(index[w]);
}
continue;
}
calls.pop();
if let Some(&(u, _)) = calls.last() {
low[u] = low[u].min(low[v]);
}
if low[v] == index[v] {
let start = order.len();
while let Some(w) = stack.pop() {
on_stack[w] = false;
order.push(w);
if w == v {
break;
}
}
if order.len() - start > 1 || edges[v].contains(&v) {
for &w in &order[start..] {
cyclic[w] = true;
}
}
}
}
}
(order, cyclic)
}
/// The symbols [`Document::inline_cycles`] last found, with the name of each symbol and a weak
/// pointer to its art. While a weak pointer to an art's allocation is held, nothing else can be at
/// that address, and the art can't be changed in place (`Arc::get_mut` refuses it and
/// `Arc::make_mut` moves it), so any edit inside a symbol's art, and any symbol added, removed,
/// renamed or replaced, gives symbols the cache does not match. Clones of a document share it.
#[derive(Default)]
pub(crate) struct InlineCycles(RwLock<Option<Arc<Found>>>);
/// The name and art of each symbol the cycles were found in, and the names of the symbols on one.
type Found = (Vec<(String, Weak<Node>)>, Arc<HashSet<String>>);
impl InlineCycles {
/// The cached names, when they were found in `symbols`.
fn get(&self, symbols: &[Symbol]) -> Option<Arc<HashSet<String>>> {
let g = self.0.read().ok()?;
let (from, found) = &**g.as_ref()?;
let same = from.len() == symbols.len()
&& from.iter().zip(symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art)));
same.then(|| found.clone())
}
/// Keep `found`, found in `symbols`.
fn set(&self, symbols: &[Symbol], found: &Arc<HashSet<String>>) {
if let Ok(mut g) = self.0.write() {
*g = Some(Arc::new((symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(), found.clone())));
}
}
}
impl Clone for InlineCycles {
fn clone(&self) -> Self {
InlineCycles(RwLock::new(self.0.read().ok().and_then(|g| g.clone())))
}
}
/// A cache, not document content: two documents are equal whatever their caches hold.
impl PartialEq for InlineCycles {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for InlineCycles {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("InlineCycles")
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The position of each node in `order`.
fn positions(order: &[usize]) -> Vec<usize> {
let mut at = vec![0; order.len()];
for (p, &i) in order.iter().enumerate() {
at[i] = p;
}
at
}
#[test]
fn components_put_dependencies_first_and_find_every_cycle_member() {
// 0 → 1 → 2 → 0 is a cycle, and 3, reached from 1, joins it through 2.
// 4 depends on 0 and is on no cycle; 5 shows itself.
let edges = vec![vec![1], vec![2, 3], vec![0], vec![2], vec![0], vec![5]];
let (order, cyclic) = components(&edges);
let mut sorted = order.clone();
sorted.sort_unstable();
assert_eq!(sorted, [0, 1, 2, 3, 4, 5]);
assert_eq!(cyclic, [true, true, true, true, false, true]);
let at = positions(&order);
assert!(at[4] > at[0], "{order:?}");
// A chain: each node after the one it depends on.
let (order, cyclic) = components(&[vec![], vec![0], vec![1]]);
assert_eq!(order, [0, 1, 2]);
assert!(cyclic.iter().all(|c| !c));
}
/// Type at the bottom of 2,000 nested groups is checked once, not once for each group above it,
/// and only the groups on the way to it are copied.
#[test]
fn resolving_deeply_nested_type_visits_each_node_once() {
use std::cell::Cell;
use vectorcraft_geom::Point;
let mut t = TextObject::point(Point::ZERO, "Type", crate::CharStyle::default());
t.runs.push(crate::TextRun::new("", crate::CharStyle::default()));
t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
let depth = 2_000;
let mut tree = Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(t))));
for i in 1..=depth {
let side = Arc::new(Node::group(NodeId(depth + i), vec![]));
tree = Arc::new(Node::group(NodeId(i), vec![side, tree]));
}
let before = tree.clone();
let asked = Cell::new(0);
let r = Rect::new(0.0, 0.0, 10.0, 20.0);
let bounds = |_: &str| {
asked.set(asked.get() + 1);
Some(r)
};
let (mut laid_out, mut changed) = (0, vec![]);
assert!(resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed));
assert!(asked.get() <= 2, "bounds asked {} times for one inline graphic", asked.get());
assert_eq!(laid_out, 1);
assert_eq!(changed, [NodeId(0)]);
// The bottom of the new tree holds the resolved type; the side groups are shared.
let (mut new, mut old) = (&tree, &before);
while let (Some(n), Some(o)) = (new.children(), old.children()) {
assert!(Arc::ptr_eq(&n[0], &o[0]));
(new, old) = (&n[1], &o[1]);
}
let NodeKind::Text(t) = &new.kind else { panic!("the type is at the bottom") };
assert_eq!(t.runs[1].inline.as_ref().and_then(|a| a.bounds), Some(r));
// Nothing left to change: the tree is kept and the type is checked once.
asked.set(0);
let kept = tree.clone();
assert!(!resolve_tree(&mut tree, &bounds, &mut |_| laid_out += 1, &mut changed));
assert!(Arc::ptr_eq(&tree, &kept));
assert_eq!(asked.get(), 1);
}
#[test]
fn a_symbol_is_measured_once_in_a_pass() {
let measured = Measured::default();
let count = std::cell::Cell::new(0);
let measure = |b: Option<Rect>| {
count.set(count.get() + 1);
b
};
let r = Some(Rect::new(0.0, 0.0, 1.0, 1.0));
for _ in 0..3 {
assert_eq!(measured.get("A", || measure(r)), r);
assert_eq!(measured.get("Missing", || measure(None)), None);
}
assert_eq!(count.get(), 2);
}
#[test]
fn components_handle_a_long_chain_without_recursion() {
let n = 200_000;
let edges: Vec<Vec<usize>> = (0..n).map(|i| if i + 1 < n { vec![i + 1] } else { vec![] }).collect();
let (order, cyclic) = components(&edges);
assert_eq!(order.first(), Some(&(n - 1)));
assert_eq!(order.last(), Some(&0));
assert!(cyclic.iter().all(|c| !c));
}
}
+5
View File
@@ -117,6 +117,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption
pub use style_libs::StyleLibrary;
pub use text::FontHeightReference;
pub use text::GlyphChoice;
pub use text::WarichuAlign;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign,
@@ -362,6 +363,10 @@ pub struct Document {
/// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved.
#[serde(skip)]
guide_cache: document_guides::GuideCache,
/// The symbols that show themselves inline ([`Document::inline_art_bounds`]): a cache, never
/// saved.
#[serde(skip)]
inline_cycles: inline::InlineCycles,
}
fn ppi72() -> f64 {
+56
View File
@@ -1247,4 +1247,60 @@ mod tests {
assert_eq!(m, before);
}
}
/// The pieces of a mesh-warped image make a knockout group, each knocking out its triangle
/// (clipping path or opacity mask art); the group keeps the image's transparency.
#[test]
fn mesh_warp_image_pieces_knock_out_by_their_triangles() {
let im = crate::ImageObject {
key: "pic".into(),
width: 4,
height: 4,
xf: Affine::scale(10.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone())));
n.opacity = 0.5;
n.blend = crate::color::BlendMode::Multiply;
let same = |p: Point| p;
for masked in [false, true] {
let g = mesh_warp_image(&n, &im, &same, 2, false, masked);
assert_eq!((g.knockout, g.opacity, g.blend, g.isolate), (crate::Knockout::On, 0.5, crate::color::BlendMode::Multiply, false));
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("a group") };
assert_eq!(children.len(), 8);
for c in children {
assert!(c.extras().warp_piece);
let outline = c.knockout_outline().unwrap_or_else(|| panic!("masked {masked}: an outline"));
let NodeKind::Path { path, .. } = &outline.kind else { panic!("a path") };
assert_eq!(path.anchor_count(), 3);
}
}
// A clip group or masked image that is not a piece knocks out by what it paints.
let mut plain = mesh_warp_image(&n, &im, &same, 1, false, false);
let NodeKind::Group { children, .. } = &mut plain.kind else { panic!("a group") };
let piece = Arc::make_mut(&mut children[0]);
piece.extras_mut().warp_piece = false;
assert!(piece.knockout_outline().is_none());
}
/// More cells than the objects live art may make cut no pieces (the count overflowed).
#[test]
fn mesh_warp_image_past_the_budget_makes_an_empty_group() {
let im = crate::ImageObject {
key: "pic".into(),
width: 4,
height: 4,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
let n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone())));
for cells in [usize::MAX, 1000] {
let g = mesh_warp_image(&n, &im, &|p| p, cells, false, false);
assert!(matches!(&g.kind, NodeKind::Group { children, .. } if children.is_empty()), "{cells}");
}
}
}
+19 -7
View File
@@ -207,9 +207,21 @@ pub fn mesh_warp_cost(cells: usize) -> usize {
/// 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, and `n`'s color mask. The result is a group with `n`'s id, name,
/// transparency, opacity mask and appearance.
/// transparency, opacity mask and appearance. It is a knockout group and each piece is a
/// [`crate::NodeExtras::warp_piece`], so where pieces overlap only the top one draws and a
/// partly transparent image keeps its own alpha there. More cells than [`MAX_GENERATED`] objects
/// allow make an empty group.
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);
let mut g = n.clone();
g.kind = NodeKind::Group { children: vec![], clip: false };
g.knockout = crate::Knockout::On;
if let Some(e) = g.extras_if_any() {
e.colorize = None;
}
if mesh_warp_cost(k) > MAX_GENERATED {
return g;
}
// A grayscale image's fill color colorizes each piece; the group of pieces takes none.
let colorize = n.extras().colorize.clone();
// Each piece shows what the image shows: its color mask's masked pixels.
@@ -243,22 +255,22 @@ pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, c
// 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));
pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))))));
let mut piece = image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))));
piece.extras_mut().warp_piece = true;
pieces.push(Arc::new(piece));
continue;
}
let mut clip = Node::path(n.id, shape, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
let mut piece = Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true });
piece.extras_mut().warp_piece = true;
pieces.push(Arc::new(piece));
}
}
}
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
}
+5
View File
@@ -75,6 +75,10 @@ pub struct NodeExtras {
/// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are
/// ([`crate::color_mask`]).
pub color_mask: Option<Box<crate::color_mask::ColorMask>>,
/// A piece of an image a raster mesh warp cut up ([`crate::live::mesh_warp_image`]): in a
/// knockout group its knockout shape is the whole area of its triangle, not only where the
/// image is opaque ([`Node::knockout_outline`]).
pub warp_piece: bool,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -103,6 +107,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
envelope_edit_margin: None,
pixel_aligned: false,
color_mask: None,
warp_piece: false,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+31
View File
@@ -716,6 +716,37 @@ impl Node {
out
}
/// As a piece of a mesh-warped image ([`crate::NodeExtras::warp_piece`]), the art whose area is its
/// knockout shape at full coverage: the art of its opacity mask (on and not inverted), or the
/// clipping path of its clip group. `None` for other objects, whose knockout shape is what they
/// paint.
pub fn knockout_outline(&self) -> Option<&Arc<Node>> {
let e = self.extras();
if !e.warp_piece {
return None;
}
if let Some(m) = e.mask.as_deref() {
return (!m.disabled && !m.invert).then_some(&m.art);
}
match &self.kind {
NodeKind::Group { children, clip: true } => children.first(),
_ => None,
}
}
/// The image a piece of a mesh-warped image draws ([`Self::knockout_outline`]): the piece itself
/// when an opacity mask cuts it, else the image its clip group clips. `None` for other objects.
pub fn warp_piece_image(&self) -> Option<&Node> {
if !self.extras().warp_piece {
return None;
}
match &self.kind {
NodeKind::Image(_) => Some(self),
NodeKind::Group { children, clip: true } => children.get(1).map(|c| &**c).filter(|c| matches!(c.kind, NodeKind::Image(_))),
_ => None,
}
}
/// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest.
/// A layer whose first child is a ruler guide holds no art, so a guide never clips it.
pub fn clips(&self) -> bool {
+3
View File
@@ -23,6 +23,7 @@ fn extras_are_written_flat_in_order() {
e.shape_mode = crate::ShapeMode::Exclude;
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
e.pixel_aligned = true;
e.warp_piece = true;
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -69,10 +70,12 @@ fn extras_are_written_flat_in_order() {
"shape_mode",
"envelope_edit_margin",
"pixel_aligned",
"warp_piece",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
assert_eq!(v["pixel_aligned"], true);
assert_eq!(v["warp_piece"], true);
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
+5
View File
@@ -56,6 +56,7 @@ impl Serialize for Node {
e.envelope_edit_margin.is_none(),
is_default(&e.pixel_aligned),
e.color_mask.is_none(),
is_default(&e.warp_piece),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -100,6 +101,7 @@ impl Serialize for Node {
field!("envelope_edit_margin", &e.envelope_edit_margin);
field!("pixel_aligned", &e.pixel_aligned);
field!("color_mask", &e.color_mask);
field!("warp_piece", &e.warp_piece);
st.end()
}
}
@@ -245,6 +247,8 @@ pub(super) struct NodeWire {
pixel_aligned: bool,
#[serde(default)]
color_mask: Option<Box<crate::color_mask::ColorMask>>,
#[serde(default)]
warp_piece: bool,
}
impl From<NodeWire> for Node {
@@ -274,6 +278,7 @@ impl From<NodeWire> for Node {
envelope_edit_margin: w.envelope_edit_margin,
pixel_aligned: w.pixel_aligned,
color_mask: w.color_mask,
warp_piece: w.warp_piece,
};
Node {
id: w.id,
+120 -30
View File
@@ -121,19 +121,22 @@ impl ObjectsOnPath {
if a.determinant() < 0.0 && self.rotate != 0.0 {
self.rotate = -self.rotate;
}
let along = paths.and_then(|(old, new)| {
let s = path_length(new)?.0 / path_length(old)?.0;
(s.is_finite() && s > 0.0).then_some(s)
});
let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt());
// A move leaves the distances along the path as they are.
let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0;
if !moves && scale.is_finite() && scale > 0.0 {
let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v);
let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale));
let (spacing, start) = moved.unwrap_or((None, None));
self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g)));
self.start = start.unwrap_or_else(|| scaled(self.start));
if !moves {
let measured = paths.and_then(|(old, new)| Some((Measured::new(old)?, Measured::new(new)?)));
let along = measured.as_ref().and_then(|(was, is)| {
let s = is.len / was.len;
(s.is_finite() && s > 0.0).then_some(s)
});
let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt());
if scale.is_finite() && scale > 0.0 {
let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v);
let moved = measured.as_ref().and_then(|(was, is)| self.moved_along(a, was, is, scale));
let (spacing, start) = moved.unwrap_or((None, None));
self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g)));
self.start = start.unwrap_or_else(|| scaled(self.start));
}
}
let after = tilt(self.rotate);
for m in &mut self.members {
@@ -150,11 +153,10 @@ impl ObjectsOnPath {
/// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along
/// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the
/// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer
/// than two objects. `None` for a path without length or a start that is not finite.
fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option<f64>, Option<f64>)> {
let (was, is) = (Spine::new(old)?, Spine::new(new)?);
let (len, closed) = path_length(old)?;
let (new_len, _) = path_length(new)?;
/// than two objects. `None` for a start that is not finite.
fn moved_along(&self, a: Affine, old: &Measured, new: &Measured, scale: f64) -> Option<(Option<f64>, Option<f64>)> {
let (was, is) = (&old.spine, &new.spine);
let (len, closed, new_len) = (old.len, old.closed, new.len);
if !self.start.is_finite() {
return None;
}
@@ -230,10 +232,10 @@ pub fn is_objects_on_path(n: &Node) -> bool {
}
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
/// (not a guide) whose first subpath has some length.
/// (not a guide) whose first subpath has a length ([`path_length`]).
pub fn attachable_path(n: &Node) -> Option<&PathData> {
match &n.kind {
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path),
_ => None,
}
}
@@ -292,11 +294,27 @@ pub fn upright_frame(n: &Node) -> Affine {
}
/// The length of `path`'s first subpath and whether it is closed. `None` for a path without
/// length.
/// length, or with a length too large to be finite.
pub fn path_length(path: &PathData) -> Option<(f64, bool)> {
let len = Spine::new(path)?.length();
let closed = path.subpaths.first().is_some_and(|s| s.closed);
(len.is_finite() && len > 1e-9).then_some((len, closed))
Measured::new(path).map(|m| (m.len, m.closed))
}
/// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that
/// one flattening serves both measuring the path and finding points along it.
struct Measured {
spine: Spine,
len: f64,
closed: bool,
}
impl Measured {
/// `None` for a path without length, or with a length too large to be finite.
fn new(path: &PathData) -> Option<Self> {
let spine = Spine::new(path)?;
let len = spine.length();
let closed = path.subpaths.first().is_some_and(|s| s.closed);
(len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed })
}
}
/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the
@@ -341,18 +359,20 @@ fn wrap(s: f64, len: f64) -> f64 {
/// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them
/// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length.
pub fn stops(path: &PathData, n: usize, spacing: Option<f64>, start: f64) -> Option<Vec<(Point, f64)>> {
let spine = Spine::new(path)?;
let (len, closed) = path_length(path)?;
let Measured { spine, len, closed } = Measured::new(path)?;
let (start, gap) = layout(len, closed, n, spacing, start);
Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect())
}
/// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others.
/// `None` for an `i` of `n` or more, without measuring the path.
pub fn stop(path: &PathData, n: usize, spacing: Option<f64>, start: f64, i: usize) -> Option<(Point, f64)> {
let spine = Spine::new(path)?;
let (len, closed) = path_length(path)?;
if i >= n {
return None;
}
let Measured { spine, len, closed } = Measured::new(path)?;
let (start, gap) = layout(len, closed, n, spacing, start);
(i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i)))
Some(spine.at_length(nth_length(len, closed, start, gap, i)))
}
/// The distance along a path of length `len` (closed or not) to object `i`, from `start` and
@@ -400,11 +420,15 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
let old = rec.clone();
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
let path_id = rec.path;
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })).cloned() else {
g.extras_mut().objects_on_path = None;
return true;
};
let path = attachable_path(path).cloned();
// `stops` measures the path: on a path without length no object moves.
let path = match &path.kind {
NodeKind::Path { path, guide: false, .. } => Some(path),
_ => None,
};
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
@@ -414,7 +438,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
}
}
let mut moved = false;
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) {
if let Some(stops) = path.and_then(|p| stops(p, rec.members.len(), rec.spacing, rec.start)) {
let turn = tilt(rec.rotate);
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
@@ -709,6 +733,72 @@ mod tests {
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
}
/// What `f` returns, and how many times it flattened a path ([`Spine::new`]).
fn flattenings<T>(f: impl FnOnce() -> T) -> (T, usize) {
crate::blend::SPINES_BUILT.with(|n| n.set(0));
let out = f();
(out, crate::blend::SPINES_BUILT.with(|n| n.get()))
}
#[test]
fn stops_and_stop_flatten_the_path_once() {
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
for path in [&open, &closed] {
let (all, count) = flattenings(|| stops(path, 4, Some(40.0), 25.0));
let all = all.unwrap();
assert_eq!((all.len(), count), (4, 1));
for (i, s) in all.iter().enumerate() {
assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, i)), (Some(*s), 1), "object {i}");
}
assert_eq!(flattenings(|| stop(path, 4, Some(40.0), 25.0, 4)), (None, 0), "an index past the last object");
assert_eq!(flattenings(|| stop(path, 0, None, 0.0, 0)), (None, 0), "no objects");
}
let point = shapes::line(Point::ZERO, Point::ZERO);
assert!(stops(&point, 2, None, 0.0).is_none() && stop(&point, 2, None, 0.0, 0).is_none());
}
#[test]
fn transforming_the_group_flattens_each_path_once() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
let b = rect(3, Rect::new(20.0, 0.0, 30.0, 10.0));
let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let stretch = Affine::scale_non_uniform(2.0, 1.0);
let wide = level.transformed(stretch);
let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) };
let ((), count) = flattenings(|| rec.transform(stretch, Some((&level, &wide))));
assert_eq!(count, 2, "the path before the transform and after it");
assert!((rec.start - 20.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}");
let moved = level.transformed(Affine::translate((5.0, 5.0)));
let ((), count) = flattenings(|| rec.transform(Affine::translate((5.0, 5.0)), Some((&level, &moved))));
assert_eq!(count, 0, "a move leaves the distances along the path as they are");
}
#[test]
fn placing_the_objects_flattens_the_path_once() {
let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default());
let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)));
let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false });
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0)));
let empty = crate::Document::new(1.0, 1.0);
let symbols = empty.symbol_boxes();
assert_eq!(flattenings(|| place(&mut g, &symbols)), (true, 1));
let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x;
assert!((x(NodeId(2))).abs() < 1e-9 && (x(NodeId(3)) - 300.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3)));
}
/// A path whose length adds up past the largest finite number cannot be measured, so nothing
/// attaches to it.
#[test]
fn a_path_too_long_to_measure_is_not_attachable() {
let huge = Node::path(NodeId(1), shapes::line(Point::new(-1e308, 0.0), Point::new(1e308, 0.0)), Default::default());
let NodeKind::Path { path, .. } = &huge.kind else { panic!("a path") };
assert!(path_length(path).is_none() && stops(path, 2, None, 0.0).is_none());
assert!(attachable_path(&huge).is_none());
let square = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
assert!(attachable_path(&square).is_some());
}
#[test]
fn a_group_without_its_path_is_a_plain_group() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
+14
View File
@@ -287,6 +287,20 @@ impl ImageBlob {
Some(out)
}
/// Whether any pixel of the image is less than fully opaque. `false` when the image can't be
/// decoded. The last image decoded is kept, as for [`Self::sample`].
pub fn has_transparency(&self) -> bool {
self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255))
}
/// The alpha of each pixel, row by row from the top: the width, the height and one byte a
/// pixel. `None` when the image can't be decoded. The last image decoded is kept, as for
/// [`Self::sample`].
pub fn alpha(&self) -> Option<(u32, u32, Vec<u8>)> {
let img = self.decoded()?;
Some((img.width(), img.height(), img.pixels().map(|p| p.0[3]).collect()))
}
/// The decoded pixels (the last image decoded is kept for the next sample).
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
thread_local! {
+4 -1
View File
@@ -17,7 +17,10 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, ScriptMetrics};
pub use char_style::{
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
WARICHU_SCALE, WarichuAlign,
};
pub use composite::{
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart,
CompositeParts,
+112 -1
View File
@@ -145,6 +145,29 @@ pub struct CharStyle {
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")]
pub standard_vertical_roman: bool,
/// Warichu (Character panel menu, East Asian options): these characters are set at
/// [`Self::warichu_scale`] percent of their size and stacked on [`Self::warichu_lines`] lines
/// inside their line, one above the other in horizontal type and side by side in vertical type.
/// A stretch of marked characters is one block that takes the settings of its first character
/// and stays whole on one line. Type on a path sets them at their own size on the path.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub warichu: bool,
/// Warichu Settings › Lines: how many lines a warichu block is stacked on, within
/// [`WARICHU_LINES`]. Saved only when not 2.
#[serde(default = "two", rename = "warichuLines", skip_serializing_if = "is_two")]
pub warichu_lines: u8,
/// Warichu Settings › Line Gap, in points from 0 to [`MAX_WARICHU_GAP`]: the space between the
/// lines of a warichu block. Saved only when not 0.
#[serde(default, rename = "warichuGap", skip_serializing_if = "crate::skip::is_default")]
pub warichu_gap: f64,
/// Warichu Settings › Scale: the size of warichu characters as a percentage of their size,
/// within [`WARICHU_SCALE`]. Saved only when not 50.
#[serde(default = "fifty", rename = "warichuScale", skip_serializing_if = "is_fifty")]
pub warichu_scale: f64,
/// Warichu Settings › Alignment: how the lines of a warichu block line up along the block.
#[serde(default, rename = "warichuAlign", skip_serializing_if = "crate::skip::is_default")]
pub warichu_align: WarichuAlign,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
@@ -417,6 +440,82 @@ fn hundred() -> f64 {
/// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em.
pub const MAX_AKI: f64 = 1000.0;
/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]).
pub const WARICHU_LINES: std::ops::RangeInclusive<u8> = 1..=6;
/// The largest Warichu Settings Line Gap ([`CharStyle::warichu_gap`]), in points.
pub const MAX_WARICHU_GAP: f64 = 100.0;
/// The Warichu Settings Scale ([`CharStyle::warichu_scale`]) range, in percent of the size.
pub const WARICHU_SCALE: std::ops::RangeInclusive<f64> = 10.0..=100.0;
fn two() -> u8 {
2
}
fn is_two(v: &u8) -> bool {
*v == 2
}
fn fifty() -> f64 {
50.0
}
fn is_fifty(v: &f64) -> bool {
*v == 50.0
}
/// How the lines of a warichu block line up along the block ([`CharStyle::warichu_align`]):
/// each line set flush left (top, in vertical type), centered or flush right (bottom), or spread
/// to the block's length with the last line set flush left, centered or flush right, or spread as
/// well. Auto is the default and sets the lines as [`WarichuAlign::JustifyLeft`] does.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum WarichuAlign {
#[default]
Auto,
Left,
Center,
Right,
JustifyLeft,
JustifyCenter,
JustifyRight,
JustifyAll,
}
impl WarichuAlign {
/// Every alignment, in the order Warichu Settings lists them.
pub const ALL: [WarichuAlign; 8] = [
WarichuAlign::Auto,
WarichuAlign::Left,
WarichuAlign::Center,
WarichuAlign::Right,
WarichuAlign::JustifyLeft,
WarichuAlign::JustifyCenter,
WarichuAlign::JustifyRight,
WarichuAlign::JustifyAll,
];
/// The name files and commands give this alignment (as it is saved).
pub fn id(self) -> &'static str {
match self {
WarichuAlign::Auto => "auto",
WarichuAlign::Left => "left",
WarichuAlign::Center => "center",
WarichuAlign::Right => "right",
WarichuAlign::JustifyLeft => "justifyLeft",
WarichuAlign::JustifyCenter => "justifyCenter",
WarichuAlign::JustifyRight => "justifyRight",
WarichuAlign::JustifyAll => "justifyAll",
}
}
/// The alignment [`WarichuAlign::id`] names; `None` for any other name.
pub fn from_id(id: &str) -> Option<WarichuAlign> {
WarichuAlign::ALL.into_iter().find(|a| a.id() == id)
}
}
pub(super) fn yes() -> bool {
true
}
@@ -464,6 +563,11 @@ impl Default for CharStyle {
tcy_up_down: 0.0,
tcy_left_right: 0.0,
standard_vertical_roman: false,
warichu: false,
warichu_lines: 2,
warichu_gap: 0.0,
warichu_scale: 50.0,
warichu_align: WarichuAlign::Auto,
small_caps: None,
digits: Digits::Default,
font_height_reference: FontHeightReference::EmBox,
@@ -484,7 +588,10 @@ impl CharStyle {
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
/// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to
/// [`MAX_AKI`], and one that is not a number becomes 0.
/// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale
/// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to
/// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its
/// own units.
pub fn keep_dimensions_in_range(&mut self) {
let default = Self::default();
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
@@ -499,6 +606,10 @@ impl CharStyle {
let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) };
self.aki_left = aki(self.aki_left);
self.aki_right = aki(self.aki_right);
self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end());
self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) };
self.warichu_scale =
if self.warichu_scale.is_nan() { default.warichu_scale } else { self.warichu_scale.clamp(*WARICHU_SCALE.start(), *WARICHU_SCALE.end()) };
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
+33 -3
View File
@@ -34,10 +34,19 @@ impl TextObject {
style.baseline_shift *= scale.points;
style.tcy_up_down *= scale.points;
style.tcy_left_right *= scale.points;
style.warichu_gap *= scale.points;
style.h_scale *= scale.horizontal;
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale]
.iter()
.all(|v| v.is_finite())
if ![
style.size,
style.leading.unwrap_or(0.0),
style.baseline_shift,
style.tcy_up_down,
style.tcy_left_right,
style.warichu_gap,
style.h_scale,
]
.iter()
.all(|v| v.is_finite())
{
return None;
}
@@ -114,6 +123,12 @@ impl TextObject {
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
/// Does layout stack some of this type's characters as warichu ([`CharStyle::warichu`])? Not
/// on a path, which sets them as they are. Exports that write live text write such type as
/// outlines, as they do ruby: their text can't place the stacked lines.
pub fn stacks_warichu(&self) -> bool {
!matches!(self.kind, TextKind::OnPath { .. }) && self.runs.iter().any(|r| r.style.warichu && !r.text.is_empty())
}
/// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them.
///
/// Called after loading and editing text: ranges from a file or an edit are untrusted, and a
@@ -492,8 +507,23 @@ impl crate::Document {
st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right);
st.aki_left = num("akiLeft").unwrap_or(st.aki_left);
st.aki_right = num("akiRight").unwrap_or(st.aki_right);
st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap);
st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale);
st.keep_dimensions_in_range();
// Lines is a whole number: a stored number outside the range, or with a fraction,
// becomes the nearest whole number within it.
if let Some(old) = def.attrs.get_mut("warichuLines")
&& old.is_number()
&& old.as_u64().is_none_or(|v| !super::WARICHU_LINES.contains(&u8::try_from(v).unwrap_or(u8::MAX)))
{
let (lo, hi) = (*super::WARICHU_LINES.start(), *super::WARICHU_LINES.end());
let v = old.as_f64().filter(|v| v.is_finite()).map_or(2.0, |v| v.round().clamp(f64::from(lo), f64::from(hi)));
// Within 1..6 after the clamp, so the cast keeps the value.
*old = (v as u8).into();
}
let kept = [
("warichuGap", Some(st.warichu_gap)),
("warichuScale", Some(st.warichu_scale)),
("size", Some(st.size)),
("h_scale", Some(st.h_scale)),
("v_scale", Some(st.v_scale)),
+2 -1
View File
@@ -357,7 +357,7 @@ impl ParaList {
/// The style the marker is set in: `first` (the style of the paragraph's first character)
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
/// Insert Aki, tate-chu-yoko, underline and strikethrough.
/// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough.
pub fn marker_style(first: &CharStyle) -> CharStyle {
CharStyle {
baseline_shift: 0.0,
@@ -370,6 +370,7 @@ impl ParaList {
aki_right: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
warichu: false,
underline: false,
strikethrough: false,
..first.clone()
+205 -1
View File
@@ -1,7 +1,8 @@
//! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine
//! and several shapes united, so the renderer and the SVG and PDF writers clip alike.
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, PathData};
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
@@ -11,6 +12,209 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
clip.clip_outline(Some(&outline_text), &unite)
}
/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque
/// in `color`, which the SVG and PDF writers put in their knockout masks: for a clip group, a
/// rectangle clipped by the same clipping path (so a viewer covers the same pixels of its edges as
/// when it clips the piece), else the path of its opacity mask's art, filled. `None` for other
/// objects and for a piece with nothing to clip by.
pub fn knockout_silhouette(n: &Node, color: Color) -> Option<Node> {
let outline = n.knockout_outline()?;
let (bp, rule) = clip_outline(outline)?;
let paint = Appearance::basic(Paint::solid(color), Paint::None, 0.0);
if n.clips() && n.extras().mask.is_none() {
let cover = Node::path(n.id, vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Shape::bounding_box(&bp).inflate(1.0, 1.0)), paint);
let children = vec![outline.clone(), std::sync::Arc::new(cover)];
return Some(Node::new(n.id, NodeKind::Group { children, clip: true }));
}
let mut p = Node::path(n.id, PathData::from_bezpath(&bp), paint);
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
Some(p)
}
/// Whether any of `pieces`, pieces of a mesh-warped image ([`Node::warp_piece_image`]), paints less
/// than opaque anywhere inside its triangle: it or its image is partly transparent, a color mask
/// clears some of its colors, a colorize paint draws its light pixels partly transparent, or the
/// pixels of its image (looked up in `doc`) are. Each distinct
/// image's pixels are scanned once. Pieces of an opaque image overlap without showing it, so the SVG
/// and PDF writers draw them one over another without masks.
pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool {
let mut scanned: Vec<*const vectorcraft_doc::ImageBlob> = vec![];
for n in pieces {
let Some(image) = n.warp_piece_image() else { continue };
let NodeKind::Image(im) = &image.kind else { continue };
let e = image.extras();
if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() {
return true;
}
let Some(blob) = doc.image_blob(im) else { continue };
if scanned.contains(&std::ptr::from_ref(blob)) {
continue;
}
scanned.push(std::ptr::from_ref(blob));
if blob.has_transparency() {
return true;
}
}
false
}
/// Which overlaps of the pieces of a mesh-warped image ([`Node::warp_piece_image`]) can show
/// through. Where an upper piece overlaps a lower one only over fully opaque pixels of the image,
/// the SVG and PDF writers draw the upper piece over the lower one without a knockout mask for it:
/// a mask there would make the opaque pixels on the upper piece's antialiased edge lighter.
pub struct WarpSeams {
pieces: Vec<Option<SeamPiece>>,
alphas: Vec<(u32, u32, Vec<u8>)>,
}
/// A piece of a mesh-warped image whose pixels decide whether its overlaps show through.
struct SeamPiece {
/// The piece's triangle, in document space.
triangle: [vectorcraft_geom::Point; 3],
/// Maps document space to the pixels of its image as decoded.
to_pixels: vectorcraft_geom::Affine,
/// The index of its image's alpha in [`WarpSeams::alphas`].
alpha: usize,
}
/// How far from an overlap, in image pixels, a less than opaque pixel makes the overlap show
/// through: a smoothed image blends each pixel with its neighbors.
const SEAM_MARGIN: f64 = 2.0;
impl WarpSeams {
/// The overlaps of `pieces` (pieces of a mesh-warped image, in paint order), with the pixels of
/// their images looked up in `doc`. Each distinct image is decoded once.
pub fn new(doc: &Document, pieces: &[&Node]) -> Self {
let mut images: Vec<*const vectorcraft_doc::ImageBlob> = vec![];
let mut alphas = vec![];
let pieces = pieces
.iter()
.map(|n| {
let image = n.warp_piece_image()?;
let NodeKind::Image(im) = &image.kind else { return None };
let e = image.extras();
if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() {
return None;
}
let triangle = triangle(n.knockout_outline()?)?;
let blob = doc.image_blob(im)?;
let alpha = match images.iter().position(|b| std::ptr::eq(*b, blob)) {
Some(i) => i,
None => {
alphas.push(blob.alpha()?);
images.push(std::ptr::from_ref(blob));
alphas.len() - 1
}
};
let (w, h, _) = &alphas[alpha];
if im.width == 0 || im.height == 0 || im.xf.determinant().abs() < 1e-12 {
return None;
}
let scale = vectorcraft_geom::Affine::scale_non_uniform(f64::from(*w) / f64::from(im.width), f64::from(*h) / f64::from(im.height));
Some(SeamPiece { triangle, to_pixels: scale * im.xf.inverse(), alpha })
})
.collect();
WarpSeams { pieces, alphas }
}
/// Whether piece `above` (an index into the pieces) drawn over piece `below` can show the
/// lower piece through where they overlap: either piece or its image is partly transparent, a
/// color mask or a colorize paint changes its alpha, or a pixel of either image within
/// [`SEAM_MARGIN`] of the overlap is less than opaque. `true` for a piece that is not a
/// triangle of an image that can be decoded; `false` when the triangles do not overlap.
pub fn shows_through(&self, below: usize, above: usize) -> bool {
let (Some(Some(a)), Some(Some(b))) = (self.pieces.get(below), self.pieces.get(above)) else { return true };
let overlap = convex_intersection(&a.triangle, &b.triangle);
overlap.len() >= 3 && [a, b].into_iter().any(|p| self.translucent_near(p, &overlap))
}
/// Whether a pixel of piece `p`'s image within [`SEAM_MARGIN`] of the convex polygon `overlap`
/// (document space) is less than opaque.
fn translucent_near(&self, p: &SeamPiece, overlap: &[vectorcraft_geom::Point]) -> bool {
let Some((w, h, alpha)) = self.alphas.get(p.alpha) else { return true };
let poly: Vec<vectorcraft_geom::Point> = overlap.iter().map(|q| p.to_pixels * *q).collect();
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for q in &poly {
(x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y));
}
let span = |lo: f64, hi: f64, n: u32| {
let lo = (lo - SEAM_MARGIN).floor().clamp(0.0, f64::from(n)) as u32;
let hi = (hi + SEAM_MARGIN).ceil().clamp(0.0, f64::from(n)) as u32;
lo..hi
};
let orientation = signed_area(&poly).signum();
for y in span(y0, y1, *h) {
for x in span(x0, x1, *w) {
let c = vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5);
let near = poly.iter().zip(poly.iter().cycle().skip(1)).all(|(s, e)| {
let edge = *e - *s;
orientation * edge.cross(c - *s) >= -SEAM_MARGIN * edge.hypot()
});
let i = y as usize * *w as usize + x as usize;
if near && alpha.get(i).is_none_or(|a| *a < 255) {
return true;
}
}
}
false
}
}
/// The three corners of the straight-sided triangle `outline` draws, in document space.
fn triangle(outline: &Node) -> Option<[vectorcraft_geom::Point; 3]> {
let (bp, _) = clip_outline(outline)?;
let mut points = vec![];
for el in bp.elements() {
match el {
vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if points.is_empty() || points.last() != Some(p) => {
points.push(*p)
}
vectorcraft_geom::PathEl::MoveTo(_) | vectorcraft_geom::PathEl::LineTo(_) | vectorcraft_geom::PathEl::ClosePath => {}
_ => return None,
}
}
if points.len() == 4 && points.first() == points.last() {
points.pop();
}
let [a, b, c] = points.as_slice() else { return None };
Some([*a, *b, *c])
}
/// Twice the signed area of polygon `poly`.
fn signed_area(poly: &[vectorcraft_geom::Point]) -> f64 {
poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.to_vec2().cross(b.to_vec2())).sum()
}
/// The intersection of convex polygons `subject` and `clip`, as a convex polygon (empty when they
/// do not overlap).
fn convex_intersection(subject: &[vectorcraft_geom::Point], clip: &[vectorcraft_geom::Point]) -> Vec<vectorcraft_geom::Point> {
let orientation = signed_area(clip).signum();
if orientation == 0.0 {
return vec![];
}
let mut out = subject.to_vec();
for (s, e) in clip.iter().zip(clip.iter().cycle().skip(1)) {
let edge = *e - *s;
let side = |p: vectorcraft_geom::Point| orientation * edge.cross(p - *s);
let input = std::mem::take(&mut out);
for (p, q) in input.iter().zip(input.iter().cycle().skip(1)) {
let (sp, sq) = (side(*p), side(*q));
if sp >= 0.0 {
out.push(*p);
}
if (sp >= 0.0) != (sq >= 0.0) {
out.push(*p + (*q - *p) * (sp / (sp - sq)));
}
}
if out.is_empty() {
break;
}
}
out
}
/// Text as one path of its glyph outlines, in document space.
fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
+1 -1
View File
@@ -69,7 +69,7 @@ pub use adjust::{
is_channel,
};
pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent};
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art,
+15 -5
View File
@@ -96,6 +96,18 @@ pub(crate) fn rgb_gradient_art(
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
}
/// The narrow stroke of a facet's own `paint`, `width` wide, that closes the hairline seam between
/// it and its neighbors where each is antialiased alone. Its joins are round: a facet seen almost
/// edge-on is a sharp sliver, and a mitered seam would reach far past its tip as a thorn, where a
/// round one reaches no more than half its width past the facet. Revolve's meets() counts on a
/// stroked seam reaching no more than half its width; faces drawn as clipped groups (whose clip,
/// in `composed_art`, already outlines the seam with round joins) are measured by their box.
pub(crate) fn seam_stroke(paint: Paint, width: f64) -> vectorcraft_doc::StrokeLayer {
let mut edge = vectorcraft_doc::StrokeLayer::new(paint, width);
edge.join = vectorcraft_doc::LineJoin::Round;
edge
}
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
pub(crate) fn facet_art(
id: vectorcraft_doc::NodeId,
@@ -109,8 +121,7 @@ pub(crate) fn facet_art(
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
if let Some(color) = paint.color() {
let paint = smooth_paint(light, color);
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
edge.join = vectorcraft_doc::LineJoin::Round;
let edge = seam_stroke(paint.clone(), seam);
return vectorcraft_doc::Node::path(
id,
path.clone(),
@@ -131,7 +142,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t
use kurbo::Shape;
use std::sync::Arc;
use vectorcraft_doc::color::BlendMode;
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
let mut boundary = BezPath::new();
@@ -163,8 +174,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t
} else {
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
};
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
edge.join = vectorcraft_doc::LineJoin::Round;
let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
Arc::new(Node::path(
id,
face.path(),
+5 -5
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind};
use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2};
use vectorcraft_three_d::{Edge, Revolve};
@@ -494,10 +494,10 @@ fn surface(
.into_iter()
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH);
// Acute face corners must stay within the overlap width. Miter joins
// amplify this seam stroke into visible spikes along the silhouette.
edge.join = LineJoin::Round;
// Acute face corners must stay within the overlap width: meets() counts on a
// stroked seam reaching no more than half its width; faces drawn as clipped
// groups are measured by their box.
let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), SEAM_WIDTH);
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
+3 -6
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2};
use vectorcraft_pathops::BoolOp;
use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View};
@@ -530,7 +530,7 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
group.blend = blend;
return group;
}
// Preserve existing white-light paints, alpha composition and seam joins exactly.
// Preserve existing white-light paints and alpha composition exactly.
let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) };
let paint = &opaque;
let children = facets
@@ -551,10 +551,7 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
.into_iter()
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
if colored {
edge.join = vectorcraft_doc::LineJoin::Round;
}
let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), seam);
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
+12 -7
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node};
use vectorcraft_geom::Rect;
use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light};
@@ -207,8 +207,8 @@ pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32
.into_iter()
.map(|face| {
if wire {
let mut edge = StrokeLayer::new(paint.clone(), WIRE);
edge.join = vectorcraft_doc::LineJoin::Round;
// A wire edge is drawn as a seam is: a round-joined stroke of the paint.
let edge = crate::lighting::seam_stroke(paint.clone(), WIRE);
return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() }));
}
let (tone, spec) = shading.tone(face.normal, &lights);
@@ -218,18 +218,23 @@ pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32
// Unshaded (No Shading, no shading color, or a face in full light) with no
// highlight: the paint itself, its color space and swatch kept.
Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => {
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]
vec![
AppearanceItem::Fill(FillLayer::new(paint.clone())),
AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)),
]
}
Some(c) => {
let fill = Paint::solid(shaded(c, tone, spec, shade));
vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))]
vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))]
}
// Gradients and patterns keep their paint, darkened by a layer of the shading color
// and lightened by one of white. As with the other 3D effects' pattern shadow, the
// layers cover the whole face, transparent pattern cells included.
None => {
let mut items =
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))];
let mut items = vec![
AppearanceItem::Fill(FillLayer::new(paint.clone())),
AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)),
];
for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] {
if let Some(color) = color.filter(|_| share > 0.0) {
let mut layer = FillLayer::new(Paint::solid(color));
+252 -2
View File
@@ -1,5 +1,5 @@
use serde_json::json;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_doc::{Document, NodeId, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
use super::*;
@@ -1293,6 +1293,9 @@ fn inflate_reads_twist_taper_and_cap() {
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
}
/// Inflate under the legacy white light keeps the polygon faces it had before the colored-light
/// batching (each face a path of its own, not a channel group) and its seam's 0.05–0.75 pt range;
/// its seams' joins are round, as every 3D seam's.
#[test]
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
let mut n = square_node();
@@ -1305,7 +1308,9 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
assert!(matches!(face.kind, NodeKind::Path { .. }));
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
assert!((0.05..=0.75).contains(&stroke.width));
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
// Round, as every seam is: a facet seen almost edge-on is a sliver, and a mitered seam
// reached past the solid as a thorn.
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Round);
}
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
n.transform(Affine::scale(0.001), false);
@@ -1697,3 +1702,248 @@ fn classic_rotate_parameters_are_checked() {
assert!(err.starts_with(says), "{p}: {err}");
}
}
/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate, Extrude &
/// Bevel (Classic, each of its shading branches) and Rotate (Classic, its No Shading and Diffuse
/// surfaces and a gradient), a face seen almost edge-on (a sharp sliver) has
/// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its
/// face more than half its width as it is outlined (a mitered seam would reach past the solid as a
/// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a
/// corner a miter would spike from.
#[test]
fn a_faces_seam_stays_within_half_its_width_of_the_face() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)],
))));
// Does `path` have a convex corner sharper than 45° that a miter with `limit` would spike from
// (one sharper than 2·asin(1/limit) is bevelled instead, so it can't thorn; a reflex corner's
// miter points inward)? An open subpath's ends are no corners, and a closing anchor repeating
// the first is one corner.
fn acute(path: &PathData, limit: f64) -> bool {
let floor = 2.0 * (1.0 / limit).asin();
path.subpaths.iter().any(|sp| {
let mut pts: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
pts.dedup_by(|a, b| a.distance(*b) < 1e-9);
if sp.closed && pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) {
pts.pop();
}
let n = pts.len();
let corners: Vec<usize> = if sp.closed { (0..n).collect() } else { (1..n.saturating_sub(1)).collect() };
// Which way the subpath turns as a whole: a convex corner turns the same way.
let area: f64 = (0..n).map(|k| pts[k].to_vec2().cross(pts[(k + 1) % n].to_vec2())).sum();
n >= 3
&& corners.into_iter().any(|k| {
let (a, b, c) = (pts[(k + n - 1) % n], pts[k], pts[(k + 1) % n]);
let (u, v) = (a - b, c - b);
let convex = !sp.closed || (b - a).cross(c - b) * area > 0.0;
let angle = (u.dot(v) / (u.hypot() * v.hypot())).clamp(-1.0, 1.0).acos();
convex && angle > floor && angle < std::f64::consts::FRAC_PI_4
})
})
}
// Views that make slivers whose corners a miter would spike from, in every case.
let views = [(-35.0, 25.0), (-85.0, 15.0), (60.0, 60.0)];
let mut cases = vec![];
for &(x, y) in &views {
let view = json!({"rotationX": x, "rotationY": y, "rotationZ": 0, "depth": 60});
// Rotate (Classic) takes no depth.
let flat = json!({"rotationX": x, "rotationY": y, "rotationZ": 0});
let with = |extra: Value| {
let mut p = view.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), None));
// A gradient under a white light: its paint and the shadow fill over it each have a seam.
cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), Some(gradient.clone())));
cases.push((INFLATE, x, y, with(json!({})), None));
// Under colored lights: one, two, and a gradient's paint.
let one = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}]});
let two = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]});
cases.push((EXTRUDE, x, y, with(one.clone()), None));
cases.push((EXTRUDE, x, y, with(two.clone()), None));
cases.push((EXTRUDE, x, y, with(one), Some(gradient.clone())));
cases.push((INFLATE, x, y, with(json!({"lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]})), None));
// Rotate turns the art flat: a thin triangle, some corner of which stays sharp enough at
// every view; under a white light and a colored one.
cases.push((ROTATE, x, y, with(json!({})), None));
cases.push((ROTATE, x, y, with(json!({"lights": [{"color": "#ff8080"}]})), None));
// Classic: shaded, unshaded (No Shading) and a gradient's paint kept under its shading
// layers each make their seam their own way.
let bevel = json!({"bevel": "classic", "bevelHeight": 1.5});
cases.push((CLASSIC_EXTRUDE, x, y, with(bevel.clone()), None));
cases.push((CLASSIC_EXTRUDE, x, y, with(json!({"bevel": "classic", "bevelHeight": 1.5, "surface": "none"})), None));
cases.push((CLASSIC_EXTRUDE, x, y, with(bevel), Some(gradient.clone())));
// Rotate (Classic) draws its plane through Classic's surfaces: No Shading and Diffuse.
for surface in ["none", "diffuse"] {
let mut p = flat.clone();
p["surface"] = json!(surface);
cases.push((CLASSIC_ROTATE, x, y, p, None));
}
let mut p = flat.clone();
p["surface"] = json!("diffuse");
cases.push((CLASSIC_ROTATE, x, y, p, Some(gradient.clone())));
}
// Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is.
let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 };
// Rotate (Classic)'s one plane is shaded at some views, not at every one (the light can leave
// it as its paint), so its Diffuse case is held to a darkened face at one view at least.
let mut rotate_shaded = false;
for (id, x, y, params, paint) in cases {
let mut n = if id == ROTATE || id == CLASSIC_ROTATE {
let mut b = BezPath::new();
b.move_to((100.0, 90.0));
b.line_to((160.0, 110.0));
b.line_to((100.0, 130.0));
b.close_path();
stroked(&b)
} else {
stroked_rect(4.0)
};
let unshaded = params["surface"] == "none";
for (k, item) in n.appearance.items.iter_mut().enumerate() {
match &paint {
Some(paint) => *item.paint_mut() = paint.clone(),
None if (id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE) && k == 0 => *item.paint_mut() = brick.clone(),
None => {}
}
}
// Each solid on its own (the fill's, then the stroke's), so every check holds for each.
for keep in 0..n.appearance.items.len() {
let mut solo = n.clone();
let item = solo.appearance.items.remove(keep);
solo.appearance.items = vec![item.clone()];
let source = item.paint().clone();
let what = format!("{id} {params} gradient {} view ({x}, {y}) solid {keep}", paint.is_some());
solo.appearance.effects.push(fx(id, params.clone()));
let art = solid_art(&solo).unwrap();
let mut seamed = vec![];
art.walk(&mut |face| {
if let Some(path) = face.path_data()
&& face.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))
{
seamed.push((path.clone(), face.appearance.items.clone()));
}
});
// Each Classic case (Extrude & Bevel and Rotate) reaches its own branch: an unshaded face
// keeps the paint itself (its swatch and color space too), a shaded one is the paint
// darkened, and a gradient's face keeps the gradient.
if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE {
let fills: Vec<&Paint> = seamed
.iter()
.filter_map(|(_, items)| match items.first() {
Some(AppearanceItem::Fill(f)) => Some(&f.paint),
_ => None,
})
.collect();
assert!(!fills.is_empty(), "{what}: faces");
if paint.is_some() || unshaded {
assert!(fills.iter().all(|f| **f == source), "{what}: every face keeps its paint as it is");
} else {
let [r, g, b] = source.color().map(|c| c.to_rgb()).unwrap_or([0.0; 3]);
let darkened = fills.iter().any(|f| match f {
Paint::Solid { color, swatch: None, .. } => {
let [fr, fg, fb] = color.to_rgb();
fr <= r + 1e-4 && fg <= g + 1e-4 && fb <= b + 1e-4 && fr + fg + fb < r + g + b - 1e-3
}
_ => false,
});
if id == CLASSIC_ROTATE {
rotate_shaded |= darkened;
} else {
assert!(darkened, "{what}: some face shaded darker than its paint");
}
}
}
// Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some
// face a miter would spike from.
let (mut checked, mut sliver): (usize, Vec<bool>) = (0, vec![]);
for (path, items) in &seamed {
let outline = path.to_bezpath();
let acute_here = items.iter().find_map(|i| match i {
AppearanceItem::Stroke(s) => Some(acute(path, s.miter_limit)),
_ => None,
});
let mut which = 0;
for item in items {
// A seam paints something (a stroke of no width or no paint draws nothing).
let AppearanceItem::Stroke(seam) = item else { continue };
if seam.width <= 0.0 || matches!(seam.paint, Paint::None) {
continue;
}
if sliver.len() <= which {
sliver.resize(which + 1, false);
}
sliver[which] |= acute_here.unwrap_or(false);
which += 1;
checked += 1;
assert_eq!(seam.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins");
// The seam outlined as it is drawn: no point of it farther from the face than
// half its width.
let drawn = crate::stroke::line_outline(&outline, seam, seam.width, seam.width * 1e-3);
let (mut farthest, mut points): (f64, usize) = (0.0, 0);
kurbo::flatten(drawn.iter(), seam.width * 1e-4, |el| {
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
points += 1;
farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4));
}
});
assert!(points > 0, "{what}: a seam outlined as nothing");
assert!(farthest <= seam.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", seam.width);
}
}
assert!(checked > 0, "{what}: seams checked");
for (k, s) in sliver.iter().enumerate() {
assert!(s, "{what}: no face with an acute corner carries seam {k}");
}
}
}
assert!(rotate_shaded, "Rotate (Classic), Diffuse: no view shades its plane darker than its paint");
}
/// Revolve (Classic) draws its faces through Classic's surfaces: on the plastic, diffuse and
/// unshaded surfaces, and with a gradient, every face's seam has round joins and reaches no more
/// than half its width past its face as it is outlined (the reach Revolve's overlap test counts on).
#[test]
fn classic_revolves_seams_are_round() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)],
))));
let mut b = BezPath::new();
b.move_to((100.0, 40.0));
b.line_to((130.0, 60.0));
b.line_to((130.0, 140.0));
b.line_to((100.0, 160.0));
let cases = [("plastic", Paint::solid(BLUE)), ("diffuse", Paint::solid(BLUE)), ("none", Paint::solid(BLUE)), ("plastic", gradient)];
for (surface, paint) in cases {
let what = format!("{surface} gradient {}", matches!(paint, Paint::Gradient(_)));
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(paint, Paint::None, 0.0));
n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface})));
let art = crate::revolve_art(&n).unwrap();
let mut seams = 0;
art.walk(&mut |face| {
let Some(path) = face.path_data() else { return };
for item in &face.appearance.items {
let AppearanceItem::Stroke(s) = item else { continue };
if s.width <= 0.0 {
continue;
}
seams += 1;
assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins");
let drawn = crate::stroke::line_outline(&path.to_bezpath(), s, s.width, s.width * 1e-3);
let mut farthest: f64 = 0.0;
kurbo::flatten(drawn.iter(), s.width * 1e-4, |el| {
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4));
}
});
assert!(farthest <= s.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", s.width);
}
});
assert!(seams > 0, "{what}: seams checked");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_json::json;
use vectorcraft_doc::color::Color;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_doc::{Document, NodeId, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath};
use super::*;
+63 -12
View File
@@ -369,11 +369,7 @@ fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result<Outcom
break;
}
if let Scope::Objects(roots) = scope {
for (id, nodes) in &found {
if let Some(at) = roots.iter().position(|r| r == id) {
roots.splice(at..=at, nodes.iter().map(|n| n.id));
}
}
*roots = replace_roots(roots, &found);
}
out.ids.extend(found.iter().map(|(id, _)| *id));
d.insert_beside(found, true)?;
@@ -382,6 +378,36 @@ fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result<Outcom
Ok(out)
}
#[cfg(test)]
thread_local! {
/// Tests: how many scope roots [`replace_roots`] looked at on this thread.
pub(super) static ROOTS_SEEN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The scope's objects after a pass replaced `found`: each found object gives way to the ids of
/// what replaces it, in its place, and the others keep their order. An id that `roots` lists more
/// than once is replaced at its first places, once for each time `found` lists it. One walk of
/// `roots`.
pub(super) fn replace_roots(roots: &[NodeId], found: &[(NodeId, Vec<Node>)]) -> Vec<NodeId> {
let mut with: HashMap<NodeId, (&[Node], usize)> = HashMap::with_capacity(found.len());
for (id, nodes) in found {
with.entry(*id).or_insert((nodes.as_slice(), 0)).1 += 1;
}
let mut out = Vec::with_capacity(roots.len());
for r in roots {
#[cfg(test)]
ROOTS_SEEN.set(ROOTS_SEEN.get() + 1);
match with.get_mut(r) {
Some((nodes, left)) if *left > 0 => {
*left -= 1;
out.extend(nodes.iter().map(|n| n.id));
}
_ => out.push(*r),
}
}
out
}
/// Run `f` on a copy of each path in scope (not guides), and keep the copies it changed: `f`
/// returns how many issues it fixed.
fn reshape(d: &mut Document, scope: &Scope, f: impl Fn(&Node, &mut PathData) -> usize) -> Result<Outcome> {
@@ -479,13 +505,38 @@ fn near_points(d: &mut Document, scope: &Scope) -> Result<Outcome> {
fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Outcome> {
let Scope::Document = scope else { return Ok(Outcome::default()) };
let empty = empty_artboards(d);
// The last first, so the others keep their places.
for &i in empty.iter().rev() {
if i < d.artboards.len() {
let id = d.artboards.remove(i).id;
d.retire_artboard_id(id);
d.retain_guides(sel, |g| g.artboard != Some(id));
}
if empty.is_empty() {
return Ok(Outcome::default());
}
// `empty` is in artboard order: one walk drops them and the others keep their order.
let mut gone: HashSet<u32> = HashSet::with_capacity(empty.len());
let mut i = 0;
d.artboards.retain(|a| {
let keep = empty.binary_search(&i).is_err();
i += 1;
if !keep {
gone.insert(a.id);
}
keep
});
count_artboard_pass();
for &id in &gone {
d.retire_artboard_id(id);
}
d.retain_guides(sel, |g| !g.artboard.is_some_and(|a| gone.contains(&a)));
count_artboard_pass();
Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty })
}
#[cfg(test)]
thread_local! {
/// Tests: how many walks [`artboards`] made over the artboards and the ruler guides on this
/// thread.
pub(super) static ARTBOARD_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Tests: count one walk over the artboards or the ruler guides.
fn count_artboard_pass() {
#[cfg(test)]
ARTBOARD_PASSES.set(ARTBOARD_PASSES.get() + 1);
}
@@ -389,6 +389,150 @@ fn the_scope_is_the_selection_or_the_document() {
}
}
/// The ids of the first layer's objects, bottom to top.
fn layer_ids(s: &Session) -> Vec<NodeId> {
match &s.doc().unwrap().doc.layers[0].kind {
NodeKind::Layer { children, .. } => children.iter().map(|c| c.id).collect(),
other => panic!("not a layer: {other:?}"),
}
}
/// A plain group of the one object `id`.
fn group(s: &mut Session, id: NodeId) -> NodeId {
wrap(s, id, |children| NodeKind::Group { children, clip: false })
}
#[test]
fn unwrapping_selected_groups_keeps_their_places_and_order() {
let mut s = session();
let c1 = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let a = rect(&mut s, 40.0, 10.0, 20.0, 20.0);
let g1 = group(&mut s, a);
let c2 = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
let empty = s
.edit("t", |d, _| {
let (parent, i, _) = d.position(c2).unwrap();
let g = d.alloc_id();
Ok(d.insert(parent, i + 1, Node::new(g, NodeKind::Group { children: vec![], clip: false }))?)
})
.unwrap();
let c3 = rect(&mut s, 100.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 130.0, 10.0, 20.0, 20.0);
let inner = group(&mut s, b);
let outer = group(&mut s, inner);
let c4 = rect(&mut s, 160.0, 10.0, 20.0, 20.0);
let x = rect(&mut s, 190.0, 10.0, 20.0, 20.0);
let unselected = group(&mut s, x);
assert_eq!(layer_ids(&s), [c1, g1, c2, empty, c3, outer, c4, unselected]);
select(&mut s, &[c1, g1, c2, empty, c3, outer, c4]);
let check = s.execute("addon.cleanup.check", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap();
let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["needlessGroups"], "scope": "selection"})).unwrap();
// The inner group is found only once its outer group is gone: on a later pass, as a root.
assert_eq!(r["fixed"]["needlessGroups"], 4, "{r}");
assert_eq!(check["total"], 4, "the check finds what Fix fixes: {check}");
let mut listed: Vec<u64> = check["findings"][0]["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
listed.sort_unstable();
let mut gone: Vec<u64> = [g1, empty, outer, inner].iter().map(|i| i.0).collect();
gone.sort_unstable();
assert_eq!(listed, gone);
assert_eq!(layer_ids(&s), [c1, a, c2, c3, b, c4, unselected], "each member takes its group's place");
for id in [g1, empty, outer, inner] {
assert!(node(&s, id).is_none(), "{id:?} unwrapped");
}
}
#[test]
fn fix_all_reaches_the_members_of_unwrapped_selected_groups() {
let mut s = session();
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
let below = rect(&mut s, 20.0, 150.0, 30.0, 30.0);
let copy = s
.edit("t", |d, _| {
let mut copy = d.node(below).unwrap().clone();
copy.id = d.alloc_id();
let (parent, i, _) = d.position(below).unwrap();
Ok(d.insert(parent, i + 1, copy)?)
})
.unwrap();
let g = group(&mut s, copy);
select(&mut s, &[below, g]);
// The copy sits apart from the object it doubles until its group is unwrapped.
assert_eq!(found(&mut s, "duplicateObjects", "selection"), 0);
assert_eq!(found(&mut s, "needlessGroups", "selection"), 1);
let r = s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap();
assert_eq!((r["fixed"]["needlessGroups"].as_u64(), r["fixed"]["duplicateObjects"].as_u64()), (Some(1), Some(1)), "{r}");
assert!(node(&s, g).is_none() && node(&s, copy).is_none());
assert!(node(&s, below).is_some());
}
#[test]
fn replacing_roots_takes_each_found_object_at_its_first_place() {
use super::checks::replace_roots;
let n = |id: u64| Node::new(NodeId(id), NodeKind::Group { children: vec![], clip: false });
let ids = |v: &[u64]| v.iter().map(|i| NodeId(*i)).collect::<Vec<_>>();
let roots = ids(&[1, 2, 1, 3]);
assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![n(10), n(11)])]), ids(&[10, 11, 2, 1, 3]));
assert_eq!(replace_roots(&roots, &[(NodeId(1), vec![]), (NodeId(1), vec![])]), ids(&[2, 3]), "listed twice: both places");
assert_eq!(replace_roots(&roots, &[(NodeId(3), vec![]), (NodeId(2), vec![n(20)])]), ids(&[1, 20, 1]));
// A found object inside a root leaves the roots as they are.
assert_eq!(replace_roots(&roots, &[(NodeId(7), vec![n(70)])]), roots);
assert_eq!(replace_roots(&[], &[(NodeId(7), vec![])]), ids(&[]));
}
/// How many scope roots fixing `n` selected stray points above `n` selected clean objects looks at.
fn roots_seen_removing(n: usize) -> usize {
use super::checks::ROOTS_SEEN;
let mut s = session();
let mut ids: Vec<NodeId> = (0..n).map(|i| rect(&mut s, (i % 20) as f64 * 15.0, (i / 20) as f64 * 15.0, 10.0, 10.0)).collect();
ids.extend((0..n).map(|i| path(&mut s, json!([{"x": i % 300, "y": i / 300}]), false)));
select(&mut s, &ids);
ROOTS_SEEN.set(0);
let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"], "scope": "selection"})).unwrap();
assert_eq!(r["total"].as_u64(), Some(n as u64));
assert_eq!(layer_ids(&s), ids[..n]);
ROOTS_SEEN.get()
}
#[test]
fn removing_many_selected_objects_looks_at_each_root_once_a_pass() {
let (small, large) = (roots_seen_removing(150), roots_seen_removing(300));
assert_eq!(small, 300, "one pass over the 300 roots");
assert!(large <= 2 * small, "{small} roots looked at for 150 removals, {large} for 300");
}
#[test]
fn deleting_many_empty_artboards_walks_the_artboards_and_guides_once() {
use super::checks::ARTBOARD_PASSES;
let mut s = session();
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
let (keep, gone) = s
.edit("t", |d, _| {
let first = d.artboards[0].id;
let keep = d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(first), ..vectorcraft_doc::Guide::new(true, 100.0) });
let mut gone = vec![];
for i in 0..40 {
let mut a = d.artboards[0].clone();
a.id = d.next_artboard_id();
let x = 1000.0 + i as f64 * 200.0;
a.rect = Rect::new(x, 1000.0, x + 100.0, 1100.0);
gone.push(d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(a.id), ..vectorcraft_doc::Guide::new(true, x + 50.0) }));
d.artboards.push(a);
}
Ok((keep, gone))
})
.unwrap();
let first = s.doc().unwrap().doc.artboards[0].clone();
ARTBOARD_PASSES.set(0);
let r = s.execute("addon.cleanup.fix", &json!({"kinds": ["emptyArtboards"], "scope": "document"})).unwrap();
assert_eq!(r["total"].as_u64(), Some(40), "{r}");
assert_eq!(ARTBOARD_PASSES.get(), 2, "one walk over the artboards and one over the guides");
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards, vec![first]);
let left: Vec<Option<NodeId>> = d.ruler_guides().into_iter().map(|g| g.id).collect();
assert_eq!(left, vec![Some(keep)]);
assert!(gone.iter().all(|id| !left.contains(&Some(*id))));
}
#[test]
fn show_selects_the_objects_with_the_issue() {
let mut s = session();
+3 -3
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply as Filter",
["Object", "Plug-ins", "Color Effects"],
None,
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}",
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}",
has_selection,
filter
),
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Ink Fixes",
["Object", "Plug-ins", "Color Effects"],
None,
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
has_doc,
print_fixes::run
),
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
@@ -11,7 +11,7 @@ use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::overprint::{OverprintBlack, is_white};
use vectorcraft_doc::{ColorMode, Node, NodeKind};
use super::super::super::colorcmds::{Recolor, Scope};
use super::super::super::colorcmds::{Recolor, Scope, keeping_series_paint};
use super::super::super::overprint::edit_counted;
use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param};
use crate::{Result, Session};
@@ -149,7 +149,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
true
};
let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false };
ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids);
ink = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids)))?;
}
if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 {
return Ok(ink);
+5 -3
View File
@@ -2,8 +2,8 @@
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes
//! into Paths.
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes, Expand Dashes
//! into Paths and Trim at Crossings ([`trim`]).
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
@@ -14,6 +14,7 @@ mod shape;
mod shared_edge;
#[cfg(test)]
mod tests;
mod trim;
use std::collections::{BTreeMap, BTreeSet};
@@ -143,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Points at Crossings",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}",
"{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too, a curved segment that loops over itself included), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}",
has_selection,
crossings
),
@@ -178,6 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
v.extend(shape::specs());
v.extend(corners::specs());
v.extend(shared_edge::specs());
v.extend(trim::specs());
v
}
@@ -0,0 +1,321 @@
//! Trim at Crossings: remove the stretch of a path between the places where other paths, or the
//! path itself, cross it. The command takes the stretch at a point, every stretch a rectangle
//! touches, or every stretch a line through some points crosses. It works on every path that can
//! be edited, selected or not.
use std::collections::HashSet;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind};
use vectorcraft_geom::kurbo::{Line, PathSeg};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, hit};
use vectorcraft_pathops::addons::pathtools as pt;
use super::super::super::draw2::replace_with_pieces;
use super::super::super::pathops::Beside;
use super::super::super::{CommandSpec, bad, cmd, finite_numbers, has_doc, number_or};
use crate::{Result, Session};
#[cfg(test)]
mod tests;
/// The undo step's name.
const LABEL: &str = "Trim at Crossings";
/// How near `at` a path counts as under it by default (pt), and the most.
const TOLERANCE: f64 = 3.0;
const MAX_TOLERANCE: f64 = 100.0;
/// Most points of `points`.
const MAX_POINTS: usize = 100_000;
/// The farthest coordinate the command takes.
const MAX_COORD: f64 = 1e7;
pub(super) fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.pathtools.trim",
"Trim at Crossings",
[],
None,
"{at: [x, y], tolerance?: pt 0..100 (3; how near `at` a path counts as under it)} or {rect: [x, y, width, height]} or {points: [[x, y]…] (2 to 100000 points)} remove stretches of paths, a stretch running between two neighboring places where the path is crossed by another path or by itself, or from such a place to an open end: `at` removes the stretch of the nearest path at that point, `rect` every stretch the rectangle touches, `points` every stretch the line through the points crosses. Give exactly one of the three. Every path that can be edited takes part, selected or not, compound path members included; hidden and locked paths, guides and clipping paths (compound ones' members included) are neither trimmed nor counted as crossing; in isolation mode only paths inside the isolated object are trimmed. A path that ends on another marks a place on the other; paths that lie along each other don't cross. A closed path that loses a stretch becomes open, an open path that loses a middle stretch becomes two paths with its appearance in its place in the stacking order, and a path nothing crosses is removed whole; a live shape becomes a plain path. One undo step, none when nothing is removed → {ids (the paths left of those trimmed), removed (stretches)}",
has_doc,
trim
)]
}
/// What picks the stretches to remove.
enum Pick {
/// The stretch of the nearest path within the tolerance of the point.
At(Point, f64),
/// Every stretch the rectangle touches.
Rect(Rect),
/// Every stretch the line through the points crosses.
Line(Vec<Point>, Rect),
}
fn pick(p: &Value) -> Result<Pick> {
const C: &str = "addon.pathtools.trim";
let given = |key: &str| p.get(key).filter(|v| !v.is_null());
let within = |v: &[f64]| v.iter().all(|x| x.abs() <= MAX_COORD);
match (given("at"), given("rect"), given("points")) {
(Some(at), None, None) => {
let [x, y] = finite_numbers(at).filter(|v| within(v)).ok_or_else(|| bad(C, "`at` must be a point [x, y]"))?;
let tolerance = number_or(p, "tolerance", TOLERANCE, C)?;
if !(0.0..=MAX_TOLERANCE).contains(&tolerance) {
return Err(bad(C, "`tolerance` must be from 0 to 100 pt"));
}
Ok(Pick::At(Point::new(x, y), tolerance))
}
(None, Some(rect), None) => {
let [x, y, w, h] = finite_numbers(rect).filter(|v| within(v)).ok_or_else(|| bad(C, "`rect` must be [x, y, width, height]"))?;
Ok(Pick::Rect(Rect::new(x, y, x + w, y + h).abs()))
}
(None, None, Some(points)) => {
let list = points.as_array().ok_or_else(|| bad(C, "`points` must be a list of [x, y] points"))?;
if !(2..=MAX_POINTS).contains(&list.len()) {
return Err(bad(C, "`points` must hold from 2 to 100000 points"));
}
let pts = list
.iter()
.map(|v| {
finite_numbers(v)
.filter(|xy| within(xy))
.map(|[x, y]| Point::new(x, y))
.ok_or_else(|| bad(C, format!("bad point {v}: expected [x, y]")))
})
.collect::<Result<Vec<Point>>>()?;
let bounds = pts.iter().fold(None, |r: Option<Rect>, q| Some(r.map_or(Rect::from_points(*q, *q), |r| r.union_pt(*q))));
Ok(Pick::Line(pts, bounds.unwrap_or_default()))
}
_ => Err(bad(C, "give exactly one of `at`, `rect` and `points`")),
}
}
/// A path that takes part: its id, geometry and control-point box.
type Part<'a> = (NodeId, &'a PathData, Rect);
/// The paths that take part, bottom to top: the ones that can be edited, without guides and
/// clipping paths. The clipping path of a clip group or a clipped layer is left out whole, the
/// members of a compound clipping path included.
fn parts(d: &Document) -> Vec<Part<'_>> {
let editable = d.editable();
let mut masks = HashSet::new();
d.walk(|n| {
if n.clips()
&& let Some(first) = n.children().and_then(|c| c.first())
{
first.walk(&mut |m| {
masks.insert(m.id);
});
}
});
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
&& !masks.contains(&n.id)
&& let Some(b) = path.control_bounds()
&& editable(n.id)
{
out.push((n.id, path, b));
}
});
out
}
/// Most box tests one command makes between a line's pieces and the paths' segments.
const MAX_LINE_TESTS: usize = 60_000_000;
/// Most tests one command makes between the boxes of the paths to trim and of the other paths.
const MAX_BOX_TESTS: usize = 50_000_000;
/// Does the line through `pts` cross `sp`? Each piece of the line compared costs one of `budget`;
/// `None` once it is spent.
fn crossed_by_line(sp: &SubPath, pts: &[Point], budget: &mut usize) -> Option<bool> {
let segs: Vec<_> = (0..sp.segment_count())
.map(|i| {
let c = sp.segment(i);
(c, Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3).inflate(1e-9, 1e-9))
})
.collect();
let Some(reach) = segs.iter().map(|s| s.1).reduce(|a, b| a.union(b)) else { return Some(false) };
for w in pts.windows(2) {
let [a, b] = w else { continue };
let piece = Rect::from_points(*a, *b);
*budget = budget.checked_sub(1)?;
if !reach.overlaps(piece) {
continue;
}
for (c, r) in &segs {
*budget = budget.checked_sub(1)?;
if r.overlaps(piece) && !PathSeg::Cubic(*c).intersect_line(Line::new(*a, *b)).is_empty() {
return Some(true);
}
}
}
Some(false)
}
/// Does the rectangle or line of `pick` touch the outline of `piece`? Never for `Pick::At`.
fn touches(piece: &SubPath, pick: &Pick, budget: &mut usize) -> Result<bool> {
match pick {
Pick::At(..) => Ok(false),
Pick::Rect(r) => Ok(hit::intersects_rect(&PathData::single(piece.clone()), *r)),
Pick::Line(pts, _) => crossed_by_line(piece, pts, budget)
.ok_or_else(|| bad("addon.pathtools.trim", "the line is too long, or the paths it passes too many, to check")),
}
}
/// `path` without the stretches `pick` takes (`on`: the place of `Pick::At` on it): its subpaths
/// then, and how many stretches went. `None` when none does. A rectangle or a line that touches no
/// part of `path` leaves it out before its crossings are looked for.
fn trimmed(
path: &PathData,
others: &[&PathData],
pick: &Pick,
on: Option<pt::Place>,
budget: &mut usize,
work: &mut pt::Work,
) -> Result<Option<(Vec<SubPath>, usize)>> {
const C: &str = "addon.pathtools.trim";
let reached = match pick {
Pick::At(..) => true,
Pick::Rect(r) => hit::intersects_rect(path, *r),
Pick::Line(..) => {
let mut any = false;
for sp in &path.subpaths {
if touches(sp, pick, budget)? {
any = true;
break;
}
}
any
}
};
if !reached {
return Ok(None);
}
let places =
pt::crossings_on_within(path, others, work).ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross"))?;
let mut subs = vec![];
let mut removed = 0;
for (si, sp) in path.subpaths.iter().enumerate() {
// The places come in subpath order: this subpath's are one run of them.
let run = places.partition_point(|p| p.0 < si)..places.partition_point(|p| p.0 <= si);
let here: Vec<(usize, f64)> = places.get(run).unwrap_or_default().iter().map(|&(_, seg, t)| (seg, t)).collect();
let spans = pt::spans(sp, &here);
let marks: Vec<bool> = match pick {
Pick::At(..) => {
let at = on.filter(|(s, ..)| *s == si).and_then(|(_, seg, t)| pt::span_at(sp, &spans, seg, t));
(0..spans.len()).map(|i| Some(i) == at).collect()
}
_ => spans.iter().map(|s| touches(&pt::slice(sp, *s), pick, budget)).collect::<Result<_>>()?,
};
match pt::without_spans(sp, &spans, &marks) {
Some(pieces) => {
removed += marks.iter().filter(|m| **m).count();
subs.extend(pieces);
}
None => subs.push(sp.clone()),
}
}
Ok((removed > 0).then_some((subs, removed)))
}
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.trim";
let pick = pick(p)?;
let st = s.doc()?;
let d = &st.doc;
let parts = parts(d);
// In isolation mode only the isolated object's paths are trimmed; paths outside it still cross them.
let root = crate::cmd::ordinary_isolation(st);
let in_scope = |id: NodeId| root.is_none_or(|r| id == r || crate::cmd::inside_isolation(d, r, id));
// The paths to trim, each with the place `at` falls on.
let targets: Vec<(usize, Option<pt::Place>)> = match &pick {
Pick::At(at, tolerance) => {
let mut best: Option<(usize, pt::Place, f64)> = None;
for (i, (id, path, b)) in parts.iter().enumerate() {
if !in_scope(*id) {
continue;
}
let b = b.inflate(*tolerance, *tolerance);
if !(b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1) {
continue;
}
// Of paths equally near, the one on top.
if let Some((si, seg, t, _, dist)) = path.nearest(*at)
&& dist <= *tolerance
&& best.is_none_or(|b| dist <= b.2)
{
best = Some((i, (si, seg, t), dist));
}
}
best.map(|(i, on, _)| (i, Some(on))).into_iter().collect()
}
Pick::Rect(r) | Pick::Line(_, r) => {
parts.iter().enumerate().filter(|(_, (id, _, b))| in_scope(*id) && b.overlaps(*r)).map(|(i, _)| (i, None)).collect()
}
};
// Each path to trim has its box tested against every other path's.
if targets.len().saturating_mul(parts.len()) > MAX_BOX_TESTS {
return Err(bad(C, "the rectangle or line reaches too many paths to check"));
}
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
let mut removed = 0;
let mut budget = MAX_LINE_TESTS;
// One budget of crossing work for every path the command trims.
let mut work = pt::Work::default();
for (i, on) in targets {
let Some((id, path, b)) = parts.get(i) else { continue };
let reach = b.inflate(1e-3, 1e-3);
let others: Vec<&PathData> =
parts.iter().enumerate().filter(|(j, (_, _, ob))| *j != i && reach.overlaps(*ob)).map(|(_, (_, o, _))| *o).collect();
let Some((subs, n)) = trimmed(path, &others, &pick, on, &mut budget, &mut work)? else { continue };
removed += n;
// A path of one subpath becomes a path per piece; the pieces of one with more stay its subpaths.
let pieces = match (path.subpaths.len(), subs.is_empty()) {
(_, true) => vec![],
(1, false) => subs.into_iter().map(PathData::single).collect(),
_ => vec![PathData::new(subs)],
};
plan.push((*id, pieces));
}
if plan.is_empty() {
return Ok(json!({ "ids": [], "removed": 0 }));
}
let ids = s.edit(LABEL, |d, sel| {
let trimmed: Vec<NodeId> = plan.iter().map(|(id, _)| *id).collect();
// The compound paths that may lose their last member.
let mut compounds: Vec<NodeId> = trimmed
.iter()
.filter_map(|id| d.parent_of(*id))
.filter(|c| matches!(d.node(*c).map(|n| &n.kind), Some(NodeKind::Compound { .. })))
.collect();
compounds.dedup();
let mut beside = Beside::replacing(d, &trimmed);
let mut all = vec![];
for (id, pieces) in plan {
let selected = sel.contains(id);
sel.anchors.remove(&id);
sel.segments.remove(&id);
let made = replace_with_pieces(d, &mut beside, id, pieces)?;
// The pieces of a selected path are selected; what was not selected stays so.
if made.is_empty() {
sel.remove(id);
} else if selected {
for n in &made {
if !sel.contains(*n) {
sel.add(*n);
}
}
}
all.extend(made);
}
beside.finish(d)?;
for c in compounds {
if d.node(c).is_some_and(|n| n.children().is_some_and(|ch| ch.is_empty())) {
d.remove(c)?;
sel.remove(c);
}
}
Ok(all)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "removed": removed }))
}
@@ -0,0 +1,527 @@
//! Trim at Crossings: the stretch at a point, the stretches a rectangle touches or a line crosses,
//! one undo step, and what takes no part.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point};
use crate::Session;
const TRIM: &str = "addon.pathtools.trim";
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
id
}
/// A line across at y = 100 from x = 0 to 300, and two uprights across it at x = 100 and x = 200.
fn across_and_uprights(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let h = line(s, &[(0.0, 100.0), (300.0, 100.0)], false);
let v1 = line(s, &[(100.0, 50.0), (100.0, 150.0)], false);
let v2 = line(s, &[(200.0, 50.0), (200.0, 150.0)], false);
(h, v1, v2)
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn exists(s: &Session, id: NodeId) -> bool {
s.doc().unwrap().doc.node(id).is_some()
}
/// The first and last anchor of the path's only subpath.
fn ends(s: &Session, id: NodeId) -> (Point, Point) {
let p = path(s, id);
assert_eq!(p.subpaths.len(), 1);
(p.subpaths[0].anchors.first().unwrap().p, p.subpaths[0].anchors.last().unwrap().p)
}
fn near(p: Point, x: f64, y: f64) -> bool {
p.distance(Point::new(x, y)) < 1e-6
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
}
/// The ids of the first layer's objects, bottom to top.
fn stack(s: &Session) -> Vec<NodeId> {
s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect()
}
fn set_flags(s: &mut Session, id: NodeId, locked: bool, visible: bool) {
s.edit("flags", |d, _| {
let n = d.node_mut(id).unwrap();
n.locked = locked;
n.visible = visible;
Ok(())
})
.unwrap();
}
#[test]
fn a_point_between_two_crossings_removes_that_stretch_and_splits_the_path() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
let look = s.doc().unwrap().doc.node(h).unwrap().appearance.clone();
let (before, n) = (stack(&s), steps(&s));
let r = s.execute(TRIM, &json!({"at": [150, 101]})).unwrap();
assert_eq!(r["removed"], json!(1));
let made = ids(&r);
assert_eq!(made.len(), 2, "{r}");
assert_eq!(made[0], h, "the first piece keeps the id");
let (a, b) = (ends(&s, made[0]), ends(&s, made[1]));
assert!(near(a.0, 0.0, 100.0) && near(a.1, 100.0, 100.0), "{a:?}");
assert!(near(b.0, 200.0, 100.0) && near(b.1, 300.0, 100.0), "{b:?}");
// Both pieces look as the path did and stand where it stood, under the uprights.
assert!(made.iter().all(|id| s.doc().unwrap().doc.node(*id).unwrap().appearance == look));
assert_eq!(stack(&s), [made[0], made[1], v1, v2]);
assert_eq!(before, [h, v1, v2]);
// The uprights are as they were, nothing got selected, and it is one undo step.
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
assert!(s.doc().unwrap().selection.is_empty());
assert_eq!(steps(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(stack(&s), before);
assert!(near(ends(&s, h).1, 300.0, 100.0));
}
#[test]
fn an_end_stretch_runs_to_the_open_end_and_the_rest_stays_one_path() {
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
let r = s.execute(TRIM, &json!({"at": [40, 100]})).unwrap();
assert_eq!((ids(&r), r["removed"].clone()), (vec![h], json!(1)));
let e = ends(&s, h);
assert!(near(e.0, 100.0, 100.0) && near(e.1, 300.0, 100.0), "{e:?}");
// No anchor is added at the crossing the rest runs through.
assert_eq!(path(&s, h).anchor_count(), 2);
// A point on no path within the tolerance removes nothing and makes no step.
let n = steps(&s);
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110]})).unwrap(), json!({"ids": [], "removed": 0}));
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 0})).unwrap()["removed"], json!(0));
assert_eq!(steps(&s), n);
assert_eq!(s.execute(TRIM, &json!({"at": [250, 110], "tolerance": 12})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 200.0, 100.0));
}
#[test]
fn a_closed_path_that_loses_a_stretch_opens() {
let mut s = session();
let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
line(&mut s, &[(50.0, 150.0), (250.0, 150.0)], false);
// The top half of the square's outline, between the two places the line crosses it.
let r = s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
assert_eq!((ids(&r), r["removed"].clone()), (vec![sq], json!(1)));
let p = path(&s, sq);
assert_eq!(p.subpaths.len(), 1);
assert!(!p.subpaths[0].closed);
let pts: Vec<Point> = p.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts.len(), 4, "{pts:?}");
assert!(pts.iter().all(|q| q.y >= 150.0 - 1e-6), "only the lower half is left: {pts:?}");
assert!(pts.iter().any(|q| near(*q, 100.0, 200.0)) && pts.iter().any(|q| near(*q, 200.0, 200.0)));
// It is a plain path now.
assert!(matches!(&s.doc().unwrap().doc.node(sq).unwrap().kind, NodeKind::Path { live: None, .. }));
}
#[test]
fn a_rectangle_removes_every_stretch_it_touches_as_one_step() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false);
let n = steps(&s);
// Over the middle of both lines across, touching neither upright.
let r = s.execute(TRIM, &json!({"rect": [140, 90, 20, 40]})).unwrap();
assert_eq!(r["removed"], json!(2));
assert_eq!(ids(&r).len(), 4);
assert_eq!(steps(&s), n + 1, "one undo step");
assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0));
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(ends(&s, h).1, 300.0, 100.0) && near(ends(&s, h2).1, 300.0, 120.0));
// A rectangle given from its far corner is the same one; one over empty space removes nothing.
assert_eq!(s.execute(TRIM, &json!({"rect": [160, 130, -20, -40]})).unwrap()["removed"], json!(2));
let n = steps(&s);
assert_eq!(s.execute(TRIM, &json!({"rect": [300, 200, 50, 50]})).unwrap()["removed"], json!(0));
assert_eq!(steps(&s), n);
// An upright's stretches: above the upper line, between the two, below the lower. A rectangle
// over its top end takes the first only.
assert_eq!(s.execute(TRIM, &json!({"rect": [95, 45, 10, 10]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v1).1, 100.0, 150.0));
}
#[test]
fn a_line_through_points_removes_the_stretches_it_crosses() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
let n = steps(&s);
// Down through the middle of the line across, then right through the lower end of the second upright.
let r = s.execute(TRIM, &json!({"points": [[150, 80], [150, 130], [250, 130]]})).unwrap();
assert_eq!(r["removed"], json!(2));
assert_eq!(steps(&s), n + 1);
assert!(near(ends(&s, h).1, 100.0, 100.0));
assert!(near(ends(&s, v2).0, 200.0, 50.0) && near(ends(&s, v2).1, 200.0, 100.0));
assert_eq!(path(&s, v1).anchor_count(), 2);
}
#[test]
fn locked_and_hidden_paths_are_not_trimmed_and_do_not_count_as_crossing() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
set_flags(&mut s, v1, true, true);
set_flags(&mut s, v2, false, false);
let n = steps(&s);
// On the locked upright, and a rectangle over the hidden one's top end: nothing goes.
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [195, 45, 10, 10]})).unwrap()["removed"], json!(0));
assert_eq!(steps(&s), n);
assert_eq!((path(&s, v1).anchor_count(), path(&s, v2).anchor_count()), (2, 2));
// Neither marks a place on the line across: with nothing else crossing it, it goes whole.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(!exists(&s, h) && exists(&s, v1) && exists(&s, v2));
// A guide doesn't count either.
s.execute("edit.undo", &json!({})).unwrap();
set_flags(&mut s, v1, false, true);
s.execute("select.set", &json!({"ids": [v1.0]})).unwrap();
s.execute("view.guides.make", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
assert!(!exists(&s, h));
}
#[test]
fn a_path_of_two_subpaths_loses_the_stretch_on_the_subpath_under_the_point() {
let mut s = session();
// Lines of different lengths, so the places on one fall at other parameters than on the other.
let line_at = |x0: f64, x1: f64, y: f64| vectorcraft_geom::SubPath::polyline(&[Point::new(x0, y), Point::new(x1, y)], false);
let two = s
.edit("two", |d, _| {
let layer = d.layers[0].id;
let id = d.alloc_id();
let both = PathData::new(vec![line_at(0.0, 300.0, 100.0), line_at(50.0, 250.0, 120.0)]);
d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, both, Default::default()))?;
Ok(id)
})
.unwrap();
line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false);
line(&mut s, &[(200.0, 50.0), (200.0, 150.0)], false);
// The middle of the lower subpath goes; the upper one stays whole, both in the one path.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 120]})).unwrap()["removed"], json!(1));
let p = path(&s, two);
let spans: Vec<(Point, Point)> = p.subpaths.iter().map(|sp| (sp.anchors[0].p, sp.anchors.last().unwrap().p)).collect();
assert_eq!(spans.len(), 3, "{spans:?}");
assert!(near(spans[0].0, 0.0, 100.0) && near(spans[0].1, 300.0, 100.0), "{spans:?}");
assert!(near(spans[1].0, 50.0, 120.0) && near(spans[1].1, 100.0, 120.0), "{spans:?}");
assert!(near(spans[2].0, 200.0, 120.0) && near(spans[2].1, 250.0, 120.0), "{spans:?}");
}
#[test]
fn a_clipping_path_is_not_trimmed_and_does_not_count_as_crossing() {
let mut s = session();
let (h, v1, _) = across_and_uprights(&mut s);
s.edit("clip", |d, _| {
if let NodeKind::Path { clipping, .. } = &mut d.node_mut(v1).unwrap().kind {
*clipping = true;
}
Ok(())
})
.unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
assert_eq!(path(&s, v1).anchor_count(), 2);
// The line across is crossed by the second upright only: its stretch from the start goes.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// Type over the rest of it takes no part either: nothing crosses the rest, and it goes whole.
let t = NodeId(s.execute("text.create", &json!({"x": 220, "y": 115, "text": "HHHH", "size": 40})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"rect": [215, 70, 70, 50]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(!exists(&s, h) && exists(&s, t));
}
#[test]
fn the_members_of_a_compound_clipping_path_are_not_trimmed_and_do_not_count_as_crossing() {
let mut s = session();
let (h, v1, _) = across_and_uprights(&mut s);
let extra = line(&mut s, &[(250.0, 250.0), (260.0, 260.0)], false);
s.execute("select.set", &json!({"ids": [v1.0, extra.0]})).unwrap();
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [h.0, compound.0]})).unwrap();
s.execute("object.clippingMask.make", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let n = steps(&s);
// A click on the clipping path's member changes nothing, and the rectangle over it neither.
assert_eq!(s.execute(TRIM, &json!({"at": [100, 60]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [245, 245, 20, 20]})).unwrap()["removed"], json!(0));
assert_eq!((steps(&s), path(&s, v1).anchor_count(), path(&s, extra).anchor_count()), (n, 2, 2));
// The clipped line across is crossed by the second upright only: its stretch from the start goes.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).0, 200.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
}
#[test]
fn in_isolation_mode_only_the_isolated_objects_paths_are_trimmed() {
let mut s = session();
let (h, v1, v2) = across_and_uprights(&mut s);
s.execute("select.set", &json!({"ids": [v1.0, v2.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("select.none", &json!({})).unwrap();
// The line across is outside the group: a click or a rectangle on it leaves it whole.
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(0));
assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(0));
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// It still crosses the uprights: a rectangle over the tops of both removes them down to it.
assert_eq!(s.execute(TRIM, &json!({"rect": [90, 40, 120, 20]})).unwrap()["removed"], json!(2));
assert!(near(ends(&s, v1).0, 100.0, 100.0) && near(ends(&s, v2).0, 200.0, 100.0));
assert!(near(ends(&s, h).0, 0.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
// An isolated path is trimmed itself.
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("object.isolate", &json!({"id": h.0})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 100.0, 100.0));
}
#[test]
fn a_rectangle_over_too_many_paths_to_check_is_refused() {
let mut s = session();
// 7,072 short lines apart from each other: each of them against every other is past the limit.
s.edit("many", |d, _| {
let layer = d.layers[0].id;
for i in 0..7072 {
let (x, y) = ((i % 100) as f64 * 3.0, (i / 100) as f64 * 3.0);
let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(x, y), Point::new(x + 1.0, y)], false);
let id = d.alloc_id();
let top = d.node(layer).unwrap().children().unwrap().len();
d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?;
}
Ok(())
})
.unwrap();
let n = steps(&s);
let err = s.execute(TRIM, &json!({"rect": [-10, -10, 400, 300]})).unwrap_err().to_string();
assert!(err.contains("too many paths"), "{err}");
assert_eq!((steps(&s), stack(&s).len()), (n, 7072));
// A rectangle over a few of them is checked.
assert_eq!(s.execute(TRIM, &json!({"rect": [-1, -1, 5, 2]})).unwrap()["removed"], json!(2));
}
#[test]
fn a_path_too_large_to_search_is_refused_only_when_touched() {
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
// A border of 10,001 segments round all of them, more than a path to trim may have.
let mut border: Vec<(f64, f64)> = (0..=10_001).map(|i| (-500.0 + i as f64 * 0.2, -500.0)).collect();
border.push((-500.0, 1500.0));
let b = line(&mut s, &border, true);
// A rectangle and a line over the middle of the line across reach inside the border's box only.
assert_eq!(s.execute(TRIM, &json!({"rect": [140, 90, 20, 20]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 100.0, 100.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"points": [[150, 80], [150, 120]]})).unwrap()["removed"], json!(1));
// A rectangle on the border is refused, and changes nothing.
let n = steps(&s);
let err = s.execute(TRIM, &json!({"rect": [-510, 0, 20, 20]})).unwrap_err().to_string();
assert!(err.contains("too many or too tangled"), "{err}");
assert_eq!((steps(&s), path(&s, b).anchor_count()), (n, 10_003));
}
#[test]
fn a_path_nothing_crosses_goes_whole_open_or_closed() {
let mut s = session();
let open = line(&mut s, &[(0.0, 10.0), (50.0, 10.0), (50.0, 40.0)], false);
let closed = line(&mut s, &[(100.0, 10.0), (150.0, 10.0), (150.0, 40.0)], true);
let n = steps(&s);
assert_eq!(s.execute(TRIM, &json!({"at": [25, 10]})).unwrap(), json!({"ids": [], "removed": 1}));
assert_eq!(s.execute(TRIM, &json!({"at": [125, 10]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(!exists(&s, open) && !exists(&s, closed));
assert_eq!(steps(&s), n + 2);
}
#[test]
fn a_path_that_ends_on_another_marks_a_place_and_paths_along_each_other_do_not() {
let mut s = session();
let h = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false);
let stub = line(&mut s, &[(150.0, 100.0), (150.0, 160.0)], false);
// The stub ends on the line: the line's stretch stops there, and the stub is one stretch.
s.execute(TRIM, &json!({"at": [50, 100]})).unwrap();
assert!(near(ends(&s, h).0, 150.0, 100.0) && near(ends(&s, h).1, 300.0, 100.0));
assert_eq!(s.execute(TRIM, &json!({"at": [150, 130]})).unwrap()["removed"], json!(1));
assert!(!exists(&s, stub));
// A second line along the first doesn't mark it: the lower one goes whole.
let along = line(&mut s, &[(200.0, 100.0), (260.0, 100.0)], false);
s.execute(TRIM, &json!({"rect": [270, 90, 10, 20]})).unwrap();
assert!(!exists(&s, h) && exists(&s, along));
}
#[test]
fn a_self_crossing_path_loses_its_loop() {
let mut s = session();
// A bow: its first and third segments cross at (50, 50).
let bow = line(&mut s, &[(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false);
let r = s.execute(TRIM, &json!({"at": [100, 50]})).unwrap();
assert_eq!(r["removed"], json!(1));
let made = ids(&r);
assert_eq!(made.len(), 2);
assert_eq!(made[0], bow);
assert!(near(ends(&s, made[0]).1, 50.0, 50.0) && near(ends(&s, made[1]).0, 50.0, 50.0));
}
#[test]
fn compound_path_members_are_trimmed_and_an_emptied_compound_path_goes() {
let mut s = session();
let a = line(&mut s, &[(0.0, 100.0), (300.0, 100.0)], false);
let b = line(&mut s, &[(100.0, 50.0), (100.0, 150.0)], false);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let compound = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
let members = |s: &Session| s.doc().unwrap().doc.node(compound).map(|n| n.children().unwrap().len());
assert_eq!(members(&s), Some(2));
// The members cross each other: a middle stretch splits one member in the compound path.
assert_eq!(s.execute(TRIM, &json!({"at": [100, 75]})).unwrap()["removed"], json!(1));
assert_eq!(members(&s), Some(2));
assert_eq!(s.execute(TRIM, &json!({"at": [100, 125]})).unwrap()["removed"], json!(1));
assert_eq!(members(&s), Some(1));
assert_eq!(s.execute(TRIM, &json!({"at": [200, 100]})).unwrap()["removed"], json!(1));
assert_eq!(members(&s), None, "the emptied compound path is removed");
assert!(stack(&s).is_empty());
}
#[test]
fn the_selection_stays_as_it_was_and_the_pieces_of_a_selected_path_are_selected() {
let mut s = session();
let (_, v1, _) = across_and_uprights(&mut s);
s.execute("select.set", &json!({"ids": [v1.0]})).unwrap();
s.execute(TRIM, &json!({"at": [150, 100]})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, [v1], "the trimmed path was not selected");
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
s.execute("select.set", &json!({"ids": [h.0]})).unwrap();
let made = ids(&s.execute(TRIM, &json!({"at": [150, 100]})).unwrap());
assert_eq!(made.len(), 2);
let sel = &s.doc().unwrap().selection;
assert!(made.iter().all(|id| sel.contains(*id)), "{:?}", sel.objects);
// The second piece now ends on an upright: nothing crosses it, and it goes whole.
s.execute("select.set", &json!({"ids": [made[1].0]})).unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [250, 100]})).unwrap(), json!({"ids": [], "removed": 1}));
assert!(s.doc().unwrap().selection.is_empty(), "a removed path leaves the selection");
}
#[test]
fn many_paths_far_from_the_trimmed_one_cost_no_crossing_search() {
let mut s = session();
let (h, ..) = across_and_uprights(&mut s);
// 3,000 short lines far below, each crossing its neighbor.
s.edit("many", |d, _| {
let layer = d.layers[0].id;
for i in 0..3000 {
let x = (i % 100) as f64 * 3.0;
let y = 1000.0 + (i / 100) as f64 * 3.0;
let sp = vectorcraft_geom::SubPath::polyline(
&[Point::new(x, y), Point::new(x + 5.0, y + 5.0), Point::new(x + 5.0, y), Point::new(x, y + 5.0)],
false,
);
let id = d.alloc_id();
let top = d.node(layer).unwrap().children().unwrap().len();
d.insert(Some(layer), top, vectorcraft_doc::Node::path(id, PathData::single(sp), Default::default()))?;
}
Ok(())
})
.unwrap();
assert_eq!(s.execute(TRIM, &json!({"at": [150, 100]})).unwrap()["removed"], json!(1));
assert!(near(ends(&s, h).1, 100.0, 100.0));
// A rectangle over all of them takes every stretch it touches.
let r = s.execute(TRIM, &json!({"rect": [-10, 990, 400, 200]})).unwrap();
assert!(r["removed"].as_u64().unwrap() >= 3000, "{}", r["removed"]);
assert_eq!(stack(&s).len(), 4, "the line across in two pieces and the uprights");
}
#[test]
fn the_tool_trims_on_release_as_one_step_and_escape_drops_a_drag() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
let mut s = session();
let (h, v1, _) = across_and_uprights(&mut s);
let h2 = line(&mut s, &[(0.0, 120.0), (300.0, 120.0)], false);
let view = crate::ViewInfo { smart_guides: false, ..crate::ViewInfo::default() };
s.select_tool("trimAtCrossings", view).unwrap();
assert_eq!(s.tool_id(), "trimAtCrossings");
let n = steps(&s);
// A drag over the middle of both lines across: nothing changes until the release.
s.pointer(&PointerEvent::new(PointerKind::Down, 140.0, 90.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 160.0, 130.0), view).unwrap();
assert!(near(ends(&s, h).1, 300.0, 100.0));
s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 130.0), view).unwrap();
assert!(near(ends(&s, h).1, 100.0, 100.0) && near(ends(&s, h2).1, 100.0, 120.0));
assert_eq!(steps(&s), n + 1, "one undo step for the drag");
// Escape during a drag: the release removes nothing.
s.pointer(&PointerEvent::new(PointerKind::Down, 90.0, 40.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 60.0), view).unwrap();
s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 60.0), view).unwrap();
assert_eq!(steps(&s), n + 1);
assert!(near(ends(&s, v1).0, 100.0, 50.0));
// A click on the upright's top stretch removes it.
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 70.0), view).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 70.0), view).unwrap();
assert!(near(ends(&s, v1).0, 100.0, 100.0));
assert_eq!(steps(&s), n + 2);
}
#[test]
fn a_line_too_long_to_check_against_the_paths_is_refused() {
let mut s = session();
// A zigzag of 1,000 segments, and a line of 100,000 points inside its box that crosses none.
let zigzag: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64 * 0.3, (i % 2) as f64 * 200.0)).collect();
let z = line(&mut s, &zigzag, false);
let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s));
let points: Vec<Value> = (0..100_000).map(|i| json!([150.0 + (i % 2) as f64 * 0.01, 150.0])).collect();
let err = s.execute(TRIM, &json!({ "points": points })).unwrap_err().to_string();
assert!(err.contains("too long"), "{err}");
assert_eq!(steps(&s), n);
assert!(*s.doc().unwrap().doc == *before);
// A short line there is checked, and removes nothing.
assert_eq!(s.execute(TRIM, &json!({"points": [[150.0, 150.0], [150.01, 150.0]]})).unwrap()["removed"], json!(0));
assert!(exists(&s, z));
}
#[test]
fn bad_params_are_refused_and_change_nothing() {
let mut s = session();
across_and_uprights(&mut s);
let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s));
for bad in [
json!({}),
json!({"at": null}),
json!({"at": [150]}),
json!({"at": [150, "y"]}),
json!({"at": [150, 1e308]}),
json!({"at": [150, 100], "tolerance": -1}),
json!({"at": [150, 100], "tolerance": 101}),
json!({"at": [150, 100], "tolerance": "3"}),
json!({"at": [150, 100], "rect": [140, 90, 20, 40]}),
json!({"rect": [140, 90, 20]}),
json!({"rect": "all"}),
json!({"rect": [0, 0, 1e9, 5]}),
json!({"points": [[150, 80]]}),
json!({"points": [[150, 80], [150]]}),
json!({"points": "x"}),
json!({"points": [[150, 80], [150, 130]], "at": [1, 2]}),
] {
assert!(s.execute(TRIM, &bad).is_err(), "{bad}");
}
assert_eq!(steps(&s), n);
assert!(*s.doc().unwrap().doc == *before);
}
+25 -8
View File
@@ -508,8 +508,9 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and
/// its undo history stay as they are.
/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on
/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and
/// when `f` leaves every target as it was, the document and its undo history stay as they are.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
@@ -519,12 +520,13 @@ pub(crate) fn edit_items(
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false))
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited)))
}
/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns
/// whether it changed the document; when it did not and `f` left every target as it was, the
/// document and its undo history stay as they are.
/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and
/// the ids `f` ran on, inside the same undo step. `after` returns whether it changed the document;
/// when it did not and `f` left every target as it was, the document and its undo history stay as
/// they are.
#[allow(clippy::too_many_arguments)]
pub(crate) fn edit_items_then(
s: &mut Session,
@@ -534,7 +536,7 @@ pub(crate) fn edit_items_then(
label: &str,
fill: bool,
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
) -> Result<()> {
if ids.is_empty() {
return Ok(());
@@ -550,7 +552,7 @@ pub(crate) fn edit_items_then(
f(n, index)?;
edited.push(*id);
}
let more = after(d, &edited)?;
let more = after(&before, d, &edited)?;
Ok(count_changed(d, &before, &edited) + usize::from(more))
})?;
Ok(())
@@ -791,6 +793,19 @@ pub(super) fn capture_series_writes(
kept || painted || stored
}
/// Store on the graph what an edit of `ids` (from `before` to `d`) changed on the series marks among them: the paint,
/// weight, opacity or blend mode of the topmost visible fill and stroke, and the object opacity and blend mode. Rows
/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series,
/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed.
pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool {
// Only objects directly in a series group can be marks: other edits skip the search of the document for graphs.
let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some());
let mut marks: Vec<NodeId> = ids.iter().copied().filter(in_series).collect();
// A later mark's capture replaces an earlier one's: take them in paint order.
marks.sort_by_cached_key(|id| d.index_path(*id));
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
}
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
fn top_row_kind(ap: &Appearance, at: usize) -> Option<bool> {
let fill = ap.items.get(at)?.is_fill();
@@ -1550,6 +1565,7 @@ fn copied_character_styles<'a>(source: &Node, targets: impl Iterator<Item = &'a
local.baseline_shift /= scale.points;
local.tcy_up_down /= scale.points;
local.tcy_left_right /= scale.points;
local.warichu_gap /= scale.points;
local.h_scale /= scale.horizontal;
// As with dimensional type commands, reject overflow and underflow before layout.
if !copied_dimensions_fit(&effective, &local) {
@@ -1568,6 +1584,7 @@ fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool {
(from.baseline_shift, to.baseline_shift),
(from.tcy_up_down, to.tcy_up_down),
(from.tcy_left_right, to.tcy_left_right),
(from.warichu_gap, to.warichu_gap),
(from.h_scale, to.h_scale),
]
.iter()
+23 -6
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Invert Colors",
["Edit", "Edit Colors"],
None,
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}",
has_selection,
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
),
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Front to Back",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}",
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Stack)
),
@@ -473,8 +473,11 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let (n, not_changed) = s.edit(label, |d, _| {
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
let n = keeping_series_paint(d, &ids, |d| {
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
Ok(n)
})?;
Ok((n, not_changed))
})?;
let mut out = json!({ "changed": n });
@@ -482,6 +485,19 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
Ok(out)
}
/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed
/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn
/// again.
pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
let marks = super::graph::series_marks_in(d, roots);
let before = (!marks.is_empty()).then(|| d.clone());
let n = recolor(d)?;
if let Some(before) = before.filter(|_| n > 0) {
super::appearance::capture_series_changes(&before, d, &marks);
}
Ok(n)
}
/// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with
/// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All
/// takes (in isolation mode, those of the isolated group), these are the objects beside them that
@@ -887,7 +903,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
};
let fills = Scope { fill: true, stroke: false, images: false, patterns: false };
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let changed = s.edit(label, |d, _| {
let run = |d: &mut Document| -> Result<usize> {
let mut changed = 0;
for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) {
let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model);
@@ -906,7 +922,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
}
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
Ok(changed)
})?;
};
let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?;
Ok(json!({ "changed": changed }))
}
+136 -8
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 (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds",
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient that color a part of it above opacity 0 (a stop at opacity 0 counts only where it mixes with a stop above opacity 0 over a stretch of some width), at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds",
has_doc,
info
),
@@ -289,10 +289,46 @@ fn spot_swatches(d: &Document) -> HashSet<&str> {
first.into_iter().filter(|&(name, spot)| spot && name != vectorcraft_color::swatch::REGISTRATION).map(|(name, _)| name).collect()
}
/// Which of gradient `g`'s stops color some part of it that draws, by the stretches
/// [`vectorcraft_color::Gradient::sample`] mixes in, for stops in any order: the pair of stop `k`
/// and stop `k + 1` covers the offsets above every offset of stops `0..=k` up to stop `k + 1`'s,
/// and both its stops count when that stretch has positive width and either stop is above opacity
/// 0. The first stop also counts when it is above opacity 0, since it colors the offsets up to its
/// own, and so does the last stop, since it colors the offsets above every stop's. Any other stop,
/// opaque or not, counts only through a pair: a stop out of order whose pairs have no width
/// (another stop before it reaches past both ends) colors nothing. A freeform gradient draws from
/// its points, so each of its stops counts while any stop is above opacity 0.
fn drawn_stops(g: &vectorcraft_color::Gradient) -> Vec<bool> {
let stops = &g.stops;
if g.kind == vectorcraft_color::GradientKind::Freeform {
let any = stops.iter().any(|s| s.opacity > 0.0);
return vec![any; stops.len()];
}
let mut drawn = vec![false; stops.len()];
if let (Some(first), Some(d)) = (stops.first(), drawn.first_mut()) {
*d = first.opacity > 0.0;
}
if let (Some(last), Some(d)) = (stops.last(), drawn.last_mut()) {
*d |= last.opacity > 0.0;
}
// `f32::max` skips a NaN offset, which the sampler never picks either.
let mut reach = f32::NEG_INFINITY;
for k in 0..stops.len().saturating_sub(1) {
reach = reach.max(stops[k].offset);
let (a, b) = (&stops[k], &stops[k + 1]);
if b.offset > reach && (a.opacity > 0.0 || b.opacity > 0.0) {
drawn[k] = true;
drawn[k + 1] = true;
}
}
drawn
}
/// The spot colors paint `p` prints (`spots`: [`spot_swatches`]): the spot swatch a solid color is
/// linked to, or those a gradient's stops are linked to, each once, in the order of the stops. A
/// link at a tint of 0 prints no ink, a gradient whose stops are all at an opacity of 0 draws
/// nothing, and a color that only looks like a spot color is not linked to one.
/// linked to, or those a gradient's drawn stops ([`drawn_stops`]) are linked to, each once, in the
/// order of the stops. A link at a tint of 0 prints no ink, a stop that colors only parts of a
/// gradient at an opacity of 0 prints none, and a color that only looks like a spot color is not
/// linked to one.
fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
let mut out: Vec<String> = vec![];
let mut add = |swatch: &Option<String>, tint: f32| {
@@ -306,7 +342,10 @@ fn spot_labels(spots: &HashSet<&str>, p: &Paint) -> Vec<String> {
};
match p {
Paint::Solid { swatch, tint, .. } => add(swatch, *tint),
Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity > 0.0) => g.gradient.stops.iter().for_each(|s| add(&s.swatch, s.tint)),
Paint::Gradient(g) => {
let drawn = drawn_stops(&g.gradient);
g.gradient.stops.iter().zip(drawn).filter(|&(_, d)| d).for_each(|(s, _)| add(&s.swatch, s.tint));
}
_ => {}
}
out
@@ -671,7 +710,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style))));
}
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type
// as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them.
let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect();
@@ -948,7 +987,7 @@ mod tests {
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None;
assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([]));
// Two panel brushes with one name: the type is the one the renderer draws with, the last one's.
// Two panel brushes with one name: the type is the one the renderer draws with, the first one's.
let twin = square(&mut s, 140.0);
s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap();
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
@@ -958,7 +997,7 @@ mod tests {
lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind }));
vectorcraft_brush::store(d, &lib);
d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into());
assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}]));
assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "art"}]));
// Type: a brushed stroke on drawn characters is listed, one on spaces alone is not.
let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap();
let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
@@ -1493,6 +1532,95 @@ mod tests {
assert_eq!(spots(&mut s), (vec![], None, None));
}
/// Spot Color Objects leaves out a spot color linked only from gradient stops at an opacity of 0
/// whose stretches all lead to other stops at an opacity of 0 or have no width, while another
/// part of the gradient draws: an inner stop, an end stop and a stop at the offset of an opaque
/// stop. A clear stop next to an opaque stop tints the stretch between them, so it stays listed.
/// Type whose characters draw such a gradient follows the same rule, with and without Selection
/// Only, and the report writes the same rows.
#[test]
fn spot_color_objects_skip_spot_stops_only_in_clear_stretches() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let ink = |c: f64| json!({"model": "cmyk", "c": c, "m": 0.5, "y": 0.0, "k": 0.0});
for (k, name) in ["Ink", "Near", "Far", "Edge", "Seam"].into_iter().enumerate() {
s.execute("swatch.new", &json!({"name": name, "color": ink(0.2 * k as f64), "spot": true})).unwrap();
}
let stop = |offset: f64, swatch: Option<&str>, opacity: f64| match swatch {
Some(sw) => json!({"offset": offset, "color": "#000", "swatch": sw, "opacity": opacity}),
None => json!({"offset": offset, "color": "#fff", "opacity": opacity}),
};
let fill = |s: &mut Session, id: &Value, stops: Value| {
s.execute("paint.setFill", &json!({"ids": [id], "gradient": {"kind": "linear", "stops": stops}})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
};
let square =
|s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
// Near borders the opaque Ink stop; Far sits between clear stops; Edge ends the ramp after a
// clear stop.
let ramp = square(&mut s, 0.0);
fill(
&mut s,
&ramp,
json!([stop(0.0, Some("Ink"), 1.0), stop(0.4, Some("Near"), 0.0), stop(0.6, Some("Far"), 0.0), stop(1.0, Some("Edge"), 0.0)]),
);
s.execute("layer.setProps", &json!({"id": ramp, "name": "Ramp"})).unwrap();
// Seam meets the opaque half only at one offset, where nothing between them has width.
let seam = square(&mut s, 20.0);
fill(&mut s, &seam, json!([stop(0.0, None, 0.0), stop(0.5, Some("Seam"), 0.0), stop(0.5, None, 1.0), stop(1.0, None, 1.0)]));
let t = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].clone();
fill(&mut s, &t, json!([stop(0.0, Some("Ink"), 1.0), stop(0.5, None, 0.0), stop(1.0, Some("Far"), 0.0)]));
s.execute("layer.setProps", &json!({"id": t, "name": "Type"})).unwrap();
let check = |s: &mut Session, params: Value| {
let i = s.execute("document.info", &params).unwrap();
let listed: Vec<(String, Value)> =
i["spotObjects"].as_array().unwrap().iter().map(|o| (o["name"].as_str().unwrap().to_string(), o["spotColors"].clone())).collect();
assert_eq!(listed, vec![("Ramp".to_string(), json!(["Ink", "Near"])), ("Type".to_string(), json!(["Ink"]))], "{params}");
assert_eq!((i["spotColorNames"].clone(), i["objects"]["spots"].as_u64()), (json!(["Ink", "Near"]), Some(2)), "{params}");
let rows = i["sections"].as_array().unwrap().iter().find(|c| c["id"] == "spotColors").unwrap()["rows"].clone();
assert_eq!(rows, json!([["Ramp", "Ink, Near"], ["Type", "Ink"]]), "{params}");
};
check(&mut s, json!({}));
s.execute("select.set", &json!({"ids": [ramp, seam, t]})).unwrap();
check(&mut s, json!({"selectionOnly": true}));
let text = s.execute("document.info", &json!({"category": "spotColors", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.contains("\nSPOT COLOR OBJECTS\nRamp: Ink, Near\nType: Ink\n"), "{text}");
assert!(!["Far", "Edge", "Seam"].iter().any(|n| text.contains(n)), "{text}");
}
/// A gradient's stops print where they color a stretch that draws, for stops in any order: the
/// sampler mixes stop `k` with stop `k + 1` above every offset of the stops before it, so an
/// out-of-order stop's pair can have no width.
#[test]
fn drawn_stops_follow_the_sampled_stretches() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientStop};
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, Color::BLACK) };
let g = |stops: Vec<GradientStop>| Gradient::new(GradientKind::Linear, stops);
// In order: the clear stop next to the opaque one, not the one after it.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)])), vec![true, true, false]);
// Out of order: the pair (0.8, 0.3) has no width, so the clear stop at 0.3 mixes only with
// the clear stop at 0.8 before it, and the pair (0.3, 1.0) is sampled above 0.8.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.8, 0.0), stop(0.3, 0.0), stop(1.0, 1.0)])), vec![false, false, true, true]);
// An opaque stop out of order: the pairs (0, 0.8) and (0.7, 1) cover every offset, so the
// pairs next to the stop at 0.3 have no width and it colors nothing.
let out_of_order = |o: f32| g(vec![stop(0.0, 0.0), stop(0.8, o), stop(0.3, 1.0), stop(0.7, 0.0), stop(1.0, 0.0)]);
assert_eq!(drawn_stops(&out_of_order(0.0)), vec![false; 5]);
// With the stop at 0.8 opaque, the pairs next to it with width are drawn.
assert_eq!(drawn_stops(&out_of_order(1.0)), vec![true, true, false, false, false]);
// The last stop colors the offsets above every stop's, even when it is out of order.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(0.6, 0.0), stop(0.5, 1.0)])), vec![false, false, true]);
// The first stop colors the offsets up to its own.
assert_eq!(drawn_stops(&g(vec![stop(0.0, 1.0), stop(0.0, 0.0), stop(1.0, 0.0)])), vec![true, false, false]);
// One stop, every stop clear, and no stops at all.
assert_eq!(drawn_stops(&g(vec![stop(0.5, 1.0)])), vec![true]);
assert_eq!(drawn_stops(&g(vec![stop(0.0, 0.0), stop(1.0, 0.0)])), vec![false, false]);
assert!(drawn_stops(&g(vec![])).is_empty());
// A freeform gradient draws from its points: its stops count while any is above opacity 0.
let mut free = g(vec![stop(0.0, 1.0), stop(0.5, 0.0), stop(1.0, 0.0)]);
free.kind = GradientKind::Freeform;
assert_eq!(drawn_stops(&free), vec![true, true, true]);
}
/// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object,
/// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of
/// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no
+228 -7
View File
@@ -2,7 +2,7 @@ use vectorcraft_pathops::addons::pathtools as pt;
use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, subpath_len, target_paths};
use super::{
Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json,
Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Rect, Result, Session, SimplifyOptions, SubPath, Value, json,
number_or, po,
};
@@ -37,10 +37,192 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64) -> Vec<Anchor> {
out
}
fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64) -> Option<SubPath> {
/// Pieces of curve that one Smooth drag measures against the drag while it searches the segments
/// under it ([`reach_samples`]), shared equally by the curved segments that come within its reach,
/// each of which gets at least one: enough to search 300,000 pt of curve within the default reach
/// of the drag.
const MAX_REACH_PIECES: usize = 1 << 17;
/// The box around the control points of `s`, which holds all of `s`.
fn control_box(s: CubicBez) -> Rect {
Rect::from_points(s.p0, s.p1).union_pt(s.p2).union_pt(s.p3)
}
/// The stretches of the drag `pts` (stretch `k` runs from `pts[k]` to the next point; a drag of one
/// point has the point as its only stretch).
fn drag_stretches(pts: &[Point]) -> Vec<usize> {
(0..pts.len().saturating_sub(1).max(1)).collect()
}
/// The stretches of the drag `pts` among `of` whose bounds come within `radius` of `r` on both
/// axes: every stretch within `radius` of a point in `r`, and perhaps a few more.
fn stretches_near(pts: &[Point], of: &[usize], r: Rect, radius: f64) -> Vec<usize> {
let r = r.inflate(radius, radius);
of.iter()
.copied()
.filter(|&k| {
let Some(&a) = pts.get(k) else { return false };
let b = pts.get(k + 1).copied().unwrap_or(a);
a.x.min(b.x) <= r.x1 && a.x.max(b.x) >= r.x0 && a.y.min(b.y) <= r.y1 && a.y.max(b.y) >= r.y0
})
.collect()
}
/// The distance from `q` to the nearest of the stretches `of` of the drag `pts`.
fn dist_to_stretches(pts: &[Point], of: &[usize], q: Point) -> f64 {
of.iter()
.filter_map(|&k| {
let a = *pts.get(k)?;
Some(dist_to_polyline(&[a, pts.get(k + 1).copied().unwrap_or(a)], q))
})
.fold(f64::INFINITY, f64::min)
}
/// The place on `s` (0 to 1) nearest to the stretches `of` of the drag `pts`, and its distance from
/// them. For each stretch the nearest place is at one of the stretch's ends, at an end of `s`, or
/// where `s` crosses the stretch's line or stops moving toward or away from it.
fn nearest_to_stretches(s: CubicBez, pts: &[Point], of: &[usize]) -> (f64, f64) {
let (c1, c2, c3) = (3.0 * (s.p1 - s.p0), 3.0 * ((s.p2 - s.p1) - (s.p1 - s.p0)), (s.p3 - s.p0) - 3.0 * (s.p2 - s.p1));
let mut best = (0.5, f64::INFINITY);
for &k in of {
let Some(&a) = pts.get(k) else { continue };
let b = pts.get(k + 1).copied().unwrap_or(a);
let mut places: Vec<f64> = vec![0.0, 1.0, s.nearest(a, 1e-9).t, s.nearest(b, 1e-9).t];
let d = b - a;
if d.hypot() > 1e-12 {
let (f0, f1, f2, f3) = (d.cross(s.p0 - a), d.cross(c1), d.cross(c2), d.cross(c3));
places.extend(kurbo::common::solve_cubic(f0, f1, f2, f3));
places.extend(kurbo::common::solve_quadratic(f1, 2.0 * f2, 3.0 * f3));
}
for u in places.into_iter().filter(|u| (0.0..=1.0).contains(u)) {
let dist = dist_to_polyline(&[a, b], s.eval(u));
if dist < best.1 {
best = (u, dist);
}
}
}
best
}
/// How many curved segments of `pd` may come within `radius` of the drag `pts`.
fn segments_in_reach(pd: &PathData, pts: &[Point], radius: f64) -> usize {
let all = drag_stretches(pts);
pd.subpaths
.iter()
.map(|sp| {
(0..sp.segment_count())
.filter(|&i| {
!vectorcraft_tools::draw2::segment_is_straight(sp, i) && !stretches_near(pts, &all, control_box(sp.segment(i)), radius).is_empty()
})
.count()
})
.sum()
}
/// Samples along the part of `c` from `t0` to `t1`, in order: (t, whether the point there is within
/// `radius` of the stretches `of` of the drag `pts`). A piece no stretch comes near gives one sample
/// out of reach; the others are halved until their control polygon is no longer than half the
/// reach, or until `budget` pieces have been measured, and then give one sample: in reach at the
/// piece's middle or at its place nearest the drag when either is in reach, otherwise out of reach
/// at its middle. Each piece measures only the stretches near
/// the piece it was cut from, so a long drag along a long curve costs about one pass over the drag
/// per halving, plus the stretches near each piece.
fn reach_samples(c: CubicBez, (t0, t1): (f64, f64), (pts, of): (&[Point], &[usize]), radius: f64, budget: &mut usize, out: &mut Vec<(f64, bool)>) {
*budget = budget.saturating_sub(1);
let s = c.subsegment(t0..t1);
let mid = 0.5 * (t0 + t1);
let near = stretches_near(pts, of, control_box(s), radius);
if near.is_empty() {
out.push((mid, false));
return;
}
let len = s.p0.distance(s.p1) + s.p1.distance(s.p2) + s.p2.distance(s.p3);
if len <= (radius / 2.0).max(1e-3) || *budget == 0 || mid <= t0 || mid >= t1 {
// The piece's middle, or the place on it nearest the drag when the middle is out of reach.
let sample = if dist_to_stretches(pts, &near, c.eval(mid)) <= radius {
(mid, true)
} else {
let (u, dist) = nearest_to_stretches(s, pts, &near);
if dist <= radius { (t0 + u * (t1 - t0), true) } else { (mid, false) }
};
out.push(sample);
return;
}
reach_samples(c, (t0, mid), (pts, &near), radius, budget, out);
reach_samples(c, (mid, t1), (pts, &near), radius, budget, out);
}
/// `sp` cut where the reach `radius` of the drag `pts` crosses its curved segments, and whether each
/// anchor of the result is in reach. An anchor in reach is added in the middle of each stretch of a
/// segment in reach that does not run on from one of the segment's anchors in reach, and an anchor
/// out of reach in the middle of each stretch out of reach between two stretches in reach, so that a
/// stretch in reach is smoothed even when the segment's anchors are not, and the stretches between
/// stay as they are. A segment is searched no more than half the reach apart where it comes within
/// the reach of the drag ([`reach_samples`]), measuring up to `share` pieces of each segment.
fn mark_reached(sp: &SubPath, pts: &[Point], radius: f64, share: usize) -> (SubPath, Vec<bool>) {
let near = |q: Point| dist_to_polyline(pts, q) <= radius;
let n = sp.anchors.len();
let marked: Vec<bool> = sp.anchors.iter().map(|a| near(a.p)).collect();
if n < 3 || !marked.iter().any(|m| *m) {
let mut places = vec![];
let all = drag_stretches(pts);
for i in 0..sp.segment_count() {
if vectorcraft_tools::draw2::segment_is_straight(sp, i) {
continue;
}
let c = sp.segment(i);
let (mut samples, mut budget) = (vec![], share);
reach_samples(c, (0.0, 1.0), (pts, &all), radius, &mut budget, &mut samples);
// Stretches of samples alike, in order: (in reach, first, last).
let mut runs: Vec<(bool, usize, usize)> = vec![];
for (k, s) in samples.iter().enumerate() {
match runs.last_mut() {
Some(r) if r.0 == s.1 => r.2 = k,
_ => runs.push((s.1, k, k)),
}
}
let last = samples.len().saturating_sub(1);
let (start, end) = (marked[i], marked[(i + 1) % n]);
for (j, &(reached, first, to)) in runs.iter().enumerate() {
let mid = samples[(first + to) / 2].0;
let joined = (first == 0 && start) || (to == last && end);
if reached && !joined {
places.push((i, mid));
}
// A gap with reach on both sides: the segment's anchor or a stretch on each side.
let before = if j == 0 { start } else { runs[j - 1].0 };
let after = runs.get(j + 1).map_or(end, |r| r.0);
if !reached && before && after {
places.push((i, mid));
}
}
}
// A place on one of the segment's ends would name that anchor rather than add one.
places.retain(|(_, t)| (1e-5..=1.0 - 1e-5).contains(t));
if places.is_empty() {
return (sp.clone(), marked);
}
let (out, added) = pt::insert_at(sp, &places);
// The anchors already there keep their marks. A new anchor split off at a sample in reach can
// land a rounding error farther away than the sample.
let mut kept = marked.into_iter();
let marked = out
.anchors
.iter()
.enumerate()
.map(|(j, a)| if added.contains(&j) { dist_to_polyline(pts, a.p) <= radius + 1e-9 * radius.max(1.0) } else { kept.next().unwrap_or(false) })
.collect();
(out, marked)
}
fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f64, sigma: f64) -> Option<SubPath> {
if sp.anchors.len() < 2 {
return None;
}
let near = &|q: Point| dist_to_polyline(pts, q) <= radius;
// A drag along a segment smooths it even when its anchors are out of reach.
let (sp, marked) = mark_reached(sp, pts, radius, share);
let sp = &sp;
if !marked.iter().any(|m| *m) {
return None;
}
if sp.closed {
@@ -115,7 +297,7 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
for (a, b) in runs.into_iter().rev() {
let lo = a.saturating_sub(1);
let hi = (b + 1).min(n - 1);
if hi - lo < 2 {
if hi == lo {
continue;
}
let mut piece = out[lo..=hi].to_vec();
@@ -150,16 +332,21 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
let sigma = radius * SMOOTH_SPREAD;
let st = s.doc()?;
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
let mut plan = vec![];
let (mut before, mut after) = (0, 0);
let mut paths = vec![];
for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, radius)) {
let (pd, _) = path_of(&st.doc, id)?;
paths.push((id, path_of(&st.doc, id)?.0));
}
// Each curved segment in reach gets the same share of the search, whatever order they come in.
let in_reach: usize = paths.iter().map(|(_, pd)| segments_in_reach(pd, &pts, radius)).sum();
let share = (MAX_REACH_PIECES / in_reach.max(1)).max(1);
for (id, pd) in paths {
let mut changed = false;
let subs: Vec<SubPath> = pd
.subpaths
.iter()
.map(|sp| match smooth_subpath(sp, &near, tol, sigma) {
.map(|sp| match smooth_subpath(sp, &pts, radius, share, tol, sigma) {
Some(n) if n != *sp && n.anchors.len() >= 2 => {
changed = true;
n
@@ -414,3 +601,37 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "id": ida.0 }))
}
#[cfg(test)]
mod tests {
use super::*;
/// Searching a curve 1,000,000 pt long under a drag of 1,000 points along it measures no more
/// pieces than the budget it is given, and a search with none left gives one sample.
#[test]
fn searching_a_segment_under_a_drag_stays_within_its_budget() {
let c = CubicBez::new(Point::new(0.0, 0.0), Point::new(300000.0, 0.0), Point::new(700000.0, 0.0), Point::new(1000000.0, 0.0));
let pts: Vec<Point> = (0..1000).map(|i| Point::new(f64::from(i) * 1000.0, 0.0)).collect();
let all = drag_stretches(&pts);
let mut budget = MAX_REACH_PIECES;
let (mut first, mut second) = (vec![], vec![]);
reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut first);
reach_samples(c, (0.0, 1.0), (&pts, &all), 12.0, &mut budget, &mut second);
assert!(first.len() <= MAX_REACH_PIECES, "{} samples", first.len());
assert_eq!((budget, second.len()), (0, 1), "the budget is spent, so the next search gives one sample");
}
/// A small piece of curve is measured only against the few stretches of a drag of 100,000
/// points that come near it, and a drag of one point has that point as its stretch.
#[test]
fn a_small_piece_is_measured_against_the_stretches_near_it() {
let pts: Vec<Point> = (0..100_000).map(|i| Point::new(f64::from(i) * 5.0, 0.0)).collect();
let all = drag_stretches(&pts);
let near = stretches_near(&pts, &all, Rect::new(1000.0, 3.0, 1004.0, 6.0), 12.0);
assert_eq!(near, (197..=203).collect::<Vec<_>>());
assert!((dist_to_stretches(&pts, &near, Point::new(1002.0, 5.0)) - 5.0).abs() < 1e-12);
let one = [Point::new(10.0, 10.0)];
assert_eq!(drag_stretches(&one), vec![0]);
assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12);
}
}
+1 -1
View File
@@ -133,7 +133,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
name: "outlineText",
ty: "boolean",
default: "false",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, ruby, warichu, inline graphics, gradient or pattern fills) and type with rotated characters are always outlines",
},
];
+1 -33
View File
@@ -1136,37 +1136,6 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
}
}
/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols'
/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the
/// art of another symbol takes one more round, so there is at most one round per symbol.
fn resolve_art_inline(doc: &mut Document) {
for _ in 0..=doc.symbols.len() {
let names: BTreeSet<String> =
art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect();
let bounds: BTreeMap<String, Option<Rect>> = names
.into_iter()
.map(|name| {
let b = doc.symbol_art_bounds(&name);
(name, b)
})
.collect();
let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds)));
let mut changed = false;
edit_art_texts_of(doc, &stale, &mut |t| {
for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) {
if let Some(b) = bounds.get(&a.symbol) {
a.bounds = *b;
}
}
refresh_bounds(t);
changed = true;
});
if !changed {
break;
}
}
}
pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
let db = vectorcraft_text::FontDb::global();
#[cfg(not(target_arch = "wasm32"))]
@@ -1188,8 +1157,7 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
d.change_unsaved(|doc| {
let doc = std::sync::Arc::make_mut(doc);
edit_art_texts_of(doc, &|_| true, &mut refresh_bounds);
resolve_art_inline(doc);
doc.resolve_inline_art();
doc.resolve_inline_art_with(&mut refresh_bounds);
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
+1 -1
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}",
has_doc,
add_point
),
+2 -2
View File
@@ -24,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|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; 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 (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), 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|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; 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 (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), 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. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)",
has_doc,
edit_gradient
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient Vector",
[],
None,
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)",
"{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)",
has_doc,
set_gradient_geom
),
+2 -2
View File
@@ -46,8 +46,8 @@ use generate::generate;
use series::default_series_color;
pub(crate) use series::{
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_object_opacity_differs,
shown_paint,
capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_marks_in,
series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+19
View File
@@ -103,6 +103,25 @@ fn series_members(doc: &Document) -> HashMap<NodeId, SeriesRef> {
out
}
/// The objects directly in a series group among `roots` and inside them, for an edit that reaches graph series marks
/// without naming them. They may include objects of groups that only look like series groups; the capture functions
/// check each one.
pub(crate) fn series_marks_in(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = Vec::new();
for id in roots {
if doc.parent_of(*id).and_then(|p| doc.node(p)).is_some_and(|g| g.extras().series_index.is_some()) {
out.push(*id);
}
let Some(root) = doc.node(*id) else { continue };
root.walk(&mut |n| {
if n.extras().series_index.is_some() {
out.extend(n.children().into_iter().flatten().map(|c| c.id));
}
});
}
out
}
/// Group Selection on a graph series targets its constituent marks and legend swatch, not a
/// group-level appearance which the generated art does not inherit. Other ids pass through.
pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
+202
View File
@@ -2209,6 +2209,208 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() {
assert_eq!(spec(&s, id).series_paints[0].fill, red);
}
/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one.
fn every_case<T: std::fmt::Debug>(cases: impl IntoIterator<Item = T>, case: impl Fn(&T)) {
let failed: Vec<T> = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect();
assert!(failed.is_empty(), "failed cases: {failed:?}");
}
/// The top fill paint of `mark`.
fn fill_of(s: &Session, mark: NodeId) -> Paint {
s.doc().unwrap().doc.node(mark).unwrap().appearance.fill_paint()
}
/// A stroke weight set with `stroke.set`, or stepped with `stroke.weightUp` or `stroke.weightDown`, on a series mark is
/// stored on the graph with the mark's stroke paint, and undo takes it back with its edit.
#[test]
fn a_stroke_weight_set_on_a_series_mark_is_kept() {
let cases = [("stroke.set", json!({"weight": 7}), 7.0), ("stroke.weightUp", json!({}), 3.0), ("stroke.weightDown", json!({}), 1.0)];
every_case(cases, |(cmd, params, weight)| {
let (mut s, id, marks) = stored_red_stroke();
let was = spec(&s, id).series_paints;
let mut p = params.clone();
p["ids"] = json!([marks[1].0]);
s.execute(cmd, &p).unwrap();
let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone();
let (fill, stroke) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint());
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(stroke.clone()), Some(*weight)), "{cmd}");
series_0_is(&mut s, id, (&fill, 0.4, &stroke, *weight), &was);
});
}
/// A gradient made on a series mark's fill with `paint.editGradient` is stored on the graph, every mark of the series has
/// that fill after the graph is drawn again, and undo takes it back with its edit.
#[test]
fn a_gradient_edit_on_a_series_mark_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
let was = spec(&s, id).series_paints;
s.execute("paint.editGradient", &json!({"ids": [marks[0].0], "kind": "linear"})).unwrap();
let fill = fill_of(&s, marks[0]);
assert!(matches!(fill, Paint::Gradient(_)), "{fill:?}");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
let red = was[0].stroke.clone().unwrap();
series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was);
}
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and
/// every mark of the series has that fill, placement included, after the graph is drawn again.
#[test]
fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
let (mut s, id, _, marks) = column_marks();
// `paint.setFill` stores the gradient before it is placed.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
let fill = fill_of(&s, marks[0]);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let placed = fill_of(&s, marks[0]);
assert_ne!(placed, fill, "the command places the gradient");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
}
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph.
#[test]
fn a_freeform_gradient_edit_on_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]);
// `paint.setFill` stores the gradient before its point is recolored.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
let fill = fill_of(&s, marks[0]);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap();
let edited = fill_of(&s, marks[0]);
assert_ne!(edited, fill, "the command recolors the point");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0));
}
/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command,
/// a color filter or Recolor Artwork is stored on the graph, and undo takes it back with its edit.
#[test]
fn a_recolored_series_mark_is_kept() {
let cases = [
("paint.invert", json!({}), false),
("paint.complement", json!({}), false),
("paint.invert", json!({}), true),
("paint.complement", json!({}), true),
("edit.colors.invert", json!({}), false),
("edit.colors.toGrayscale", json!({}), false),
("addon.color.filter", json!({"effect": "addon.color.invert"}), false),
("recolor.apply", json!({"map": {"#3366cc": "#00ff00"}}), false),
];
every_case(cases, |(cmd, params, item)| {
let (mut s, id, marks) = stored_red_stroke();
// Not the gray the series starts in: Complement leaves a gray as it is.
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#3366cc"})).unwrap();
let was = spec(&s, id).series_paints;
let mut p = params.clone();
if *cmd == "recolor.apply" {
// Recolor Artwork works on the selection.
s.execute("select.set", &json!({"ids": [marks[0].0]})).unwrap();
} else {
p["ids"] = json!([marks[0].0]);
}
if *item {
p["item"] = json!(top_row(&s, marks[0], true));
}
let was_fill = fill_of(&s, marks[0]);
s.execute(cmd, &p).unwrap();
let fill = fill_of(&s, marks[0]);
assert_ne!(fill, was_fill, "{cmd} recolors the fill");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()), "{cmd} item {item}");
let red = was[0].stroke.clone().unwrap();
series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was);
});
}
/// The ink limit of Print Ink Fixes on a series mark's fill is stored on the graph.
#[test]
fn an_ink_limit_on_a_series_mark_is_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap();
let id = graph(&mut s, "column");
let mark = series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap()[0].id;
// `paint.setFill` stores the heavy color before the limit lowers it.
s.execute("paint.setFill", &json!({"ids": [mark.0], "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap();
let heavy = fill_of(&s, mark);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(heavy.clone()));
s.execute("addon.color.printFixes", &json!({"ids": [mark.0], "inkLimit": 300})).unwrap();
let fill = fill_of(&s, mark);
assert_ne!(fill, heavy, "the limit lowers the ink");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0));
}
/// A recolor of a whole graph, or of one series group, stores the new fill of each series it reaches, and the marks have
/// it after the graph is drawn again.
#[test]
fn recoloring_a_graph_or_a_series_group_stores_its_marks_fills() {
let cases = [("edit.colors.invert", true), ("edit.colors.invert", false), ("paint.invert", true), ("paint.invert", false)];
every_case(cases, |(cmd, whole)| {
let (mut s, id, gray, _) = column_marks();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (group, other) = (series(&n, 0).id, series(&n, 1).children().unwrap()[0].clone());
let target = if *whole { id } else { group };
s.execute(cmd, &json!({"ids": [target.0]})).unwrap();
let fill = marks_of(&s, id, 0).last().unwrap().0.clone();
assert_ne!(fill, gray, "{cmd} recolors the series");
let kept = spec(&s, id).series_paints;
assert_eq!(kept[0].fill, Some(fill.clone()), "{cmd} whole {whole}");
let other_now = fill_of(&s, other.id);
if *whole {
assert_ne!(other_now, other.appearance.fill_paint());
assert_eq!(kept[1].fill, Some(other_now.clone()));
} else {
assert_eq!(other_now, other.appearance.fill_paint(), "the other series is left as it was");
assert!(kept.get(1).is_none_or(|p| p.fill.is_none()), "{kept:?}");
}
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0));
assert!(marks_of(&s, id, 1).iter().all(|m| m.0 == other_now), "{:?}", marks_of(&s, id, 1));
});
}
/// A recolor that changes several marks of one series to different fills stores the frontmost mark's fill, whatever
/// order the ids are given in.
#[test]
fn recoloring_several_marks_of_a_series_stores_the_frontmost_ones_fill() {
let cases =
[("edit.colors.invert", false), ("paint.invert", true)].into_iter().flat_map(|c| [[0, 1, 2], [2, 1, 0], [1, 2, 0]].map(|o| (c.0, c.1, o)));
every_case(cases, |(cmd, item, order)| {
let (mut s, id, _, marks) = column_marks();
for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
}
let mut p = json!({"ids": order.map(|i| marks[i].0)});
if *item {
p["item"] = json!(top_row(&s, marks[0], true));
}
s.execute(cmd, &p).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill_of(&s, marks[2])), "{cmd} {order:?}");
});
}
/// Blend Horizontally across a series' three marks stores the fill it gives the middle one on the graph.
#[test]
fn blending_a_series_marks_fills_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
for (mark, hex) in [(marks[0], "#ff0000"), (marks[2], "#0000ff")] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
}
let was = spec(&s, id).series_paints;
s.execute("select.set", &json!({"ids": [marks[0].0, marks[1].0, marks[2].0]})).unwrap();
s.execute("edit.colors.blendHorizontally", &json!({})).unwrap();
let mid = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint();
assert_ne!(Some(&mid), was[0].fill.as_ref());
assert_eq!(spec(&s, id).series_paints[0].fill, Some(mid.clone()));
let red = was[0].stroke.clone().unwrap();
series_0_is(&mut s, id, (&mid, 0.4, &red, 2.0), &was);
}
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
#[test]
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
+5 -7
View File
@@ -22,16 +22,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Inline graphics follow their symbols: after an edit of a document that has (or had) symbols,
/// resolve the inline art of every text again (only texts whose art changed are touched).
/// resolve the inline art of every text again, in the layers and in the art of symbols, graph
/// designs, pattern tiles and opacity masks, and lay out again the texts whose art changed (a
/// symbol's type before the type that shows that symbol inline).
pub(crate) fn refresh(before: &Document, d: &mut Document) {
if before.symbols.is_empty() && d.symbols.is_empty() {
return;
}
for id in d.resolve_inline_art() {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
}
}
d.resolve_inline_art_with(&mut refresh_bounds);
}
fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
@@ -85,7 +83,7 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result<Value> {
}
let caret = s.edit("Insert Inline Symbol", |d, _| {
let mut art = InlineArt { scale, baseline_shift: shift, ..InlineArt::new(symbol.clone()) };
art.bounds = d.symbol_art_bounds(&symbol);
art.bounds = d.inline_art_bounds(&symbol);
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let style = edit::insertion_style(&t.runs, a, b);
let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]);
+2 -2
View File
@@ -88,7 +88,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"Invert",
[],
None,
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}",
"{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -583,7 +583,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
}
Ok(())
},
|d, changed| {
|_, d, changed| {
let series = super::graph::capture_series_paints(d, changed, fill);
let labels = super::graph::capture_label_styles(d, changed, &|st| {
if fill {
File diff suppressed because one or more lines are too long
+7
View File
@@ -172,6 +172,13 @@ fn accepted(doc: &'static str, depth: usize) -> Option<Vec<&'static str>> {
Some(keys)
}
/// Every key the description `doc` takes, those of the commands its `…` entries name included, or
/// `None` when that isn't known: it doesn't parse, a `…` entry names no command, or one names a
/// command in [`UNCHECKED`].
pub(crate) fn accepted_keys(doc: &'static str) -> Option<Vec<&'static str>> {
accepted(doc, 0)
}
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
/// its `…` entries name)?
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
+116 -27
View File
@@ -150,28 +150,47 @@ pub fn location_keys(spec: &CommandSpec) -> Vec<(&'static str, Location)> {
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.
/// Commands and the param of theirs, a key in [`FILE_KEYS`], that names part of the document (a path
/// of its art, a setting or an attribute it stores), not a file. Each names a param the command
/// takes ([`takes_param`]).
pub(crate) const NOT_FILES: &[(&str, &str)] = &[
("object.setLiveShape", "path"),
("path.reverse", "paths"),
("object.pathfinder.options", "paths"),
("object.maskImage", "path"),
("text.createInPath", "path"),
("imageTrace.make", "paths"),
("symbol.instance.setAppearance", "path"),
("symbol.instance.selectPart", "path"),
("object.objectsOnPath.attach", "path"),
("attributes.set", "url"),
("svgInteractivity.info", "files"),
("addon.cleanup.rejoin", "paths"),
("object.slice.options", "url"),
("document.exportForScreens", "folders"),
];
/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there,
/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`.
const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])];
/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`, and the
/// path fidelity setting (0 to 100) of Image Trace's tracing settings. The first key of each is a
/// param the command takes ([`takes_param`]).
const NESTED_NOT_FILES: &[(&str, &[&str])] = &[
("stroke.set", &["startArrow", "Custom", "path"]),
("stroke.set", &["endArrow", "Custom", "path"]),
("imageTrace.make", &["params", "paths"]),
("imageTrace.make", &["savedPreset", "paths"]),
("imageTrace.makeAndExpand", &["params", "paths"]),
("imageTrace.makeAndExpand", &["savedPreset", "paths"]),
];
/// Does `spec` take `key` as a param: its params description names it as one of its keys, or one
/// of the commands its `…` entries name does ([`super::params::accepted_keys`]), or its schema has
/// it? When the description doesn't say which keys it takes, any `key:` in its text counts
/// ([`names_key`]). A word in its prose, in a nested object or in its result isn't a param.
fn takes_param(spec: &CommandSpec, key: &str) -> bool {
spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == key)) || described_param(spec.params, key)
}
/// [`takes_param`] for the params description `doc` alone.
fn described_param(doc: &'static str, key: &str) -> bool {
match super::params::accepted_keys(doc) {
Some(keys) => keys.contains(&key),
None => names_key(doc, key) || super::params::takes_key(doc, key),
}
}
/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`],
/// [`FILE_WORDS`])?
@@ -195,16 +214,14 @@ pub(crate) fn denied(spec: &CommandSpec) -> Option<&'static str> {
None
}
/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a key in
/// [`FILE_KEYS`] that [`NOT_FILES`] doesn't name for it), or `None`.
/// Why a plug-in may never call `spec` from its id ([`denied_id`]) or its params (a param in
/// [`FILE_KEYS`], [`takes_param`], that [`NOT_FILES`] doesn't name for it), or `None`.
fn denied_by_rule(spec: &CommandSpec) -> Option<&'static str> {
let id = spec.id;
if denied_id(id) {
return Some("id");
}
let mut keys = FILE_KEYS.iter().filter(|k| !NOT_FILES.contains(&(id, **k)));
let in_schema = |k: &str| spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == k));
if keys.any(|k| names_key(spec.params, k) || in_schema(k)) {
if FILE_KEYS.iter().any(|k| !NOT_FILES.contains(&(id, *k)) && takes_param(spec, k)) {
return Some("file key");
}
None
@@ -498,9 +515,10 @@ impl Saved {
mod tests {
use super::*;
/// Every command in the registry is sorted: the ones a plug-in may call name no file, folder,
/// clipboard, preference or script in their params text, and the known file, document, script
/// and history commands are refused.
/// Every command in the registry is sorted: the ones a plug-in may call take no file, folder,
/// clipboard, preference or script param, and the known file, document, script and history
/// commands are refused. Each [`NOT_FILES`] key, and the first key of each [`NESTED_NOT_FILES`]
/// list, is a param its command takes, not a word in its description's prose or result.
#[test]
fn every_command_is_sorted() {
let specs = crate::cmd::command_specs();
@@ -521,18 +539,43 @@ mod tests {
"edit.copy",
"edit.paste",
"prefs.set",
"stroke.arrowheads.list",
] {
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
assert!(denied(spec).is_some(), "{id} is allowed");
}
for id in ["select.all", "object.transform", "path.reverse", "symbol.instance.selectPart"] {
for id in [
"select.all",
"object.transform",
"path.reverse",
"symbol.instance.selectPart",
"object.setLiveShape",
"object.maskImage",
"imageTrace.make",
"svgInteractivity.info",
"addon.cleanup.rejoin",
] {
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
assert_eq!(denied(spec), None, "{id} is refused");
}
for (id, key) in NOT_FILES {
let Some(options) = crate::cmd::find_command("object.pathfinder.options") else { panic!("no command object.pathfinder.options") };
assert_eq!(denied(options), Some("reviewed"));
let real = |id: &str, key: &str| {
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
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 in_schema = spec.schema.is_some_and(|s| s.params.iter().any(|p| p.name == key));
let Some(described) = crate::cmd::params::accepted_keys(spec.params) else {
panic!("{id}: its params description doesn't say which keys it takes")
};
in_schema || described.contains(&key)
};
for (id, key) in NOT_FILES {
assert!(FILE_KEYS.contains(key), "{id} `{key}` isn't a file key");
assert!(real(id, key), "{id} has no `{key}` param");
}
for (id, keys) in NESTED_NOT_FILES {
let (Some(first), Some(last)) = (keys.first(), keys.last()) else { panic!("{id}: an empty key list") };
assert!(FILE_KEYS.contains(last), "{id} {keys:?} doesn't end at a file key");
assert!(real(id, first), "{id} has no `{first}` param");
}
let leaks: Vec<&str> = specs
.iter()
@@ -540,7 +583,7 @@ mod tests {
.filter(|c| {
let lower = c.id.to_ascii_lowercase();
FILE_WORDS.iter().any(|w| lower.contains(w))
|| FILE_KEYS.iter().any(|k| names_key(c.params, k) && !NOT_FILES.contains(&(c.id, *k)))
|| FILE_KEYS.iter().any(|k| takes_param(c, k) && !NOT_FILES.contains(&(c.id, *k)))
|| names_key(c.params, "sourceDocument")
})
.map(|c| c.id)
@@ -598,10 +641,23 @@ mod tests {
assert!(names_file("select.all", &json!([[[{"url": "file:///etc/hosts"}]]])));
assert!(!names_file("effect.apply", &json!({"effect": "distort.glass", "params": {"distortion": 5, "textureImage": "frosted"}})));
assert!(!names_file("object.move", &json!({"dx": 5, "dy": 0, "ids": [1, 2], "opts": {"copy": false}})));
// `path` is a shape's path in object.setLiveShape, but only as its own param.
assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]})));
assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}})));
// `path` is the path text is set on in text.createInPath, but only as its own param.
assert!(!names_file("text.createInPath", &json!({"path": 5, "text": "a"})));
assert!(names_file("text.createInPath", &json!({"options": {"path": "/tmp/x"}})));
assert!(names_file("object.move", &json!({"path": [[0, 0]]})));
// object.setLiveShape takes no `path`; its description says "on any path:" in its prose.
assert!(names_file("object.setLiveShape", &json!({"path": "/tmp/x"})));
// Image Trace's path fidelity setting, only inside its tracing settings.
for id in ["imageTrace.make", "imageTrace.makeAndExpand"] {
assert!(!names_file(id, &json!({"preset": "Default", "params": {"mode": "color", "paths": 50}})));
assert!(!names_file(id, &json!({"savedPreset": {"paths": 50, "corners": 75}})));
assert!(names_file(id, &json!({"paths": 50})));
assert!(names_file(id, &json!({"params": {"paths": 50, "path": "/tmp/x"}})));
assert!(names_file(id, &json!({"params": {"file": "/tmp/x"}})));
assert!(names_file(id, &json!({"params": {"paths": {"file": "/tmp/x"}}})));
assert!(names_file(id, &json!({"options": {"paths": 50}})));
}
assert!(names_file("object.move", &json!({"params": {"paths": 50}})));
// A custom arrowhead's outline in stroke.set, only where stroke.set takes one.
let outline = json!({"subpaths": []});
assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}})));
@@ -675,6 +731,39 @@ mod tests {
assert_eq!(key_location("pathData"), None);
}
/// A file key counts as a command's param only where its description takes it: not a word in
/// the prose after the params, in a nested object or in the result. A description that doesn't
/// say which keys it takes counts any `key:` in its text.
#[test]
fn a_param_is_a_key_the_description_takes() {
assert!(described_param("{path: id, text?: string}", "path"));
assert!(described_param("{id?|ids?, path?: id}", "path"));
assert!(!described_param("{id?, radius?: pt} rounds the corners on any path: …", "path"));
assert!(!described_param("{ids?: [id]} reverses the paths: their direction", "paths"));
assert!(!described_param("{} → {id, path: the mask's path}", "path"));
assert!(!described_param("{params?: {paths: 0-100}}", "paths"));
// The keys of the command a `…` entry names.
assert!(described_param("{…as links.relink}", "folder"));
assert!(!described_param("{…as links.relink}", "url"));
// No parameter object, or a `…` entry that names no command: any `key:` counts.
assert!(described_param("path: a file", "path"));
assert!(described_param("{…more settings, id?} saves to path: a file", "path"));
assert!(!described_param("{…more settings, id?} reverses the subpath: x", "path"));
for (id, key) in [
("object.setLiveShape", "path"),
("path.reverse", "paths"),
("object.pathfinder.options", "paths"),
("object.maskImage", "path"),
("imageTrace.make", "paths"),
("svgInteractivity.info", "files"),
("addon.cleanup.rejoin", "paths"),
] {
let Some(spec) = crate::cmd::find_command(id) else { panic!("no command {id}") };
assert!(!takes_param(spec, key), "{id} takes `{key}`");
assert!(!NOT_FILES.contains(&(id, key)), "{id} `{key}` is exempt");
}
}
#[test]
fn keys_are_found_as_whole_keys() {
assert!(names_key("{path: a .wasm file}", "path"));
@@ -58,6 +58,7 @@ pub(super) const ALLOWED: &[&str] = &[
"addon.pathtools.shape.set",
"addon.pathtools.slideAnchor",
"addon.pathtools.smooth",
"addon.pathtools.trim",
"addon.pen.append",
"addon.pen.close",
"addon.pen.closeJoin",
@@ -167,6 +168,7 @@ pub(super) const ALLOWED: &[&str] = &[
"effect.deferredStatus",
"effect.duplicate",
"effect.expandAppearance",
"effect.expandAsWireframes",
"effect.list",
"effect.move",
"effect.remove",
@@ -691,8 +693,8 @@ pub(super) const ALLOWED: &[&str] = &[
];
/// Commands a plug-in may not call although no rule refuses them, in order: they change the
/// preferences, a tool's options, a drag's state or the font names the whole app knows, or run
/// plug-ins again.
/// preferences, a tool's options, a drag's state or the font names the whole app knows, run
/// plug-ins again, or read a file and report where it is on disk.
pub(super) const DENIED: &[&str] = &[
"addon.cleanup.splitText",
"addon.pathtools.movePointsTogether",
@@ -704,5 +706,6 @@ pub(super) const DENIED: &[&str] = &[
"object.liquify",
"object.pathfinder.options",
"stroke.arrowheads.edit",
"stroke.arrowheads.list",
"type.compositeFonts.set",
];
+1
View File
@@ -368,6 +368,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
p!("linuxDisplayServer", "Performance", "", "Linux Display Server", choice(LINUX_DISPLAY_SERVERS)),
p!("monitorProfileDisplay", "Performance", "", "Use Monitor Profile for Canvas", bool),
// File Handling
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
+3 -3
View File
@@ -15,7 +15,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Point, Rect};
use super::colorcmds::{NotChanged, Recolor, Scope, left_out};
use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out};
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
pub fn specs() -> Vec<CommandSpec> {
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Recolor Artwork",
["Edit", "Edit Colors"],
None,
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
"{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}",
has_doc,
apply
),
@@ -1707,7 +1707,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden)))
.collect();
map.converted.set(false);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
let changed = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &f, &lf).run(d, &ids)))?;
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
// Rewriting the group's swatches can change the colors of art linked to them, left out or
+2 -2
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md) → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Weight Up",
[],
Some("Cmd+Alt+."),
"{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn → {objects}",
"{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}",
has_doc,
|s, p| stroke_step(s, p, true)
),
File diff suppressed because one or more lines are too long
+23 -2
View File
@@ -215,6 +215,11 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("tateChuYoko", json!(v.tate_chu_yoko)),
("tcyUpDown", json!(v.tcy_up_down)),
("tcyLeftRight", json!(v.tcy_left_right)),
("warichu", json!(v.warichu)),
("warichuLines", json!(v.warichu_lines)),
("warichuGap", json!(v.warichu_gap)),
("warichuScale", json!(v.warichu_scale)),
("warichuAlign", json!(v.warichu_align)),
("akiLeft", json!(v.aki_left)),
("akiRight", json!(v.aki_right)),
("standardVerticalRoman", json!(v.standard_vertical_roman)),
@@ -573,6 +578,19 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
let lines = vectorcraft_doc::text::WARICHU_LINES;
if changed("warichuLines") && !lines.contains(&st.warichu_lines) {
return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end())));
}
let scale = vectorcraft_doc::text::WARICHU_SCALE;
for (key, value, lo, hi) in [
("warichuGap", st.warichu_gap, 0.0, vectorcraft_doc::text::MAX_WARICHU_GAP),
("warichuScale", st.warichu_scale, *scale.start(), *scale.end()),
] {
if changed(key) && !(lo..=hi).contains(&value) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
// A language of the list, stored as its tag (as text.setFormat takes it).
if let Some(l) = &st.language
&& kept.and_then(|k| k.get("language")) != m.get("language")
@@ -1057,12 +1075,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
/// scales, leading, shifts, Insert Aki or stroke width are out of the range `charStyle.new` takes, whose
/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
/// `src` of those skipped)
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
const RANGED: [&str; 18] = [
const RANGED: [&str; 21] = [
"size",
"h_scale",
"v_scale",
@@ -1072,6 +1090,9 @@ fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
"akiRight",
"tcyUpDown",
"tcyLeftRight",
"warichuLines",
"warichuGap",
"warichuScale",
"stroke_width",
"left_indent",
"right_indent",
+4 -3
View File
@@ -824,8 +824,8 @@ fn load_all_styles_loads_both_kinds_in_one_step() {
assert_eq!(s.doc().unwrap().doc, before);
}
/// A loaded character style whose size, scale, leading, shift or stroke width is out of the range
/// `charStyle.new` takes is skipped.
/// A loaded character style whose size, scale, leading, shift, warichu Line Gap or stroke width is
/// out of the range `charStyle.new` takes is skipped.
#[test]
fn loaded_styles_out_of_range_are_skipped() {
let (mut s, _) = session_with_text("Title");
@@ -834,11 +834,12 @@ fn loaded_styles_out_of_range_are_skipped() {
("Flat", json!({"h_scale": 0.5})),
("Shifted", json!({"baseline_shift": 5000.0})),
("Thick", json!({"stroke_width": -2.0})),
("Gapped", json!({"warichuGap": 500.0})),
("Fine", json!({"size": 12.0, "h_scale": 50.0})),
];
let data = styles_file(&file, &[]);
let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick"]}));
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick", "Gapped"]}));
assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]);
}
+100 -42
View File
@@ -68,11 +68,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn has_thread_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) {
Ok(())
} else {
Err("no threaded text selected".into())
}
let selected: Ids = st.selection.objects.iter().copied().collect();
if st.doc.text_threads.iter().flatten().any(|id| selected.contains(id)) { Ok(()) } else { Err("no threaded text selected".into()) }
}
/// The text object `id` when it can be a frame of a thread: area type or type on a path.
@@ -87,6 +84,43 @@ fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
thread_local! {
/// Tests: how many threads' stories [`reflow`] copied on this thread.
static COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many frames [`leave_threads`] passed to [`continued_marks`] on this thread.
static MARK_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many lookups and insertions [`Ids`] made on this thread.
static ID_LOOKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A set of node ids, for the threaded-text commands to test whether a frame is selected, already
/// listed or in a thread in the same time however many ids the set holds.
#[derive(Clone, Default)]
struct Ids(std::collections::HashSet<NodeId>);
impl Ids {
fn contains(&self, id: &NodeId) -> bool {
#[cfg(test)]
ID_LOOKS.with(|c| c.set(c.get() + 1));
self.0.contains(id)
}
/// Add `id` to the set; whether it was not in it already.
fn insert(&mut self, id: NodeId) -> bool {
#[cfg(test)]
ID_LOOKS.with(|c| c.set(c.get() + 1));
self.0.insert(id)
}
}
impl FromIterator<NodeId> for Ids {
fn from_iter<I: IntoIterator<Item = NodeId>>(ids: I) -> Self {
Self(ids.into_iter().collect())
}
}
/// [`vectorcraft_text::thread::continued`] for the frames of a thread that [`leave_threads`] marks.
fn continued_marks(frames: &[&TextObject]) -> Vec<bool> {
#[cfg(test)]
MARK_VISITS.with(|c| c.set(c.get() + frames.len()));
vectorcraft_text::thread::continued(frames)
}
/// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing
@@ -371,10 +405,12 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let given: Ids = ids.iter().copied().collect();
let mut listed = given.clone();
let mut out = ids.to_vec();
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| given.contains(id))) {
for id in thread {
if !out.contains(id) && text_of(d, *id).is_some() {
if text_of(d, *id).is_some() && listed.insert(*id) {
out.push(*id);
}
}
@@ -399,13 +435,14 @@ pub(crate) fn frames(d: &Document) -> Vec<(NodeId, TextObject)> {
/// threaded and the frames before it end inside one. A threaded frame whose mark changes lays out
/// its first line again, so its thread re-flows after the edit.
pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
let gone: Vec<(NodeId, &TextObject, Option<NodeId>)> =
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, t, kept_type(d, *id))).collect();
// Each frame that left, with the type that takes its place.
let gone: std::collections::HashMap<NodeId, (&TextObject, Option<NodeId>)> =
was.iter().filter(|(id, _)| text_of(d, *id).is_none()).map(|(id, t)| (*id, (t, kept_type(d, *id)))).collect();
if gone.is_empty() {
return;
}
let find = |id: &NodeId| gone.iter().find(|(g, _, _)| g == id);
let mut stayed = vec![];
let find = |id: &NodeId| gone.get(id);
let mut changed = vec![];
for i in 0..d.text_threads.len() {
let thread = d.text_threads[i].clone();
if !thread.iter().any(|id| find(id).is_some()) {
@@ -414,28 +451,33 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
let keep: Vec<NodeId> = thread
.iter()
.filter_map(|id| match find(id) {
Some((_, _, kept)) => *kept,
Some((_, kept)) => *kept,
None => Some(*id),
})
.collect();
if let (Some((_, last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
if let (Some((last, None)), Some(stay)) = (thread.last().and_then(find), keep.last()) {
take_overflow(d, *stay, last);
}
stayed.extend(keep.iter().copied());
changed.push(keep.clone());
d.text_threads[i] = keep;
}
d.text_threads.retain(|t| t.len() >= 2);
let marks: Vec<(NodeId, bool)> = stayed
.iter()
.map(|id| {
let on = d.text_threads.iter().find(|t| t.contains(id)).is_some_and(|t| {
let live: Vec<(NodeId, &TextObject)> = t.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
live.iter().zip(vectorcraft_text::thread::continued(&frames)).any(|((i, _), on)| on && i == id)
});
(*id, on)
})
.collect();
// One pass over each changed thread: a frame left alone continues nothing, and the area type and
// type on a path of a thread that stays take their marks in thread order.
let mut marks: Vec<(NodeId, bool)> = vec![];
for keep in &changed {
if keep.len() < 2 {
marks.extend(keep.iter().map(|id| (*id, false)));
continue;
}
let live: Vec<(NodeId, &TextObject)> = keep.iter().filter_map(|i| Some((*i, text_of(d, *i)?))).collect();
let frames: Vec<&TextObject> = live.iter().map(|(_, f)| *f).collect();
let mut on: std::collections::HashMap<NodeId, bool> = std::collections::HashMap::new();
for ((id, _), mark) in live.iter().zip(continued_marks(&frames)) {
*on.entry(*id).or_default() |= mark;
}
marks.extend(keep.iter().map(|id| (*id, on.get(id).copied().unwrap_or(false))));
}
for (id, on) in marks {
if text_of(d, id).is_some_and(|t| t.first_para_continues != on)
&& let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
@@ -450,21 +492,23 @@ pub(crate) fn leave_threads(d: &mut Document, was: &[(NodeId, TextObject)]) {
/// thread with frames outside `art`, to the text it shows. When such a frame leaves its thread,
/// [`leave_threads`] puts the text it has no room for after the text of the last frame that stays.
pub(crate) fn keep_shown_text(d: &Document, art: &mut Node) {
let mut inside = vec![];
art.walk(&mut |n| inside.push(n.id));
let last: Vec<NodeId> = d
let mut inside = Ids::default();
art.walk(&mut |n| {
inside.insert(n.id);
});
let last: Ids = d
.text_threads
.iter()
.filter(|t| t.iter().any(|id| !inside.contains(id)))
.filter_map(|t| t.last().copied().filter(|id| inside.contains(id)))
.collect();
if !last.is_empty() {
if !last.0.is_empty() {
cut_to_shown(art, &last);
}
}
/// Cut the area type and type on a path `ids` in `n` to the text each one shows.
fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
fn cut_to_shown(n: &mut Node, ids: &Ids) {
if ids.contains(&n.id)
&& let NodeKind::Text(t) = &mut n.kind
&& !matches!(t.kind, TextKind::Point)
@@ -567,21 +611,35 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
*d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f;
}
}
// Existing threads containing any of these frames merge into the new one, keeping order.
// Existing threads containing any of these frames merge into the new one, keeping order:
// each frame takes in the first thread not yet taken in that lists it.
let mut threads: Vec<Option<Vec<NodeId>>> = std::mem::take(&mut d.text_threads).into_iter().map(Some).collect();
let mut listing: std::collections::HashMap<NodeId, Vec<usize>> = std::collections::HashMap::new();
for (i, t) in threads.iter().enumerate() {
for id in t.iter().flatten() {
listing.entry(*id).or_default().push(i);
}
}
let mut order: Vec<NodeId> = vec![];
let mut listed = Ids::default();
for id in &ids {
match d.text_threads.iter().position(|t| t.contains(id)) {
Some(i) => {
for x in d.text_threads.remove(i) {
if !order.contains(&x) {
let thread = listing.get(id).and_then(|at| at.iter().find_map(|i| threads[*i].take()));
match thread {
Some(thread) => {
for x in thread {
if listed.insert(x) {
order.push(x);
}
}
}
None if !order.contains(id) => order.push(*id),
None => {}
None => {
if listed.insert(*id) {
order.push(*id);
}
}
}
}
d.text_threads = threads.into_iter().flatten().collect();
d.text_threads.push(order);
// Force a re-flow of the new thread on this edit.
touch(d, ids[0]);
@@ -706,11 +764,12 @@ fn touch(d: &mut Document, id: NodeId) {
}
fn release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let ids: Ids = selected_roots(s)?.into_iter().collect();
s.edit("Release Threaded Text", |d, _| {
// The released frames' text joins the rest of the story (they become empty).
for thread in d.text_threads.clone() {
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|i| ids.contains(i));
for i in 0..d.text_threads.len() {
let thread = d.text_threads[i].clone();
let (out, keep): (Vec<NodeId>, Vec<NodeId>) = thread.iter().partition(|id| ids.contains(id));
if out.is_empty() {
continue;
}
@@ -750,7 +809,6 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
t.first_para_continues = false;
refresh_bounds(t);
}
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
d.text_threads[i] = keep;
}
d.text_threads.retain(|t| t.len() >= 2);
@@ -760,7 +818,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let ids: Ids = selected_roots(s)?.into_iter().collect();
s.edit("Remove Threading", |d, _| {
let (gone, kept): (Vec<Vec<NodeId>>, Vec<Vec<NodeId>>) =
std::mem::take(&mut d.text_threads).into_iter().partition(|t| t.iter().any(|i| ids.contains(i)));
+240
View File
@@ -1053,6 +1053,246 @@ fn a_frame_that_starts_its_thread_after_outlining_has_its_first_line_indent() {
}
}
/// Whether frame `id` of `d` is marked as continuing a paragraph.
fn marked(d: &Document, id: NodeId) -> bool {
text_of(d, id).unwrap().first_para_continues
}
/// The marks of `thread`'s frames, each found from the frames before it: a frame continues a
/// paragraph when the last of them with text does not end with a paragraph break. A frame left
/// alone continues nothing.
fn marks_by_scan(d: &Document, thread: &[NodeId]) -> Vec<bool> {
(0..thread.len())
.map(|k| {
thread.len() >= 2
&& thread[..k]
.iter()
.rev()
.map(|id| text_of(d, *id).unwrap().plain_text())
.find(|t| !t.is_empty())
.is_some_and(|t| !t.ends_with('\n'))
})
.collect()
}
/// When frames leave their threads (from the start, the middle or the end of a thread, outlined or
/// with their type under a new id, leaving a thread with one frame), each frame that stays takes
/// the mark found from the frames before it, and a thread that lost no frame keeps its marks.
#[test]
fn frames_that_stay_after_others_leave_their_threads_take_the_marks_of_their_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
let mut frame = |text: &str| {
NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": 60}})).unwrap()))
};
let [a1, a2, a3, a4, a5] = ["One", "", "Two\n", "Three", "Four"].map(&mut frame);
let [b1, b2] = ["Alpha", "Beta"].map(&mut frame);
let [c1, c2] = ["x", "y"].map(&mut frame);
let [d1, d2, d3, d4] = ["M", "N\n", "O", "P"].map(&mut frame);
let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 20, "height": 20})).unwrap()));
let mut d = (*s.doc().unwrap().doc).clone();
d.text_threads = vec![vec![a1, a2, a3, a4, a5], vec![b1, b2], vec![c1, c2], vec![d1, d2, d3, d4]];
// Every mark is the opposite of what it becomes, or (in the thread that loses no frame) wrong.
for id in [a1, a2, a3, a4, a5, b1, b2, c1, d1, d2, d3, d4] {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.first_para_continues = true;
}
}
let shown: Vec<(NodeId, String)> = d.text_threads.iter().flatten().map(|id| (*id, text_of(&d, *id).unwrap().plain_text())).collect();
let was = frames(&d);
// a1, b2 and d3 become a rectangle; a4 becomes a group holding its type under a new id.
let outline = d.node(rect).unwrap().kind.clone();
for id in [a1, b2, d3] {
d.node_mut(id).unwrap().kind = outline.clone();
}
let mut kept = d.node(a4).unwrap().clone();
kept.id = d.alloc_id();
let new_a4 = kept.id;
let layer = d.parent_of(a4);
d.remove(a4).unwrap();
d.insert(layer, usize::MAX, Node::group(a4, vec![std::sync::Arc::new(kept)])).unwrap();
leave_threads(&mut d, &was);
assert_eq!(d.text_threads, vec![vec![a2, a3, new_a4, a5], vec![c1, c2], vec![d1, d2, d4]]);
for thread in [&[a2, a3, new_a4, a5][..], &[b1], &[d1, d2, d4]] {
let want = marks_by_scan(&d, thread);
assert_eq!(thread.iter().map(|id| marked(&d, *id)).collect::<Vec<_>>(), want, "{thread:?}");
}
assert_eq!([a2, a3, new_a4, a5, b1, d1, d2, d4].map(|id| marked(&d, id)), [false, false, false, true, false, false, true, false]);
assert_eq!([c1, c2].map(|id| marked(&d, id)), [true, false], "a thread that lost no frame keeps its marks");
assert!(!matches!(d.node(a4).unwrap().kind, NodeKind::Text(_)), "the group in the frame's place is not type");
for (id, before) in shown {
let now = if id == a4 { new_a4 } else { id };
if let Some(t) = text_of(&d, now) {
assert_eq!(t.plain_text(), before, "frame {id:?} keeps its text");
}
}
}
/// The frames that stay in a long thread after one leaves are marked in one pass over the thread.
#[test]
fn a_long_thread_is_marked_in_one_pass_when_a_frame_leaves() {
const FRAMES: usize = 256;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w0", "area": {"width": 150, "height": 60}})).unwrap()));
let rect = NodeId(new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap()));
let mut d = (*s.doc().unwrap().doc).clone();
let layer = d.parent_of(first);
let model = d.node(first).unwrap().clone();
let mut thread = vec![first];
for k in 1..FRAMES {
let mut n = model.clone();
n.id = d.alloc_id();
if let NodeKind::Text(t) = &mut n.kind {
t.runs.truncate(1);
t.runs[0].text = format!("w{k}");
t.first_para_continues = true;
}
thread.push(d.insert(layer, usize::MAX, n).unwrap());
}
d.text_threads = vec![thread.clone()];
let was = frames(&d);
let out = thread[FRAMES / 2];
d.node_mut(out).unwrap().kind = d.node(rect).unwrap().kind.clone();
MARK_VISITS.with(|c| c.set(0));
leave_threads(&mut d, &was);
let visits = MARK_VISITS.with(std::cell::Cell::get);
let rest: Vec<NodeId> = thread.iter().copied().filter(|id| *id != out).collect();
assert_eq!(d.text_threads, vec![rest.clone()]);
assert!(visits <= FRAMES, "{visits} frames visited to mark a thread of {} frames", rest.len());
assert_eq!(rest.iter().map(|id| marked(&d, *id)).collect::<Vec<_>>(), marks_by_scan(&d, &rest));
}
/// `count` threads of `per` area type frames each, made in one edit, each frame holding a word.
fn many_threads(count: usize, per: usize) -> (Session, Vec<Vec<NodeId>>) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let first = NodeId(new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "w", "area": {"width": 150, "height": 60}})).unwrap()));
let threads = s
.edit("Make", |d, _| {
let layer = d.parent_of(first);
let model = d.node(first).unwrap().clone();
let mut threads = vec![];
for _ in 0..count {
let mut thread = vec![];
for _ in 0..per {
let mut n = model.clone();
n.id = d.alloc_id();
thread.push(d.insert(layer, usize::MAX, n).unwrap());
}
threads.push(thread);
}
d.text_threads = threads.clone();
Ok(threads)
})
.unwrap();
assert_eq!(s.doc().unwrap().doc.text_threads, threads);
(s, threads)
}
/// Select the first frame of each of `threads`.
fn select_firsts(s: &mut Session, threads: &[Vec<NodeId>]) {
let ids: Vec<u64> = threads.iter().map(|t| t[0].0).collect();
s.execute("select.set", &json!({ "ids": ids })).unwrap();
}
/// Reset the count of [`Ids`] lookups, run `f`, and give its result with the lookups it made.
fn looks<T>(f: impl FnOnce() -> T) -> (T, usize) {
ID_LOOKS.with(|c| c.set(0));
let out = f();
(out, ID_LOOKS.with(std::cell::Cell::get))
}
/// Whether Release Selection and Remove Threading are available is found in one pass over the
/// threads' frames, however many frames are selected.
#[test]
fn thread_commands_are_offered_after_one_pass_over_the_threads() {
let (mut s, made) = many_threads(300, 3);
let (threads, loose) = made.split_at(150);
s.edit("Unthread", |d, _| {
d.text_threads = threads.to_vec();
Ok(())
})
.unwrap();
// 450 frames out of any thread, then the last frame of the last thread.
let mut ids: Vec<u64> = loose.iter().flatten().map(|id| id.0).collect();
ids.push(threads[149][2].0);
s.execute("select.set", &json!({ "ids": ids })).unwrap();
let (offered, n) = looks(|| has_thread_selection(&s));
assert!(offered.is_ok());
assert!(n <= 2 * (451 + 450), "{n} id lookups");
s.execute("select.set", &json!({ "ids": ids[..450] })).unwrap();
assert!(has_thread_selection(&s).is_err(), "frames out of any thread are no threaded text");
let rect = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
assert!(has_thread_selection(&s).is_err(), "a rectangle is no threaded text");
}
/// The frames of the selected frames' threads are listed with one lookup per frame.
#[test]
fn the_frames_threaded_with_a_selection_are_listed_in_one_pass() {
let (s, threads) = many_threads(150, 3);
let firsts: Vec<NodeId> = threads.iter().map(|t| t[0]).collect();
let d = &s.doc().unwrap().doc;
let (all, n) = looks(|| with_threads(d, &firsts));
let mut want = firsts.clone();
want.extend(threads.iter().flat_map(|t| t[1..].iter().copied()));
assert_eq!(all, want);
assert!(n <= 4 * (150 + 450), "{n} id lookups");
}
/// The last frames of many threads copied out of them are found with a few lookups per frame and
/// per object copied.
#[test]
fn the_last_frames_copied_out_of_many_threads_are_found_in_one_pass() {
let (s, threads) = many_threads(150, 3);
let d = &s.doc().unwrap().doc;
let lasts: Vec<std::sync::Arc<Node>> = threads.iter().map(|t| std::sync::Arc::new(d.node(t[2]).unwrap().clone())).collect();
let mut art = Node::group(NodeId(u64::MAX), lasts);
let before = art.clone();
let ((), n) = looks(|| keep_shown_text(d, &mut art));
assert!(n <= 4 * (151 + 450), "{n} id lookups");
assert_eq!(art, before, "each copied frame already shows all of its word");
}
/// Threading one frame of each of many threads merges them in one pass, in the order of the
/// selection.
#[test]
fn creating_a_thread_from_many_threads_merges_them_in_one_pass() {
let (mut s, threads) = many_threads(150, 3);
select_firsts(&mut s, &threads);
let (made, n) = looks(|| s.execute("text.thread.create", &json!({})));
made.unwrap();
assert!(n <= 8 * (150 + 450), "{n} id lookups");
assert_eq!(s.doc().unwrap().doc.text_threads, vec![threads.concat()]);
}
/// Release Selection on one frame of each of many threads finds the selected frames with one
/// lookup per frame.
#[test]
fn releasing_frames_of_many_threads_takes_one_pass() {
let (mut s, threads) = many_threads(150, 3);
select_firsts(&mut s, &threads);
let (released, n) = looks(|| s.execute("text.thread.releaseSelection", &json!({})));
released.unwrap();
assert!(n <= 2 * 450, "{n} id lookups");
let rest: Vec<Vec<NodeId>> = threads.iter().map(|t| t[1..].to_vec()).collect();
assert_eq!(s.doc().unwrap().doc.text_threads, rest);
}
/// Remove Threading on the last frame of each of many threads finds the selected frames with one
/// lookup per frame.
#[test]
fn removing_the_threads_of_many_frames_takes_one_pass() {
let (mut s, threads) = many_threads(150, 3);
let lasts: Vec<u64> = threads.iter().map(|t| t[2].0).collect();
s.execute("select.set", &json!({ "ids": lasts })).unwrap();
let (removed, n) = looks(|| s.execute("text.thread.remove", &json!({})));
removed.unwrap();
assert!(n <= 2 * 450, "{n} id lookups");
assert!(s.doc().unwrap().doc.text_threads.is_empty());
}
/// The text that a thread's last frame has no room for is measured as the frame lays it out,
/// without the first-line indent of the paragraph it continues: when the frame is outlined, the
/// frame before it takes only that text.
+102
View File
@@ -1663,6 +1663,108 @@ fn tate_chu_yoko_settings_are_character_attributes_saved_only_when_set() {
assert_eq!(offsets(&s), before);
}
/// Warichu and Warichu Settings (Character panel menu, East Asian options): character attributes
/// saved only when they differ from their defaults (off, 2 lines, no gap, 50%, Auto). Both
/// commands set them in one undo step, a call that changes nothing records none, a bad value is
/// refused naming the key before anything changes, the file and character styles keep them, and
/// SVG and PDF write type with warichu as outlines.
#[test]
fn warichu_is_a_character_attribute_set_saved_and_exported() {
use vectorcraft_doc::WarichuAlign;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "ab cdef g"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let settings = |s: &Session| {
runs(s)
.iter()
.map(|r| (r.text.clone(), r.style.warichu, r.style.warichu_lines, r.style.warichu_gap, r.style.warichu_scale, r.style.warichu_align))
.collect::<Vec<_>>()
};
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
let text_ops = |pdf: &[u8]| {
let s = String::from_utf8_lossy(pdf);
s.contains("Tj") || s.contains("TJ")
};
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
// text.setFormat with the values the characters already have changes nothing and records no
// undo step.
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"warichu": false, "warichuLines": 2, "warichuAlign": "auto"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
s.execute("select.set", &json!({"ids": []})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let p = json!({"id": id, "start": 3, "end": 7, "warichu": true, "warichuLines": 3, "warichuGap": 2.5, "warichuScale": 40, "warichuAlign": "justifyAll"});
s.execute("text.setRangeStyle", &p).unwrap();
let want = vec![
("ab ".to_string(), false, 2, 0.0, 50.0, WarichuAlign::Auto),
("cdef".to_string(), true, 3, 2.5, 40.0, WarichuAlign::JustifyAll),
(" g".to_string(), false, 2, 0.0, 50.0, WarichuAlign::Auto),
];
assert_eq!(settings(&s), want);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let saved = serde_json::to_value(&runs(&s)[1].style).unwrap();
assert_eq!(
[&saved["warichu"], &saved["warichuLines"], &saved["warichuGap"], &saved["warichuScale"], &saved["warichuAlign"]],
[&json!(true), &json!(3), &json!(2.5), &json!(40.0), &json!("justifyAll")]
);
// Saved and reopened as they were.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(
t.runs.iter().map(|r| (r.style.warichu, r.style.warichu_lines, r.style.warichu_align)).collect::<Vec<_>>(),
[(false, 2, WarichuAlign::Auto), (true, 3, WarichuAlign::JustifyAll), (false, 2, WarichuAlign::Auto)]
);
// A character style captured from the characters carries them.
let attrs = super::super::textstyles::char_attrs(&runs(&s)[1].style);
assert_eq!(
(attrs["warichu"].clone(), attrs["warichuLines"].clone(), attrs["warichuAlign"].clone()),
(json!(true), json!(3), json!("justifyAll"))
);
// Exports draw the stacked lines as outlines.
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
assert!(!svg.contains("<text"), "{svg}");
let pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
assert!(!text_ops(&pdf));
// Undo takes the whole change back.
s.execute("edit.undo", &json!({})).unwrap();
assert!(settings(&s).iter().all(|r| !r.1 && r.2 == 2));
s.execute("edit.redo", &json!({})).unwrap();
// text.setFormat sets the selected objects' characters.
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"warichuScale": 70})).unwrap();
assert!(settings(&s).iter().all(|r| r.4 == 70.0));
let before = settings(&s);
for (cmd, p, want) in [
("text.setFormat", json!({"warichuLines": 7}), "`warichuLines` must be a whole number from 1 to 6"),
("text.setFormat", json!({"warichuLines": 2.5}), "`warichuLines` must be a whole number from 1 to 6"),
("text.setFormat", json!({"warichuScale": 5}), "`warichuScale` must be from 10 to 100"),
("text.setFormat", json!({"warichuGap": -1}), "`warichuGap` must be from 0 to 100"),
("text.setRangeStyle", json!({"id": id, "warichuGap": "2"}), "`warichuGap` must be a number"),
("text.setRangeStyle", json!({"id": id, "warichu": "yes"}), "`warichu` must be true or false"),
(
"text.setRangeStyle",
json!({"id": id, "warichuAlign": "middle"}),
"`warichuAlign` must be one of \"auto\", \"left\", \"center\", \"right\", \"justifyLeft\", \"justifyCenter\", \"justifyRight\", \"justifyAll\"",
),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}");
}
assert_eq!(settings(&s), before);
}
/// Insert Aki Left and Right (Character panel, East Asian options): character attributes in
/// thousandths of an em, 0 by default and saved only when not 0. Both commands set either side
/// alone, keeping the other side of every run, in one undo step; a value that is not a number or
+1
View File
@@ -220,6 +220,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool {
st.baseline_shift *= f;
st.tcy_up_down *= f;
st.tcy_left_right *= f;
st.warichu_gap *= f;
st.leading = Some((st.size + gap).max(min_leading));
if let Some(art) = &mut piece.inline {
art.baseline_shift *= f;
+40 -11
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), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays; each warped object's bounding box (the objects inside groups and the art of linked masks too) turns as the warp turns the art at its center (object.resetBoundingBox squares it again); corners where the box already stands change nothing and add no undo step",
has_doc,
distort
),
@@ -109,11 +109,18 @@ impl Projective {
/// Warp `n`'s anchors and handles; gradients (pinned first if unplaced) follow the warp's affine
/// approximation at their center. The art of a linked opacity mask is warped with the object it
/// masks; an unlinked one stays where it is.
fn warp_node(n: &mut Node, pr: &Projective) {
/// masks; an unlinked one stays where it is. Each warped object's bounding-box angle follows the
/// warp's affine approximation at that object's own center before the warp (as for the objects
/// with no editable points); an object with no bounds keeps its angle, and a layer has none.
/// Returns `n`'s geometric bounds before the warp ([`Node::geometric_bounds`]; none for a layer),
/// gathered from its children's so that each subtree is measured once.
fn warp_node(n: &mut Node, pr: &Projective) -> Option<Rect> {
n.pin_gradients();
match &mut n.kind {
// The children before these are left out of a group's or compound path's bounds.
let skip = n.children().map_or(0, Vec::len) - n.editing_children().len();
let before = match &mut n.kind {
NodeKind::Path { path, live, .. } => {
let b = path.bounds();
*live = None;
for sp in &mut path.subpaths {
for a in &mut sp.anchors {
@@ -122,24 +129,41 @@ fn warp_node(n: &mut Node, pr: &Projective) {
a.h_out = pr.apply(a.h_out);
}
}
b
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
warp_node(Arc::make_mut(c), pr);
}
NodeKind::Layer { children, .. } => {
warp_children(children, pr, |_| false);
None
}
// A clip group is bounded by its clip path.
NodeKind::Group { children, clip: true } => warp_children(children, pr, |i| i == 0),
NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => warp_children(children, pr, |i| i >= skip),
_ => {
// No editable points: the affine approximation (which maps the gradients too).
if let Some(b) = n.geometric_bounds() {
let b = n.geometric_bounds();
if let Some(b) = b {
n.transform(pr.affine_near(b.center()), false);
}
return;
return b;
}
};
if let Some(b) = before {
n.bbox_angle = vectorcraft_doc::orient::transformed_angle(n.bbox_angle, pr.affine_near(b.center()));
}
if let Some(m) = n.extras_if_any().and_then(|x| x.mask.as_mut()).filter(|m| m.linked) {
warp_node(Arc::make_mut(&mut m.art), pr);
}
n.appearance.warp_gradients(&|p| pr.affine_near(p));
before
}
/// Warps each of `children` and returns the union of the pre-warp bounds of those `counts` picks
/// by index.
fn warp_children(children: &mut [Arc<Node>], pr: &Projective, counts: impl Fn(usize) -> bool) -> Option<Rect> {
children.iter_mut().enumerate().fold(None, |acc, (i, c)| {
let b = warp_node(Arc::make_mut(c), pr);
if counts(i) { vectorcraft_geom::union_opt(acc, b) } else { acc }
})
}
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
@@ -178,6 +202,11 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
if !r.width().is_finite() || !r.height().is_finite() || r.width().abs() < 1e-9 || r.height().abs() < 1e-9 {
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
}
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
// Corners that stay where they are change nothing: no edit and no undo step.
if [tl, tr, br, bl] == src.corners() {
return Ok(ids_json);
}
let pr = Projective::from_box(src, [tl, tr, br, bl]);
s.edit("Free Distort", |d, _| {
for id in &ids {
@@ -187,7 +216,7 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(ids_json)
}
// ---------- eyedropper ----------
+5
View File
@@ -220,6 +220,10 @@ pub struct Prefs {
/// Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it through XWayland
/// only when a pen is attached (#2028). Read when the app starts.
pub linux_display_server: String,
/// The desktop canvas converts its sRGB into the ICC profile of the display that shows the
/// window, where the system doesn't (Windows without Advanced Color, X11). Display only: it
/// changes no document, proof or export.
pub monitor_profile_display: bool,
// File Handling
pub background_save: bool,
pub background_export: bool,
@@ -499,6 +503,7 @@ impl Default for Prefs {
real_time_drawing: true,
render_threads: -1,
linux_display_server: s("automatic"),
monitor_profile_display: true,
background_save: true,
background_export: true,
autosave_recovery: true,
+6 -6
View File
@@ -270,6 +270,9 @@ impl Session {
for tree in text_trees_mut(doc) {
each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
// The symbols' type is measured now: the inline graphics showing those symbols take their
// art's measured size, a symbol's type before the type that shows that symbol inline.
doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame);
}
/// Measure the type in open document `index` that `stale` picks (in its trees as
@@ -287,12 +290,9 @@ impl Session {
each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
// Inline graphics take their size from their symbols' art, whose type may just have
// been measured again: the text holding one whose size changed is measured again too.
if !doc.resolve_inline_art().is_empty() {
for tree in text_trees_mut(doc) {
each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
}
// been measured again: the text holding one whose size changed is measured again too,
// a symbol's type before the type that shows that symbol inline.
doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame);
});
}
+222 -1
View File
@@ -4,7 +4,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::{Node, OrientedBox};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_tools::bbox::Handle;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@@ -190,6 +190,227 @@ fn free_transform_distort_starts_from_the_turned_box() {
}
}
/// Where the line through `a` and `b` crosses the line through `c` and `d`.
fn cross(a: Point, b: Point, c: Point, d: Point) -> Point {
let (r, q) = (b - a, d - c);
let t = ((c - a).x * q.y - (c - a).y * q.x) / (r.x * q.y - r.y * q.x);
a + r * t
}
/// The counter-clockwise angle of the direction from `from` to `to`.
fn direction(from: Point, to: Point) -> f64 {
-(to.y - from.y).atan2(to.x - from.x).to_degrees()
}
fn near_deg(a: f64, b: f64) -> bool {
vectorcraft_geom::normalize_deg(a - b).abs() < 1e-6
}
fn json_points(pts: [Point; 4]) -> Value {
json!(pts.map(|p| [p.x, p.y]))
}
/// `pts` turned by `deg` (counter-clockwise) about `c`.
fn turned(pts: [Point; 4], c: Point, deg: f64) -> [Point; 4] {
let a = Affine::translate(c.to_vec2()) * Affine::rotate(-deg.to_radians()) * Affine::translate(-c.to_vec2());
pts.map(|p| a * p)
}
/// A perspective quad for the 100 × 100 box at the origin: its top-right corner raised 20 pt.
const RAISED: [(f64, f64); 4] = [(0.0, 0.0), (100.0, -20.0), (100.0, 100.0), (0.0, 100.0)];
fn raised() -> [Point; 4] {
RAISED.map(|(x, y)| Point::new(x, y))
}
/// The angle a warp onto `quad` (from a box square to the page) turns an upright object's x axis
/// to, where the object's center lands at `center`: every horizontal line of the box is drawn
/// through the point where the lines of the quad's top and bottom sides cross.
fn upright_angle_after(quad: [Point; 4], center: Point) -> f64 {
direction(cross(quad[0], quad[1], quad[3], quad[2]), center)
}
/// Where a warped rectangle's center lands: where the diagonals of its warped corners cross.
fn warped_center(s: &Session, id: NodeId) -> Point {
let p = anchors(s, id);
assert_eq!(p.len(), 4, "{p:?}");
cross(p[0], p[2], p[1], p[3])
}
#[test]
fn distort_turns_an_upright_box_with_the_warp_at_its_center() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let quad = raised();
s.execute("object.distort", &json!({ "corners": json_points(quad) })).unwrap();
// The top tilts up to the right: a small counter-clockwise angle (atan 0.1 for this quad).
let want = upright_angle_after(quad, cross(quad[0], quad[2], quad[1], quad[3]));
assert!(near(want, 0.1_f64.atan().to_degrees()), "{want}");
assert!(near(angle(&s, a), want), "{} vs {want}", angle(&s, a));
// The Selection and Free Transform tools' box stands at it.
assert!(near(selection_box(&s).angle, want), "{:?}", selection_box(&s));
// Reset Bounding Box squares it to the page and leaves the art.
let pts = anchors(&s, a);
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
assert_eq!(angle(&s, a), 0.0);
assert_eq!(anchors(&s, a), pts);
// A warp that only stretches a turned object along its own axes keeps its angle exactly.
let mut s = session();
let t = turned_rect(&mut s);
let b = selection_box(&s);
let wide = OrientedBox { angle: b.angle, rect: Rect::new(b.rect.x0, b.rect.y0, b.rect.x1 + 50.0, b.rect.y1) };
let r = b.rect;
s.execute("object.distort", &json!({"corners": json_points(wide.corners()), "from": [r.x0, r.y0, r.x1, r.y1], "angle": b.angle})).unwrap();
assert_eq!(angle(&s, t), b.angle);
assert!(near(selection_box(&s).rect.width(), 150.0), "{:?}", selection_box(&s));
}
/// Every object's angle in `n`'s subtree, opacity mask art included, each with whether it is the
/// art of an unlinked mask (or inside one).
fn angles_with_links(n: &Node, unlinked: bool, out: &mut Vec<(f64, bool)>) {
out.push((n.bbox_angle, unlinked));
for c in n.children().into_iter().flatten() {
angles_with_links(c, unlinked, out);
}
if let Some(m) = &n.extras().mask {
angles_with_links(&m.art, unlinked || !m.linked, out);
}
}
fn group_angles(s: &Session, g: NodeId) -> Vec<(f64, bool)> {
let mut out = vec![];
angles_with_links(s.doc().unwrap().doc.node(g).unwrap(), false, &mut out);
out
}
fn selected(s: &Session) -> NodeId {
s.doc().unwrap().selection.objects[0]
}
#[test]
fn distort_turns_each_member_and_linked_mask_by_its_own_warp() {
let mut s = session();
// A path turned 20° with a linked mask turned with it.
let a = rect(&mut s, 100.0, 100.0, 60.0, 40.0);
let am = rect(&mut s, 100.0, 100.0, 60.0, 40.0);
sel(&mut s, &[a, am]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
// A path with an unlinked mask.
let u = rect(&mut s, 200.0, 100.0, 40.0, 40.0);
let um = rect(&mut s, 200.0, 100.0, 40.0, 40.0);
sel(&mut s, &[u, um]);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
// A compound path turned 10°.
let c1 = rect(&mut s, 100.0, 200.0, 40.0, 40.0);
let c2 = rect(&mut s, 120.0, 220.0, 40.0, 40.0);
sel(&mut s, &[c1, c2]);
s.execute("object.compoundPath.make", &json!({})).unwrap();
let compound = selected(&s);
s.execute("object.rotate", &json!({"angle": 10})).unwrap();
// A clip group.
let k1 = rect(&mut s, 250.0, 200.0, 50.0, 50.0);
let k2 = rect(&mut s, 260.0, 210.0, 50.0, 50.0);
sel(&mut s, &[k1, k2]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let clip = selected(&s);
sel(&mut s, &[a, u, compound, clip]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
let before = group_angles(&s, g);
assert!(before.len() >= 10 && before.iter().any(|(a, _)| near(*a, 20.0)) && before.iter().any(|(_, u)| *u), "{before:?}");
// A warp that turns the group 30° about its center turns every object 30° more, except the
// art of the unlinked mask, which stays.
let b = s.doc().unwrap().doc.bounds_of(&[g], false).unwrap();
let n = undo_len(&s);
s.execute("object.distort", &json!({ "corners": json_points(turned(OrientedBox::aligned(b).corners(), b.center(), 30.0)) })).unwrap();
assert_eq!(undo_len(&s), n + 1, "one step");
let after = group_angles(&s, g);
assert_eq!(after.len(), before.len());
for ((old, unlinked), (new, _)) in before.iter().zip(&after) {
if *unlinked {
assert_eq!(new, old, "{before:?} → {after:?}");
} else {
assert!(near_deg(*new, old + 30.0), "{before:?} → {after:?}");
}
}
// Under a perspective warp each object follows the warp at its own center: two members of a
// group, one above the other, tilt by different amounts, and the group by the warp at its own
// center.
let mut s = session();
let p = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
let q = rect(&mut s, 0.0, 50.0, 100.0, 50.0);
sel(&mut s, &[p, q]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
let quad = raised();
s.execute("object.distort", &json!({ "corners": json_points(quad) })).unwrap();
let (want_p, want_q) = (upright_angle_after(quad, warped_center(&s, p)), upright_angle_after(quad, warped_center(&s, q)));
assert!(near(angle(&s, p), want_p) && near(angle(&s, q), want_q), "{} {} vs {want_p} {want_q}", angle(&s, p), angle(&s, q));
assert!(want_p > want_q + 1.0, "{want_p} {want_q}");
let want_g = upright_angle_after(quad, cross(quad[0], quad[2], quad[1], quad[3]));
assert!(near(angle(&s, g), want_g), "{} vs {want_g}", angle(&s, g));
}
#[test]
fn mirrored_and_collapsing_distorts_keep_valid_angles() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
sel(&mut s, &[a]);
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
// Corners swapped left for right mirror the art: the box stands at the mirrored angle.
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
let [tl, tr, br, bl] = OrientedBox::aligned(b).corners();
s.execute("object.distort", &json!({ "corners": json_points([tr, tl, bl, br]) })).unwrap();
assert!(near(angle(&s, a), -30.0), "{}", angle(&s, a));
// A warp that collapses the art to a point leaves the angle as it was.
let n = undo_len(&s);
let c = b.center();
match s.execute("object.distort", &json!({ "corners": json_points([c; 4]) })) {
Ok(_) => assert!(near(angle(&s, a), -30.0), "{}", angle(&s, a)),
Err(_) => {
assert_eq!(undo_len(&s), n);
assert!(near(angle(&s, a), -30.0));
}
}
}
#[test]
fn distort_angles_undo_redo_and_reopen_together() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let before = anchors(&s, a);
let n = undo_len(&s);
let b = Rect::new(0.0, 0.0, 100.0, 100.0);
s.execute("object.distort", &json!({ "corners": json_points(turned(OrientedBox::aligned(b).corners(), b.center(), 30.0)) })).unwrap();
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Free Distort"));
assert!(near(angle(&s, a), 30.0), "{}", angle(&s, a));
let warped = anchors(&s, a);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((anchors(&s, a), angle(&s, a)), (before, 0.0));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(anchors(&s, a), warped);
assert!(near(angle(&s, a), 30.0));
// A native file keeps the angle.
let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
s.execute("document.open", &json!({"name": "warped.vectorcraft", "dataBase64": data})).unwrap();
assert!(near(angle(&s, a), 30.0));
sel(&mut s, &[a]);
let b = selection_box(&s);
assert!(near(b.angle, 30.0) && near(b.rect.width(), 100.0) && near(b.rect.height(), 100.0), "{b:?}");
// The next handle drag scales along the turned box: the Right handle 50 pt further out.
let right = b.to_doc() * Handle::Right.pos(b.rect);
let to = b.to_doc() * (Handle::Right.pos(b.rect) + vectorcraft_geom::Vec2::new(50.0, 0.0));
s.select_tool("selection", ViewInfo::default()).unwrap();
drag_with(&mut s, &[right, to, to], Mods::default());
let nb = selection_box(&s);
assert!(near(nb.angle, 30.0) && near(nb.rect.width(), 150.0) && near(nb.rect.height(), 100.0), "{nb:?}");
}
#[test]
fn transform_panel_sizes_follow_the_turned_box() {
let mut s = session();
+21
View File
@@ -721,3 +721,24 @@ fn other_documents_brushes_load_and_unreadable_files_are_errors() {
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(vectorcraft_brush::library(&vectorcraft_format::load(&bytes).unwrap()).len(), 1);
}
/// A library file whose brushes repeat a name loads with the later brush numbered, and each one
/// can be added to a document by its own name.
#[test]
fn a_library_file_that_repeats_a_brush_name_loads_with_the_later_one_numbered() {
use vectorcraft_brush::{Brush, Calligraphic};
let nib = |size: f64| Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(Calligraphic { size, ..Default::default() }) };
let mut file = vectorcraft_doc::Document::new(100.0, 100.0);
vectorcraft_brush::store(&mut file, &[nib(1.0), nib(2.0)]);
let b64 = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&file));
let mut s = session();
let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Nibs.vectorcraft"}));
let library = r["library"].as_str().unwrap().to_string();
let listed = run(&mut s, "brush.library", json!({ "library": library }));
let names: Vec<&str> = listed["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Nib", "Nib 2"]);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": library, "name": "Nib 2"}));
assert_eq!(strs(&r["added"]), ["Nib 2"]);
let size = |b: Brush| if let BrushKind::Calligraphic(c) = b.kind { c.size } else { 0.0 };
assert_eq!(size(brush(&s, "Nib 2")), 2.0);
}
+31
View File
@@ -631,6 +631,37 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() {
assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err());
}
/// A document whose brushes repeat a name opens with the later brush numbered: strokes of the name
/// keep the first brush, and renaming or deleting one brush leaves the other and its strokes alone.
#[test]
fn a_repeated_brush_name_is_numbered_when_the_document_opens() {
use vectorcraft_brush::{Brush, BrushKind, Calligraphic};
let nib = |size: f64| Brush { name: "Nib".into(), kind: BrushKind::Calligraphic(Calligraphic { size, ..Default::default() }) };
let mut s = session();
vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &[nib(1.0), nib(2.0)]);
let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
s.execute("brush.apply", &json!({"name": "Nib", "ids": [a.0]})).unwrap();
let saved = s.execute("document.serialize", &json!({})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap();
let names = |o: &mut Session| -> Vec<String> {
let list = o.execute("brush.list", &json!({})).unwrap();
list["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap().to_string()).collect()
};
assert_eq!(names(&mut o), ["Nib", "Nib 2"]);
assert_eq!(brush_of(&o, a).as_deref(), Some("Nib"));
assert_eq!(o.execute("brush.get", &json!({"name": "Nib"})).unwrap()["size"], json!(1.0));
assert_eq!(o.execute("brush.get", &json!({"name": "Nib 2"})).unwrap()["size"], json!(2.0));
let b = rect(&mut o, 80.0, 10.0, 50.0, 50.0);
o.execute("brush.apply", &json!({"name": "Nib 2", "ids": [b.0]})).unwrap();
o.execute("brush.options", &json!({"name": "Nib", "newName": "Pen"})).unwrap();
assert_eq!(names(&mut o), ["Pen", "Nib 2"]);
assert_eq!((brush_of(&o, a).as_deref(), brush_of(&o, b).as_deref()), (Some("Pen"), Some("Nib 2")));
o.execute("brush.delete", &json!({"name": "Nib 2"})).unwrap();
assert_eq!(names(&mut o), ["Pen"]);
assert_eq!((brush_of(&o, a).as_deref(), brush_of(&o, b)), (Some("Pen"), None));
}
/// `brush.move`, which the Brushes panel runs when a brush is dragged, reorders brushes among those of
/// their type in one undo step and refuses to move one into another type.
#[test]
+6 -3
View File
@@ -4134,7 +4134,10 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
sorted.sort();
assert_eq!(sorted, ["#000000", "#c82832", "#f0f0dc"], "{method}");
let n = node(&s, g);
assert_eq!(names(&n), order, "{method}");
// Overlapping fills the ring whole and draws the field inside it above the ring: that path starts a second
// group of the field's color, above the black one.
let want: Vec<String> = if method == "overlapping" { order.iter().chain(order.first()).cloned().collect() } else { order.clone() };
assert_eq!(names(&n), want, "{method}");
// Each group holds its color's paths, and in turn they are the paths of the trace without
// byColor, in their order.
let groups = n.children().unwrap();
@@ -4148,8 +4151,8 @@ fn image_trace_expand_by_color_groups_each_colors_paths() {
// Release to Layers: a sublayer for each color, in the same order.
let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap();
let subs = ids(&r);
assert_eq!(subs.len(), 3, "{method}");
for (sub, name) in subs.iter().zip(&order) {
assert_eq!(subs.len(), want.len(), "{method}");
for (sub, name) in subs.iter().zip(&want) {
let l = node(&s, *sub);
assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}");
assert_eq!(names(&l), std::slice::from_ref(name), "{method}");
+204
View File
@@ -1606,6 +1606,210 @@ fn smoothing_a_whole_closed_path_smooths_a_bump_at_its_start() {
assert!(start <= far + 0.5, "the bump at the start stays {start} pt high, the far one {far} pt");
}
/// `path.smoothRegion` along `drag` with the Smooth tool's reach and default Fidelity → the ids changed.
fn smooth_pass_ids(s: &mut Session, drag: &[Point]) -> serde_json::Value {
let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect();
s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": 2.5})).unwrap()["ids"].clone()
}
/// The corner, in degrees, at each anchor between two segments of `p`'s first open subpath.
fn smooth_test_kinks(p: &PathData) -> Vec<f64> {
let sp = &p.subpaths[0];
(1..sp.segment_count())
.map(|i| {
let (a, b) = (sp.segment(i - 1), sp.segment(i));
let tin = if a.p3.distance(a.p2) > 1e-9 { a.p3 - a.p2 } else { a.p3 - a.p1 };
let tout = if b.p1.distance(b.p0) > 1e-9 { b.p1 - b.p0 } else { b.p2 - b.p0 };
tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees()
})
.collect()
}
/// A 200 pt segment rising to a sharp peak at (200, 125) in its middle, then a 100 pt line:
/// (100, 200) to (300, 200) to (400, 200).
fn long_peak_segment(s: &mut Session) -> NodeId {
let anchors = [
json!({"x": 100.0, "y": 200.0, "out": [200.0, 100.0]}),
json!({"x": 300.0, "y": 200.0, "in": [200.0, 100.0]}),
json!({"x": 400.0, "y": 200.0}),
];
smooth_test_path(s, &anchors, false)
}
/// The sharpest bend of `p` between x = 180 and 220: the most it turns, in radians per point, over
/// points about half a point apart.
fn sharpest_bend_near_200(p: &PathData) -> f64 {
use kurbo::ParamCurve;
let sp = &p.subpaths[0];
let pts: Vec<Point> = (0..sp.segment_count()).flat_map(|i| (0..=800).map(move |k| sp.segment(i).eval(f64::from(k) / 800.0))).collect();
pts.windows(3)
.filter(|w| (180.0..=220.0).contains(&w[1].x))
.map(|w| (w[1] - w[0], w[2] - w[1]))
.filter(|(u, v)| u.hypot() > 1e-6 && v.hypot() > 1e-6)
.map(|(u, v)| u.cross(v).atan2(u.dot(v)).abs() / (0.5 * (u.hypot() + v.hypot())))
.fold(0.0, f64::max)
}
/// A drag along the middle of a long segment smooths the stretch under it although both of the
/// segment's anchors are out of reach; the rest of the segment and the line after it keep their shape.
#[test]
fn a_smooth_drag_along_a_segment_smooths_it_with_its_anchors_out_of_reach() {
use kurbo::ParamCurve;
let mut s = session();
let id = long_peak_segment(&mut s);
let before = path(&s, id);
let drag = smooth_drag_along(126.0, 185.0, 215.0);
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0]));
let after = path(&s, id);
let (b0, b1) = (sharpest_bend_near_200(&before), sharpest_bend_near_200(&after));
assert!(b1 < 0.97 * b0, "the peak's sharpest bend {b0} -> {b1}");
let kinks = smooth_test_kinks(&after);
// The anchors where the reach was cut join smoothly; the corner before the line stays.
assert!(kinks[..kinks.len() - 1].iter().all(|k| *k < 0.5), "{kinks:?}");
let a = &after.subpaths[0].anchors;
assert_eq!((a[0].p, a[0].h_out.y < 200.0), (Point::new(100.0, 200.0), true), "the first anchor keeps its place: {:?}", a[0]);
let (end, last) = (a[a.len() - 2], a[a.len() - 1]);
assert!(end.p == Point::new(300.0, 200.0) && !end.has_out(), "the segment's end keeps its place: {end:?}");
assert_eq!(last, before.subpaths[0].anchors[2], "and the line after it stays");
// Every point of the segment farther than the reach from the drag is still on the path.
let seg = before.subpaths[0].segment(0);
let sp = &after.subpaths[0];
let on_after: Vec<Point> = (0..sp.segment_count()).flat_map(|i| (0..=400).map(move |k| sp.segment(i).eval(f64::from(k) / 400.0))).collect();
let off_drag = |q: Point| {
drag.windows(2)
.map(|w| {
let ab = w[1] - w[0];
(w[0] + ab * ((q - w[0]).dot(ab) / ab.hypot2()).clamp(0.0, 1.0)).distance(q)
})
.fold(f64::INFINITY, f64::min)
};
for q in (0..=200).map(|k| seg.eval(f64::from(k) / 200.0)).filter(|q| off_drag(*q) > 14.0) {
let gap = on_after.iter().map(|r| r.distance(q)).fold(f64::INFINITY, f64::min);
assert!(gap < 1.0, "{q:?} is {gap} pt off the smoothed path");
}
}
/// A drag that reaches a segment at its first anchor, leaves it and comes back to its middle smooths
/// the middle too, and the stretch between stays as it was.
#[test]
fn a_smooth_drag_that_comes_back_to_a_segment_smooths_where_it_comes_back() {
use kurbo::ParamCurve;
let mut s = session();
let id = long_peak_segment(&mut s);
let before = path(&s, id);
let drag = [Point::new(100.0, 200.0), Point::new(100.0, 320.0), Point::new(185.0, 320.0), Point::new(185.0, 126.0), Point::new(215.0, 126.0)];
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0]));
let after = path(&s, id);
let (b0, b1) = (sharpest_bend_near_200(&before), sharpest_bend_near_200(&after));
assert!(b1 < 0.97 * b0, "the peak's sharpest bend {b0} -> {b1}");
let between = before.subpaths[0].segment(0).eval(0.3);
let sp = &after.subpaths[0];
let gap = (0..sp.segment_count())
.flat_map(|i| (0..=400).map(move |k| sp.segment(i).eval(f64::from(k) / 400.0)))
.map(|r| r.distance(between))
.fold(f64::INFINITY, f64::min);
assert!(gap < 0.5, "{between:?} is {gap} pt off the smoothed path");
}
/// A click on a curve 100,000 pt long, between the places a fixed number of samples along it would
/// look at, still finds the curve under it.
#[test]
fn a_smooth_drag_finds_a_very_long_curve_under_it() {
use kurbo::ParamCurve;
let mut s = session();
let anchors = [json!({"x": 0.0, "y": 0.0, "out": [30000.0, -9000.0]}), json!({"x": 100000.0, "y": 0.0, "in": [70000.0, -9000.0]})];
let id = smooth_test_path(&mut s, &anchors, false);
let at = path(&s, id).subpaths[0].segment(0).eval(0.5 + 0.5 / 1024.0);
assert_eq!(smooth_pass_ids(&mut s, &[at]), json!([id.0]));
}
/// A click just within the reach of a segment's peak, with both of the segment's anchors out of
/// reach, smooths the segment although only a few points of it are within the reach.
#[test]
fn a_smooth_click_that_only_just_reaches_a_segment_smooths_it() {
let mut s = session();
let id = long_peak_segment(&mut s);
let before = path(&s, id);
assert_eq!(smooth_pass_ids(&mut s, &[Point::new(200.0, 113.1)]), json!([id.0]));
assert_ne!(path(&s, id), before);
}
/// A drag that runs along a very long curve first and then crosses a second curve away from its
/// middle, with both of its anchors out of reach, smooths the second curve too: each curve in reach
/// gets the same share of the search, so the long one does not use it all.
#[test]
fn a_long_curve_searched_first_leaves_the_next_curve_its_share() {
let mut s = session();
let long = smooth_test_path(
&mut s,
&[json!({"x": 0.0, "y": 0.0, "out": [30000.0, 1.0]}), json!({"x": 100000.0, "y": 0.0, "in": [70000.0, 1.0]})],
false,
);
let next = smooth_test_path(
&mut s,
&[json!({"x": 100100.0, "y": -100.0, "out": [100130.0, 200.0]}), json!({"x": 100100.0, "y": 900.0, "in": [100130.0, 600.0]})],
false,
);
s.execute("select.set", &json!({"ids": [long.0, next.0]})).unwrap();
let before = path(&s, next);
let mut drag: Vec<Point> = (0..=1000).map(|i| Point::new(f64::from(i) * 100.0, 0.0)).collect();
drag.push(Point::new(100200.0, 0.0));
let ids = smooth_pass_ids(&mut s, &drag);
assert!(ids.as_array().is_some_and(|v| v.contains(&json!(next.0))), "{ids}");
assert_ne!(path(&s, next), before);
}
/// An open path of two anchors is smoothed: an S-curve smoothed along its whole length.
#[test]
fn smoothing_smooths_an_open_two_anchor_path() {
use kurbo::ParamCurve;
let mut s = session();
let anchors = [json!({"x": 100.0, "y": 200.0, "out": [170.0, 130.0]}), json!({"x": 200.0, "y": 200.0, "in": [130.0, 270.0]})];
let id = smooth_test_path(&mut s, &anchors, false);
let before = path(&s, id);
let seg = before.subpaths[0].segment(0);
let drag: Vec<Point> = (0..=20).map(|k| seg.eval(f64::from(k) / 20.0)).collect();
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([id.0]));
let after = path(&s, id);
let (t0, t1) = (total_turning(&before), total_turning(&after));
assert!(t1 < t0, "open: turning {t0} -> {t1}");
let a = &after.subpaths[0].anchors;
assert_eq!((a[0].p, a[a.len() - 1].p), (Point::new(100.0, 200.0), Point::new(200.0, 200.0)), "the ends stay");
}
/// A closed path of two anchors is smoothed along the middle of one side and stays closed.
#[test]
fn smoothing_smooths_a_closed_two_anchor_path() {
use kurbo::ParamCurve;
let mut s = session();
let anchors = [
json!({"x": 100.0, "y": 300.0, "in": [100.0, 400.0], "out": [100.0, 200.0]}),
json!({"x": 300.0, "y": 300.0, "in": [300.0, 160.0], "out": [300.0, 440.0]}),
];
let id = smooth_test_path(&mut s, &anchors, true);
let before = path(&s, id);
let top = before.subpaths[0].segment(0);
let mid = top.eval(0.5);
assert_eq!(smooth_pass_ids(&mut s, &[mid - kurbo::Vec2::new(10.0, 0.0), mid + kurbo::Vec2::new(10.0, 0.0)]), json!([id.0]));
let after = path(&s, id);
assert!(after.subpaths[0].closed && after != before, "{after:?}");
}
/// A drag that passes nowhere near a path's segments, or only over the middle of a straight
/// segment, leaves the path as it was.
#[test]
fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() {
let mut s = session();
let id = long_peak_segment(&mut s);
let before = path(&s, id);
assert_eq!(smooth_pass_ids(&mut s, &smooth_drag_along(260.0, 100.0, 400.0)), json!([]), "60 pt off the path");
assert_eq!(path(&s, id), before);
let line = smooth_test_path(&mut s, &[json!({"x": 100.0, "y": 400.0}), json!({"x": 300.0, "y": 400.0}), json!({"x": 300.0, "y": 450.0})], false);
let before = path(&s, line);
assert_eq!(smooth_pass_ids(&mut s, &smooth_drag_along(400.0, 190.0, 210.0)), json!([]), "a straight segment");
assert_eq!(path(&s, line), before);
}
#[test]
fn join_tool_joins_two_lines() {
let mut s = session();
+159 -1
View File
@@ -2,7 +2,7 @@
use serde_json::json;
use vectorcraft_doc::text::INLINE_CHAR;
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
use vectorcraft_doc::{Node, NodeId, NodeKind, TextObject};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use crate::*;
@@ -167,6 +167,164 @@ fn inline_graphics_follow_their_symbol() {
let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
}
/// The first text in tree `n`, its opacity mask art included.
fn first_text(n: &Node) -> Option<TextObject> {
if let NodeKind::Text(t) = &n.kind {
return Some((**t).clone());
}
if let Some(t) = n.extras().mask.as_deref().and_then(|m| first_text(&m.art)) {
return Some(t);
}
n.children().into_iter().flatten().find_map(|c| first_text(c))
}
/// The first text in the art of symbol `name`.
fn art_text(s: &Session, name: &str) -> TextObject {
let d = &s.doc().unwrap().doc;
first_text(&d.symbols.iter().find(|x| x.name == name).unwrap().art).unwrap()
}
/// The bounds of the first inline graphic in `t`.
fn inline_bounds(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
t.runs.iter().find_map(|r| r.inline.as_ref()).unwrap().bounds
}
/// Redefines symbol `name` as a 40 × 20 ellipse.
fn redefine_as_wide_ellipse(s: &mut Session, name: &str) {
let e = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap();
s.execute("select.set", &json!({"ids": [e["id"]]})).unwrap();
s.execute("symbol.update", &json!({"name": name})).unwrap();
}
/// A symbol made from type with an inline graphic: when the symbol the graphic shows changes, the
/// type in the symbol's art is laid out again, the symbol's art takes its new size, and an inline
/// graphic of that symbol follows. Undo, redo and opening a saved copy keep it so.
#[test]
fn inline_graphics_in_symbol_art_follow_their_symbol() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let id = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": id.0, "at": 1, "symbol": "Dot", "scale": 1})).unwrap();
s.execute("symbol.new", &json!({"name": "Holder", "ids": [id.0]})).unwrap();
let line = text(&mut s, "x");
s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "Holder", "scale": 1})).unwrap();
let held = art_text(&s, "Holder");
let w0 = held.cached_bounds.unwrap().width();
let holder0 = s.doc().unwrap().doc.symbol_art_bounds("Holder").unwrap();
redefine_as_wide_ellipse(&mut s, "Dot");
let d = &s.doc().unwrap().doc;
let held = art_text(&s, "Holder");
let b = inline_bounds(&held).unwrap();
assert!((b.width() / b.height() - 2.0).abs() < 0.1, "{b:?}");
assert_eq!(Some(b), d.symbol_art_bounds("Dot"));
let w1 = held.cached_bounds.unwrap().width();
assert!((w1 - w0 - 20.0).abs() < 1.5, "{w0} → {w1}");
assert!(held.cached_ink.is_some() && !held.cached_baselines.is_empty());
let holder1 = d.symbol_art_bounds("Holder").unwrap();
assert!(holder1.width() > holder0.width() + 1.0, "{holder0:?} → {holder1:?}");
assert_eq!(inline_bounds(&obj(&s, line)), Some(holder1), "an inline graphic of the holder follows it");
// Undo and redo.
s.execute("edit.undo", &json!({})).unwrap();
assert!((art_text(&s, "Holder").cached_bounds.unwrap().width() - w0).abs() < 1e-6);
s.execute("edit.redo", &json!({})).unwrap();
assert!((art_text(&s, "Holder").cached_bounds.unwrap().width() - w1).abs() < 1e-6);
// Opening a saved copy measures it the same way.
let doc = (*s.doc().unwrap().doc).clone();
let mut back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
back.title = "copy".into();
let mut s2 = Session::new();
s2.add_document(back, None);
let held2 = art_text(&s2, "Holder");
assert_eq!(inline_bounds(&held2), s2.doc().unwrap().doc.symbol_art_bounds("Dot"));
assert!((held2.cached_bounds.unwrap().width() - w1).abs() < 1e-6);
assert_eq!(inline_bounds(&obj(&s2, line)), s2.doc().unwrap().doc.symbol_art_bounds("Holder"));
}
/// Type with an inline graphic in an opacity mask and in a pattern tile follows its symbol.
#[test]
fn inline_graphics_in_masks_and_pattern_tiles_follow_their_symbol() {
let mut s = session();
circle_symbol(&mut s, "Dot", 10.0);
let masked = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": masked.0, "at": 1, "symbol": "Dot"})).unwrap();
let owner = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.edit("Mask", |d, _| {
let art = d.remove(masked)?;
d.node_mut(owner).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new((*art).clone(), true)));
Ok(())
})
.unwrap();
let tiled = text(&mut s, "cd");
s.execute("text.insertInline", &json!({"id": tiled.0, "at": 1, "symbol": "Dot"})).unwrap();
s.execute("object.pattern.make", &json!({"ids": [tiled.0], "name": "Tile", "edit": false})).unwrap();
redefine_as_wide_ellipse(&mut s, "Dot");
let d = &s.doc().unwrap().doc;
let want = d.symbol_art_bounds("Dot");
let mask = first_text(&d.node(owner).unwrap().extras().mask.as_deref().unwrap().art).unwrap();
assert_eq!(inline_bounds(&mask), want);
let tile = d.patterns.iter().find(|p| p.name == "Tile").unwrap().art.iter().find_map(|n| first_text(n)).unwrap();
assert_eq!(inline_bounds(&tile), want);
}
/// Symbol C shows B inline and B shows A: with the symbols listed C, B, A, a change to A reaches C
/// through B's new size.
#[test]
fn symbols_are_resolved_after_the_symbols_they_show_inline() {
let mut s = session();
circle_symbol(&mut s, "A", 10.0);
for (name, shows) in [("B", "A"), ("C", "B")] {
let t = text(&mut s, "z");
s.execute("text.insertInline", &json!({"id": t.0, "at": 0, "symbol": shows})).unwrap();
s.execute("symbol.new", &json!({"name": name, "ids": [t.0]})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
}
s.edit("Reverse the symbols", |d, _| {
d.symbols.reverse();
Ok(())
})
.unwrap();
let b0 = s.doc().unwrap().doc.symbol_art_bounds("B");
redefine_as_wide_ellipse(&mut s, "A");
let d = &s.doc().unwrap().doc;
assert_eq!(inline_bounds(&art_text(&s, "B")), d.symbol_art_bounds("A"));
assert_ne!(d.symbol_art_bounds("B"), b0);
assert_eq!(inline_bounds(&art_text(&s, "C")), d.symbol_art_bounds("B"));
}
/// A symbol whose art shows the symbol itself inline, directly or through another symbol, is an
/// empty slot, and stays one through later edits.
#[test]
fn a_symbol_that_shows_itself_inline_is_an_empty_slot() {
let mut s = session();
for name in ["Loop", "P", "Q"] {
circle_symbol(&mut s, name, 10.0);
}
for (name, shows) in [("Loop", "Loop"), ("P", "Q"), ("Q", "P")] {
let t = text(&mut s, "ab");
s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": shows})).unwrap();
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("symbol.update", &json!({"name": name})).unwrap();
}
let line = text(&mut s, "x");
s.execute("text.insertInline", &json!({"id": line.0, "at": 0, "symbol": "P"})).unwrap();
for _ in 0..2 {
for name in ["Loop", "P", "Q"] {
assert_eq!(inline_bounds(&art_text(&s, name)), None, "{name}");
assert_eq!(s.doc().unwrap().doc.inline_art_bounds(name), None, "{name}");
}
assert_eq!(inline_bounds(&obj(&s, line)), None);
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
}
let _ = s.execute("document.serialize", &json!({"format": "png"})).unwrap();
// Redefined without the inline graphic, Q is on no cycle, and neither is P.
redefine_as_wide_ellipse(&mut s, "Q");
let d = &s.doc().unwrap().doc;
assert!(d.inline_art_bounds("P").is_some());
assert_eq!(inline_bounds(&obj(&s, line)), d.symbol_art_bounds("P"));
assert_eq!(inline_bounds(&art_text(&s, "P")), d.symbol_art_bounds("Q"));
assert_eq!(d.inline_art_bounds("Loop"), None);
}
/// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and
/// live SVG text writes the bullet.
#[test]
+28
View File
@@ -464,6 +464,34 @@ fn linux_display_server_defaults_to_automatic_and_validates() {
assert_eq!(s.prefs.linux_display_server, "automatic");
}
/// Performance › Use Monitor Profile for Canvas: on by default and for preference files written
/// before it, a boolean only, saved, listed under Performance, and set without changing the
/// document.
#[test]
fn monitor_profile_display_is_on_by_default_and_changes_no_document() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
assert!(s.prefs.monitor_profile_display);
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
assert!(old.monitor_profile_display);
let (revision, doc) = (s.active().unwrap().revision, s.active().unwrap().doc.clone());
s.execute("prefs.set", &json!({"key": "monitorProfileDisplay", "value": false})).unwrap();
assert!(!s.prefs.monitor_profile_display);
assert_eq!(s.prefs.to_json()["monitorProfileDisplay"], false);
for bad in [json!("off"), json!(0), json!(null)] {
assert!(s.execute("prefs.set", &json!({"key": "monitorProfileDisplay", "value": bad})).is_err(), "{bad}");
assert!(!s.prefs.monitor_profile_display);
}
assert_eq!(s.active().unwrap().revision, revision);
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &doc), "the document is not edited");
let l = s.execute("prefs.list", &json!({})).unwrap();
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "monitorProfileDisplay").unwrap().clone();
assert_eq!((row["category"].as_str(), row["label"].as_str()), (Some("Performance"), Some("Use Monitor Profile for Canvas")));
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert!(s.prefs.monitor_profile_display);
}
/// Linux Display Server's pen choice (#2028): set by value or by label, saved, and off by default.
#[test]
fn linux_display_server_has_a_choice_for_an_attached_pen() {
+91
View File
@@ -291,3 +291,94 @@ fn a_warp_on_a_group_bends_the_image_in_it() {
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
}
/// A selected embedded 40×40 px red image at half alpha filling [`FRAME`], with Effect › Warp ›
/// Arc at 50 %.
fn half_clear_arc(s: &mut Session) {
clear_arc(s, |_| 128);
}
/// [`half_clear_arc`] with each column `x` of pixels at alpha `alpha(x)`.
fn clear_arc(s: &mut Session, alpha: impl Fn(u32) -> u8) {
let img = image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([255, 0, 0, alpha(x)]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.edit("Place", |d, sel| {
d.images.insert("half".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
let im =
vectorcraft_doc::ImageObject { key: "half".into(), width: 40, height: 40, xf, 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("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
}
/// The points of a 50 × 50 grid over the bent image of [`half_clear_arc`], away from its outer
/// edges, where the document drawn on white at 8 px a point is not half red (its green is not in
/// `green`; half red over white is (255, 128, 128)): the first few.
fn not_half_red(s: &Session, green: std::ops::RangeInclusive<u8>) -> Vec<(f64, f64, [u8; 4])> {
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 300.0, 300.0), 8.0, true);
let mut out = vec![];
for i in 0..50 {
for j in 0..50 {
let (u, v) = (0.03 + 0.94 * f64::from(i) / 49.0, 0.03 + 0.94 * f64::from(j) / 49.0);
let p = arced(50.0, u, v);
let px = img.pixel((p.x * 8.0).floor() as u32, (p.y * 8.0).floor() as u32);
if !green.contains(&px[1]) && out.len() < 8 {
out.push((u, v, px));
}
}
}
out
}
/// The pieces a warp cuts a half transparent image into overlap: the image stays half
/// transparent there, on the canvas and in a PDF opened again.
#[test]
fn a_half_transparent_warped_image_has_no_darker_seams() {
let mut s = session();
half_clear_arc(&mut s);
assert_eq!(not_half_red(&s, 120..=136), vec![], "canvas");
// Opened again, each piece is drawn through masks of the pieces above it, and pixels on a
// triangle's antialiased edge come out a little lighter. Pieces whose alphas add up where they
// overlap draw a green of about 64 there.
let pdf = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap();
let mut back = session();
back.execute("document.open", &json!({"name": "half.pdf", "dataBase64": pdf["dataBase64"]})).unwrap();
assert_eq!(not_half_red(&back, 120..=152), vec![], "pdf");
}
/// An image whose left half is opaque and whose right half is at half alpha keeps its opaque
/// pixels opaque where the pieces of a warp overlap, away from the partly transparent pixels, on
/// the canvas and in a PDF opened again.
#[test]
fn opaque_pixels_of_a_partly_transparent_warped_image_have_no_lighter_seams() {
let mut s = session();
clear_arc(&mut s, |x| if x < 20 { 255 } else { 128 });
// The points of a fine grid over the left 30 % of the bent image, away from its outer edges,
// where the document drawn on white at 8 px a point is not red: the first few.
let not_red = |s: &Session| {
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 300.0, 300.0), 8.0, true);
let mut out = vec![];
for i in 0..300 {
for j in 0..800 {
let (u, v) = (0.03 + 0.27 * f64::from(i) / 299.0, 0.03 + 0.94 * f64::from(j) / 799.0);
let p = arced(50.0, u, v);
let px = img.pixel((p.x * 8.0).floor() as u32, (p.y * 8.0).floor() as u32);
if px[1] > 6 && out.len() < 8 {
out.push((u, v, px));
}
}
}
out
};
assert_eq!(not_red(&s), vec![], "canvas");
let pdf = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap();
let mut back = session();
back.execute("document.open", &json!({"name": "mixed.pdf", "dataBase64": pdf["dataBase64"]})).unwrap();
assert_eq!(not_red(&back), vec![], "pdf");
}
+20 -1
View File
@@ -571,6 +571,25 @@ fn distort_command_warps_the_linked_mask_of_a_path_in_a_group() {
assert_eq!(a.extras().mask.as_ref().unwrap().art.path_data(), a.path_data(), "and so is its mask");
}
/// Corners where the box already stands change nothing: no undo step, the document as it was. A
/// corner moved by a hundredth of a point is a warp.
#[test]
fn unchanged_distort_records_no_history() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
let doc = s.doc().unwrap().doc.clone();
let n = undo_len(&s);
let at = |x1: f64| json!([[b.x0, b.y0], [x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]]);
assert_eq!(s.execute("object.distort", &json!({"corners": at(b.x1)})).unwrap()["ids"], json!([a.0]));
assert_eq!(undo_len(&s), n);
assert!(s.doc().unwrap().doc == doc, "the document is unchanged");
s.execute("object.distort", &json!({"corners": at(b.x1 + 0.01)})).unwrap();
assert_eq!(undo_len(&s), n + 1);
assert!(s.doc().unwrap().doc != doc);
}
#[test]
fn distort_perspective_midpoint_is_projective() {
// Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real
@@ -1239,7 +1258,7 @@ fn distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle() {
}
s.execute("object.distort", &json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": 90, "ids": [a.0]})).unwrap();
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
assert_eq!(undo_len(&s), history + 1);
assert_eq!(undo_len(&s), history, "corners where the box stands change nothing");
// Without `from`, the box is the objects' bounds in the turned frame: the same box here.
s.execute("object.distort", &json!({"corners": corners, "angle": 90, "ids": [a.0]})).unwrap();
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
+8
View File
@@ -849,6 +849,14 @@ fn structured_junk() {
("addon.pathtools.movePointsTogether", json!({"enabled": true})),
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})),
("addon.pathtools.movePointsTogether", json!({"enabled": false})),
// Path Precision Tools: Trim at Crossings.
("addon.pathtools.trim", json!({"at": [1e308, -1e308], "tolerance": 1e308})),
("addon.pathtools.trim", json!({"at": ["x", null], "rect": [0, 0, f64::NAN, 1], "points": "all"})),
("addon.pathtools.trim", json!({"rect": [-1e7, -1e7, 2e7, 2e7]})),
("addon.pathtools.trim", json!({"rect": [0, 0, -1e308, 1e308]})),
("addon.pathtools.trim", json!({"points": [[0, 0], [1e7, 1e7], [-1e7, 5], [0, 0]]})),
("addon.pathtools.trim", json!({"points": [[0, 0], [1, "y"], null]})),
("addon.pathtools.trim", json!({"at": [50, 50], "tolerance": 100})),
// Arrange Tools add-on.
("addon.arrange.grid", json!({"columns": u64::MAX, "gapX": 1e6, "gapY": -1e6, "centerOnArtboard": true})),
("addon.arrange.grid", json!({"layout": "row", "gapX": f64::MAX, "columns": -1})),
+17 -4
View File
@@ -7486,6 +7486,13 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
n.appearance = ap.into();
n
};
let frame = |doc: &mut Document, ap: Appearance| {
let outer = Node::path(doc.alloc_id(), square(), Appearance::default());
let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(30.0, 20.0, 70.0, 50.0)), Appearance::default());
let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::NonZero });
n.appearance = ap.into();
n
};
// A group of a filled square and `second`.
let group = |second: fn(&mut Document) -> Node| {
move |doc: &mut Document, ap: Appearance| {
@@ -7510,7 +7517,12 @@ fn a_link_to_a_twist_style_is_kept_where_the_reader_reads_it() {
for (what, doc, kept) in [
("a twisted curve", twisted(45.0, filled(), path(oval())), true),
("a curve twisted a quarter turn back", twisted(-90.0, filled(), path(oval())), true),
("a square twisted three quarters of a turn", twisted(270.0, filled(), path(square())), true),
("a square twisted half a turn", twisted(180.0, filled(), path(square())), true),
("a square twisted half a turn back", twisted(-180.0, filled(), path(square())), true),
("a square twisted three quarters of a turn", twisted(270.0, filled(), path(square())), false),
("a square twisted three quarters of a turn back", twisted(-270.0, filled(), path(square())), false),
("a straight compound path twisted half a turn", twisted(180.0, filled(), frame), true),
("a straight compound path twisted a full turn", twisted(360.0, filled(), frame), false),
("a curve twisted past a quarter turn", twisted(120.0, filled(), path(oval())), false),
("a stroked curve", twisted(45.0, Appearance::basic(black_fill(), red.clone(), 2.0), path(oval())), false),
("a curved compound path", twisted(-60.0, filled(), ring), true),
@@ -8406,8 +8418,9 @@ fn brushes_the_reader_cant_read_are_left_out() {
}
left_out(vec![Brush { name: "A/B".into(), ..keep.clone() }], "slash");
left_out(vec![Brush { kind: BrushKind::Bristle(Bristle::default()), ..keep.clone() }], "a second brush of the name");
// A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back. A
// path with "X" is drawn with the Pattern brush, so it comes back as its drawn look.
// A Pattern brush named "X" is left out, so a Calligraphic brush named "X" after it comes back,
// as "X 2", the name the library gives it when it is read. A path with "X" is drawn with the
// Pattern brush, so it comes back as its drawn look.
let mut doc = Document::new(300.0, 200.0);
let x = Brush { name: "X".into(), ..keep.clone() };
vectorcraft_brush::store(&mut doc, &[Brush { name: "X".into(), kind: BrushKind::Pattern(PatternBrush::default()) }, x.clone()]);
@@ -8415,7 +8428,7 @@ fn brushes_the_reader_cant_read_are_left_out() {
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
assert_eq!(vectorcraft_brush::library(&back), [x]);
assert_eq!(vectorcraft_brush::library(&back), [Brush { name: "X 2".into(), ..x }]);
let n = &back.layers[0].children().unwrap()[0];
assert!(n.appearance.stroke().is_none_or(|s| s.brush.is_none()), "not with the other brush of the name: {n:#?}");
}
@@ -208,6 +208,7 @@ pub(in crate::ai_editing) fn holds(n: &Node, t: &TextObject) -> bool {
shape_ok
&& t.wrap.is_empty()
&& t.ruby.is_empty()
&& !t.stacks_warichu()
&& t.runs.iter().any(|r| r.text.chars().any(|c| !c.is_whitespace()))
&& runs_held(t)
&& plain(n)
@@ -964,6 +965,7 @@ impl TextDoc {
&& t.wrap.is_empty()
&& plain(n)
&& t.ruby.is_empty()
&& !t.stacks_warichu()
&& runs_held(t)
&& (story_scale(t) / scale - 1.0).abs() <= 1e-9
&& within_reader(t, scale)
+11 -4
View File
@@ -1107,11 +1107,17 @@ fn no_guides(n: &Node, depth: usize) -> bool {
/// Twist keeps the file's drawn look.
const MAX_TWIST: f64 = 90.0;
/// The largest Twist angle, in degrees either way, read as a live effect on a path or compound path
/// with straight segments: VectorCraft's own limit. One with a larger Twist keeps the file's drawn
/// look.
const MAX_STRAIGHT_TWIST: f64 = 180.0;
/// Is `object` one that style `st`, whose effect moves the outline, is read on? It and everything
/// in it [`moves_alike`], and the style paints no gradient. Twist is read only when nothing is
/// stroked: on a path or compound path with straight segments at any angle, or up to [`MAX_TWIST`]
/// degrees either way on another object with no guide in it. Transform that scales, turns or reflects
/// is read only when nothing is stroked (its reference point lies on the bounds without strokes).
/// stroked and its angle is a number: up to [`MAX_STRAIGHT_TWIST`] degrees either way on a path or
/// compound path with straight segments, or up to [`MAX_TWIST`] degrees either way on another object
/// with no guide in it. Transform that scales, turns or reflects is read only when nothing is stroked
/// (its reference point lies on the bounds without strokes).
fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
use crate::import::ai::style::StyleItem;
let stroked = st.items.iter().any(|i| matches!(i, StyleItem::Stroke { paint, .. } if !paint.is_none()))
@@ -1121,7 +1127,8 @@ fn moves_with(object: &Node, st: &crate::import::ai::style::ArtStyle) -> bool {
&& object.children().into_iter().flatten().all(|c| moves_alike(c, 1))
&& st.effects.iter().all(|e| match e.id.as_str() {
"distort.twist" => {
!stroked && (straight(object) || (no_guides(object, 0) && e.params["angle"].as_f64().is_some_and(|a| a.abs() <= MAX_TWIST)))
let within = |limit: f64| e.params["angle"].as_f64().is_some_and(|a| a.abs() <= limit);
!stroked && if straight(object) { within(MAX_STRAIGHT_TWIST) } else { no_guides(object, 0) && within(MAX_TWIST) }
}
"distort.transform" => {
let p = &e.params;
+39 -2
View File
@@ -7159,7 +7159,6 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
let object = |hidden: &str| {
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
};
let square = object(&format!("{}f\n", rect_path(20.0, 20.0, 60.0, 50.0)));
let oval = "20 35 m\n20 45 60 45 60 35 c\n60 25 20 25 20 35 c\n";
let curved = object(&format!("{oval}f\n"));
let ring = object(&format!("*u\n{oval}f\n30 35 m\n30 40 50 40 50 35 c\n50 30 30 30 30 35 c\nf\n*U\n"));
@@ -7184,7 +7183,6 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
("a twisted group", read(&twist(90.0, None), &distort_group()), 2),
("a group with a hidden member", read(&twist(45.0, None), &hidden_member), 2),
("a curve twisted a quarter turn back", read(&twist(-90.0, BLUE_FILL), &curved), 0),
("a square twisted three quarters of a turn, as before", read(&twist(270.0, BLUE_FILL), &square), 0),
("a straight compound path with a guide, as before", read(&twist(45.0, BLUE_FILL), &with_guide(&rect_path(20.0, 20.0, 60.0, 50.0))), 2),
] {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
@@ -7213,6 +7211,45 @@ fn twist_is_read_on_curves_compound_paths_and_groups_with_nothing_stroked_up_to_
}
}
#[test]
fn twist_is_read_on_straight_paths_and_compound_paths_up_to_half_a_turn() {
// GUARDS: a path or compound path with straight segments was read with a live Twist at any angle.
// The object, `hidden` painted with its style, under its drawn look.
let object = |hidden: &str| {
format!("0 Ae\nu\n{RED}{}f\nU\n%_Bb\n%_{}0 BB\n1 (Bent) XW\n", rect_path(16.0, 16.0, 64.0, 54.0), hidden.replace('\n', "\n%_"))
};
let square = object(&format!("{}f\n", rect_path(20.0, 20.0, 60.0, 50.0)));
let frame = object(&format!("*u\n{}f\n{}f\n*U\n", rect_path(20.0, 20.0, 60.0, 50.0), rect_path(30.0, 30.0, 50.0, 40.0)));
let twist = |angle: f64| distort_styles("Adobe Twirl", &format!("{angle} /Real (angle) ,\n"), BLUE_FILL);
let read = |angle: f64, art_: &str| read_ok(&format!("{}{}", twist(angle), layer(art_)));
for (what, r, members, angle) in [
("a square twisted half a turn", read(180.0, &square), 0, 180.0),
("a square twisted half a turn back", read(-180.0, &square), 0, -180.0),
("a straight compound path twisted half a turn", read(180.0, &frame), 2, 180.0),
] {
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{what}: {a:#?}");
assert_eq!(a[0].children().map_or(0, |c| c.len()), members, "{what}: the object, not its look: {a:#?}");
let [e] = a[0].appearance.effects.as_slice() else { panic!("{what}: {:?}", a[0].appearance) };
assert_eq!((e.id.as_str(), e.params["angle"].as_f64()), ("distort.twist", Some(angle)), "{what}");
}
for (what, r) in [
("a square twisted just past half a turn", read(181.0, &square)),
("a square twisted just past half a turn back", read(-181.0, &square)),
("a square twisted three quarters of a turn", read(270.0, &square)),
("a square twisted three quarters of a turn back", read(-270.0, &square)),
("a square twisted two turns", read(720.0, &square)),
("a square twisted ten turns", read(3600.0, &square)),
("a straight compound path twisted a full turn", read(360.0, &frame)),
] {
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{what}: {a:#?}");
assert!(a[0].appearance.effects.is_empty(), "{what}: the drawn look: {a:#?}");
assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{what}: {:?}", r.warnings);
}
}
#[test]
fn a_bare_comment_line_in_a_prolog_leaves_its_end_alone() {
// GUARDS: a bare `%` line's empty marker matched the empty begin marker of a section that has none.
+4 -3
View File
@@ -98,13 +98,14 @@ pub(super) fn rotated(t: &TextObject) -> bool {
})
}
/// Can 8-bit text hold type `t`: horizontal, without ruby (which a text object can't hold), its
/// runs plain characters (no inline graphics) that 8-bit text can hold, filled and stroked with
/// colours?
/// Can 8-bit text hold type `t`: horizontal, without ruby or warichu (which a text object can't
/// hold), its runs plain characters (no inline graphics) that 8-bit text can hold, filled and
/// stroked with colours?
pub(super) fn holds_as_text(t: &TextObject) -> bool {
let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None);
!t.vertical
&& t.ruby.is_empty()
&& !t.stacks_warichu()
&& t.runs.iter().any(|r| !r.text.is_empty())
&& t.runs.iter().all(|r| {
r.inline.is_none()
+6
View File
@@ -1296,6 +1296,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open Tate-chu-yoko Settings (the Character panel menu's Tate-chu-yoko Settings…, with Show East Asian Options) on the characters the Character panel acts on, filled in with the values they share (dialog `tateChuYokoSettings`: tcyUpDown, tcyLeftRight (pt, -1296..1296)): OK runs text.setFormat (text.setRangeStyle while the Type tool edits text) with the values that changed, as one undo step; an error with no type selected",
),
(
"ui.warichuSettings",
"Warichu Settings…",
"",
"{} open Warichu Settings (the Character panel menu's Warichu Settings…, with Show East Asian Options) on the characters the Character panel acts on, filled in with the values they share (dialog `warichuSettings`: warichuLines 1..6, warichuGap (pt, 0..100), warichuScale (%, 10..100), warichuAlign): OK runs text.setFormat (text.setRangeStyle while the Type tool edits text) with the values that changed, as one undo step, and leaves Warichu itself as it is; an error with no type selected",
),
(
"ui.colorGuideLimit",
"Limit Color Guide to Library",
+6 -4
View File
@@ -2,9 +2,9 @@
//! rule, equalise handles, add evenly spaced anchors, close), what its live path effects add to them
//! ([`add_anchors`], [`smooth_anchors`], [`split_at_anchors`]), the readouts the Measure & Inspect
//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]), the places
//! where paths cross ([`crossings`]), anchors slid along their path ([`slide`]), touching edges
//! merged ([`shared_edge`]), paths divided into dashes ([`divide_dashes`]) and the Parametric Shape
//! Tool's shapes ([`shapes`]).
//! where paths cross ([`crossings`]), the stretches between them ([`spans`]), anchors slid along
//! their path ([`slide`]), touching edges merged ([`shared_edge`]), paths divided into dashes
//! ([`divide_dashes`]) and the Parametric Shape Tool's shapes ([`shapes`]).
mod crossings;
mod dashes;
@@ -14,19 +14,21 @@ mod shared_edge;
mod slide;
#[cfg(test)]
mod tests;
mod trim;
use std::collections::BTreeSet;
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
pub use crossings::{MAX_CROSSING_SEGMENTS, crossings, insert_at};
pub use crossings::{MAX_CROSSED_BY_SEGMENTS, MAX_CROSSING_SEGMENTS, Place, Work, crossings, crossings_on, crossings_on_within, insert_at};
pub use dashes::{DashSpec, Piece, divide_dashes};
pub use extend::{ExtendMode, extend_length_to, extend_open};
pub use shared_edge::{
COINCIDENCE, EndSide, Follow, MAX_COINCIDENCE, MergePlan, MergePreview, Stretch, apply_merge, ends_at, merge_preview, stretches_at,
};
pub use slide::{MAX_SLIDE, slide_anchor, slide_stops, slide_t_at};
pub use trim::{Span, slice, span_at, spans, without_spans};
use crate::fit::{end_tangent, start_tangent};
+182 -17
View File
@@ -1,9 +1,10 @@
//! Where paths cross: [`crossings`] finds the places, [`insert_at`] puts anchors there (which
//! Split / Add Points at Crossings then keeps or cuts at).
//! Split / Add Points at Crossings then keeps or cuts at), and [`crossings_on`] finds the places on
//! one path (which Trim at Crossings removes stretches between).
use std::collections::{BTreeMap, BTreeSet};
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, ParamCurveNearest, Point, Rect};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
/// Most segments [`crossings`] compares at once.
@@ -85,16 +86,82 @@ fn segment_crossings(a: &CubicBez, b: &CubicBez, budget: &mut usize) -> Option<V
Some(found)
}
/// Do segments `i` and `j` of `sp` meet at an anchor they share?
fn adjacent(sp: &SubPath, i: usize, j: usize) -> bool {
let n = sp.segment_count();
i.abs_diff(j) == 1 || (sp.closed && n > 1 && i.abs_diff(j) == n - 1)
/// Where segment `c` crosses itself, as its two parameters in order: a cubic loops over itself at
/// most once. `None` when it doesn't, or only at a cusp or an end.
fn self_crossing(c: &CubicBez) -> Option<(f64, f64)> {
// In powers of t, c(t) = a t³ + b t² + k t + p0. Two parameters s and t meet where
// a (s² + st + t²) + b (s + t) + k = 0: with u = s + t and v = st, crossing that with `a` gives u,
// and dotting it with `a` gives v; s and t are the roots of z² - u z + v.
let (p0, p1, p2, p3) = (c.p0.to_vec2(), c.p1.to_vec2(), c.p2.to_vec2(), c.p3.to_vec2());
let a = p3 - p2 * 3.0 + p1 * 3.0 - p0;
let b = (p2 - p1 * 2.0 + p0) * 3.0;
let k = (p1 - p0) * 3.0;
let (ab, aa) = (a.cross(b), a.dot(a));
// A curve with no loop can leave these near zero, however long and thin a loop is: the checks
// at the end drop what that gives.
if ab == 0.0 || aa == 0.0 {
return None;
}
let u = -a.cross(k) / ab;
let v = u * u + (b * u + k).dot(a) / aa;
let disc = u * u - 4.0 * v;
if !disc.is_finite() || disc <= 1e-12 {
return None;
}
let (s, t) = ((u - disc.sqrt()) / 2.0, (u + disc.sqrt()) / 2.0);
let inside = |x: f64| END < x && x < 1.0 - END;
// The two places are one point, and the curve passes it in two directions (a straight curve
// that doubles back on itself meets itself along its length, not at a crossing).
let (ds, dt) = (c.deriv().eval(s).to_vec2(), c.deriv().eval(t).to_vec2());
let across = ds.cross(dt).abs() > 1e-15 * ds.hypot() * dt.hypot();
(inside(s) && inside(t) && across && c.eval(s).distance(c.eval(t)) < SAME).then_some((s, t))
}
/// Where `paths` cross one another (and, with `self_crossings`, where each crosses itself): for
/// each path, the places on it, in order. Segments that meet at an anchor they share don't count as
/// crossing there. `None` past [`MAX_CROSSING_SEGMENTS`] segments or when the curves are too tangled
/// to sort out, or when a coordinate isn't finite.
/// For each segment of `sp`, and one past the last, the first segment at or after it that has a
/// length (the segment count when none has): segments with none lie at one point, between anchors
/// that coincide.
fn with_length_from(sp: &SubPath) -> Vec<usize> {
let n = sp.segment_count();
let mut out = vec![n; n + 1];
for i in (0..n).rev() {
let c = sp.segment(i);
let next = if control_box(&c).size().to_vec2().hypot() < SAME { out.get(i + 1).copied().unwrap_or(n) } else { i };
if let Some(o) = out.get_mut(i) {
*o = next;
}
}
out
}
/// The anchors segments `i` and `j` of `sp` share (`with_length` from [`with_length_from`]): none,
/// one, or both ends for the two segments of a closed subpath of two. Segments with no length between
/// them leave them meeting at that point.
fn shared_anchors(sp: &SubPath, with_length: &[usize], i: usize, j: usize) -> Vec<Point> {
let (n, lo, hi) = (sp.segment_count(), i.min(j), i.max(j));
let from = |k: usize| with_length.get(k).copied().unwrap_or(n);
let mut out = vec![];
if hi > lo && from(lo + 1) >= hi {
out.extend([sp.segment(lo).p3, sp.segment(hi).p0]);
}
// Round the first anchor of a closed subpath.
if sp.closed && n > 1 && hi > lo && from(hi + 1) >= n && from(0) >= lo {
out.extend([sp.segment(hi).p3, sp.segment(lo).p0]);
}
out
}
/// Where segments `a` and `b` cross, without the places at the anchors in `shared`: two segments that
/// meet at an anchor don't cross there, but they can cross again elsewhere.
fn crossings_apart(a: &CubicBez, b: &CubicBez, shared: &[Point], budget: &mut usize) -> Option<Vec<(f64, f64)>> {
let mut found = segment_crossings(a, b, budget)?;
found.retain(|(ta, _)| !shared.iter().any(|p| a.eval(*ta).distance(*p) < SAME));
Some(found)
}
/// Where `paths` cross one another (and, with `self_crossings`, where each crosses itself, a segment
/// that loops over itself included): for each path, the places on it, in order. Segments that meet
/// at an anchor they share don't count as crossing there. `None` past [`MAX_CROSSING_SEGMENTS`]
/// segments or when the curves are too tangled to sort out, or when a coordinate isn't finite.
pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option<Vec<Vec<Place>>> {
let mut segs: Vec<(usize, usize, usize, CubicBez, Rect)> = vec![];
for (k, path) in paths.iter().enumerate() {
@@ -111,19 +178,22 @@ pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option<Vec<Vec<Pl
}
}
}
let with_length: Vec<Vec<Vec<usize>>> = paths.iter().map(|p| p.subpaths.iter().map(with_length_from).collect()).collect();
let mut out: Vec<Vec<Place>> = vec![vec![]; paths.len()];
let mut budget = MAX_STEPS;
for (i, (ka, sa, ia, ca, ra)) in segs.iter().enumerate() {
if self_crossings && let Some((s, t)) = self_crossing(ca) {
out.get_mut(*ka)?.extend([(*sa, *ia, s), (*sa, *ia, t)]);
}
for (kb, sb, ib, cb, rb) in segs.iter().skip(i + 1) {
if ka == kb
&& (!self_crossings || (sa == sb && paths.get(*ka).and_then(|p| p.subpaths.get(*sa)).is_some_and(|sp| adjacent(sp, *ia, *ib))))
{
if (ka == kb && !self_crossings) || !ra.inflate(SAME, SAME).overlaps(*rb) {
continue;
}
if !ra.inflate(SAME, SAME).overlaps(*rb) {
continue;
}
for (ta, tb) in segment_crossings(ca, cb, &mut budget)? {
let shared = match (paths.get(*ka).and_then(|p| p.subpaths.get(*sa)), with_length.get(*ka).and_then(|w| w.get(*sa))) {
(Some(sp), Some(w)) if ka == kb && sa == sb => shared_anchors(sp, w, *ia, *ib),
_ => vec![],
};
for (ta, tb) in crossings_apart(ca, cb, &shared, &mut budget)? {
out.get_mut(*ka)?.push((*sa, *ia, ta));
out.get_mut(*kb)?.push((*sb, *ib, tb));
}
@@ -136,6 +206,101 @@ pub fn crossings(paths: &[&PathData], self_crossings: bool) -> Option<Vec<Vec<Pl
Some(out)
}
/// Most segments of other paths [`crossings_on`] compares one path with.
pub const MAX_CROSSED_BY_SEGMENTS: usize = 200_000;
/// Most pairs of segments [`crossings_on`] looks at.
const MAX_PAIRS: usize = 60_000_000;
/// The work [`crossings_on_within`] may still do: pairs of segments to look at and subdivision steps.
/// One value shared by several calls bounds them all together.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Work {
pairs: usize,
steps: usize,
}
impl Default for Work {
/// The work one [`crossings_on`] call may do.
fn default() -> Self {
Self { pairs: MAX_PAIRS, steps: MAX_STEPS }
}
}
/// Where `others` cross `path`, and where `path` crosses itself (a segment that loops over itself
/// included): the places on `path`, in order, within the work of [`Work::default`].
pub fn crossings_on(path: &PathData, others: &[&PathData]) -> Option<Vec<Place>> {
crossings_on_within(path, others, &mut Work::default())
}
/// [`crossings_on`], drawing on `work`. Only the segments of `others` whose control boxes meet the
/// box of `path` are compared with it, so the paths far from it cost one box test per segment.
/// Segments of `path` that meet at an anchor they share don't count as crossing there, and a
/// segment of `others` with a coordinate that isn't finite is left out. `None` when `path` has more
/// than [`MAX_CROSSING_SEGMENTS`] segments or a coordinate that isn't finite, when more than
/// [`MAX_CROSSED_BY_SEGMENTS`] segments of `others` lie in its box, when the pairs of segments to
/// look at are more than `work` has left, or when the curves are too tangled to sort out in the
/// steps it has left.
pub fn crossings_on_within(path: &PathData, others: &[&PathData], work: &mut Work) -> Option<Vec<Place>> {
let mut own: Vec<(usize, usize, CubicBez, Rect)> = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
for s in 0..sp.segment_count() {
let c = sp.segment(s);
if ![c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
return None;
}
own.push((si, s, c, control_box(&c)));
if own.len() > MAX_CROSSING_SEGMENTS {
return None;
}
}
}
let Some(reach) = own.iter().map(|o| o.3).reduce(|a, b| a.union(b)).map(|r| r.inflate(SAME, SAME)) else { return Some(vec![]) };
let mut near: Vec<(CubicBez, Rect)> = vec![];
for sp in others.iter().flat_map(|p| &p.subpaths) {
for s in 0..sp.segment_count() {
let c = sp.segment(s);
let r = control_box(&c);
if [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()) && reach.overlaps(r) {
near.push((c, r));
if near.len() > MAX_CROSSED_BY_SEGMENTS {
return None;
}
}
}
}
work.pairs = work.pairs.checked_sub(own.len().saturating_mul(near.len() + own.len() / 2))?;
let with_length: Vec<Vec<usize>> = path.subpaths.iter().map(with_length_from).collect();
let mut out: Vec<Place> = vec![];
let budget = &mut work.steps;
for (i, (sa, ia, ca, ra)) in own.iter().enumerate() {
let ra = ra.inflate(SAME, SAME);
if let Some((s, t)) = self_crossing(ca) {
out.extend([(*sa, *ia, s), (*sa, *ia, t)]);
}
for (cb, rb) in &near {
if ra.overlaps(*rb) {
out.extend(segment_crossings(ca, cb, budget)?.into_iter().map(|(ta, _)| (*sa, *ia, ta)));
}
}
for (sb, ib, cb, rb) in own.iter().skip(i + 1) {
if !ra.overlaps(*rb) {
continue;
}
let shared = match (path.subpaths.get(*sa), with_length.get(*sa)) {
(Some(sp), Some(w)) if sa == sb => shared_anchors(sp, w, *ia, *ib),
_ => vec![],
};
for (ta, tb) in crossings_apart(ca, cb, &shared, budget)? {
out.push((*sa, *ia, ta));
out.push((*sb, *ib, tb));
}
}
}
out.sort_by(|x, y| (x.0, x.1).cmp(&(y.0, y.1)).then(x.2.total_cmp(&y.2)));
out.dedup_by(|x, y| (x.0, x.1) == (y.0, y.1) && (x.2 - y.2).abs() < END);
Some(out)
}
/// `sp` with an anchor at each of `places` (segment, t), the shape unchanged → (the subpath, the
/// indexes of the anchors at those places: the new ones, and existing ones where a place falls on
/// an anchor). Places off the subpath are left out.
@@ -434,3 +434,235 @@ fn splitting_reads_an_anchors_kind_from_its_handles() {
assert_eq!(split_at_anchors(&sq, true, false).len(), 3);
assert!(split_at_anchors(&sq, true, false).iter().all(|p| !p.closed));
}
#[test]
fn crossings_on_finds_one_paths_places_among_many() {
// A horizontal line crossed by two uprights, with a self-crossing bow far away.
let across = PathData::single(line(&[(0.0, 50.0), (300.0, 50.0)], false));
let up1 = PathData::single(line(&[(100.0, 0.0), (100.0, 100.0)], false));
let up2 = PathData::single(line(&[(200.0, 0.0), (200.0, 100.0)], false));
let far = PathData::single(line(&[(1000.0, 0.0), (1100.0, 100.0), (1100.0, 0.0), (1000.0, 100.0)], false));
let places = crossings_on(&across, &[&up1, &up2, &far]).unwrap();
assert_eq!(places.len(), 2, "{places:?}");
let x = |p: &Place| across.subpaths[p.0].segment(p.1).eval(p.2).x;
assert!(near(x(&places[0]), 100.0, 1e-6) && near(x(&places[1]), 200.0, 1e-6));
// A path's crossings with itself count, once on each of the two segments.
let own = crossings_on(&far, &[&across]).unwrap();
assert_eq!(own.iter().map(|p| p.1).collect::<Vec<_>>(), [0, 2], "{own:?}");
// A path that ends on another marks the other there, and its own end.
let stub = PathData::single(line(&[(150.0, 50.0), (150.0, 90.0)], false));
assert_eq!(crossings_on(&across, &[&stub]).unwrap().len(), 1);
assert!(near(crossings_on(&stub, &[&across]).unwrap()[0].2, 0.0, 1e-6));
// Lines lying along each other don't cross, and a path with a coordinate that isn't finite is refused.
let along = PathData::single(line(&[(50.0, 50.0), (250.0, 50.0)], false));
assert!(crossings_on(&across, &[&along]).unwrap().is_empty());
let broken = PathData::single(line(&[(0.0, f64::NAN), (10.0, 10.0)], false));
assert!(crossings_on(&broken, &[&across]).is_none());
assert_eq!(crossings_on(&across, &[&broken, &up1]).unwrap().len(), 1, "a broken path beside it is left out");
assert!(crossings_on(&PathData::default(), &[&across]).unwrap().is_empty());
}
#[test]
fn segments_that_share_an_anchor_still_cross_elsewhere() {
// A straight segment, then a curve from its end that swings back across it near x = 17.4.
let hook = SubPath::new(
vec![Anchor::corner(pt(0.0, 0.0)), Anchor { h_out: pt(-50.0, 50.0), ..Anchor::corner(pt(100.0, 0.0)) }, Anchor::corner(pt(50.0, -50.0))],
false,
);
let path = PathData::single(hook.clone());
let places = crossings_on(&path, &[]).unwrap();
assert_eq!(places.iter().map(|p| p.1).collect::<Vec<_>>(), [0, 1], "{places:?}");
let x = hook.segment(0).eval(places[0].2).x;
assert!(near(x, 17.4, 0.01), "{x}");
assert!(hook.segment(1).eval(places[1].2).distance(pt(x, 0.0)) < 1e-3);
// Split at Crossings finds it too, and neither counts the anchor the segments share.
assert_eq!(crossings(&[&path], true).unwrap()[0], places);
// The two segments of a closed subpath share both ends: a lens has no crossing.
let lens = SubPath::new(
vec![
Anchor { h_out: pt(30.0, -40.0), h_in: pt(30.0, 40.0), ..Anchor::corner(pt(0.0, 0.0)) },
Anchor { h_in: pt(70.0, -40.0), h_out: pt(70.0, 40.0), ..Anchor::corner(pt(100.0, 0.0)) },
],
true,
);
assert!(crossings_on(&PathData::single(lens.clone()), &[]).unwrap().is_empty());
assert!(crossings(&[&PathData::single(lens)], true).unwrap()[0].is_empty());
}
#[test]
fn segments_with_no_length_between_them_still_share_their_anchor() {
// An open path with its middle anchor doubled, and a closed one whose last anchor sits on its
// first: the segments either side of the one with no length meet there and don't cross.
let doubled = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false));
let ring = PathData::single(line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)], true));
for p in [&doubled, &ring] {
assert!(crossings_on(p, &[]).unwrap().is_empty(), "{:?}", crossings_on(p, &[]));
assert!(crossings(&[p], true).unwrap()[0].is_empty(), "{:?}", crossings(&[p], true));
}
// A segment that crosses them elsewhere still does.
let bow = PathData::single(line(&[(0.0, 0.0), (100.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)], false));
assert_eq!(crossings_on(&bow, &[]).unwrap().iter().map(|p| p.1).collect::<Vec<_>>(), [0, 3]);
}
#[test]
fn one_work_budget_bounds_several_calls_together() {
// 1,000 segments in a straight row: 500,000 pairs a call, so 120 calls spend 60,000,000.
let row: Vec<(f64, f64)> = (0..=1000).map(|i| (i as f64, 0.0)).collect();
let path = PathData::single(line(&row, false));
let mut work = Work::default();
let calls = (0..200).take_while(|_| crossings_on_within(&path, &[], &mut work).is_some()).count();
assert_eq!(calls, 120);
assert!(crossings_on(&path, &[]).is_some(), "a call of its own has its own budget");
}
#[test]
fn a_segment_that_loops_over_itself_crosses_itself() {
// One cubic that meets itself at t = 0.25 and t = 0.75.
let looped = PathData::single(SubPath::new(
vec![Anchor { h_out: pt(13.0 / 9.0, 1.0), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(0.0, 1.0), ..Anchor::corner(pt(1.0, 0.0)) }],
false,
));
let places = crossings_on(&looped, &[]).unwrap();
assert_eq!(places.len(), 2, "{places:?}");
assert!(near(places[0].2, 0.25, 1e-9) && near(places[1].2, 0.75, 1e-9), "{places:?}");
assert_eq!(crossings(&[&looped], true).unwrap()[0], places);
assert!(crossings(&[&looped], false).unwrap()[0].is_empty());
// Its two visits to that point divide it into three stretches, and the loop can go alone.
let sp = &looped.subpaths[0];
let three = spans(sp, &places.iter().map(|p| (p.1, p.2)).collect::<Vec<_>>());
assert_eq!(three.len(), 3, "{three:?}");
let left = without_spans(sp, &three, &[false, true, false]).unwrap();
assert_eq!(left.len(), 2);
let x = sp.segment(0).eval(0.25);
assert_eq!((left[0].anchors[0].p, left[1].anchors.last().unwrap().p), (pt(0.0, 0.0), pt(1.0, 0.0)));
assert!(left[0].anchors.last().unwrap().p.distance(x) < 1e-3 && left[1].anchors[0].p.distance(x) < 1e-3);
// The same loop squashed to a 10⁻¹² of its height.
let flat = PathData::single(SubPath::new(
vec![
Anchor { h_out: pt(13.0 / 9.0, 1e-12), ..Anchor::corner(pt(0.0, 0.0)) },
Anchor { h_in: pt(0.0, 1e-12), ..Anchor::corner(pt(1.0, 0.0)) },
],
false,
));
assert_eq!(crossings_on(&flat, &[]).unwrap().iter().map(|p| (p.2 * 4.0).round()).collect::<Vec<_>>(), [1.0, 3.0]);
// An arch, an S and a cusp don't cross themselves.
for (h1, h2) in [((0.0, 1.0), (1.0, 1.0)), ((0.3, 1.0), (0.7, -1.0)), ((1.0, 1.0), (0.0, 1.0))] {
let c = PathData::single(SubPath::new(
vec![Anchor { h_out: pt(h1.0, h1.1), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(h2.0, h2.1), ..Anchor::corner(pt(1.0, 0.0)) }],
false,
));
assert!(crossings_on(&c, &[]).unwrap().is_empty(), "{h1:?} {h2:?}");
}
// Nor does a straight curve that doubles back along itself, at a slant.
let back = PathData::single(SubPath::new(
vec![Anchor { h_out: pt(1.6, 1.2), ..Anchor::corner(pt(0.0, 0.0)) }, Anchor { h_in: pt(-0.8, -0.6), ..Anchor::corner(pt(0.8, 0.6)) }],
false,
));
assert!(crossings_on(&back, &[]).unwrap().is_empty());
}
#[test]
fn spans_divide_a_subpath_at_its_places() {
let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
assert_eq!(spans(&open, &[]), [(0.0, 2.0)]);
assert_eq!(spans(&open, &[(1, 0.5), (0, 0.25)]), [(0.0, 0.25), (0.25, 1.5), (1.5, 2.0)]);
// Places at an open end, off the subpath, or twice at one point, add no stretch.
assert_eq!(spans(&open, &[(0, 0.0), (1, 1.0), (7, 0.5), (0, 0.5), (0, 0.5 + 1e-9), (0, f64::NAN)]), [(0.0, 0.5), (0.5, 2.0)]);
// A place at an anchor is the same from either segment.
assert_eq!(spans(&open, &[(0, 1.0), (1, 0.0)]), [(0.0, 1.0), (1.0, 2.0)]);
let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
assert_eq!(spans(&ring, &[]), [(0.0, 4.0)]);
assert_eq!(spans(&ring, &[(1, 0.5)]), [(1.5, 5.5)], "one place: from it round to it again");
assert_eq!(spans(&ring, &[(3, 0.5), (0, 0.5)]), [(0.5, 3.5), (3.5, 4.5)]);
// The closing segment's end is the first anchor.
assert_eq!(spans(&ring, &[(3, 1.0), (0, 0.0), (2, 0.0)]), [(0.0, 2.0), (2.0, 4.0)]);
assert!(spans(&SubPath::new(vec![Anchor::corner(pt(1.0, 1.0))], false), &[(0, 0.5)]).is_empty());
// Which stretch a place is on: the one that starts there, the last one at an open end.
let three = spans(&open, &[(1, 0.5), (0, 0.25)]);
assert_eq!(span_at(&open, &three, 0, 0.1), Some(0));
assert_eq!(span_at(&open, &three, 0, 0.25), Some(1));
assert_eq!(span_at(&open, &three, 1, 1.0), Some(2));
assert_eq!(span_at(&open, &three, 0, f64::NAN), None);
let two = spans(&ring, &[(3, 0.5), (0, 0.5)]);
assert_eq!((span_at(&ring, &two, 0, 0.25), span_at(&ring, &two, 3, 0.75), span_at(&ring, &two, 2, 0.0)), (Some(1), Some(1), Some(0)));
// The closing segment's end is the first anchor: on the stretch that starts there, or runs through it.
let at_first = spans(&ring, &[(0, 0.0), (2, 0.0)]);
assert_eq!((span_at(&ring, &at_first, 3, 1.0), span_at(&ring, &at_first, 0, 0.0)), (Some(0), Some(0)));
assert_eq!((span_at(&ring, &two, 3, 1.0), span_at(&ring, &spans(&ring, &[]), 3, 1.0)), (Some(1), Some(0)));
}
#[test]
fn a_subpath_without_some_stretches_keeps_the_rest_as_it_was() {
// An open line of three segments, crossed twice on its middle one: the middle stretch goes.
let open = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0)], false);
let three = spans(&open, &[(1, 0.25), (1, 0.75)]);
let left = without_spans(&open, &three, &[false, true, false]).unwrap();
let xs = |sp: &SubPath| sp.anchors.iter().map(|a| a.p.x).collect::<Vec<_>>();
// A straight segment's parameter doesn't run evenly along it: the cuts are where it puts them.
let (q1, q3) = (open.segment(1).eval(0.25).x, open.segment(1).eval(0.75).x);
assert!(100.0 < q1 && q1 < q3 && q3 < 200.0);
assert_eq!(left.iter().map(xs).collect::<Vec<_>>(), [vec![0.0, 100.0, q1], vec![q3, 200.0, 300.0]]);
assert!(left.iter().all(|p| !p.closed && p.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p)), "straight pieces have no handles");
// Nothing marked: unchanged. All marked: nothing left. An end stretch: one piece.
assert!(without_spans(&open, &three, &[false, false, false]).is_none());
assert!(without_spans(&open, &three, &[]).is_none());
assert_eq!(without_spans(&open, &three, &[true, true, true]), Some(vec![]));
assert_eq!(without_spans(&open, &three, &[true, false, false]).unwrap().iter().map(xs).collect::<Vec<_>>(), [vec![q1, 200.0, 300.0]]);
// Stretches left side by side stay one piece with no anchor where they meet.
let four = spans(&open, &[(0, 0.5), (1, 0.5), (2, 0.5)]);
assert_eq!(
without_spans(&open, &four, &[false, false, false, true]).unwrap().iter().map(xs).collect::<Vec<_>>(),
[vec![0.0, 100.0, 200.0, 250.0]]
);
// A closed square that loses a stretch opens, and its first anchor stays inside the piece.
let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
let two = spans(&ring, &[(1, 0.5), (2, 0.5)]);
let open_ring = without_spans(&ring, &two, &[true, false]).unwrap();
assert_eq!(open_ring.len(), 1);
let pts: Vec<Point> = open_ring[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts, [pt(50.0, 100.0), pt(0.0, 100.0), pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 50.0)]);
assert!(!open_ring[0].closed);
// The same when the removed stretch is the one through the first anchor.
let through = without_spans(&ring, &two, &[false, true]).unwrap();
assert_eq!(through[0].anchors.iter().map(|a| a.p).collect::<Vec<_>>(), [pt(100.0, 50.0), pt(100.0, 100.0), pt(50.0, 100.0)]);
// Three places: the two stretches left on both sides of the first anchor are one piece through it.
let three = spans(&ring, &[(0, 0.5), (1, 0.5), (2, 0.5)]);
assert_eq!(three, [(0.5, 1.5), (1.5, 2.5), (2.5, 4.5)]);
let around = without_spans(&ring, &three, &[false, true, false]).unwrap();
assert_eq!(around.len(), 1, "{around:?}");
let pts: Vec<Point> = around[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts, [pt(50.0, 100.0), pt(0.0, 100.0), pt(0.0, 0.0), pt(100.0, 0.0), pt(100.0, 50.0)]);
// A closed subpath with one place loses all of itself.
assert_eq!(without_spans(&ring, &spans(&ring, &[(0, 0.5)]), &[true]), Some(vec![]));
}
#[test]
fn a_slice_of_a_curve_keeps_its_shape_and_the_anchors_it_runs_through() {
let mut circle = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
for i in 0..4 {
auto_smooth(&mut circle, i, SMOOTH_RATIO);
}
let piece = slice(&circle, (0.5, 2.5));
assert_eq!(piece.anchors.len(), 4);
assert!(!piece.closed);
// Its ends are corners with the handle off the piece retracted; the anchors between stay smooth, and
// the whole segment between them keeps its handles.
assert_eq!((piece.anchors[0].kind, piece.anchors[3].kind), (AnchorKind::Corner, AnchorKind::Corner));
assert_eq!((piece.anchors[0].h_in, piece.anchors[3].h_out), (piece.anchors[0].p, piece.anchors[3].p));
assert_eq!((piece.anchors[1].p, piece.anchors[2].p), (circle.anchors[1].p, circle.anchors[2].p));
assert_eq!((piece.anchors[1].h_out, piece.anchors[2].h_in), (circle.anchors[1].h_out, circle.anchors[2].h_in));
assert_eq!((piece.anchors[1].kind, piece.anchors[2].kind), (AnchorKind::Smooth, AnchorKind::Smooth));
// Every point of the piece is on the circle's outline.
for i in 0..piece.segment_count() {
for k in 0..=8 {
let p = piece.segment(i).eval(k as f64 / 8.0);
let d = PathData::single(circle.clone()).nearest(p).unwrap().4;
assert!(d < 1e-6, "{p:?} is {d} off");
}
}
assert!(near(length(&piece), length(&circle) / 2.0, 1e-3));
// No length, backwards or not finite: nothing.
assert!(slice(&circle, (1.0, 1.0)).anchors.is_empty());
assert!(slice(&circle, (2.0, 1.0)).anchors.is_empty());
assert!(slice(&circle, (f64::NAN, 1.0)).anchors.is_empty());
}
+153
View File
@@ -0,0 +1,153 @@
//! The stretches of a subpath between the places it is crossed ([`spans`]), the stretch a place
//! lies on ([`span_at`]), a stretch as a subpath of its own ([`slice`]) and the subpath with some
//! of its stretches removed ([`without_spans`]).
use kurbo::{ParamCurve, Point};
use vectorcraft_geom::{Anchor, AnchorKind, SubPath};
/// A parameter this close to a segment's end is its anchor.
const END: f64 = 1e-6;
/// Places closer than this (pt) are one.
const SAME: f64 = 1e-3;
/// A stretch of a subpath: where it starts and ends, each a segment's index plus the parameter on
/// that segment. On a closed subpath the end is past the segment count where the stretch runs
/// through the first anchor.
pub type Span = (f64, f64);
/// The point at position `u` (a segment's index plus the parameter on it) of `sp`, which has
/// segments. A closed subpath's positions wrap round.
fn point_at(sp: &SubPath, u: f64) -> Point {
let n = sp.segment_count();
let u = if sp.closed { u.rem_euclid(n as f64) } else { u.clamp(0.0, n as f64) };
let seg = (u.floor() as usize).min(n.saturating_sub(1));
sp.segment(seg).eval(u - seg as f64)
}
/// The stretches `places` (segment, t) divide `sp` into, in the subpath's order. An open subpath
/// starts and ends a stretch at its ends; a closed one with no place is one stretch, the whole of
/// it, and so is a closed one with one place, from that place round to it again. Places the subpath
/// runs between without moving 0.001 pt away are one (a loop's two visits to a point are two), so
/// are a place and an open end, and places off the subpath are left out.
pub fn spans(sp: &SubPath, places: &[(usize, f64)]) -> Vec<Span> {
let n = sp.segment_count();
if n == 0 {
return vec![];
}
let nf = n as f64;
let mut at: Vec<f64> = places
.iter()
.filter(|(seg, t)| *seg < n && t.is_finite())
.map(|&(seg, t)| {
let t = if t <= END {
0.0
} else if t >= 1.0 - END {
1.0
} else {
t
};
let u = seg as f64 + t;
if sp.closed && u >= nf { u - nf } else { u }
})
.collect();
at.sort_by(f64::total_cmp);
// Two positions are one place when the subpath stays at the same point between them; a loop
// that comes back to a point visits it twice.
let same = |a: f64, b: f64| {
let p = point_at(sp, a);
(b - a).abs() < 1.0 && [0.25, 0.5, 0.75, 1.0].iter().all(|f| point_at(sp, a + (b - a) * f).distance(p) < SAME)
};
at.dedup_by(|later, earlier| same(*earlier, *later));
if !sp.closed {
at.retain(|u| !same(0.0, *u) && !same(*u, nf));
let bounds: Vec<f64> = std::iter::once(0.0).chain(at).chain([nf]).collect();
return bounds.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect();
}
// The last place and the first meet round the first anchor.
while let (Some(&first), Some(&last)) = (at.first(), at.last())
&& at.len() > 1
&& same(last - nf, first)
{
at.pop();
}
let Some(&first) = at.first() else { return vec![(0.0, nf)] };
at.push(first + nf);
at.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
/// Which of `stretches` of `sp` the place (`seg`, `t`) lies on. A place where two stretches meet
/// is on the one that starts there.
pub fn span_at(sp: &SubPath, stretches: &[Span], seg: usize, t: f64) -> Option<usize> {
let nf = sp.segment_count() as f64;
if !t.is_finite() {
return None;
}
let u = (seg as f64 + t.clamp(0.0, 1.0)).min(nf);
// On a closed subpath the closing segment's end is the first anchor.
let u = if sp.closed && u >= nf { u - nf } else { u };
let on = |&(from, to): &Span| (from <= u && u < to) || (sp.closed && from <= u + nf && u + nf < to);
stretches.iter().position(on).or_else(|| (!sp.closed && u >= nf).then(|| stretches.len().checked_sub(1)).flatten())
}
/// The stretch `(from, to)` of `sp` as an open subpath of its own: the anchors it runs through keep
/// their kind and handles, and its ends are corners. Empty when the stretch has no length.
pub fn slice(sp: &SubPath, (from, to): Span) -> SubPath {
let n = sp.segment_count();
let mut anchors: Vec<Anchor> = vec![];
if n == 0 || !(from.is_finite() && to.is_finite()) || from < 0.0 || to <= from {
return SubPath::new(anchors, false);
}
let to = to.min(from + n as f64);
let mut i = from.floor() as usize;
while (i as f64) < to {
let (t0, t1) = ((from - i as f64).max(0.0), (to - i as f64).min(1.0));
let seg = i % n;
i += 1;
if t1 <= t0 {
continue;
}
let whole = sp.segment(seg);
let c = if t0 <= 0.0 && t1 >= 1.0 { whole } else { whole.subsegment(t0..t1) };
let (h1, h2) = if sp.segment_is_line(seg) { (c.p0, c.p3) } else { (c.p1, c.p2) };
match anchors.last_mut() {
Some(a) => a.h_out = h1,
None => anchors.push(Anchor { p: c.p0, h_in: c.p0, h_out: h1, kind: AnchorKind::Corner }),
}
// An anchor the stretch runs through keeps its kind.
let through = t1 >= 1.0 && (i as f64) < to;
let kind = if through { sp.anchors.get((seg + 1) % sp.anchors.len()).map_or(AnchorKind::Corner, |a| a.kind) } else { AnchorKind::Corner };
anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind });
}
SubPath::new(anchors, false)
}
/// `sp` without the stretches of `stretches` that `remove` marks: the pieces left, each an open
/// subpath, in the subpath's order. Stretches left side by side stay one piece, so no anchor is
/// added where a stretch that stays meets another. `None` when no stretch is marked, and no piece
/// when all are.
pub fn without_spans(sp: &SubPath, stretches: &[Span], remove: &[bool]) -> Option<Vec<SubPath>> {
let n = stretches.len();
let gone = |i: usize| remove.get(i).copied().unwrap_or(false);
let first_gone = (0..n).find(|&i| gone(i))?;
let nf = sp.segment_count() as f64;
// A closed subpath is walked from its first removed stretch, so the stretches left on both
// sides of its first anchor are one piece.
let start = if sp.closed { first_gone } else { 0 };
let mut out = vec![];
let mut run: Option<Span> = None;
for k in 0..n {
let i = (start + k) % n;
let Some(&(from, to)) = stretches.get(i).filter(|_| !gone(i)) else {
out.extend(run.take().map(|r| slice(sp, r)));
continue;
};
run = Some(match run {
// Past the first anchor of a closed subpath the positions start again.
Some((a, b)) => (a, if to < b { to + nf } else { to }),
None => (from, to),
});
}
out.extend(run.map(|r| slice(sp, r)));
out.retain(|p| p.anchors.len() >= 2);
Some(out)
}
+59 -5
View File
@@ -69,15 +69,25 @@ impl Exporter<'_> {
}
return;
}
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
// where the elements above it don't paint (the same look for Normal blending). The masks
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
let elements: Vec<_> = Node::knockout_elements(children)
.into_iter()
.map(|c| (c, self.measure(c)))
.filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
.collect();
if !elements.is_empty() && elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
return self.warp_pieces(s, &elements, page);
}
for (c, m) in &elements {
self.measured_node(s, c, page, false, Some(m));
}
return;
}
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
// where the elements above it don't paint (the same look for Normal blending). The masks
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
let Some(((first, measured), rest)) = elements.split_first() else { return };
for (c, m) in rest.iter().rev() {
let mask = self.knockout_mask(s, c, page, m);
@@ -101,6 +111,48 @@ impl Exporter<'_> {
s.opacity = 1.0;
s
};
self.shapes_mask(s, &[(&shape, Some(measured))], page)
}
/// The pieces of a partly transparent mesh-warped image (`elements`, each with its box) as the
/// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece
/// is drawn through one soft mask of where the pieces above it that reach its box don't cover
/// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a
/// few triangles and no mask nests in another. A piece above that overlaps it only over opaque
/// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask.
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc<Node>, Measured)], page: Rect) {
// Each piece's triangle and its box: a piece paints inside its triangle only.
let silhouette = |c: &Node| {
let b = c.knockout_outline()?.geometric_bounds()?;
Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b))
};
let silhouettes: Vec<Option<(Node, Rect)>> = elements.iter().map(|(c, _)| silhouette(c)).collect();
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces);
for (i, (c, measured)) in elements.iter().enumerate() {
let Some((_, reach)) = &silhouettes[i] else { continue };
let reach = reach.inflate(1.0, 1.0);
let shapes: Vec<(&Node, Option<&Measured>)> = silhouettes
.iter()
.enumerate()
.skip(i + 1)
.filter_map(|(j, s)| s.as_ref().filter(|(_, b)| rects_overlap(*b, reach) && seams.shows_through(i, j)))
.map(|(n, _)| (n, None))
.collect();
if shapes.is_empty() {
self.measured_node(s, c, page, false, Some(measured));
continue;
}
let mask = self.shapes_mask(s, &shapes, page);
s.push_mask(mask);
self.measured_node(s, c, page, false, Some(measured));
s.pop();
}
}
/// A luminosity mask that is 1 − the coverage of `shapes` (each with its box when measured):
/// white, then black through an alpha mask of the shapes drawn one over another.
fn shapes_mask(&mut self, s: &mut Surface, shapes: &[(&Node, Option<&Measured>)], page: Rect) -> krilla::mask::Mask {
let transform = s.group_transform();
let mut sb = s.stream_builder();
let mut ms = Surface::stream(sb.surface());
@@ -112,7 +164,9 @@ impl Exporter<'_> {
let mut als = Surface::stream(ab.surface());
als.push_transform(&transform);
let overprint = std::mem::take(&mut self.overprint);
self.measured_node(&mut als, &shape, page, false, Some(measured));
for (shape, measured) in shapes {
self.measured_node(&mut als, shape, page, false, *measured);
}
self.overprint = overprint;
als.pop();
als.finish();
+3 -2
View File
@@ -38,8 +38,9 @@ impl Exporter<'_> {
// stroke overprints.
let text_space = xf(t.xf);
// Ruby can't be real text in one run (it isn't characters of the base text): the type's
// characters go out as outlines where ruby is present, ruby included.
let plain = (!self.outline_text && t.ruby.is_empty()).then(|| t.plain_text());
// characters go out as outlines where ruby is present, ruby included. Warichu's stacked
// lines go out as outlines too.
let plain = (!self.outline_text && t.ruby.is_empty() && !t.stacks_warichu()).then(|| t.plain_text());
let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t);
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
+14 -3
View File
@@ -284,9 +284,20 @@ impl Renderer {
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None);
self.nested += 1;
let shape = compose != Compose::Plus && !c.knockout_shape;
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
self.draw_arc(ctx, f, c);
self.shape_of = 0;
// A piece of a mesh-warped image knocks out its whole triangle, where its neighbors
// overlap it, at full coverage whatever the image's own alpha there.
if shape && let Some(outline) = c.knockout_outline() {
if let Some(region) = self.clip_of(outline) {
ctx.set_transform(f.view);
ctx.set_paint(f.ink.fixed([255, 255, 255, 255]));
ctx.set_fill_rule(fill_rule(region.1));
ctx.fill_path(&region.0);
}
} else {
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
self.draw_arc(ctx, f, c);
self.shape_of = 0;
}
self.nested -= 1;
ctx.pop_layer();
}
+1
View File
@@ -25,6 +25,7 @@ mod knockout;
mod layeropts;
mod longstroke;
mod memory;
mod meshwarp;
mod movedstroke;
mod objectfx;
mod paintedbrush;
+4
View File
@@ -931,6 +931,8 @@ fn brush_art_draws_edits_of_the_symbols_and_patterns_it_shows() {
painted.transform(Affine::translate((0.0, 8.0)), true);
let scatter = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(painted)]);
let mut lib = vectorcraft_brush::library(&d);
// The default library has its own `Dots`, which a stroke would draw with first.
lib.retain(|b| b.name != "Dots");
lib.push(Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, spacing: (150.0, 150.0), ..Default::default() }) });
vectorcraft_brush::store(&mut d, &lib);
let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
@@ -997,6 +999,8 @@ fn brush_art_draws_renamed_symbols_and_patterns_it_shows() {
painted.transform(Affine::translate((0.0, 8.0)), true);
let scatter = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(painted)]);
let mut lib = vectorcraft_brush::library(&d);
// The default library has its own `Dots`, which a stroke would draw with first.
lib.retain(|b| b.name != "Dots");
lib.push(Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, spacing: (150.0, 150.0), ..Default::default() }) });
vectorcraft_brush::store(&mut d, &lib);
let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
+104
View File
@@ -0,0 +1,104 @@
//! The pieces a raster mesh warp cuts an image into (envelopes, Effect › Warp) overlap their
//! neighbors: a partly transparent image keeps its own alpha where they do.
use crate::*;
use vectorcraft_doc::live::mesh_warp_image;
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Point;
/// A 4 × 4 px red PNG, every pixel `alpha` opaque.
fn png(alpha: u8) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, alpha]));
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// The image of [`png`] over (10, 10)–(90, 90), pushed through `f` as 4 × 4 cells (with `masked`,
/// as Preserve Shape Using Transparency cuts it), on a page of 100 × 100 pt, after `tweak`
/// adjusts the warped group.
fn warped(f: &dyn Fn(Point) -> Point, masked: bool, pixelated: bool, tweak: impl FnOnce(&mut Node)) -> Document {
let mut d = Document::new(100.0, 100.0);
d.images.insert("pic".into(), ImageBlob::png(png(128)));
let im = ImageObject {
key: "pic".into(),
width: 4,
height: 4,
xf: Affine::translate((10.0, 10.0)) * Affine::scale(20.0),
link: None,
placement: Default::default(),
pixels: None,
};
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
let mut g = mesh_warp_image(&n, &im, f, 4, pixelated, masked);
tweak(&mut g);
d.insert(Some(d.layers[0].id), 0, g).unwrap();
d
}
/// `d` at 4 px a point over transparency.
fn drawn(d: &Document) -> Rendered {
Renderer::new().render(d, 400, 400, Affine::scale(4.0), &RenderOptions { background: None, ..Default::default() })
}
/// The pixels of `r` inside (x0, y0)–(x1, y1) (points) whose alpha is more than 4 off `want`, with
/// that alpha: the first few.
fn uneven(r: &Rendered, (x0, y0, x1, y1): (u32, u32, u32, u32), want: u8) -> Vec<(u32, u32, u8)> {
let mut out = vec![];
for y in y0 * 4..y1 * 4 {
for x in x0 * 4..x1 * 4 {
let a = r.pixel(x, y)[3];
if (i32::from(a) - i32::from(want)).abs() > 4 && out.len() < 8 {
out.push((x, y, a));
}
}
}
out
}
/// Inside the image, away from its outer edges.
const INSIDE: (u32, u32, u32, u32) = (12, 12, 88, 88);
#[test]
fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() {
let same = |p: Point| p;
for masked in [false, true] {
for pixelated in [false, true] {
let r = drawn(&warped(&same, masked, pixelated, |_| {}));
// The cells meet at x and y = 30, 50 and 70, and each cell's diagonal crosses it.
assert_eq!(uneven(&r, INSIDE, 128), vec![], "masked {masked}, pixelated {pixelated}");
}
}
// A wave bends every edge between the pieces.
let wave = |p: Point| Point::new(p.x + 3.0 * (p.y / 7.0).sin(), p.y + 2.0 * (p.x / 9.0).cos());
for masked in [false, true] {
let r = drawn(&warped(&wave, masked, false, |_| {}));
assert_eq!(uneven(&r, (16, 16, 84, 84), 128), vec![], "waved, masked {masked}");
}
}
#[test]
fn the_images_opacity_applies_once_over_the_warp_pieces() {
let same = |p: Point| p;
for masked in [false, true] {
let r = drawn(&warped(&same, masked, false, |g| g.opacity = 0.5));
assert_eq!(uneven(&r, INSIDE, 64), vec![], "masked {masked}");
}
}
/// A clip group of a half transparent image made by hand, in a knockout group, knocks out by
/// where its image is opaque: only the pieces of a warp knock out by their whole triangle.
#[test]
fn other_clip_groups_in_a_knockout_group_knock_out_by_their_alpha() {
let same = |p: Point| p;
let d = warped(&same, false, false, |g| {
let NodeKind::Group { children, .. } = &mut g.kind else { panic!("a group") };
for c in children {
Arc::make_mut(c).extras_mut().warp_piece = false;
}
});
let r = drawn(&d);
// On the shared edge at x = 30, y = 20: the neighbors' alphas add up there.
let a = r.pixel(120, 80)[3];
assert!(a > 160, "{a}");
}
+71 -4
View File
@@ -122,14 +122,21 @@ impl Reuse {
}
/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]):
/// more than one, each a clip group of a triangle and one and the same image?
/// more than one, each a clip group of a triangle and one and the same image, or that image masked
/// by a triangle (Preserve Shape Using Transparency)?
fn image_pieces(children: &[std::sync::Arc<Node>]) -> bool {
let mut first: Option<(&str, u32, u32)> = None;
children.len() > 1
&& children.iter().all(|c| {
let NodeKind::Group { children, clip: true } = &c.kind else { return false };
let [clip, im] = children.as_slice() else { return false };
let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false };
let (shape, im) = match (&c.kind, c.extras().mask.as_deref()) {
(NodeKind::Group { children, clip: true }, _) => match children.as_slice() {
[clip, im] => (&**clip, &**im),
_ => return false,
},
(NodeKind::Image(_), Some(m)) => (&*m.art, &**c),
_ => return false,
};
let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&shape.kind, &im.kind) else { return false };
let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3;
let image = (im.key.as_str(), im.width, im.height);
triangle && *first.get_or_insert(image) == image
@@ -578,6 +585,16 @@ impl Writer<'_> {
return;
}
let elements = Node::knockout_elements(children);
if !elements.is_empty() && elements.iter().all(|c| c.knockout_outline().is_some()) {
let pieces: Vec<&Node> = elements.iter().map(|c| &***c).collect();
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
return self.warp_pieces(&elements);
}
for c in elements {
self.node(c);
}
return;
}
// Where every element over another covers it fully, drawing them in order knocks out as
// much: the masks would only add seams along the shapes' antialiased edges.
if elements.iter().skip(1).all(|c| paints_opaque(c)) {
@@ -616,6 +633,56 @@ impl Writer<'_> {
}
}
/// The pieces of a partly transparent mesh-warped image (`elements`) as the elements of a
/// knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece is drawn
/// through one mask of where the pieces above it that reach it don't cover
/// (their triangles, [`vectorcraft_effects::knockout_silhouette`], black over white):
/// only neighbors overlap, so each mask holds a few triangles and no mask nests in another.
/// A piece above that overlaps it only over opaque pixels ([`vectorcraft_effects::WarpSeams`])
/// is left out of its mask.
fn warp_pieces(&mut self, elements: &[&std::sync::Arc<Node>]) {
// Each piece's triangle and its box: a piece paints inside its triangle only.
let silhouette = |c: &Node| {
let b = c.knockout_outline()?.geometric_bounds()?;
Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::BLACK)?, b))
};
let silhouettes: Vec<Option<(Node, Rect)>> = elements.iter().map(|c| silhouette(c)).collect();
let pieces: Vec<&Node> = elements.iter().map(|c| &***c).collect();
let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces);
for (i, c) in elements.iter().enumerate() {
let Some((own, reach)) = &silhouettes[i] else { continue };
let reach = reach.inflate(1.0, 1.0);
let overlaps = |b: &Rect| b.x0 <= reach.x1 && reach.x0 <= b.x1 && b.y0 <= reach.y1 && reach.y0 <= b.y1;
let above: Vec<&Node> = silhouettes
.iter()
.enumerate()
.skip(i + 1)
.filter_map(|(j, s)| s.as_ref().filter(|(_, b)| overlaps(b) && seams.shows_through(i, j)))
.map(|(n, _)| n)
.collect();
if above.is_empty() {
self.node(c);
continue;
}
let region = self.region_attrs(&[own]);
let anonymous = std::mem::replace(&mut self.anonymous, true);
let art: Vec<String> = above.iter().map(|a| self.detached(2, |w| w.node(a))).collect();
self.anonymous = anonymous;
let mid = self.fresh_id("knockout");
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}>"));
self.def(2, &format!("<rect {region} fill=\"white\"/>"));
for a in art {
self.defs.push_str(&a);
}
self.def(1, "</mask>");
self.line(&format!("<g mask=\"url(#{mid})\">"));
self.depth += 1;
self.node(c);
self.depth -= 1;
self.line("</g>");
}
}
/// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object
/// opacity without its own mask, unless those define its shape). `region` is the attributes of
/// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares.
+8 -3
View File
@@ -82,7 +82,8 @@ impl Writer<'_> {
let heights = vectorcraft_text::FontDb::global().font_heights_resolved(&resolved);
let t = &*heights;
let inline = t.runs.iter().any(|r| r.inline.is_some());
// Ruby is written as outlines with its base text: a reader's <text> can't place it. So are
// Ruby is written as outlines with its base text: a reader's <text> can't place it. So is
// type with warichu, whose stacked lines a reader's <text> can't place either. So are
// small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869),
// and a glyph chosen for a character, which a reader's <text> draws with the default glyph. So
// is type in one of the document's composite fonts, which a reader can't find by its name.
@@ -92,8 +93,12 @@ impl Writer<'_> {
|| r.style.glyph_alternate.is_some()
|| vectorcraft_doc::CompositeFont::named(&doc.composite_fonts, &r.style.font_family).is_some()
}) || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty()));
let outlined =
self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable;
let outlined = self.opts.outline_text
|| t.vertical
|| !t.ruby.is_empty()
|| t.stacks_warichu()
|| (inline && matches!(t.kind, TextKind::OnPath { .. }))
|| unwritable;
let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) };
let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none());
// Live type with an inside or outside character stroke writes that stroke apart from the
+1
View File
@@ -41,3 +41,4 @@ mod text_layout;
mod text_paint_order;
mod text_stroke;
mod title_control_chars;
mod warp_pieces;
+132
View File
@@ -0,0 +1,132 @@
//! The pieces a raster mesh warp cuts an image into overlap their neighbors: in SVG a partly
//! transparent image keeps its own alpha where they do, and the pieces share one copy of the image.
use vectorcraft_doc::live::mesh_warp_image;
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Point};
use vectorcraft_svg::{ExportOptions, export_full};
/// A 100 × 100 pt page holding a 4 × 4 px red image at half alpha over (10, 10)–(90, 90), pushed
/// through `f` as 4 × 4 cells (with `masked`, as Preserve Shape Using Transparency cuts it).
fn warped(f: &dyn Fn(Point) -> Point, masked: bool) -> Document {
warped_pixels(f, masked, [255, 0, 0, 128], |_| {})
}
/// [`warped`] with every pixel `rgba`, after `tweak` adjusts the image object.
fn warped_pixels(f: &dyn Fn(Point) -> Point, masked: bool, rgba: [u8; 4], tweak: impl FnOnce(&mut Node)) -> Document {
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba(rgba));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = Document::new(100.0, 100.0);
d.images.insert("pic".into(), ImageBlob::png(png));
let im = ImageObject {
key: "pic".into(),
width: 4,
height: 4,
xf: Affine::translate((10.0, 10.0)) * Affine::scale(20.0),
link: None,
placement: Default::default(),
pixels: None,
};
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
tweak(&mut n);
let g = mesh_warp_image(&n, &im, f, 4, false, masked);
d.insert(d.default_layer(), 0, g).unwrap();
d
}
/// `svg` drawn at 4 px a point over transparency: of the pixels inside (12, 12)–(88, 88), the most
/// and the least opaque, each with its position.
fn alpha_range(svg: &str) -> ((u32, u32, u8), (u32, u32, u8)) {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).unwrap();
let mut pixmap = resvg::tiny_skia::Pixmap::new(400, 400).unwrap();
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(4.0, 4.0), &mut pixmap.as_mut());
let (mut most, mut least) = ((0, 0, 0u8), (0, 0, 255u8));
for y in 48..352 {
for x in 48..352 {
let a = pixmap.pixel(x, y).unwrap().alpha();
if a > most.2 {
most = (x, y, a);
}
if a < least.2 {
least = (x, y, a);
}
}
}
(most, least)
}
#[test]
fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() {
let same = |p: Point| p;
for masked in [false, true] {
let svg = export_full(&warped(&same, masked), &ExportOptions::default(), None).svg;
// Where pieces overlap, the image's alpha does not add up (it reached 75 % there). The
// viewer rasterizes each piece's triangle and the masks over it one by one, so pixels on a
// triangle's antialiased edge come out a little lighter.
let (most, least) = alpha_range(&svg);
assert!(most.2 <= 134, "masked {masked}: {most:?}");
assert!(least.2 >= 104, "masked {masked}: {least:?}");
// The 32 pieces draw one copy of the image.
assert_eq!(svg.matches("data:image/png;base64,").count(), 1, "masked {masked}");
}
}
/// An image whose left half is opaque and whose right half is at half alpha, cut into 4 × 4 cells:
/// its opaque pixels stay opaque on the pieces' antialiased edges, away from the partly
/// transparent pixels, and the right half stays half transparent.
#[test]
fn opaque_pixels_stay_opaque_where_warp_pieces_overlap() {
let img = image::RgbaImage::from_fn(16, 16, |x, _| image::Rgba([255, 0, 0, if x < 8 { 255 } else { 128 }]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
for masked in [false, true] {
let mut d = Document::new(100.0, 100.0);
d.images.insert("pic".into(), ImageBlob::png(png.clone()));
let im = ImageObject {
key: "pic".into(),
width: 16,
height: 16,
xf: Affine::translate((10.0, 10.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
pixels: None,
};
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
let g = mesh_warp_image(&n, &im, &|p| p, 4, false, masked);
d.insert(d.default_layer(), 0, g).unwrap();
let svg = export_full(&d, &ExportOptions::default(), None).svg;
let tree = resvg::usvg::Tree::from_str(&svg, &resvg::usvg::Options::default()).unwrap();
let mut pixmap = resvg::tiny_skia::Pixmap::new(400, 400).unwrap();
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(4.0, 4.0), &mut pixmap.as_mut());
let alpha = |x0: u32, x1: u32| {
let mut range = (255u8, 0u8);
for y in 48..352 {
for x in x0..x1 {
let a = pixmap.pixel(x, y).unwrap().alpha();
range = (range.0.min(a), range.1.max(a));
}
}
range
};
// The first column of cells and its seam with the second, (12, 12)–(29.75, 88).
let opaque = alpha(48, 119);
assert!(opaque.0 >= 253, "masked {masked}: {opaque:?}");
// The right half, (62, 12)–(88, 88).
let half = alpha(248, 352);
assert!(half.0 >= 104 && half.1 <= 134, "masked {masked}: {half:?}");
}
}
/// Pieces of an opaque image are drawn one over another without knockout masks; a colorize paint
/// draws a gray image's light pixels partly transparent, so its pieces take them.
#[test]
fn only_pieces_that_show_through_take_knockout_masks() {
let same = |p: Point| p;
let knockouts = |d: &Document| export_full(d, &ExportOptions::default(), None).svg.matches("id=\"knockout").count();
assert_eq!(knockouts(&warped_pixels(&same, false, [128, 128, 128, 255], |_| {})), 0);
let colorized = warped_pixels(&same, false, [128, 128, 128, 255], |n| {
n.extras_mut().colorize = Some(Box::new(vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb(1.0, 0.0, 0.0))));
});
assert!(knockouts(&colorized) > 0);
}
+10
View File
@@ -75,6 +75,11 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
tcy_up_down,
tcy_left_right,
standard_vertical_roman,
warichu,
warichu_lines,
warichu_gap,
warichu_scale,
warichu_align,
digits,
font_height_reference,
no_break,
@@ -123,6 +128,11 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
&& tcy_up_down == &b.tcy_up_down
&& tcy_left_right == &b.tcy_left_right
&& standard_vertical_roman == &b.standard_vertical_roman
&& warichu == &b.warichu
&& warichu_lines == &b.warichu_lines
&& warichu_gap == &b.warichu_gap
&& warichu_scale == &b.warichu_scale
&& warichu_align == &b.warichu_align
&& digits == &b.digits
&& font_height_reference == &b.font_height_reference
&& no_break == &b.no_break

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