feat: add SVG Paste As and external smart-object editing

Add bounded Windows SVG clipboard capture and five explicit paste modes,
external SVG Smart Object working-copy synchronization, and shared Shape
Tools controls from #3446. Preserve color profiles, edit guards, atomic
undo, existing clipboard routes, and English visual fixtures.

Keep the browser release within the existing 24 MiB WASM size gate.
This commit is contained in:
Skyfuller1303
2026-10-11 20:20:11 +02:00
parent 6f85054288
commit 897f200727
141 changed files with 18196 additions and 1351 deletions
+2
View File
@@ -80,6 +80,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
| `docs/images/vector-paste-dialog-*-en.png` | English Paste As dialog, dark and light themes (no artwork) | PhotoCraft contributors | Offscreen `vector_paste_ui::tests::capture_vector_paste_visual_fixtures` with a synthetic clipboard provider | MIT OR Apache-2.0 |
| `docs/images/smart-svg-editor-*-en.png` | English SVG Smart Object editor chooser, dark and light themes (no artwork) | PhotoCraft contributors | Offscreen `smart_svg_ui::tests::capture_smart_svg_visual_fixtures` with a synthetic editor service | MIT OR Apache-2.0 |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
Generated
+3
View File
@@ -4195,6 +4195,7 @@ version = "0.8.2"
dependencies = [
"arboard",
"async-io",
"clipboard-win",
"core-graphics2",
"dispatch2",
"eframe",
@@ -4224,6 +4225,7 @@ dependencies = [
"rfd",
"ron",
"serde_json",
"tempfile",
"winit",
"winresource",
"winsafe",
@@ -4448,6 +4450,7 @@ dependencies = [
"blake3",
"crc32fast",
"flate2",
"imagesize",
"photocraft-affinity",
"photocraft-algo",
"photocraft-cms",
+21 -13
View File
@@ -228,12 +228,6 @@ fn warn_all(err: &mut dyn Write, ws: &[String]) {
}
}
/// The engine knows which type families aren't installed. Warn before an export
/// uses a fallback face rather than silently changing the appearance of the text.
fn missing_font_warnings(fonts: Vec<String>) -> Vec<String> {
fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect()
}
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
.map_err(|error| error.to_string())
@@ -264,10 +258,7 @@ fn info(a: &Args, out: &mut dyn Write) -> R {
let mut h = Headless::trusted_local();
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String));
}
let warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file);
// Drop per-session noise.
@@ -306,7 +297,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
if let Some(st) = h.session.active() {
reported_fonts.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&st.doc).into_iter().filter(|w| ws.contains(w)));
}
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@@ -326,7 +319,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let new_warnings: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.map(|st| photocraft_engine::type_extra_cmds::missing_font_warnings(&st.doc))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
@@ -342,7 +335,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let remaining: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.map(|st| photocraft_engine::type_extra_cmds::missing_font_warnings(&st.doc))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
@@ -641,6 +634,7 @@ mod missing_font_warning_tests {
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert_eq!(info["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().is_some_and(|s| s.contains(family))).count(), 1);
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
@@ -653,6 +647,20 @@ mod missing_font_warning_tests {
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
assert!(stderr.contains("warning: 2 layer(s) flattened; layers, masks and blend modes are not kept"), "{stderr}");
// Opening reports the original font; commands report only newly introduced fonts.
let new_family = "DefinitelyMissingPhotoCraftTypeface1251New";
let params = json!({"x": 6, "y": 68, "text": "Another missing font", "font": new_family, "size": 20}).to_string();
let (code, stdout, stderr) = invoke(&["run", &native_str, "--cmd", "type.create", "--params", &params, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(&format!("font '{family}'")).count(), 1);
assert_eq!(stderr.matches(&format!("font '{new_family}'")).count(), 1);
let command: Value = serde_json::from_str(&stdout).unwrap();
let warnings = command["warnings"].as_array().unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].as_str().is_some_and(|s| s.contains(new_family)));
assert!(stderr.contains("warning: 3 layer(s) flattened; layers, masks and blend modes are not kept"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
+3
View File
@@ -35,6 +35,7 @@ serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "=3.27.0"
rfd = { workspace = true }
arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
@@ -88,6 +89,8 @@ winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
# Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal.
xcap = "=0.9.8"
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
# Bounded reads of registered SVG/Unicode clipboard formats, behind safe Win32 wrappers.
clipboard-win = "=5.4.1"
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
[target.'cfg(target_os = "macos")'.dev-dependencies]
+422
View File
@@ -0,0 +1,422 @@
//! Explicit desktop SVG editing. Clipboard/source data never supplies a command or program path.
//!
//! The owned temporary SVG is watched off the UI thread. Closing the PhotoCraft session stops
//! watching and removes its temporary directory; it does not terminate the external editor.
use photocraft_ui_egui::{ExternalSvgEditor, ExternalSvgSession};
use std::sync::Arc;
pub(crate) fn open(editor: ExternalSvgEditor, svg: Arc<Vec<u8>>) -> Result<Box<dyn ExternalSvgSession>, String> {
#[cfg(windows)]
{
native::open(editor, svg)
}
#[cfg(not(windows))]
{
let _ = (editor, svg);
Err("External SVG editing is currently supported on Windows only".into())
}
}
#[cfg(any(windows, test))]
mod native {
use super::*;
use std::fs::{Metadata, OpenOptions};
use std::io::{Read, Write};
#[cfg(windows)]
use std::path::Path;
use std::path::PathBuf;
use std::process::Child;
use std::sync::mpsc::{self, Receiver, Sender, SyncSender, TrySendError};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use tempfile::TempDir;
const MAX_BYTES: usize = 16 * 1024 * 1024;
#[cfg(windows)]
const POLL_INTERVAL: Duration = Duration::from_millis(200);
#[cfg(windows)]
const SAVE_SETTLE: Duration = Duration::from_millis(600);
#[cfg(windows)]
pub(super) fn open(editor: ExternalSvgEditor, svg: Arc<Vec<u8>>) -> Result<Box<dyn ExternalSvgSession>, String> {
let executable = resolve_editor(editor)?;
let owned = OwnedSvg::create(svg.as_slice())?;
// Separate executable and argument: neither SVG bytes nor source metadata reach a shell.
let child = std::process::Command::new(executable).arg(&owned.file).spawn().map_err(|e| format!("Could not open the external SVG editor: {e}"))?;
Ok(Box::new(start(owned, svg.as_ref().clone(), Some(child), POLL_INTERVAL, SAVE_SETTLE)?))
}
#[cfg(windows)]
fn resolve_editor(editor: ExternalSvgEditor) -> Result<PathBuf, String> {
match editor {
ExternalSvgEditor::Program(path) => validate_executable(Path::new(&path)),
ExternalSvgEditor::VectorCraft => {
let mut candidates = vec![];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Some(root) = std::env::var_os(variable) {
candidates.push(PathBuf::from(root).join("VectorCraft/vectorcraft.exe"));
}
}
if let Some(root) = std::env::var_os("LOCALAPPDATA") {
candidates.push(PathBuf::from(root).join("Programs/VectorCraft/vectorcraft.exe"));
}
candidates
.into_iter()
.find_map(|path| validate_executable(&path).ok())
.ok_or_else(|| "VectorCraft was not found. Choose its executable with Other editor".into())
}
ExternalSvgEditor::Illustrator => {
let mut candidates = vec![];
for variable in ["ProgramFiles", "ProgramFiles(x86)"] {
let Some(root) = std::env::var_os(variable) else { continue };
let adobe = PathBuf::from(root).join("Adobe");
// One known installation level only; never search the user's disks recursively.
let Ok(entries) = std::fs::read_dir(&adobe) else { continue };
for entry in entries.take(64).flatten() {
if !entry.file_name().to_string_lossy().starts_with("Adobe Illustrator") {
continue;
}
candidates.push(entry.path().join("Support Files/Contents/Windows/Illustrator.exe"));
}
}
candidates.sort();
candidates
.into_iter()
.rev()
.find_map(|path| validate_executable(&path).ok())
.ok_or_else(|| "Illustrator was not found. Choose its executable with Other editor".into())
}
}
}
#[cfg(windows)]
fn validate_executable(path: &Path) -> Result<PathBuf, String> {
if !path.is_absolute() || !path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) {
return Err("Choose an absolute Windows .exe path; scripts and command lines are not supported".into());
}
let metadata = std::fs::symlink_metadata(path).map_err(|e| format!("Cannot read the chosen editor: {e}"))?;
ensure_regular(&metadata)?;
path.canonicalize().map_err(|e| format!("Cannot resolve the chosen editor: {e}"))
}
struct OwnedSvg {
directory: TempDir,
root: PathBuf,
file: PathBuf,
}
impl OwnedSvg {
fn create(svg: &[u8]) -> Result<Self, String> {
if svg.is_empty() || svg.len() > MAX_BYTES {
return Err("The SVG must contain between 1 byte and 16 MiB".into());
}
let directory =
tempfile::Builder::new().prefix("photocraft-svg-edit-").tempdir().map_err(|e| format!("Cannot create the SVG editing directory: {e}"))?;
let root = directory.path().canonicalize().map_err(|e| format!("Cannot resolve the SVG editing directory: {e}"))?;
let file = root.join("Artwork.svg");
let mut output = OpenOptions::new().write(true).create_new(true).open(&file).map_err(|e| format!("Cannot create the SVG editing file: {e}"))?;
output.write_all(svg).and_then(|()| output.sync_all()).map_err(|e| format!("Cannot write the SVG editing file: {e}"))?;
Ok(Self { directory, root, file })
}
fn stamp(&self) -> Result<Stamp, String> {
let directory_metadata = std::fs::symlink_metadata(&self.root).map_err(|e| format!("Cannot inspect the SVG editing directory: {e}"))?;
if !directory_metadata.is_dir() || is_redirect(&directory_metadata) || self.directory.path().canonicalize().ok().as_ref() != Some(&self.root) {
return Err("The SVG editing directory was replaced or redirected".into());
}
let metadata = std::fs::symlink_metadata(&self.file).map_err(|e| format!("Cannot inspect the edited SVG: {e}"))?;
ensure_regular(&metadata)?;
Stamp::from_metadata(&metadata)
}
fn read(&self, expected: &Stamp) -> Result<Vec<u8>, String> {
if &self.stamp()? != expected {
return Err("The SVG is still being saved".into());
}
let mut options = OpenOptions::new();
options.read(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
// Open the reparse point itself, so replacing the file with a symlink cannot
// make this read follow it between the metadata check and opening the handle.
options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT
}
let file = options.open(&self.file).map_err(|e| format!("Cannot read the edited SVG: {e}"))?;
let metadata = file.metadata().map_err(|e| format!("Cannot inspect the edited SVG handle: {e}"))?;
ensure_regular(&metadata)?;
if &Stamp::from_metadata(&metadata)? != expected {
return Err("The SVG is still being saved".into());
}
let size = usize::try_from(metadata.len()).map_err(|_| "The edited SVG is too large")?;
let mut bytes = Vec::new();
bytes.try_reserve_exact(size).map_err(|_| "Not enough memory to read the edited SVG")?;
// Also bound reads if a concurrent writer grows the file after the metadata check.
file.take(MAX_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(|e| format!("Cannot read the edited SVG: {e}"))?;
if bytes.is_empty() || bytes.len() > MAX_BYTES {
return Err("The edited SVG must contain between 1 byte and 16 MiB".into());
}
if bytes.len() != size || &self.stamp()? != expected {
return Err("The SVG is still being saved".into());
}
Ok(bytes)
}
}
fn ensure_regular(metadata: &Metadata) -> Result<(), String> {
if !metadata.is_file() || is_redirect(metadata) {
return Err("The SVG or editor must be a regular file, not a link or reparse point".into());
}
Ok(())
}
fn is_redirect(metadata: &Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
metadata.file_attributes() & 0x0000_0400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
}
#[cfg(not(windows))]
{
false
}
}
#[derive(Clone, PartialEq, Eq)]
struct Stamp {
modified: SystemTime,
length: u64,
}
impl Stamp {
fn from_metadata(metadata: &Metadata) -> Result<Self, String> {
if metadata.len() == 0 || metadata.len() > MAX_BYTES as u64 {
return Err("The edited SVG must contain between 1 byte and 16 MiB".into());
}
Ok(Self { modified: metadata.modified().map_err(|e| format!("Cannot inspect the SVG save time: {e}"))?, length: metadata.len() })
}
}
struct Session {
stop: Sender<()>,
updates: Receiver<Result<Vec<u8>, String>>,
}
impl ExternalSvgSession for Session {
fn poll(&mut self) -> Option<Result<Vec<u8>, String>> {
self.updates.try_recv().ok()
}
}
impl Drop for Session {
fn drop(&mut self) {
// No thread join or editor termination on the UI thread. The worker owns TempDir
// and drops it after this bounded wakeup, including when the update receiver closes.
let _ = self.stop.send(());
}
}
fn start(owned: OwnedSvg, initial: Vec<u8>, child: Option<Child>, interval: Duration, settle: Duration) -> Result<Session, String> {
let (stop, stopped) = mpsc::channel();
// At most two bounded updates await the UI, never an unbounded save queue.
let (send, updates) = mpsc::sync_channel(2);
thread::Builder::new()
.name("photocraft-svg-editor".into())
.spawn(move || watch(owned, initial, child, stopped, send, interval, settle))
.map_err(|e| format!("Cannot watch SVG editor saves: {e}"))?;
Ok(Session { stop, updates })
}
fn watch(
owned: OwnedSvg,
mut last_bytes: Vec<u8>,
mut child: Option<Child>,
stopped: Receiver<()>,
send: SyncSender<Result<Vec<u8>, String>>,
interval: Duration,
settle: Duration,
) {
// The editor may save before the watcher starts. First settled read must compare bytes.
let mut last_stamp: Option<Stamp> = None;
let mut pending: Option<(Stamp, Instant)> = None;
let mut last_error: Option<String> = None;
while let Err(mpsc::RecvTimeoutError::Timeout) = stopped.recv_timeout(interval) {
if child.as_mut().is_some_and(|process| process.try_wait().ok().flatten().is_some()) {
child = None;
}
let candidate = match owned.stamp() {
Ok(stamp) => {
if last_stamp.as_ref() == Some(&stamp) {
continue;
}
match &pending {
Some((old, since)) if old == &stamp && since.elapsed() >= settle => owned.read(&stamp),
Some((old, _)) if old == &stamp => continue,
_ => {
pending = Some((stamp, Instant::now()));
continue;
}
}
}
Err(error) => {
pending = None;
Err(error)
}
};
match candidate {
Ok(bytes) => {
last_error = None;
if bytes == last_bytes {
last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone());
continue;
}
match send.try_send(Ok(bytes.clone())) {
Ok(()) => {
last_bytes = bytes;
last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone());
}
Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => break,
}
}
Err(error) => {
last_stamp = None;
if last_error.as_ref() == Some(&error) {
continue;
}
match send.try_send(Err(error.clone())) {
Ok(()) => last_error = Some(error),
Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => break,
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const EMPTY_SAVE: &str = "The edited SVG must contain between 1 byte and 16 MiB";
const CHANGING_SAVE: &str = "The SVG is still being saved";
fn update(session: &mut Session, deadline: Instant) -> Result<Vec<u8>, String> {
loop {
assert!(Instant::now() < deadline, "timed out waiting for an SVG save");
if let Some(update) = session.poll() {
return update;
}
thread::sleep(Duration::from_millis(10));
}
}
fn expect_saved(session: &mut Session, expected: &[u8], deadline: Instant) {
loop {
match update(session, deadline) {
Ok(bytes) => {
assert_eq!(bytes, expected, "unexpected SVG save contents");
return;
}
Err(error) => assert!(matches!(error.as_str(), EMPTY_SAVE | CHANGING_SAVE), "unexpected SVG watcher error: {error}"),
}
}
}
fn expect_empty_error(session: &mut Session, deadline: Instant) {
loop {
match update(session, deadline) {
Err(error) if error == EMPTY_SAVE => return,
Err(error) if error == CHANGING_SAVE => {}
other => panic!("expected the held-empty SVG error, got {other:?}"),
}
}
}
fn queued_session(updates: Vec<Result<Vec<u8>, String>>) -> Session {
let (stop, _stopped) = mpsc::channel();
let (send, receive) = mpsc::channel();
for update in updates {
send.send(update).unwrap();
}
Session { stop, updates: receive }
}
#[test]
fn saves_debounce_repeat_and_recover_after_invalid_file() {
let initial = b"<svg/>".to_vec();
let owned = OwnedSvg::create(&initial).unwrap();
let file = owned.file.clone();
let root = owned.root.clone();
let settle = Duration::from_millis(100);
let deadline = Instant::now() + Duration::from_secs(3);
let mut session = start(owned, initial, None, Duration::from_millis(10), settle).unwrap();
let save_started = Instant::now();
let first = b"<svg>first complete</svg>";
std::fs::write(&file, first).unwrap();
expect_saved(&mut session, first, deadline);
assert!(save_started.elapsed() >= settle, "a changed save must settle before delivery");
assert!(session.poll().is_none());
std::fs::write(&file, b"").unwrap();
expect_empty_error(&mut session, deadline);
std::fs::write(&file, b"<svg>second complete</svg>").unwrap();
expect_saved(&mut session, b"<svg>second complete</svg>", deadline);
assert!(session.poll().is_none());
drop(session);
while root.exists() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
}
assert!(!root.exists(), "worker must clean its owned temporary SVG");
}
#[test]
fn exact_save_wait_skips_only_expected_queued_transient_errors() {
let bytes = b"<svg>complete</svg>";
let mut session = queued_session(vec![Err(EMPTY_SAVE.into()), Err(CHANGING_SAVE.into()), Ok(bytes.to_vec())]);
expect_saved(&mut session, bytes, Instant::now() + Duration::from_secs(3));
assert!(session.poll().is_none());
}
#[test]
#[should_panic(expected = "unexpected SVG watcher error")]
fn exact_save_wait_rejects_unexpected_errors() {
let mut session = queued_session(vec![Err("Cannot read the edited SVG: permission denied".into()), Ok(b"<svg/>".to_vec())]);
expect_saved(&mut session, b"<svg/>", Instant::now() + Duration::from_secs(3));
}
#[test]
fn oversize_file_is_rejected_before_allocating() {
let owned = OwnedSvg::create(b"<svg/>").unwrap();
std::fs::File::create(&owned.file).unwrap().set_len(MAX_BYTES as u64 + 1).unwrap();
assert!(owned.stamp().is_err());
assert!(OwnedSvg::create(&[]).is_err());
}
#[cfg(windows)]
#[test]
fn script_and_command_line_are_not_editor_executables() {
assert!(validate_executable(Path::new("cmd.exe /c start")).is_err());
assert!(validate_executable(Path::new("C:/editor.cmd")).is_err());
assert!(validate_executable(Path::new("editor.exe")).is_err());
}
#[cfg(windows)]
#[test]
fn replacement_symlink_is_rejected_without_reading_target() {
let owned = OwnedSvg::create(b"<svg/>").unwrap();
let target = owned.root.join("other.svg");
std::fs::write(&target, b"private target").unwrap();
std::fs::remove_file(&owned.file).unwrap();
// Symlink creation requires Developer Mode or elevation. Other tests run everywhere.
if std::os::windows::fs::symlink_file(&target, &owned.file).is_err() {
return;
}
assert!(owned.stamp().is_err());
let _ = std::fs::remove_file(&owned.file);
}
}
}
+3
View File
@@ -39,6 +39,8 @@ mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_window;
// Pure logic is tested on every platform; only Linux runs the check.
#[cfg(not(target_arch = "wasm32"))]
mod external_svg;
#[cfg(any(target_os = "linux", test))]
mod linux_libs;
mod logging;
@@ -46,6 +48,7 @@ mod monitor_profile;
mod photoshop_settings;
mod screen_color;
mod services;
mod vector_clipboard;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
mod tablet;
+2
View File
@@ -472,6 +472,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})
}),
clipboard_get_vector: Some(Box::new(crate::vector_clipboard::read)),
open_external_svg: if cfg!(windows) { Some(Box::new(crate::external_svg::open)) } else { None },
clipboard_get_image: Some({
let clip = clip.clone();
Box::new(move || {
+188
View File
@@ -0,0 +1,188 @@
//! Bounded, explicit reads of vector data from the Windows clipboard.
//!
//! Registered SVG takes precedence over SVG published as Unicode text. The engine validates
//! the SVG document; this boundary only normalizes encoding and recognizes the root element.
use photocraft_ui_egui::VectorClipboard;
#[cfg(any(windows, test))]
use photocraft_ui_egui::{MAX_VECTOR_CLIPBOARD_BYTES, VectorClipboardSource};
pub(crate) fn read() -> Result<Option<VectorClipboard>, String> {
#[cfg(windows)]
{
reader::read_with(windows::Clipboard::open)
}
#[cfg(not(windows))]
{
Ok(None)
}
}
#[cfg(any(windows, test))]
mod reader {
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Format {
Svg,
Text,
}
/// The backend owns the clipboard lock for the entire read, including format selection.
pub(super) trait ClipboardData {
fn available(&self, format: Format) -> bool;
fn size(&self, format: Format) -> Result<usize, String>;
fn copy(&mut self, format: Format, out: &mut [u8]) -> Result<usize, String>;
fn sequence(&self) -> Option<u32>;
}
pub(super) fn read_with<B: ClipboardData>(open: impl FnOnce() -> Result<B, String>) -> Result<Option<VectorClipboard>, String> {
let mut clipboard = open()?;
let format = if clipboard.available(Format::Svg) {
Format::Svg
} else if clipboard.available(Format::Text) {
Format::Text
} else {
return Ok(None);
};
let size = clipboard.size(format)?;
if size == 0 {
return if format == Format::Text { Ok(None) } else { Err("The SVG clipboard data is empty".into()) };
}
if size > MAX_VECTOR_CLIPBOARD_BYTES {
return Err("The vector clipboard data exceeds the 16 MiB limit".into());
}
let mut bytes = Vec::new();
bytes.try_reserve_exact(size).map_err(|_| "Not enough memory to read the vector clipboard data")?;
bytes.resize(size, 0);
let copied = clipboard.copy(format, &mut bytes)?;
if copied != size {
return Err("The vector clipboard data could not be read completely".into());
}
let svg = match format {
Format::Svg => normalize_svg(bytes)?,
Format::Text => {
let text = unicode_text(&bytes)?;
if !svg_candidate(&text) {
return Ok(None);
}
text.into_bytes()
}
};
if !svg_candidate(std::str::from_utf8(&svg).map_err(|_| "The SVG clipboard data is not valid UTF-8")?) {
return Err("The registered SVG clipboard format does not contain SVG markup".into());
}
Ok(Some(VectorClipboard {
svg,
source: match format {
Format::Svg => VectorClipboardSource::RegisteredSvg,
Format::Text => VectorClipboardSource::SvgText,
},
sequence: clipboard.sequence(),
}))
}
fn normalize_svg(mut bytes: Vec<u8>) -> Result<Vec<u8>, String> {
while bytes.last() == Some(&0) {
bytes.pop();
}
if bytes.contains(&0) {
return Err("The SVG clipboard data contains an embedded NUL".into());
}
std::str::from_utf8(&bytes).map_err(|_| "The SVG clipboard data is not valid UTF-8")?;
Ok(bytes)
}
fn unicode_text(bytes: &[u8]) -> Result<String, String> {
let (units, remainder) = bytes.as_chunks::<2>();
if !remainder.is_empty() {
return Err("The Unicode clipboard data has an invalid byte length".into());
}
if !units.contains(&[0, 0]) {
return Err("The Unicode clipboard data is not NUL-terminated".into());
}
let mut text = String::new();
// UTF-16 can expand to three UTF-8 bytes per code unit. Keep the normalized result capped.
text.try_reserve(bytes.len().min(MAX_VECTOR_CLIPBOARD_BYTES)).map_err(|_| "Not enough memory to read the Unicode clipboard data")?;
let units = units.iter().map(|pair| u16::from_le_bytes(*pair)).take_while(|unit| *unit != 0);
for character in char::decode_utf16(units) {
let character = character.map_err(|_| "The Unicode clipboard data is not valid UTF-16")?;
let size = text.len().checked_add(character.len_utf8()).ok_or("The Unicode clipboard data is too large")?;
if size > MAX_VECTOR_CLIPBOARD_BYTES {
return Err("The vector clipboard data exceeds the 16 MiB limit".into());
}
text.try_reserve(character.len_utf8()).map_err(|_| "Not enough memory to read the Unicode clipboard data")?;
text.push(character);
}
Ok(text)
}
/// Only recognize a root `<svg` token, never a substring in ordinary text or another XML root.
fn svg_candidate(text: &str) -> bool {
let mut rest = text.strip_prefix('\u{feff}').unwrap_or(text).trim_start_matches(xml_space);
if let Some(after) = rest.strip_prefix("<?xml") {
if !after.chars().next().is_some_and(xml_space) {
return false;
}
let Some((_, after)) = after.split_once("?>") else { return false };
rest = after.trim_start_matches(xml_space);
}
while let Some(after) = rest.strip_prefix("<!--") {
let Some((_, after)) = after.split_once("-->") else { return false };
rest = after.trim_start_matches(xml_space);
}
rest.strip_prefix("<svg").and_then(|after| after.chars().next()).is_some_and(|character| xml_space(character) || matches!(character, '/' | '>'))
}
fn xml_space(character: char) -> bool {
matches!(character, ' ' | '\t' | '\r' | '\n')
}
#[cfg(test)]
mod tests;
}
#[cfg(windows)]
mod windows {
use super::reader::{ClipboardData, Format};
use clipboard_win::{formats, raw};
pub(super) struct Clipboard {
_lock: clipboard_win::Clipboard,
svg: u32,
}
impl Clipboard {
pub(super) fn open() -> Result<Self, String> {
let lock = clipboard_win::Clipboard::new_attempts(10).map_err(|error| format!("The clipboard is unavailable: {error}"))?;
let svg = raw::register_format("image/svg+xml").ok_or("Could not register the SVG clipboard format")?.get();
Ok(Self { _lock: lock, svg })
}
fn id(&self, format: Format) -> u32 {
match format {
Format::Svg => self.svg,
Format::Text => formats::CF_UNICODETEXT,
}
}
}
impl ClipboardData for Clipboard {
fn available(&self, format: Format) -> bool {
raw::is_format_avail(self.id(format))
}
fn size(&self, format: Format) -> Result<usize, String> {
raw::size(self.id(format)).map(|size| size.get()).ok_or_else(|| "Could not determine the vector clipboard data size".into())
}
fn copy(&mut self, format: Format, out: &mut [u8]) -> Result<usize, String> {
raw::get(self.id(format), out).map_err(|error| format!("Could not read the vector clipboard data: {error}"))
}
fn sequence(&self) -> Option<u32> {
raw::seq_num().map(|number| number.get())
}
}
}
@@ -0,0 +1,188 @@
use super::*;
use std::cell::Cell;
use std::rc::Rc;
const SVG: &[u8] = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2\" height=\"3\"/>";
#[derive(Default)]
struct Calls {
size: Cell<usize>,
copy: Cell<usize>,
closed: Cell<usize>,
}
#[derive(Default)]
struct Lock(Rc<Calls>);
impl std::ops::Deref for Lock {
type Target = Rc<Calls>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Drop for Lock {
fn drop(&mut self) {
self.closed.set(self.closed.get() + 1);
}
}
#[derive(Default)]
struct MockClipboard {
svg: Option<Vec<u8>>,
text: Option<Vec<u8>>,
reported_size: Option<usize>,
copied: Option<usize>,
read_error: bool,
calls: Lock,
}
impl MockClipboard {
fn bytes(&self, format: Format) -> Option<&[u8]> {
match format {
Format::Svg => self.svg.as_deref(),
Format::Text => self.text.as_deref(),
}
}
}
impl ClipboardData for MockClipboard {
fn available(&self, format: Format) -> bool {
self.bytes(format).is_some()
}
fn size(&self, format: Format) -> Result<usize, String> {
self.calls.size.set(self.calls.size.get() + 1);
self.reported_size.or_else(|| self.bytes(format).map(<[u8]>::len)).ok_or_else(|| "missing format".into())
}
fn copy(&mut self, format: Format, out: &mut [u8]) -> Result<usize, String> {
self.calls.copy.set(self.calls.copy.get() + 1);
if self.read_error {
return Err("read failed".into());
}
let bytes = self.bytes(format).unwrap();
out.copy_from_slice(bytes);
Ok(self.copied.unwrap_or(bytes.len()))
}
fn sequence(&self) -> Option<u32> {
Some(42)
}
}
fn text_bytes(text: &str) -> Vec<u8> {
text.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect()
}
#[test]
fn registered_svg_wins_over_unicode_text_in_one_locked_snapshot() {
let clipboard = MockClipboard { svg: Some(SVG.to_vec()), text: Some(text_bytes("<svg/>")), ..Default::default() };
let calls = clipboard.calls.clone();
let payload = read_with(|| Ok(clipboard)).unwrap().unwrap();
assert_eq!(payload.svg, SVG);
assert_eq!(payload.source, VectorClipboardSource::RegisteredSvg);
assert_eq!(payload.sequence, Some(42));
assert_eq!(calls.size.get(), 1);
assert_eq!(calls.copy.get(), 1);
assert_eq!(calls.closed.get(), 1);
}
#[test]
fn unicode_svg_preserves_non_ascii_and_surrogate_pairs() {
let svg = "\u{feff}<?xml version=\"1.0\"?>\n<!-- synthetic -->\n<svg><text>Grün 文 🦀</text></svg>";
let payload = read_with(|| Ok(MockClipboard { text: Some(text_bytes(svg)), ..Default::default() })).unwrap().unwrap();
assert_eq!(payload.svg, svg.as_bytes());
assert_eq!(payload.source, VectorClipboardSource::SvgText);
}
#[test]
fn registered_svg_removes_only_trailing_native_nuls() {
let svg = [b"\xef\xbb\xbf".as_slice(), SVG, &[0, 0, 0]].concat();
let payload = read_with(|| Ok(MockClipboard { svg: Some(svg), ..Default::default() })).unwrap().unwrap();
assert_eq!(payload.svg, [b"\xef\xbb\xbf".as_slice(), SVG].concat());
assert!(read_with(|| Ok(MockClipboard { svg: Some(b"<svg>\0</svg>".to_vec()), ..Default::default() })).is_err());
}
#[test]
fn no_format_and_ordinary_text_are_quiet() {
let clipboard = MockClipboard::default();
let calls = clipboard.calls.clone();
assert!(read_with(|| Ok(clipboard)).unwrap().is_none());
assert_eq!(calls.size.get(), 0);
assert_eq!(calls.copy.get(), 0);
assert_eq!(calls.closed.get(), 1);
for text in ["", "hello", "quoted <svg/>", "<svgarbage/>", "<html><svg/></html>", "<?xml-stylesheet?><svg/>", "<!-- unfinished <svg/>"] {
assert!(read_with(|| Ok(MockClipboard { text: Some(text_bytes(text)), ..Default::default() })).unwrap().is_none(), "{text:?}");
}
}
#[test]
fn registered_svg_rejects_wrong_roots_and_invalid_encoding_without_fallback() {
for svg in [Vec::new(), b"<svgarbage/>".to_vec(), b"<html><svg/></html>".to_vec(), vec![0xff, 0xfe], b"<!-- cut short".to_vec()] {
let clipboard = MockClipboard { svg: Some(svg), text: Some(text_bytes("<svg/>")), ..Default::default() };
let calls = clipboard.calls.clone();
assert!(read_with(|| Ok(clipboard)).is_err());
assert_eq!(calls.size.get(), 1, "never silently fall back from advertised SVG");
assert_eq!(calls.closed.get(), 1);
}
}
#[test]
fn limits_are_checked_before_reading_or_allocating_payloads() {
for format in [Format::Svg, Format::Text] {
let clipboard = MockClipboard {
svg: (format == Format::Svg).then(Vec::new),
text: (format == Format::Text).then(Vec::new),
reported_size: Some(MAX_VECTOR_CLIPBOARD_BYTES + 1),
..Default::default()
};
let calls = clipboard.calls.clone();
assert!(read_with(|| Ok(clipboard)).unwrap_err().contains("16 MiB"));
assert_eq!(calls.copy.get(), 0);
assert_eq!(calls.closed.get(), 1);
}
let mut svg = SVG.to_vec();
svg.resize(MAX_VECTOR_CLIPBOARD_BYTES, b' ');
assert_eq!(read_with(|| Ok(MockClipboard { svg: Some(svg), ..Default::default() })).unwrap().unwrap().svg.len(), MAX_VECTOR_CLIPBOARD_BYTES);
}
#[test]
fn normalized_utf8_output_is_capped_even_when_utf16_input_fits() {
let mut bytes = Vec::new();
for _ in 0..(MAX_VECTOR_CLIPBOARD_BYTES / 3 + 1) {
bytes.extend_from_slice(&('文' as u16).to_le_bytes());
}
bytes.extend_from_slice(&[0, 0]);
assert!(bytes.len() < MAX_VECTOR_CLIPBOARD_BYTES);
assert!(read_with(|| Ok(MockClipboard { text: Some(bytes), ..Default::default() })).unwrap_err().contains("16 MiB"));
}
#[test]
fn malformed_unicode_is_reported() {
for bytes in [vec![b'<'], vec![0x00, 0xd8, 0, 0], vec![0x00, 0xdc, 0, 0], vec![b'a', 0]] {
assert!(read_with(|| Ok(MockClipboard { text: Some(bytes), ..Default::default() })).is_err());
}
}
#[test]
fn busy_read_failure_and_short_reads_do_not_produce_payloads() {
let busy = read_with::<MockClipboard>(|| Err("clipboard busy".into()));
assert_eq!(busy.unwrap_err(), "clipboard busy");
for (read_error, copied) in [(true, None), (false, Some(SVG.len() - 1)), (false, Some(SVG.len() + 1))] {
let clipboard = MockClipboard { svg: Some(SVG.to_vec()), read_error, copied, ..Default::default() };
let calls = clipboard.calls.clone();
assert!(read_with(|| Ok(clipboard)).is_err());
assert_eq!(calls.closed.get(), 1);
}
}
#[test]
fn candidate_requires_svg_root_token_after_optional_prolog_and_comments() {
for valid in ["<svg/>", "<svg></svg>", " \n<svg\twidth=\"2\"/>", "<?xml version=\"1.0\"?><svg/>", "<!-- one --><!-- two --> <svg/>"] {
assert!(svg_candidate(valid), "{valid}");
}
for invalid in ["<SVG/>", "<svg", "<svg:other/>", "<?xml?><svg/>", "<!DOCTYPE svg><svg/>", "<?xml version=\"1.0\" <svg/>", "<div><svg/></div>"] {
assert!(!svg_candidate(invalid), "{invalid}");
}
}
+2
View File
@@ -35,6 +35,8 @@ TRUSTED INTERNAL STATE
|---|---|---|
| PSD/PSB bytes to model | `crates/psd`, then `crates/io` | lengths, dimensions, channels, nesting, decompression, offsets |
| Raster bytes to image | `crates/codecs` | dimensions, pixels, decoded allocation, format-specific limits |
| Vector clipboard to document | native clipboard service, `crates/io`, `crates/engine` | explicit paste only, immutable snapshot, byte/layer-cache/decoded-image budgets, SVG parser and scaled intermediate-buffer limits, no external image path/URL resolution |
| SVG smart-object external editing | native editor service, UI session, memory-only engine commands | explicit editor selection, direct executable invocation, owned temporary files, bounded stable-save snapshots, pinned document/content/fingerprint, undoable validated replacement |
| `.pcraft` to document | `crates/format` | archive bounds, decompressed totals, hashes, schema/version validation |
| JSON to command | `crates/automation`, `crates/engine` | request budget, method/capability, parameter type/range, enabled state |
| Path to filesystem effect | `crates/automation/src/files.rs`, app services | authorized root/handle, link policy, create/replace semantics |
+5 -5
View File
@@ -260,7 +260,7 @@ fn warp(
// that matters is the one at the source point landing in the clip's centre.
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0),
};
let scale = h.local_scale(cx, cy);
let reduced;
@@ -341,10 +341,10 @@ fn warp(
return Ok(None);
}
let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect();
let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
// Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint.
let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0);
let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) };
+59
View File
@@ -0,0 +1,59 @@
use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
#[test]
fn clipped_warp_matches_the_existing_kernel_at_every_depth() {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]);
source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]);
let clip = Rect::new(10, 10, 52, 52);
for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] {
let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap();
let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap();
assert_eq!(full.read_region(clip), bounded.read_region(clip));
assert!(clip.contains_rect(&bounded.content_bounds()));
assert!(bounded.tiles().count() <= clip.tiles().count());
}
}
}
}
#[test]
fn huge_scaled_warp_allocates_only_the_destination_clip() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]);
let clip = Rect::new(0, 0, 64, 64);
let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
assert_eq!(output.pixel(32, 32), source.pixel(10, 10));
assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none());
}
#[test]
fn inverse_footprint_handles_extreme_finite_affines() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]);
// A strongly skewed inverse maps a small visible tile beyond i32 limits.
let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let clip = Rect::new(0, 0, 32, 32);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
}
#[test]
fn source_center_does_not_add_extreme_coordinates_in_i32() {
let mut source = Surface::new(PixelFormat::RGBA8);
let origin = 1_500_000_000;
let bounds = Rect::new(origin, origin, origin + 4, origin + 4);
source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]);
let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap();
assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2));
}
+18 -5
View File
@@ -308,8 +308,9 @@ pub struct MenuParams {
pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme
/// (pro, proMedium, proLightGray, proLight, studio, studioLight, classic, solarizedDark,
/// adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),
@@ -322,9 +323,21 @@ pub struct UiSetParams {
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). Other fields are an error.
/// `shape.edit` names and units; colour comes from `tools.setColors` unless shapeStrokePaint
/// supplies an explicit paint. shapeOptions patches mode (shape, path, pixels), operation
/// (new, combine, subtract, intersect, exclude), blend (a blend mode name), opacity (0..100),
/// antiAlias, showPath, showTransform, fromCenter, constrain, alignEdges, smoothIndents and
/// alignCanvas (bools), pathWidth (0.5..10 screen points), pathColor ([r,g,b] bytes or null),
/// sizeMode (unconstrained, square, fixed, proportional), sizeWidth and sizeHeight (1..300000
/// document pixels; their ratio is used for proportional sizing), starRatio (inner/outer
/// radius fraction, 0.01..1), and arrowheads (a nested patch: start/end bools, width/length
/// in document pixels, 0..10000, concavity in percent, -50..50). shapeFillPaint and
/// shapeStrokePaint use the `shape.edit` fill grammar: hex or RGBA colour, a colour-model
/// object, a gradient object, a catalog pattern object, or null to disable fill/stroke. Gradient
/// locations/opacity stops/midpoints are fractions, scale is percent, angle is degrees;
/// pattern scale is percent, angle is degrees, and phase is document pixels. camTransformOnDrop
/// (bool) is the Content-Aware Move tool's Transform On Drop. tileGrid (bool) is a developer overlay
/// of the active layer's storage tiles. Other fields are an error.
pub fields: Value,
}
+221 -3
View File
@@ -269,6 +269,8 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
| "layer.smartObjects.editContents"
| "layer.smartObjects.convertToLayers"
| "layer.smartObjects.saveContents"
| "layer.smartObjects.exportSvg"
| "layer.smartObjects.updateSvg"
);
if (id.starts_with("layer.smartObjects.") && !in_memory_smart_command)
|| matches!(
@@ -318,9 +320,13 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
let keys: &[&str] = match id {
"image.adjustments.colorLookup" | "layer.newAdjustmentLayer.colorLookup" | "layer.setAdjustment" => &["file"],
"filter.distort.displace" => &["mapPath"],
"layer.quickExportAsPng" | "layer.exportAs" | "image.applyDataSet" | "layer.smartObjects.editContents" | "layer.smartObjects.convertToLayers" => {
&["path"]
}
"layer.quickExportAsPng"
| "layer.exportAs"
| "image.applyDataSet"
| "layer.smartObjects.editContents"
| "layer.smartObjects.convertToLayers"
| "layer.smartObjects.exportSvg"
| "layer.smartObjects.updateSvg" => &["path"],
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
@@ -728,6 +734,58 @@ mod tests {
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
}
#[test]
fn installed_profile_enumeration_is_denied_even_with_workspace_roots() {
let (inside, _, workspace) = roots("installed-profile-policy");
for mut headless in [crate::Headless::new(), crate::Headless::with_workspace(workspace)] {
headless.command_run("file.new", serde_json::json!({"width": 4, "height": 4})).unwrap();
let before = headless.session.active().unwrap().doc.clone();
let revision = headless.session.active().unwrap().revision;
let history = headless.session.active().unwrap().history.past_len();
for params in [serde_json::json!({}), serde_json::json!({"class": "display", "colorSpace": "rgb"})] {
// Filters are applied only after the engine's ambient directory scan.
let error = authorize_engine_command("edit.installedProfiles", &params).unwrap_err();
assert!(error.to_string().contains("ambient filesystem paths"), "{error}");
assert!(authorize_desktop_engine_command("edit.installedProfiles", &params).is_err());
let error = headless.command_run("edit.installedProfiles", params).unwrap_err();
assert!(error.to_string().contains("ambient filesystem paths"), "{error}");
}
let after = headless.session.active().unwrap();
assert_eq!(after.doc, before);
assert_eq!(after.revision, revision);
assert_eq!(after.history.past_len(), history);
}
std::fs::remove_dir_all(inside.parent().unwrap()).unwrap();
}
#[test]
fn installed_profile_enumeration_cannot_bypass_policy_through_actions() {
for authorize in [authorize_engine_step, authorize_desktop_engine_step] {
let mut session = photocraft_engine::Session::new();
session.execute("file.new", serde_json::json!({"width": 4, "height": 4})).unwrap();
session.authorize = Some(authorize);
session.actions.list.push(photocraft_engine::actions_cmds::Action::new(
"Profiles",
vec![("edit.installedProfiles".into(), serde_json::json!({})), ("layer.new.layer".into(), serde_json::json!({}))],
));
let before = session.active().unwrap().doc.clone();
let revision = session.active().unwrap().revision;
let history = session.active().unwrap().history.past_len();
// Also exercise the hook through direct Session dispatch, without Headless's gate.
let error = session.execute("edit.installedProfiles", serde_json::json!({})).unwrap_err();
assert!(error.to_string().contains("ambient filesystem paths"), "{error}");
let result = session.execute("actions.play", serde_json::json!({"action": "Profiles"})).unwrap();
assert_eq!(result["ran"], 0);
assert_eq!(result["failed"]["step"], 0);
assert_eq!(result["failed"]["id"], "edit.installedProfiles");
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem paths"), "{result}");
let after = session.active().unwrap();
assert_eq!(after.doc, before);
assert_eq!(after.revision, revision);
assert_eq!(after.history.past_len(), history);
}
}
#[test]
fn apply_data_set_is_judged_by_the_values_it_applies() {
let mut headless = crate::Headless::new();
@@ -831,6 +889,161 @@ mod tests {
assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}");
}
#[test]
fn inline_vector_paste_needs_no_filesystem_authority() {
let params = serde_json::json!({
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2\" height=\"2\"><rect width=\"2\" height=\"2\"/></svg>",
"mode": "path",
});
assert!(authorize_engine_command("edit.pasteVector", &params).is_ok());
assert!(authorize_desktop_engine_command("edit.pasteVector", &params).is_ok());
let mut headless = crate::Headless::new();
headless.command_run("file.new", serde_json::json!({"width": 8, "height": 8})).unwrap();
let layers = headless.session.active().unwrap().doc.layer_count();
let result = headless.command_run("edit.pasteVector", params).unwrap();
assert_eq!(result["mode"], "path");
assert_eq!(headless.session.active().unwrap().doc.paths.len(), 1);
assert_eq!(headless.session.active().unwrap().doc.layer_count(), layers);
// The vector-path registry exception does not authorise any host-file command.
assert!(headless.command_run("file.placeEmbedded", serde_json::json!({"path": "/outside/source.svg"})).is_err());
}
#[test]
fn shape_parity_commands_use_document_geometry_without_filesystem_authority() {
let mut headless = crate::Headless::new();
headless.command_run("file.new", serde_json::json!({"width": 16, "height": 16})).unwrap();
let created = headless.command_run("shape.create", serde_json::json!({"kind": "rect", "rect": [2, 2, 4, 4]})).unwrap();
let geometry = headless.command_run("shape.info", serde_json::json!({"layer": created["layer"]})).unwrap()["path"].clone();
// A saved document path may have a filename-looking name. Resolution stays inside
// the document; it never opens a host file with that name.
let name = "/outside/photocraft-probe.svg";
headless.command_run("path.set", serde_json::json!({"name": name, "path": geometry})).unwrap();
for (id, params) in [
("path.align", serde_json::json!({"name": name, "action": "left", "to": "canvas"})),
("path.arrange", serde_json::json!({"name": name, "action": "front"})),
("path.merge", serde_json::json!({"name": name})),
] {
let spec = photocraft_engine::command_specs().iter().find(|spec| spec.id == id).unwrap();
assert!(!documents_filesystem_path_params(id, spec.params), "{id}");
assert!(authorize_engine_command(id, &params).is_ok(), "{id}");
assert!(authorize_desktop_engine_command(id, &params).is_ok(), "{id}");
assert!(headless.command_run(id, params).is_ok(), "{id}");
assert!(headless.session.active().unwrap().doc.paths.iter().any(|path| path.name == name));
}
// Pixel shape drawing accepts inline vector data, not a file path. Select a new
// raster layer so the command has an ordinary document-owned pixel destination.
headless.command_run("layer.new.layer", serde_json::json!({"name": "Pixel target"})).unwrap();
let params = serde_json::json!({"kind": "path", "path": geometry, "color": "#ff0000"});
let spec = photocraft_engine::command_specs().iter().find(|spec| spec.id == "shape.drawPixels").unwrap();
assert!(!documents_filesystem_path_params(spec.id, spec.params));
assert!(authorize_engine_command(spec.id, &params).is_ok());
assert!(authorize_desktop_engine_command(spec.id, &params).is_ok());
assert!(headless.command_run(spec.id, params).is_ok());
assert!(headless.command_run(spec.id, serde_json::json!({"kind": "path", "path": name})).is_err());
assert!(headless.command_run("file.placeEmbedded", serde_json::json!({"path": name})).is_err());
assert!(headless.command_run("layer.smartObjects.replaceContents", serde_json::json!({"path": name})).is_err());
}
#[test]
fn svg_smart_handoff_is_memory_only_and_linked_sources_stay_denied() {
let mut headless = crate::Headless::new();
headless.command_run("file.new", serde_json::json!({"width": 16, "height": 16})).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="red"/></svg>"#;
let inserted = headless.command_run("edit.pasteVector", serde_json::json!({"svg": svg, "mode": "smartObject"})).unwrap();
let layer = inserted["layers"][0].clone();
let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer": layer})).unwrap();
assert_eq!(handoff["svg"], svg);
let params = serde_json::json!({
"document": handoff["document"], "layer": layer,
"contentsId": handoff["contentsId"], "fingerprint": handoff["fingerprint"],
"svg": svg.replace("red", "blue"),
});
assert!(authorize_desktop_engine_command("layer.smartObjects.updateSvg", &params).is_ok());
assert_eq!(headless.command_run("layer.smartObjects.updateSvg", params.clone()).unwrap()["updated"], true);
for id in ["layer.smartObjects.exportSvg", "layer.smartObjects.updateSvg"] {
let mut ambient = params.clone();
ambient["path"] = serde_json::json!("/outside/source.svg");
assert!(authorize_engine_command(id, &ambient).is_err(), "{id}");
assert!(authorize_desktop_engine_command(id, &ambient).is_err(), "{id}");
assert!(headless.command_run(id, ambient).is_err(), "{id}");
}
let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc);
let id = photocraft_doc::LayerId(layer.as_u64().unwrap());
let photocraft_doc::LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("SVG smart object") };
sm.source = photocraft_doc::SmartSource::Linked { path: "/outside/source.svg".into() };
let before = headless.session.active().unwrap().doc.clone();
assert!(headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer": layer})).is_err());
assert!(headless.command_run("layer.smartObjects.updateSvg", params).is_err());
assert_eq!(headless.session.active().unwrap().doc, before);
assert!(headless.command_run("layer.smartObjects.exportContents", serde_json::json!({"layer": layer, "path": "/outside/export.svg"})).is_err());
}
#[test]
fn svg_update_cannot_replay_desktop_lut_file_in_any_shared_instance() {
let (_, outside, _) = roots("svg-stored-filter");
let lut = outside.join("private.cube");
let cube = "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
std::fs::write(&lut, cube).unwrap();
for shared_only in [false, true] {
let mut headless = crate::Headless::new();
headless.command_run("file.new", serde_json::json!({"width": 16, "height": 16})).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="red"/></svg>"#;
let pasted = headless.command_run("edit.pasteVector", serde_json::json!({"svg": svg,"mode":"smartObject"})).unwrap();
let target = photocraft_doc::LayerId(pasted["layers"][0].as_u64().unwrap());
let filtered = if shared_only {
let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc);
let duplicate = doc.layer(target).unwrap().duplicate();
let id = duplicate.id;
doc.layers.push(duplicate);
id
} else {
target
};
headless.session.select_layer(filtered).unwrap();
// Record a natural desktop Color Lookup operation with its original file params,
// then restore the untrusted automation hook as for an imported document.
let authorization = headless.session.authorize.take();
headless.session.execute("image.adjustments.colorLookup", serde_json::json!({"file":lut.to_string_lossy()})).unwrap();
headless.session.authorize = authorization;
let photocraft_doc::LayerContent::Smart(sm) = &headless.session.active().unwrap().doc.layer(filtered).unwrap().content else {
panic!("smart object")
};
assert_eq!(sm.smart_filters.last().unwrap().params["file"], lut.to_string_lossy().as_ref());
let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer":target.0})).unwrap();
let params = serde_json::json!({"document":handoff["document"],"layer":target.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"svg":svg.replace("red","blue")});
let before = headless.session.active().unwrap().doc.clone();
let revision = headless.session.active().unwrap().revision;
let error = headless.command_run("layer.smartObjects.updateSvg", params.clone()).unwrap_err();
assert!(error.to_string().contains("ambient filesystem paths"), "{error}");
assert_eq!(headless.session.active().unwrap().doc, before);
assert_eq!(headless.session.active().unwrap().revision, revision);
// A hidden stored filter is not executed, so its unused filename grants no access.
let doc = Arc::make_mut(&mut headless.session.active_mut().unwrap().doc);
let photocraft_doc::LayerContent::Smart(sm) = &mut doc.layer_mut(filtered).unwrap().content else { panic!("smart object") };
sm.smart_filters.last_mut().unwrap().visible = false;
assert_eq!(headless.command_run("layer.smartObjects.updateSvg", params).unwrap()["updated"], true);
}
}
#[test]
fn svg_update_keeps_inline_and_builtin_smart_filters_working() {
let cube = "LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
for filter_params in [serde_json::json!({"lut":"warm"}), serde_json::json!({"data":cube,"fileName":"look.cube"})] {
let mut headless = crate::Headless::new();
headless.command_run("file.new", serde_json::json!({"width":16,"height":16})).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"><rect width="4" height="4" fill="red"/></svg>"#;
let pasted = headless.command_run("edit.pasteVector", serde_json::json!({"svg":svg,"mode":"smartObject"})).unwrap();
let id = photocraft_doc::LayerId(pasted["layers"][0].as_u64().unwrap());
headless.command_run("image.adjustments.colorLookup", filter_params).unwrap();
let handoff = headless.command_run("layer.smartObjects.exportSvg", serde_json::json!({"layer":id.0})).unwrap();
let result = headless.command_run("layer.smartObjects.updateSvg",serde_json::json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"svg":svg.replace("red","blue")})).unwrap();
assert_eq!(result["updated"], true);
let photocraft_doc::LayerContent::Smart(sm) = &headless.session.active().unwrap().doc.layer(id).unwrap().content else { panic!("smart object") };
assert_eq!(sm.smart_filters.len(), 1);
assert!(!sm.cache.as_ref().unwrap().content_bounds().is_empty());
}
}
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
if matches!(id, "prefs.get" | "prefs.reset") {
return false;
@@ -841,6 +1054,7 @@ mod tests {
"filter.blurGallery.pathBlur"
| "filter.render.flame"
| "shape.create"
| "shape.drawPixels"
| "shape.edit"
| "shape.info"
| "shape.presets.list"
@@ -848,6 +1062,9 @@ mod tests {
| "path.list"
| "path.info"
| "path.set"
| "path.merge"
| "path.align"
| "path.arrange"
| "path.transform"
| "path.moveAnchors"
| "path.moveHandle"
@@ -866,6 +1083,7 @@ mod tests {
| "layer.vectorMask.info"
| "paint.symmetryFromPath"
| "edit.defineCustomShape"
| "edit.pasteVector"
| "layer.combineShapes.unite"
| "layer.combineShapes.subtractFrontShape"
| "layer.combineShapes.intersectShapeAreas"
@@ -0,0 +1,130 @@
//! Data-set exports must authorize stored replacement reads before producing any variation.
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use photocraft_automation::{AuthorizedWorkspace, Headless};
use photocraft_engine::Session;
use serde_json::json;
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static NEXT_SCRATCH_ID: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"photocraft-data-set-authority-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(),
NEXT_SCRATCH_ID.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&root).unwrap();
Self(root)
}
fn blue_replacement(&self) -> PathBuf {
let outside = self.0.join("outside.png");
let mut image = Session::new();
image.execute("file.new", json!({"width":2,"height":2,"background":"#0000ff"})).unwrap();
let bytes = photocraft_io::export(&image.active().unwrap().doc, "png", &Default::default()).unwrap().bytes;
std::fs::write(&outside, bytes).unwrap();
outside
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn template(mut headless: Headless, replacement: &Path) -> Headless {
headless.command_run("file.new", json!({"width":2,"height":2,"background":"white"})).unwrap();
let layer = headless.session.active().unwrap().doc.layers[0].id.0;
headless
.command_run(
"image.variables.define",
json!({"defs":[
{"name":"show","layer":layer,"type":"visibility"},
{"name":"photo","layer":layer,"type":"pixelReplacement"}
]}),
)
.unwrap();
headless
.command_run(
"image.variables.dataSets",
json!({"dataSets":[
{"name":"plain","values":[{"variable":"show","kind":"visibility","value":false}]},
{"name":"pixels","values":[{"variable":"photo","kind":"pixels","value":replacement.to_str().unwrap()}]}
]}),
)
.unwrap();
headless
}
#[test]
fn workspace_export_denies_ambient_pixel_reads_before_output_and_allows_nonpixel_subset() {
let scratch = Scratch::new();
let replacement = scratch.blue_replacement();
let source_bytes = std::fs::read(&replacement).unwrap();
for read_authority in [false, true] {
let write_root = scratch.0.join(if read_authority { "read-write" } else { "write-only" });
std::fs::create_dir(&write_root).unwrap();
let workspace = AuthorizedWorkspace::new(read_authority.then_some(write_root.as_path()), Some(&write_root)).unwrap();
let mut headless = template(Headless::with_workspace(workspace), &replacement);
let before = headless.session.active().unwrap().doc.clone();
let revision = headless.session.active().unwrap().revision;
let history = headless.session.active().unwrap().history.entries();
let past = headless.session.active().unwrap().history.past_len();
let active = headless.session.active().unwrap().active_layer;
// The first row is allowed, but a forbidden read in the later row rejects the whole batch.
let error = headless.export_data_sets(None, "blocked", &json!({"format":"png"})).unwrap_err().to_string();
assert!(error.contains("image.applyDataSet") && error.contains("ambient filesystem"), "{error}");
assert_eq!(std::fs::read_dir(write_root.join("blocked")).unwrap().count(), 0);
let after = headless.session.active().unwrap();
assert!(Arc::ptr_eq(&before, &after.doc));
assert_eq!(after.revision, revision);
assert_eq!(after.history.entries(), history);
assert_eq!(after.history.past_len(), past);
assert_eq!(after.active_layer, active);
assert_eq!(std::fs::read(&replacement).unwrap(), source_bytes);
// Only the selected rows are checked: a visibility-only export needs no read authority.
let result = headless.export_data_sets(None, "safe", &json!({"format":"png","dataSets":["plain"]})).unwrap();
assert_eq!(result["count"], 1);
let output = write_root.join("safe/plain.png");
assert_eq!(std::fs::read_dir(write_root.join("safe")).unwrap().count(), 1);
let exported = photocraft_io::import("plain.png", &std::fs::read(output).unwrap()).unwrap().document;
assert_eq!(photocraft_compose::render(&exported, exported.bounds()).px[0], [0.0; 4]);
let after = headless.session.active().unwrap();
assert!(Arc::ptr_eq(&before, &after.doc));
assert_eq!(after.revision, revision);
assert_eq!(after.history.entries(), history);
assert_eq!(after.history.past_len(), past);
assert_eq!(after.active_layer, active);
drop(headless);
}
}
#[test]
fn trusted_local_export_keeps_pixel_replacement_behavior() {
let scratch = Scratch::new();
let replacement = scratch.blue_replacement();
let output = scratch.0.join("trusted");
let mut headless = template(Headless::trusted_local(), &replacement);
let before = headless.session.active().unwrap().doc.clone();
let revision = headless.session.active().unwrap().revision;
let past = headless.session.active().unwrap().history.past_len();
let result = headless.export_data_sets(None, output.to_str().unwrap(), &json!({"format":"png","dataSets":["pixels"]})).unwrap();
assert_eq!(result["count"], 1);
let exported = photocraft_io::import("pixels.png", &std::fs::read(output.join("pixels.png")).unwrap()).unwrap().document;
assert_eq!(photocraft_compose::render(&exported, exported.bounds()).px[0], [0.0, 0.0, 1.0, 1.0]);
assert!(Arc::ptr_eq(&before, &headless.session.active().unwrap().doc));
assert_eq!(headless.session.active().unwrap().revision, revision);
assert_eq!(headless.session.active().unwrap().history.past_len(), past);
drop(headless);
}
+27 -16
View File
@@ -1232,27 +1232,38 @@ async fn convention_resources_are_live_json() {
serde_json::from_str::<Value>(v["contents"][0]["text"].as_str().unwrap()).unwrap()
};
assert!(read("photocraft://document").await["document"].is_null());
let before = read("photocraft://commands").await;
assert_eq!(before.as_array().unwrap().iter().find(|command| command["id"] == "file.close").unwrap()["enabled"], false);
json_of(&call(&client, "doc_new", json!({"width":8,"height":6})).await);
assert_eq!(read("photocraft://document").await["width"], 8);
// Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold
// anything, and the other tests in this binary fill and purge those caches concurrently, so
// its `enabled` flag and paired `disabledReason` can flip between the two reads. Validate
// each availability pair; every stable field and every other command must match exactly.
let stable = |mut v: Value| {
for c in v.as_array_mut().into_iter().flatten() {
if c["id"] == "edit.purge.all" {
let reason = if c["enabled"].as_bool().unwrap() { Value::Null } else { json!("nothing to purge") };
assert_eq!(c["disabledReason"], reason, "purge availability stays internally consistent");
let command = c.as_object_mut().unwrap();
command.remove("enabled");
command.remove("disabledReason");
let resource_commands = read("photocraft://commands").await;
assert_eq!(resource_commands.as_array().unwrap().iter().find(|command| command["id"] == "file.close").unwrap()["enabled"], true);
let tool_commands = json_of(&call(&client, "command_list", json!({})).await);
let normalize = |mut commands: Value| {
assert!(commands.as_array().is_some_and(|array| array.len() > 100), "{commands}");
let mut purge_entries = 0;
for command in commands.as_array_mut().unwrap() {
assert!(command["enabled"].is_boolean(), "{command}");
if command["id"] == "edit.purge.all" {
// Purge All also observes process-wide caches populated by parallel tests.
// Its enabled bit and corresponding reason can change between read-only requests.
let enabled = command["enabled"].as_bool().unwrap();
let reason = command.get("disabledReason").expect("commands include their disabled reason");
if enabled {
assert!(reason.is_null(), "{command}");
} else {
assert_eq!(reason, &json!("nothing to purge"), "purge availability stays internally consistent: {command}");
}
let fields = command.as_object_mut().unwrap();
fields.remove("enabled");
fields.remove("disabledReason");
purge_entries += 1;
}
}
v
assert_eq!(purge_entries, 1, "both snapshots must contain the Purge All command");
commands
};
let commands = stable(read("photocraft://commands").await);
assert!(commands.as_array().is_some_and(|a| a.len() > 100), "{commands}");
assert_eq!(commands, stable(json_of(&call(&client, "command_list", json!({})).await)));
assert_eq!(normalize(resource_commands), normalize(tool_commands));
assert!(client.read_resource(rmcp::model::ReadResourceRequestParams::new("photocraft://missing")).await.is_err());
let preview = call(&client, "render_preview", json!({"max_side":4})).await;
assert!(preview.content.iter().any(|c| c.as_image().is_some()));
+1 -1
View File
@@ -1409,7 +1409,7 @@ fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option
fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
let LayerContent::Shape(sh) = &layer.content else { return None };
sh.stroke.as_ref()?;
let (fs, ss) = shape_split::split(sh, cx.canvas, cx.depth)?;
let (fs, ss) = shape_split::split_with_patterns(sh, cx.canvas, cx.depth, cx.patterns.source(), cx.mode)?;
Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
}
+2 -135
View File
@@ -8,7 +8,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType};
use photocraft_doc::{ColorMode, Pattern, Rect};
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
const MAX_PREPARED_PATTERNS: usize = 64;
@@ -83,140 +83,7 @@ impl<'a> PreparedPatterns<'a> {
}
}
/// A pattern's pixels (straight-alpha RGBA, row-major) as they look painted into a document of
/// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the
/// library and in the document), but in a Grayscale or CMYK document it paints that mode's
/// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
/// [paintable](Pattern::is_paintable).
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
return Vec::new();
};
let mut px = vec![[0.0f32; 4]; n];
p.surface.read_rgba_into(p.rect(), &mut px);
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
if paints_in && p.surface.format().mode != mode {
let fmt = PixelFormat::new(mode, SampleType::F32, true);
let mut v = [0.0f32; 5];
for q in &mut px {
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
}
}
px
}
/// A pattern converted once to straight-alpha RGBA for sampling.
pub struct Tile {
w: usize,
h: usize,
/// Premultiplied RGBA.
px: Vec<[f32; 4]>,
}
impl Tile {
pub fn new(p: &Pattern) -> Option<Tile> {
Tile::new_in(p, ColorMode::Rgb)
}
/// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]).
/// `None` when the pattern isn't [paintable](Pattern::is_paintable).
pub fn new_in(p: &Pattern, mode: ColorMode) -> Option<Tile> {
if !p.is_paintable() {
return None;
}
let (w, h) = (p.width as usize, p.height as usize);
let mut px = pattern_rgba(p, mode);
for q in &mut px {
for c in 0..3 {
q[c] *= q[3];
}
}
Some(Tile { w, h, px })
}
#[inline]
fn at(&self, x: i64, y: i64) -> [f32; 4] {
let xi = x.rem_euclid(self.w as i64) as usize;
let yi = y.rem_euclid(self.h as i64) as usize;
self.px[yi * self.w + xi]
}
/// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`).
/// Returns straight alpha.
pub fn sample(&self, u: f64, v: f64) -> [f32; 4] {
let (u, v) = (u - 0.5, v - 0.5);
let (x0, y0) = (u.floor(), v.floor());
let (fx, fy) = ((u - x0) as f32, (v - y0) as f32);
let (x0, y0) = (x0 as i64, y0 as i64);
let mut acc = [0.0f32; 4];
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
if wy == 0.0 {
continue;
}
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
if wx == 0.0 {
continue;
}
let p = self.at(x0.saturating_add(dx), y0.saturating_add(dy));
let k = wx * wy;
for c in 0..4 {
acc[c] += p[c] * k;
}
}
}
if acc[3] <= 0.0 {
return [0.0; 4];
}
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
}
}
/// Where a pattern sits: document point → tile coordinates.
#[derive(Clone, Copy, Debug)]
pub struct Placement {
origin: (f64, f64),
/// Inverse rotation (cos, sin) and inverse scale.
cs: (f64, f64),
inv_scale: f64,
}
impl Placement {
/// `frame`: the layer's frame (used when `link`); `phase` in pixels; `scale` as a fraction;
/// `angle` in degrees counter-clockwise.
pub fn new(frame: Rect, link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement {
let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) };
Self::anchored(anchor, link, phase, scale, angle)
}
/// Like [`Placement::new`], with the linked origin given as a point. Layer effects anchor
/// linked patterns at the layer's effects reference point (PSD `fxrp`): Photoshop's
/// pattern overlays and pattern strokes match pixel-for-pixel tiled from there.
pub fn anchored(anchor: (f64, f64), link: bool, phase: (f32, f32), scale: f32, angle: f32) -> Placement {
let base = if link { anchor } else { (0.0, 0.0) };
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
let s = f64::from(scale);
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
let a = f64::from(angle).to_radians();
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
}
/// (origin, inverse rotation (cos, sin), inverse scale), for GPU shaders.
pub fn parts(&self) -> ((f64, f64), (f64, f64), f64) {
(self.origin, self.cs, self.inv_scale)
}
/// Tile coordinates of the document point `(x, y)`.
#[inline]
pub fn map(&self, x: f64, y: f64) -> (f64, f64) {
let (dx, dy) = (x - self.origin.0, y - self.origin.1);
// Undo a counter-clockwise rotation (y points down on screen).
let (c, s) = self.cs;
let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
(rx * self.inv_scale, ry * self.inv_scale)
}
}
pub use photocraft_vector::pattern::{Placement, Tile, pattern_rgba};
/// Renders `tile` over `rect` (row-major straight RGBA).
pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> {
+26 -4
View File
@@ -6,14 +6,15 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Mutex, OnceLock};
use photocraft_color::{PixelFormat, SampleType};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::vector::ShapeLayer;
use photocraft_doc::{Fill, Pattern};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
/// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
/// their tile addresses, part of the key, can't be reused).
type Entry = ((Surface, Surface), u64, Option<Surface>);
type Entry = ((Surface, Surface), u64, Option<Surface>, Vec<Pattern>);
fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new();
@@ -27,10 +28,29 @@ const CAPACITY: usize = 32;
/// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is
/// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects).
pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surface, Surface)> {
split_with_patterns(sh, canvas, depth, &[], ColorMode::Rgb)
}
/// Splits patterned paints with the same embedded tiles and colour mode as the shape cache.
pub fn split_with_patterns(sh: &ShapeLayer, canvas: Rect, depth: SampleType, patterns: &[Pattern], mode: ColorMode) -> Option<(Surface, Surface)> {
sh.stroke.as_ref()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
depth.hash(&mut h);
mode.hash(&mut h);
let mut used_patterns = Vec::new();
for fill in sh.fill.iter().chain(sh.stroke.iter().map(|s| &s.paint)) {
if let Fill::Pattern { id, name, .. } = fill
&& let Some(p) = photocraft_doc::pattern::find(patterns, id, name)
{
(&p.id, &p.name, p.width, p.height, p.surface.format()).hash(&mut h);
format!("{:?}", p.surface.default_pixel()).hash(&mut h);
for (k, tile) in p.surface.tiles() {
(k.tx, k.ty, std::sync::Arc::as_ptr(tile) as usize).hash(&mut h);
}
used_patterns.push(p.clone());
}
}
format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h);
if let Some(c) = &sh.cache {
format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h);
@@ -51,7 +71,9 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac
let fmt = PixelFormat::RGBA8.with_sample(depth);
let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() };
let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() };
let mut parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
let render =
|shape| photocraft_vector::CompiledShape::with_patterns(shape, photocraft_vector::DEFAULT_TOLERANCE, canvas, patterns, mode).render(fmt, canvas);
let mut parts = (render(&fill_only), render(&stroke_only));
if let Some(cache) = &sh.cache {
fit_stroke(&parts.0, &mut parts.1, cache, canvas);
}
@@ -62,7 +84,7 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac
c.0.remove(&old);
}
let tick = c.1;
c.0.insert(key, (parts.clone(), tick, sh.cache.clone()));
c.0.insert(key, (parts.clone(), tick, sh.cache.clone(), used_patterns));
Some(parts)
}
+29 -1
View File
@@ -289,7 +289,19 @@ impl ShapeStroke {
}
}
/// Parametric ("live") shape the path was generated from, kept for re-editing (Properties panel).
/// Filled line arrowheads; dimensions are document pixels.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LineArrowheads {
pub start: bool,
pub end: bool,
pub width: f64,
pub length: f64,
/// Indentation of the arrowhead base, in percent (`-50..=50`).
pub concavity: f64,
}
/// Parametric shape geometry retained for subsequent editing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum LiveShape {
@@ -306,12 +318,18 @@ pub enum LiveShape {
rect: [f64; 4],
sides: u32,
star_ratio: f64,
#[serde(default)]
corner_radius: f64,
#[serde(default)]
smooth_indents: bool,
},
/// Line from `from` to `to` drawn as a filled bar `weight` px thick.
Line {
from: [f64; 2],
to: [f64; 2],
weight: f64,
#[serde(default)]
arrowheads: Option<LineArrowheads>,
},
}
@@ -334,6 +352,16 @@ pub struct ShapeLayer {
mod tests {
use super::*;
#[test]
fn older_live_shapes_default_new_geometry_fields() {
let polygon: LiveShape = serde_json::from_str(r#"{"kind":"polygon","rect":[0,0,10,10],"sides":3,"star_ratio":1}"#).unwrap();
assert!(matches!(polygon, LiveShape::Polygon { corner_radius: 0.0, smooth_indents: false, .. }));
let line: LiveShape = serde_json::from_str(r#"{"kind":"line","from":[0,0],"to":[10,10],"weight":2}"#).unwrap();
assert!(matches!(line, LiveShape::Line { arrowheads: None, .. }));
let heads: LineArrowheads = serde_json::from_str(r#"{"start":true,"end":false,"width":10,"length":20}"#).unwrap();
assert_eq!(heads.concavity, 0.0);
}
#[test]
fn segments_open_and_closed() {
let s = Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
+6 -6
View File
@@ -1624,10 +1624,11 @@ mod tests {
#[test]
fn destructive_levels_dont_clip_in_32_bit() {
// Input 15..230, gamma 1.3, output 10..245: integer documents stop at the output range,
// while float documents preserve the curve's signed and HDR values beyond it.
// Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in
// integer documents, but the signed HDR curve continues past both it and 0..1 in a
// 32-bit document. These endpoints follow the direct levels_float formula.
let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245});
for depth in [8, 16, 32] {
for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, -20.30889, 265.7503)] {
let mut s = crate::Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap();
@@ -1635,12 +1636,11 @@ mod tests {
s.execute("select.deselect", json!({})).unwrap();
s.execute("image.adjustments.levels", params.clone()).unwrap();
let px = pixels(&s);
assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]);
assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]);
if depth == 32 {
assert!(px[0][0].is_finite() && px[0][0] < 0.0, "float black keeps its signed result: {:?}", px[0]);
assert!(px[1][0].is_finite() && px[1][0] > 1.0, "float white keeps its HDR result: {:?}", px[1]);
} else {
assert!((px[0][0] * 255.0 - 10.0).abs() < 0.6, "black @{depth}: {:?}", px[0]);
assert!((px[1][0] * 255.0 - 245.0).abs() < 0.6, "white @{depth}: {:?}", px[1]);
}
}
}
+3
View File
@@ -1242,11 +1242,14 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::transform_cmds::specs());
v.extend(crate::float_cmds::specs());
v.extend(crate::vector_cmds::specs());
v.extend(crate::shape_tool_cmds::specs());
v.extend(crate::path_edit_cmds::specs());
v.extend(crate::smartselect_cmds::specs());
v.extend(crate::cutout_cmds::specs());
v.extend(crate::symmetry_cmds::specs());
v.extend(crate::edit_cmds::specs());
v.extend(crate::vector_paste_cmds::specs());
v.extend(crate::smart_svg_cmds::specs());
v.extend(crate::color_cmds::specs());
v.extend(crate::brush_cmds::specs());
v.extend(crate::brush_preset_cmds::specs());
+7
View File
@@ -99,8 +99,11 @@ pub mod sample_cmds;
pub mod sampler_cmds;
pub mod select_extra_cmds;
pub mod selection_cmds;
mod shape_combine_cmds;
pub mod shape_tool_cmds;
pub mod slice_cmds;
pub mod smart_cmds;
pub mod smart_svg_cmds;
pub mod smartselect_cmds;
pub mod snap;
pub mod solid_fill_cmds;
@@ -119,6 +122,7 @@ pub mod type_styles_cmds;
mod variables_cmds;
pub use variables_cmds::{export_data_sets_with, import_data_sets_text};
pub mod vector_cmds;
pub mod vector_paste_cmds;
mod video_cmds;
pub use video_cmds::render_video_with;
pub mod vp_cmds;
@@ -385,6 +389,9 @@ pub struct Session {
/// Shape path context menu: copied vector fill and stroke styles.
pub path_fill_clipboard: Option<photocraft_doc::Fill>,
pub path_stroke_clipboard: Option<photocraft_doc::ShapeStroke>,
/// Pattern tiles referenced by copied paints; surfaces retain their COW snapshots.
pub(crate) path_fill_pattern: Option<photocraft_doc::Pattern>,
pub(crate) path_stroke_pattern: Option<photocraft_doc::Pattern>,
/// Colour management: proofing state, monitor profile, display transforms.
pub color: color_cmds::ColorState,
/// Open Edit Contents documents and the smart objects they update.
+1 -1
View File
@@ -46,7 +46,7 @@ fn knot(cmd: &str, p: &Value) -> Result<[usize; 2]> {
}
/// Applies `f` to the targeted path in one undoable step labelled `label`.
fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result<Value> {
pub(crate) fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned();
let layer = if name == "layer" { Some(layer_id(s, p)?) } else { None };
let shape = layer.filter(|id| s.active().and_then(|d| d.doc.layer(*id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))));
+88
View File
@@ -0,0 +1,88 @@
//! Combine selected shape layers while retaining the frontmost layer's position and style.
use photocraft_doc::{FillRule, LayerContent, LayerId, Path, PathOp};
use serde_json::Value;
use crate::{EngineError, Result, Session};
#[cfg(test)]
mod tests;
/// `None` preserves the existing single-layer/component operation. An explicit component
/// or layer never implicitly merges the rest of the layer selection.
pub(crate) fn selected(s: &mut Session, p: &Value, op: PathOp, bake: bool, label: &str) -> Result<Option<LayerId>> {
if p.get("layer").is_some() || p.get("subpath").is_some() {
return Ok(None);
}
let st = s.active().ok_or(EngineError::NoDocument)?;
// Normalize the whole selection first: a selected group carries its children,
// even though that group itself is not a shape operand.
let ids = crate::layer_multi_cmds::top_level(&st.doc, &st.selected_layers());
let ids: Vec<_> = ids.into_iter().filter(|id| st.doc.layer(*id).is_some_and(|layer| matches!(layer.content, LayerContent::Shape(_)))).collect();
if ids.len() < 2 {
return Ok(None);
}
let top = ids.last().copied().ok_or(EngineError::NoDocument)?;
let mut combined = Path::default();
for (operand_index, &id) in ids.iter().enumerate() {
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let locks = st.doc.effective_locks(id);
if locks.all || locks.pixels || locks.position {
return Err(EngineError::Other("A selected shape layer is locked".into()));
}
let LayerContent::Shape(shape) = &layer.content else {
return Err(EngineError::Other("Select only shape layers to combine".into()));
};
if shape.path.inverted {
return Err(EngineError::Other("Cannot combine inverted shape paths".into()));
}
// One selected layer is one boolean operand. In particular, intersecting a
// compound front layer means A ∩ (B ∪ C), not (A ∩ B) ∩ C. Canonical contours
// also let operands with different winding rules share a NonZero result.
let mut operand = if shape.path.components().len() > 1 || shape.path.fill_rule != FillRule::NonZero {
photocraft_vector::components::merge(&shape.path, 0.1, 16_000_000).map_err(EngineError::Other)?
} else {
shape.path.clone()
};
if operand.subpaths.is_empty() {
if operand_index > 0 && op == PathOp::Intersect {
combined.subpaths.clear();
}
continue;
}
let first = combined.subpaths.is_empty();
if first && operand_index > 0 && matches!(op, PathOp::Subtract | PathOp::Intersect) {
continue;
}
for (index, subpath) in operand.subpaths.iter_mut().enumerate() {
subpath.op = if index == 0 { if first { PathOp::Combine } else { op } } else { PathOp::Join };
}
combined.subpaths.extend(operand.subpaths);
photocraft_vector::flatten::validate_shape(&combined, None).map_err(|e| EngineError::Other(e.to_string()))?;
}
if bake {
let tolerance = p.get("tolerance").and_then(Value::as_f64).unwrap_or(0.1);
combined = photocraft_vector::components::merge(&combined, tolerance, 16_000_000).map_err(EngineError::Other)?;
}
let layer = st.doc.layer(top).ok_or(EngineError::NoLayer(top))?;
let LayerContent::Shape(mut merged) = layer.content.clone() else {
return Err(EngineError::Other("Select only shape layers to combine".into()));
};
merged.path = combined;
merged.live = None;
merged.cache = None;
merged.psd_raw = None;
photocraft_vector::flatten::validate_shape(&merged.path, merged.stroke.as_ref()).map_err(|e| EngineError::Other(e.to_string()))?;
s.edit(label, |doc, active| {
crate::vector_cmds::refresh_shape(doc, &mut merged);
for &id in &ids {
if id != top {
doc.remove(id).ok_or(EngineError::NoLayer(id))?;
}
}
doc.layer_mut(top).ok_or(EngineError::NoLayer(top))?.content = LayerContent::Shape(merged);
*active = Some(top);
Ok(())
})?;
crate::layer_multi_cmds::reselect(s, vec![top], Some(top));
Ok(Some(top))
}
@@ -0,0 +1,108 @@
use photocraft_doc::{LayerContent, LayerId, PathOp};
use serde_json::json;
use crate::{Session, layer_multi_cmds};
fn fixture() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":64})).unwrap();
s
}
fn rect(s: &mut Session, x: i32) -> LayerId {
LayerId(s.execute("shape.create", json!({"rect":[x,0,12,12]})).unwrap()["layer"].as_u64().unwrap())
}
#[test]
fn mixed_selection_leaves_locked_raster_untouched_and_supports_undo_redo() {
let mut s = fixture();
let a = rect(&mut s, 0);
let raster = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.lockLayers", json!({"layer":raster.0,"all":true})).unwrap();
assert!(s.active().unwrap().doc.effective_locks(raster).all);
let b = rect(&mut s, 6);
layer_multi_cmds::set_selection(&mut s, vec![a, raster, b], Some(raster), None).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
assert_eq!(s.execute("layer.combineShapes.unite", json!({})).unwrap()["layer"], b.0);
let after = s.active().unwrap().doc.clone();
assert_eq!(after.layer(raster), before.layer(raster));
assert!(after.layer(a).is_none());
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
#[test]
fn selected_group_protects_selected_shape_children_before_shape_filtering() {
let mut s = fixture();
let background = s.active().unwrap().active_layer.unwrap();
let child = rect(&mut s, 20);
let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
s.select_layer(background).unwrap();
let a = rect(&mut s, 0);
let b = rect(&mut s, 6);
layer_multi_cmds::set_selection(&mut s, vec![group, child, a, b], Some(b), None).unwrap();
assert!(s.active().unwrap().selected_layers().contains(&child));
let before = s.active().unwrap().doc.clone();
assert_eq!(s.execute("layer.combineShapes.unite", json!({})).unwrap()["layer"], b.0);
let after = &s.active().unwrap().doc;
assert_eq!(after.layer(group), before.layer(group));
assert_eq!(after.layer(child), before.layer(child));
assert!(after.layer(a).is_none());
assert!(after.layer(b).is_some());
}
#[test]
fn mixed_selection_with_one_shape_keeps_single_layer_component_editing() {
let mut s = fixture();
let raster = s.active().unwrap().active_layer.unwrap();
let shape = rect(&mut s, 0);
s.execute("shape.create", json!({"addTo":shape.0,"rect":[6,0,12,12]})).unwrap();
layer_multi_cmds::set_selection(&mut s, vec![raster, shape], Some(shape), None).unwrap();
let before = s.active().unwrap().doc.clone();
assert_eq!(s.execute("layer.combineShapes.subtractFrontShape", json!({})).unwrap()["layer"], shape.0);
let after = &s.active().unwrap().doc;
assert_eq!(after.layer(raster), before.layer(raster));
assert_eq!(after.walk().len(), before.walk().len());
let LayerContent::Shape(edited) = &after.layer(shape).unwrap().content else { panic!("shape") };
assert_eq!(edited.path.subpaths[1].op, PathOp::Subtract);
}
#[test]
fn combining_invalidates_original_psd_geometry_but_undo_restores_it() {
use std::sync::Arc;
let mut s = fixture();
let a = rect(&mut s, 0);
let b = rect(&mut s, 6);
let st = s.active_mut().unwrap();
let LayerContent::Shape(front) = &mut Arc::make_mut(&mut st.doc).layer_mut(b).unwrap().content else { panic!("shape") };
let raw = Arc::new(photocraft_io::vector_map::vmsk_bytes(&front.path, 0, 64, 64));
assert_eq!(photocraft_io::vector_map::path_from_vmsk(&raw, 64, 64).unwrap().0, front.path);
front.psd_raw = Some(raw.clone());
let original = front.clone();
layer_multi_cmds::set_selection(&mut s, vec![a, b], Some(b), None).unwrap();
let before = s.active().unwrap().doc.clone();
s.execute("layer.combineShapes.unite", json!({})).unwrap();
let after = s.active().unwrap().doc.clone();
let LayerContent::Shape(merged) = &after.layer(b).unwrap().content else { panic!("shape") };
assert_ne!(merged.path, original.path);
assert!(merged.psd_raw.is_none());
assert!(merged.live.is_none());
assert_eq!(merged.fill, original.fill);
assert_eq!(merged.stroke, original.stroke);
assert_eq!(after.layer(b).unwrap().name, before.layer(b).unwrap().name);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
let LayerContent::Shape(restored) = &s.active().unwrap().doc.layer(b).unwrap().content else { panic!("shape") };
assert_eq!(restored.psd_raw.as_ref(), Some(&raw));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
+251
View File
@@ -0,0 +1,251 @@
//! Shape-tool pixel drawing and path-component alignment/stacking.
use crate::commands::{CommandSpec, blend_from_str, has_paintable};
use crate::vector_cmds::{bad, has_doc, layer_id, parse_path, resolve_path, shape_path};
use crate::{Result, Session};
use photocraft_doc::{LayerId, Path};
use photocraft_geom::{Rect, TILE_SIZE};
use serde_json::{Value, json};
fn draw_pixels(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "shape.drawPixels";
let path = if p.get("kind").and_then(Value::as_str) == Some("path") {
parse_path(p.get("path").ok_or_else(|| bad(CMD, "Missing path"))?).map_err(|e| bad(CMD, e))?
} else {
shape_path(p)?
};
photocraft_vector::flatten::validate_shape(&path, None).map_err(|e| bad(CMD, e))?;
let opacity = match p.get("opacity") {
None => 1.0,
Some(v) => v.as_f64().filter(|v| (0.0..=100.0).contains(v)).ok_or_else(|| bad(CMD, "Opacity must be 0..100"))? as f32 / 100.0,
};
let mode = blend_from_str(string(p, "mode", "normal", CMD)?).ok_or_else(|| bad(CMD, "Unknown blend mode"))?;
if let Some(target) = p.get("target") {
let valid = target.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "quickMask"))
|| target.get("channel").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).is_some();
if !valid {
return Err(bad(CMD, "Invalid paint target"));
}
}
let aa = match p.get("antiAlias") {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(CMD, "antiAlias must be boolean"))?,
};
let color = match p.get("color") {
None => s.tools.foreground,
Some(v) => {
let c = crate::vector_cmds::color(v).ok_or_else(|| bad(CMD, "Invalid color"))?;
let rgb = c.to_rgb();
[rgb[0], rgb[1], rgb[2], c.alpha]
}
};
if color.iter().any(|c| !c.is_finite()) {
return Err(bad(CMD, "Color must be finite"));
}
let damage = s.edit("Draw Pixel Shape", |doc, active| {
let raster = photocraft_vector::fill_rasterizer(&path, photocraft_vector::DEFAULT_TOLERANCE);
let area = raster.pixel_bounds().unwrap_or(Rect::EMPTY).intersect(&doc.bounds());
let selection = doc.selection.clone();
let id = match p.get("layer") {
Some(v) => Some(LayerId(v.as_u64().ok_or_else(|| bad(CMD, "Invalid layer id"))?)),
None => *active,
};
if matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels | crate::channel_cmds::Target::Mask)
&& id.is_some_and(|id| doc.effective_locks(id).all)
{
return Err(bad(CMD, "Layer is locked"));
}
let (surface, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
// Match the resampling memory policy, accounting for complete stored tiles.
// A sparse document can have huge dimensions without allocating any pixels.
if !area.is_empty() {
let tiles_x = ((area.x1 - 1).div_euclid(TILE_SIZE) - area.x0.div_euclid(TILE_SIZE) + 1) as u64;
let tiles_y = ((area.y1 - 1).div_euclid(TILE_SIZE) - area.y0.div_euclid(TILE_SIZE) + 1) as u64;
let bytes = tiles_x
.saturating_mul(tiles_y)
.saturating_mul(TILE_SIZE as u64)
.saturating_mul(TILE_SIZE as u64)
.saturating_mul(surface.format().bytes_per_pixel() as u64);
let limit = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 };
if bytes > limit {
return Err(bad(CMD, "Pixel shape exceeds drawing memory limit"));
}
}
// Bounded tile buffers, including for very large documents.
let step = TILE_SIZE as usize;
for y in (area.y0..area.y1).step_by(step) {
for x in (area.x0..area.x1).step_by(step) {
let tile = Rect::new(x, y, x.saturating_add(TILE_SIZE).min(area.x1), y.saturating_add(TILE_SIZE).min(area.y1));
let mut cov = raster.render(tile);
let width = tile.width() as usize;
for (i, c) in cov.iter_mut().enumerate() {
if !aa {
*c = f32::from(*c >= 0.5);
}
if let Some(sel) = &selection {
*c *= sel.sample_channel(tile.x0 + (i % width) as i32, tile.y0 + (i / width) as i32, 0);
}
}
crate::vector_cmds::paint_coverage(surface, tile, &cov, color, opacity, mode, lock);
}
}
surface.prune();
Ok(area)
})?;
Ok(json!({"damage": damage}))
}
fn components(s: &Session, p: &Value, cmd: &str) -> Result<(Path, Vec<usize>)> {
string(p, "name", "work", cmd)?;
if p.get("layer").is_some_and(|v| v.as_u64().is_none()) {
return Err(bad(cmd, "Invalid layer id"));
}
let path = if p.get("name").and_then(Value::as_str) == Some("layer") {
let id = layer_id(s, p)?;
let layer = s.active().and_then(|st| st.doc.layer(id)).ok_or(crate::EngineError::NoLayer(id))?;
match &layer.content {
photocraft_doc::LayerContent::Shape(sh) => sh.path.clone(),
_ => layer.vector_mask.as_ref().ok_or_else(|| bad(cmd, "Layer has no path"))?.path.clone(),
}
} else {
resolve_path(s, p.get("name").and_then(Value::as_str))?
};
photocraft_vector::flatten::validate_shape(&path, None).map_err(|e| bad(cmd, e))?;
let count = path.components().len();
let mut selected = match p.get("components") {
None => (0..count).collect(),
Some(v) => v
.as_array()
.ok_or_else(|| bad(cmd, "components must be an array"))?
.iter()
.map(|v| v.as_u64().and_then(|v| usize::try_from(v).ok()).filter(|i| *i < count).ok_or_else(|| bad(cmd, "Invalid component index")))
.collect::<Result<Vec<_>>>()?,
};
selected.sort_unstable();
selected.dedup();
if selected.is_empty() {
return Err(bad(cmd, "No path components selected"));
}
Ok((path, selected))
}
fn check_lock(s: &Session, p: &Value, position: bool) -> Result<()> {
if p.get("name").and_then(Value::as_str) == Some("layer") {
let id = p.get("layer").and_then(Value::as_u64).map(LayerId).map_or_else(|| layer_id(s, p), Ok)?;
let locks = s.active().ok_or(crate::EngineError::NoDocument)?.doc.effective_locks(id);
if locks.all || locks.pixels || (position && locks.position) {
return Err(bad("path.components", "Layer is locked"));
}
}
Ok(())
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.align";
check_lock(s, p, true)?;
let (original, selected) = components(s, p, CMD)?;
let action = p.get("action").and_then(Value::as_str).ok_or_else(|| bad(CMD, "Missing action"))?;
let canvas = match string(p, "to", "selection", CMD)? {
"selection" => None,
"canvas" => {
let d = &s.active().ok_or(crate::EngineError::NoDocument)?.doc;
Some([0.0, 0.0, d.size.width as f64, d.size.height as f64])
}
_ => return Err(bad(CMD, "to must be selection or canvas")),
};
// A complete live shape moves as a whole; retain its editable parameters.
// Component edits of compound paths still use the general path editor below.
if p.get("name").and_then(Value::as_str) == Some("layer") && original.components().len() == 1 {
let id = layer_id(s, p)?;
let live =
s.active().and_then(|st| st.doc.layer(id)).is_some_and(|l| matches!(&l.content, photocraft_doc::LayerContent::Shape(sh) if sh.live.is_some()));
if live {
let mut aligned = original.clone();
photocraft_vector::components::align(&mut aligned, &selected, action, canvas).map_err(|e| bad(CMD, e))?;
photocraft_vector::flatten::validate_shape(&aligned, None).map_err(|e| bad(CMD, e))?;
let before = original.control_bounds().ok_or_else(|| bad(CMD, "Path has no bounds"))?;
let after = aligned.control_bounds().ok_or_else(|| bad(CMD, "Path has no bounds"))?;
let transform = photocraft_geom::Affine::translate(after.0 - before.0, after.1 - before.1);
let path = crate::vector_cmds::with_shape(s, id, "Align Path Components", |sh, _| {
crate::vector_cmds::transform_shape(sh, &transform);
// Keep the translated source geometry, including imported path precision.
sh.path = aligned;
Ok(crate::vector_cmds::path_json(&sh.path))
})?;
return Ok(json!({"name":"layer","path":path}));
}
}
crate::path_edit_cmds::edit_path(s, CMD, p, "Align Path Components", |path| {
photocraft_vector::components::align(path, &selected, action, canvas)?;
photocraft_vector::flatten::validate_shape(path, None).map_err(String::from)
})
}
fn string<'a>(p: &'a Value, key: &str, default: &'a str, cmd: &str) -> Result<&'a str> {
match p.get(key) {
None => Ok(default),
Some(v) => v.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a string"))),
}
}
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.arrange";
check_lock(s, p, false)?;
let (_, selected) = components(s, p, CMD)?;
let action = p.get("action").and_then(Value::as_str).ok_or_else(|| bad(CMD, "Missing action"))?;
crate::path_edit_cmds::edit_path(s, CMD, p, "Arrange Path Components", |path| photocraft_vector::components::arrange(path, &selected, action))
}
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.merge";
check_lock(s, p, false)?;
components(s, p, CMD)?;
let tolerance = match p.get("tolerance") {
None => 0.1,
Some(v) => v.as_f64().filter(|v| (0.05..=10.0).contains(v)).ok_or_else(|| bad(CMD, "Invalid merge tolerance"))?,
};
crate::path_edit_cmds::edit_path(s, CMD, p, "Merge Shape Components", |path| {
*path = photocraft_vector::components::merge(path, tolerance, 16_000_000)?;
Ok(())
})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
id: "path.merge",
label: "Merge Shape Components",
menu: &[],
shortcut: None,
params: r##"{name:work|layer|saved path,layer:id?,tolerance:0.05..10=0.1} (bakes boolean components into outlines; 16M coverage-pixel budget at 4x)"##,
enabled: has_doc,
run: merge,
journal: true,
},
CommandSpec {
id: "shape.drawPixels",
label: "Draw Pixel Shape",
menu: &[],
shortcut: None,
params: r##"{kind,rect/from/to/path: shape.create geometry,color: foreground,mode: blend mode,opacity: 0..100=100,antiAlias: bool=true,target: pixels|mask|quickMask|{channel:i},layer:id?} (drawing bounds limited to 8 GiB of tiles on 64-bit, 2 GiB on 32-bit)"##,
enabled: has_paintable,
run: draw_pixels,
journal: true,
},
CommandSpec {
id: "path.align",
label: "Align Path Components",
menu: &[],
shortcut: None,
params: r##"{name:work|layer|saved path,layer:id?,components:[index,...]?=all,action:left|centerX|right|top|centerY|bottom|distributeX|distributeY,to:selection|canvas=selection}"##,
enabled: has_doc,
run: align,
journal: true,
},
CommandSpec {
id: "path.arrange",
label: "Arrange Path Components",
menu: &[],
shortcut: None,
params: r##"{name:work|layer|saved path,layer:id?,components:[index,...]?=all,action:front|forward|backward|back}"##,
enabled: has_doc,
run: arrange,
journal: true,
},
]
}
+58 -7
View File
@@ -70,6 +70,11 @@ pub fn encode_source(doc: &Document) -> Result<Vec<u8>> {
/// Decodes smart-object contents (a `.pcraft` bundle, PSD/PSB, or any flat image format).
pub fn decode_source(file_name: &str, bytes: &[u8]) -> Result<Document> {
if photocraft_io::svg::is_svg(bytes) {
return photocraft_io::svg::import_svg_bounded_layers(file_name, bytes, 24_000_000)
.map(|imported| imported.document)
.map_err(|e| other(format!("can't read SVG smart object contents: {e}")));
}
crate::file_cmds::import(file_name, bytes).map_err(|e| other(format!("can't read smart object contents \"{file_name}\": {e}")))
}
@@ -199,15 +204,52 @@ pub fn svg_source_image(bytes: &[u8], fmt: PixelFormat, scale: f64) -> Result<So
if let Some(img) = cache_get(&key) {
return Ok(img);
}
let buf = photocraft_io::svg::rasterize(bytes, scale as f32).map_err(|e| other(format!("can't render the SVG smart object: {e}")))?;
let buf = photocraft_io::svg::rasterize_bounded(bytes, scale as f32, 24_000_000).map_err(|e| other(format!("can't render the SVG smart object: {e}")))?;
let img = SourceImage { surface: Arc::new(buffer_to_surface(&buf, fmt)), bounds: buf.rect };
cache_put(key, img.clone());
Ok(img)
}
/// Render SVG's sRGB colours into the destination profile before placement. The profile is
/// part of the source cache key, so assigning a different profile cannot reuse old colours.
fn svg_source_image_in(bytes: &[u8], doc: &Document, scale: f64) -> Result<SourceImage> {
use photocraft_color::{ColorMode, SampleType};
let fmt = doc.pixel_format();
let profile = crate::color_cmds::document_profile(doc);
// An exact built-in sRGB destination needs no CMS conversion. Keep the direct render
// path (and its depth-specific cache) without approximating custom profile equality.
if doc.mode == ColorMode::Rgb && profile.to_bytes().as_ref() == photocraft_cms::Builtin::Srgb.profile().to_bytes().as_ref() {
return svg_source_image(bytes, fmt, scale);
}
let mut h = blake3::Hasher::new();
h.update(b"svg-icc\0");
h.update(bytes);
h.update(&scale.to_bits().to_le_bytes());
h.update(&profile.to_bytes());
let key = (*h.finalize().as_bytes(), fmt);
if let Some(image) = cache_get(&key) {
return Ok(image);
}
let source = svg_source_image(bytes, PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), scale)?;
let image = convert_svg_source(source, doc)?;
cache_put(key, image.clone());
Ok(image)
}
fn convert_svg_source(source: SourceImage, doc: &Document) -> Result<SourceImage> {
use photocraft_cms::{Builtin, Intent, Transform};
let fmt = doc.pixel_format();
let profile = crate::color_cmds::document_profile(doc);
let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true)
.map_err(|e| other(format!("can't convert the SVG smart object's colours: {e}")))?;
let surface = crate::color_cmds::convert_surface(&source.surface, photocraft_color::ColorMode::Rgb, fmt, &transform).convert(fmt);
Ok(SourceImage { surface: Arc::new(surface), bounds: source.bounds })
}
/// The scale a vector source is rasterised at for its placement: the larger axis scale of the
/// transform in 1/16 steps (so nudges reuse the cache), between 1/16 and 64.
fn vector_scale(sm: &SmartObject) -> f64 {
pub(crate) fn vector_scale(sm: &SmartObject) -> f64 {
let [a, b, c, d, ..] = sm.transform.m;
let k = (a * a + b * b).sqrt().max((c * c + d * d).sqrt());
if !k.is_finite() {
@@ -357,12 +399,13 @@ fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect
}
/// The source placed in document pixels, before any smart filter.
fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result<Surface> {
fn place(name: &str, bytes: &[u8], doc: &Document, sm: &SmartObject) -> Result<Surface> {
let fmt = doc.pixel_format();
// A vector source is rendered at the placement's scale, and the placement is divided by
// that scale so the rendered pixels land where the source units would.
if sm.stack_mode.is_none() && sm.warp.is_none() && photocraft_io::svg::is_svg(bytes) {
let k = vector_scale(sm);
let img = svg_source_image(bytes, fmt, k)?;
let img = svg_source_image_in(bytes, doc, k)?;
return Ok(match &sm.perspective {
Some(p) => {
let [h0, h1, h2, h3, h4, h5, h6, h7, h8] = *p;
@@ -377,6 +420,8 @@ fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result
});
}
let img = match sm.stack_mode {
Some(mode) if photocraft_io::svg::is_svg(bytes) => convert_svg_source(stack_image(name, bytes, PixelFormat::RGBA32F, mode)?, doc)?,
None if photocraft_io::svg::is_svg(bytes) => svg_source_image_in(bytes, doc, 1.0)?,
Some(mode) => stack_image(name, bytes, fmt, mode)?,
None => source_image(name, bytes, fmt)?,
};
@@ -413,12 +458,18 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Surface>> {
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
let mut placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
if photocraft_io::svg::is_svg(&bytes) {
let mut h = blake3::Hasher::new();
h.update(&placed_key);
h.update(&crate::color_cmds::document_profile(doc).to_bytes());
placed_key = *h.finalize().as_bytes();
}
let placed = || -> Result<Stage> {
if let Some(stage) = stack_cache::get(&placed_key) {
return Ok(stage);
}
let surface = place(&name, &bytes, fmt, sm)?;
let surface = place(&name, &bytes, doc, sm)?;
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
let stage = Stage { extent: canvas.union(&surface.content_bounds()), surface };
stack_cache::put(placed_key, &stage);
@@ -618,7 +669,7 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) {
/// Forget PSD placed-layer data that no longer describes the smart object (its source changed):
/// PSD export then writes fresh placed-layer blocks and embeds the new source.
fn detach_psd(l: &mut Layer) {
pub(crate) fn detach_psd(l: &mut Layer) {
if let LayerContent::Smart(sm) = &mut l.content {
sm.psd_raw = None;
}
+1
View File
@@ -2,6 +2,7 @@ use super::*;
mod selection;
mod stack_cache;
mod svg_profile;
const DEPTHS: [u64; 3] = [8, 16, 32];
const W: i32 = 64;
@@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface {
let doc = &s.active().unwrap().doc;
let sm = active_smart(s);
let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap();
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds())
apply_smart_filters(&place(&name, &bytes, doc, &sm).unwrap(), &sm, doc.bounds())
}
#[test]
@@ -0,0 +1,74 @@
use super::*;
use photocraft_cms::Builtin;
const SVG: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#808080"/></svg>"##;
#[test]
fn svg_builtin_srgb_keeps_direct_render_at_each_depth() {
let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="4" height="8" fill="#336699"/><rect x="4" width="4" height="4" fill="#e65c1a" fill-opacity="0.5"/></svg>"##;
for depth in DEPTHS {
let s = session(depth);
let mut doc = (*s.active().unwrap().doc).clone();
for explicit_profile in [false, true] {
doc.icc_profile = explicit_profile.then(|| Builtin::Srgb.profile().to_bytes());
let expected = svg_source_image(svg, doc.pixel_format(), 1.0).unwrap();
let actual = svg_source_image_in(svg, &doc, 1.0).unwrap();
assert_eq!(actual.bounds, expected.bounds);
assert_eq!(actual.surface, expected.surface, "sRGB at {depth}-bit, explicit={explicit_profile}");
}
}
}
#[test]
fn svg_smart_rerender_checks_embedded_raster_dimensions() {
let mut header = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
header.extend_from_slice(&32768u32.to_be_bytes());
header.extend_from_slice(&32768u32.to_be_bytes());
header.extend_from_slice(&[8, 6, 0, 0, 0]);
let data = header.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
let svg =
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\"><image width=\"1\" height=\"1\" href=\"data:image/png,{data}\"/></svg>");
assert!(svg_source_image(svg.as_bytes(), PixelFormat::RGBA32F, 1.0).unwrap_err().to_string().contains("decoded pixel budget"));
let s = session(8);
let smart = SmartObject::new(SmartSource::Embedded { file_name: "Clipboard.svg".into(), bytes: Arc::new(svg.into_bytes()) }, Affine::IDENTITY, None);
assert!(render(&s.active().unwrap().doc, &smart).unwrap_err().to_string().contains("decoded pixel budget"));
}
#[test]
fn svg_smart_contents_and_stack_reject_excessive_editable_caches() {
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"256\" height=\"256\">{}</svg>", "<rect width=\"256\" height=\"256\"/>".repeat(400));
assert!(decode_source("Clipboard.svg", svg.as_bytes()).unwrap_err().to_string().contains("cache budget"));
assert!(stack_image("Clipboard.svg", svg.as_bytes(), PixelFormat::RGBA32F, photocraft_doc::StackMode::Mean).is_err());
}
#[test]
fn svg_smart_render_cache_tracks_destination_profile_and_transform() {
for depth in DEPTHS {
let s = session(depth);
let srgb = (*s.active().unwrap().doc).clone();
let mut linear = srgb.clone();
linear.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes());
let mut smart = SmartObject::new(SmartSource::Embedded { file_name: "clipboard.svg".into(), bytes: Arc::new(SVG.to_vec()) }, Affine::IDENTITY, None);
let original = render(&srgb, &smart).unwrap().unwrap();
let corrected = render(&linear, &smart).unwrap().unwrap();
assert!((original.pixel(3, 3)[0] - 128.0 / 255.0).abs() < 0.01);
assert!((corrected.pixel(3, 3)[0] - 0.216).abs() < 0.01, "linear profile at {depth}-bit");
assert_eq!(render(&srgb, &smart).unwrap().unwrap(), original, "profile-specific placed cache");
smart.transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 4.0, 2.0] };
let scaled = render(&linear, &smart).unwrap().unwrap();
assert!((scaled.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "scale must keep corrected colours");
assert_eq!(scaled.format(), linear.pixel_format());
let bounds = [0.0, 0.0, 8.0, 8.0];
smart.warp = Some(photocraft_geom::warp::Warp::custom(photocraft_geom::warp::BezierMesh::identity(bounds, 1, 1), bounds));
let warped = render(&linear, &smart).unwrap().unwrap();
assert!((warped.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "warp must keep corrected colours");
linear.layers = vec![Layer::new("Saved SVG", LayerContent::Smart(smart.clone()))];
let saved = encode_source(&linear).unwrap();
let reopened = decode_source("saved.pcraft", &saved).unwrap();
let LayerContent::Smart(restored) = &reopened.layers[0].content else { panic!("smart object lost on save") };
assert_eq!(restored.source, smart.source);
assert_eq!(restored.transform, smart.transform);
let rerendered = render(&reopened, restored).unwrap().unwrap();
assert!((rerendered.pixel(10, 8)[0] - corrected.pixel(3, 3)[0]).abs() < 0.01, "saved source keeps ICC and warp");
}
}
+6
View File
@@ -89,6 +89,12 @@ fn place(s: &Session, doc: &Document, sm: &SmartObject, layers: &mut [Layer]) ->
}
let h = placement(sm);
let affine = sm.perspective.is_none().then_some(sm.transform);
if let SmartSource::Embedded { bytes, .. } = &sm.source
&& photocraft_io::svg::is_svg(bytes)
&& let Some(transform) = affine
{
crate::vector_paste_cmds::check_destination_shapes(layers, transform, doc.size).map_err(other)?;
}
for l in layers {
released(l, |l| crate::transform_cmds::transform_layer(None, photocraft_doc::Locks::default(), l, &h, affine, Interp::Bicubic))?;
crate::transform_cmds::refresh_text(doc, l);
+245
View File
@@ -0,0 +1,245 @@
//! Memory-only SVG smart-object handoff. The shell owns editor selection and temporary files;
//! commands own optimistic concurrency, bounded rendering and one shared-content transaction.
use std::sync::Arc;
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartContentsId, SmartObject, SmartSource};
use photocraft_geom::{Rect, Size};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const EXPORT: &str = "layer.smartObjects.exportSvg";
const UPDATE: &str = "layer.smartObjects.updateSvg";
const MAX_PIXELS: u64 = 24_000_000;
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
fn layer(s: &Session, p: &Value, cmd: &str, required: bool) -> Result<LayerId> {
if !p.is_object() {
return Err(bad(cmd, "parameters must be an object"));
}
match p.get("layer") {
Some(v) => v.as_u64().map(LayerId).ok_or_else(|| bad(cmd, "`layer` must be an integer layer id")),
None if required => Err(bad(cmd, "missing `layer`")),
None => crate::commands::layer_param(s, p),
}
}
fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> {
match &doc.layer(id).ok_or(EngineError::NoLayer(id))?.content {
LayerContent::Smart(sm) => Ok(sm),
_ => Err(EngineError::Other("the layer is not a smart object".into())),
}
}
fn embedded(sm: &SmartObject) -> Result<(&str, &Arc<Vec<u8>>)> {
let SmartSource::Embedded { file_name, bytes } = &sm.source else {
return Err(EngineError::Other("external SVG editing requires embedded SVG contents".into()));
};
if bytes.is_empty() || bytes.len() > crate::vector_paste_cmds::MAX_VECTOR_CLIPBOARD_BYTES || !photocraft_io::svg::is_svg(bytes) {
return Err(EngineError::Other("external SVG editing requires SVG contents within 16 MiB".into()));
}
Ok((file_name, bytes))
}
fn enabled(s: &Session) -> std::result::Result<(), String> {
let state = s.active().ok_or("no document open")?;
if s.job_on(state.doc.id).is_some() {
return Err("the document has a running background job".into());
}
if s.tools.mask_targeted || state.channel_view.layer_mask.is_some() || state.channel_view.target != crate::channel_cmds::ChannelTarget::Composite {
return Err("SVG contents editing requires the layer composite target".into());
}
Ok(())
}
fn check_instances(s: &Session, id: LayerId) -> Result<Vec<LayerId>> {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let contents = smart(doc, id)?.contents_id;
if s.smart_links.iter().any(|link| link.parent == doc.id && link.contents_id == contents) {
return Err(EngineError::Other("these smart-object contents are already open in PhotoCraft".into()));
}
let ids: Vec<_> =
doc.walk().into_iter().filter_map(|(_, _, l)| matches!(&l.content, LayerContent::Smart(sm) if sm.contents_id == contents).then_some(l.id)).collect();
for id in &ids {
let locks = doc.effective_locks(*id);
if locks.all || locks.pixels {
return Err(EngineError::Other("a shared smart-object instance is locked".into()));
}
embedded(smart(doc, *id)?)?;
}
Ok(ids)
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer(s, p, EXPORT, false)?;
check_instances(s, id)?;
let sm = smart(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
let (_, bytes) = embedded(sm)?;
let svg = std::str::from_utf8(bytes).map_err(|_| bad(EXPORT, "SVG contents must be UTF-8"))?;
Ok(
json!({"document":s.active().ok_or(EngineError::NoDocument)?.doc.id.0,"svg":svg,"fingerprint":blake3::hash(bytes).to_hex().to_string(),"contentsId":sm.contents_id.0}),
)
}
/// Mirror the renderer's mesh vertices before it allocates destination tiles. Without a warp,
/// a homography has corner extrema only when its denominator keeps one nonzero sign.
fn placed_bounds(sm: &SmartObject, size: Size) -> Result<Rect> {
let (mut w, mut h) = (f64::from(size.width), f64::from(size.height));
let warp = sm.warp.as_ref().filter(|warp| !warp.is_identity());
if sm.warp.is_none() && sm.stack_mode.is_none() {
let scale = crate::smart_cmds::vector_scale(sm);
w = (w * scale).ceil() / scale;
h = (h * scale).ceil() / scale;
}
let (nx, ny) = if warp.is_some() { (((w / 4.0).ceil() as usize).clamp(1, 512), ((h / 4.0).ceil() as usize).clamp(1, 512)) } else { (1, 1) };
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut sign = None;
for j in 0..=ny {
for i in 0..=nx {
let (x, y) = (w * i as f64 / nx as f64, h * j as f64 / ny as f64);
let (x, y) = warp.map_or((x, y), |warp| warp.map(x, y));
let (x, y) = if let Some(m) = sm.perspective {
let denominator = m[6] * x + m[7] * y + m[8];
let positive = denominator > 0.0;
if !denominator.is_finite() || denominator.abs() < 1e-9 || sign.is_some_and(|prev| prev != positive) {
return Err(bad(UPDATE, "SVG placement crosses an unbounded perspective"));
}
sign = Some(positive);
((m[0] * x + m[1] * y + m[2]) / denominator, (m[3] * x + m[4] * y + m[5]) / denominator)
} else {
let [a, b, c, d, e, f] = sm.transform.m;
(a * x + c * y + e, b * x + d * y + f)
};
if !x.is_finite() || !y.is_finite() || x.abs() > 1_000_000.0 || y.abs() > 1_000_000.0 {
return Err(bad(UPDATE, "SVG placement exceeds the supported coordinate range"));
}
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
// Unwarped vectors rasterise at a quantised scale. Include its rounded source-pixel
// edges and the inverse sampler's border, as well as the mesh renderer's one-pixel border.
let padding = if warp.is_some() { 1 } else { 4 };
Ok(Rect::new(x0.floor() as i32 - padding, y0.floor() as i32 - padding, x1.ceil() as i32 + padding, y1.ceil() as i32 + padding))
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer(s, p, UPDATE, true)?;
let document = p.get("document").and_then(Value::as_u64).ok_or_else(|| bad(UPDATE, "`document` must be an integer document id"))?;
if s.active().map(|state| state.doc.id.0) != Some(document) {
return Err(bad(UPDATE, "the smart object's parent document is no longer active"));
}
let svg = p.get("svg").and_then(Value::as_str).ok_or_else(|| bad(UPDATE, "`svg` must be an SVG string"))?;
let expected = p
.get("fingerprint")
.and_then(Value::as_str)
.filter(|hash| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
.ok_or_else(|| bad(UPDATE, "`fingerprint` must be a BLAKE3 hex digest"))?;
let contents = p.get("contentsId").and_then(Value::as_u64).map(SmartContentsId).ok_or_else(|| bad(UPDATE, "`contentsId` must be an integer"))?;
let ids = check_instances(s, id)?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let target = smart(doc, id)?;
let (name, original) = embedded(target)?;
if target.contents_id != contents || !blake3::hash(original).to_hex().as_str().eq_ignore_ascii_case(expected) {
return Err(bad(UPDATE, "smart-object contents changed since external editing started"));
}
for instance in &ids {
let (_, bytes) = embedded(smart(doc, *instance)?)?;
if !blake3::hash(bytes).to_hex().as_str().eq_ignore_ascii_case(expected) {
return Err(bad(UPDATE, "shared smart-object instances have conflicting contents"));
}
}
if original.as_slice() == svg.as_bytes() {
return Ok(json!({"layer":id.0,"contentsId":contents.0,"fingerprint":expected,"updated":false}));
}
// Persisted smart filters are executable input too. A source replacement replays them
// without Session::execute, so authorize every filter that render_in will apply, across
// all shared instances, before parsing/rendering any replacement. Desktop sessions keep
// their normal user-selected file access; untrusted automation must not inherit it.
if let Some(authorize) = s.authorize {
for instance in &ids {
let sm = smart(doc, *instance)?;
if sm.filters_enabled {
for filter in sm.smart_filters.iter().filter(|filter| filter.visible) {
authorize(&filter.command, &filter.params)?;
}
}
}
}
if svg.is_empty() || svg.len() > crate::vector_paste_cmds::MAX_VECTOR_CLIPBOARD_BYTES || !photocraft_io::svg::is_svg(svg.as_bytes()) {
return Err(bad(UPDATE, "SVG contents must contain a valid SVG document within 16 MiB"));
}
// Save-back can intentionally remove the last drawable object. Use the same bounded
// importer as clipboard preparation without its paste-only nonempty-layer requirement.
let imported = photocraft_io::svg::import_svg_bounded(name, svg.as_bytes(), MAX_PIXELS)
.map_err(|error| bad(UPDATE, format!("can't import edited SVG contents: {error}")))?;
let size = imported.document.size;
if size.area() > MAX_PIXELS {
return Err(bad(UPDATE, "SVG contents exceed the 24 million pixel limit"));
}
let source = SmartSource::Embedded { file_name: name.to_string(), bytes: Arc::new(svg.as_bytes().to_vec()) };
let mut replacements: Vec<Layer> = Vec::with_capacity(ids.len());
let mut pixels = 0u64;
for instance in ids {
let mut replacement = doc.layer(instance).cloned().ok_or(EngineError::NoLayer(instance))?;
let LayerContent::Smart(sm) = &mut replacement.content else { return Err(EngineError::NoLayer(instance)) };
let bounds = placed_bounds(sm, size)?;
let extent = if sm.filters_enabled && sm.smart_filters.iter().any(|filter| filter.visible) { bounds.union(&doc.bounds()) } else { bounds };
pixels = pixels.checked_add(extent.size().area()).ok_or_else(|| bad(UPDATE, "SVG placement cache budget exceeded"))?;
if pixels > MAX_PIXELS {
return Err(bad(UPDATE, "SVG placement caches exceed the 24 million pixel limit"));
}
sm.source = source.clone();
replacements.push(replacement);
}
// Finish every fallible render before committing any document state.
for replacement in &mut replacements {
if !crate::smart_cmds::refresh_layer(doc, replacement)? {
return Err(bad(UPDATE, "SVG contents could not be rendered"));
}
crate::smart_cmds::detach_psd(replacement);
}
s.edit("Update SVG Contents", |doc, _| {
for replacement in replacements {
let id = replacement.id;
*doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = replacement;
}
Ok(())
})?;
Ok(json!({"layer":id.0,"contentsId":contents.0,"fingerprint":blake3::hash(svg.as_bytes()).to_hex().to_string(),"updated":true}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
id: EXPORT,
label: "Export SVG Contents",
menu: &[],
shortcut: None,
params: r#"{"layer":id?} → {document,svg,fingerprint,contentsId}"#,
enabled,
run: export,
journal: false,
},
CommandSpec {
id: UPDATE,
label: "Update SVG Contents",
menu: &[],
shortcut: None,
params: r#"{"document":id,"layer":id,"svg":"<svg …>","fingerprint":"original BLAKE3 hex","contentsId":id} → {layer,contentsId,fingerprint,updated}"#,
enabled,
run: update,
journal: true,
},
]
}
#[cfg(test)]
mod tests;
+292
View File
@@ -0,0 +1,292 @@
use super::*;
use photocraft_doc::{DocId, Locks, SmartFilter};
use photocraft_geom::Affine;
const RED: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="red"/></svg>"#;
const BLUE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="blue"/></svg>"#;
const GREEN: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6" fill="lime"/></svg>"#;
fn session(depth: u32) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap();
let result = s.execute("edit.pasteVector", json!({"svg":RED,"mode":"smartObject"})).unwrap();
let id = LayerId(result["layers"][0].as_u64().unwrap());
(s, id)
}
fn request(s: &mut Session, id: LayerId, svg: &str) -> Value {
let handoff = s.execute(EXPORT, json!({"layer":id.0})).unwrap();
json!({"document":handoff["document"],"layer":id.0,"svg":svg,"fingerprint":handoff["fingerprint"],"contentsId":handoff["contentsId"]})
}
fn source(s: &Session, id: LayerId) -> Vec<u8> {
embedded(smart(&s.active().unwrap().doc, id).unwrap()).unwrap().1.as_ref().clone()
}
fn fails_without_edit(s: &mut Session, params: Value) {
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
assert!(s.execute(UPDATE, params).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().revision, revision);
}
#[test]
fn export_and_unchanged_save_have_no_history_step() {
let (mut s, id) = session(8);
let revision = s.active().unwrap().revision;
let params = request(&mut s, id, RED);
let result = s.execute(UPDATE, params).unwrap();
assert_eq!(result["updated"], false);
assert_eq!(s.active().unwrap().revision, revision);
assert!(s.undo());
assert!(s.active().unwrap().doc.layer(id).is_none());
}
#[test]
fn deleting_last_svg_object_saves_transparency_and_undo_restores_source() {
let (mut s, id) = session(8);
let before = s.active().unwrap().doc.clone();
let empty = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"/>"#;
let params = request(&mut s, id, empty);
s.execute(UPDATE, params).unwrap();
assert_eq!(source(&s, id), empty.as_bytes());
assert_eq!(smart(&s.active().unwrap().doc, id).unwrap().cache.as_ref().unwrap().pixel(15, 11), vec![0.0; 4]);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn valid_updates_repeat_at_all_depths_and_roundtrip_original_svg() {
for depth in [8, 16, 32] {
let (mut s, id) = session(depth);
let original = s.active().unwrap().doc.clone();
let params = request(&mut s, id, BLUE);
let result = s.execute(UPDATE, params).unwrap();
assert_eq!(result["updated"], true);
let sm = smart(&s.active().unwrap().doc, id).unwrap();
assert_eq!(sm.cache.as_ref().unwrap().format(), s.active().unwrap().doc.pixel_format());
assert_eq!(sm.cache.as_ref().unwrap().pixel(15, 11), vec![0.0, 0.0, 1.0, 1.0]);
assert_eq!(source(&s, id), BLUE.as_bytes());
let after = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, original);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
let params = request(&mut s, id, GREEN);
s.execute(UPDATE, params).unwrap();
assert_eq!(source(&s, id), GREEN.as_bytes());
let bytes = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
let loaded = photocraft_format::load_from_bytes(&bytes).unwrap();
let loaded_source = &smart(&loaded, id).unwrap().source;
assert!(matches!(loaded_source, SmartSource::Embedded {file_name,bytes} if file_name == "Clipboard.svg" && bytes.as_slice() == GREEN.as_bytes()));
}
}
#[test]
fn shared_instances_keep_placement_filters_masks_and_one_undo_transaction() {
let (mut s, id) = session(8);
let duplicate = {
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let mut layer = doc.layer(id).unwrap().duplicate();
let LayerContent::Smart(sm) = &mut layer.content else { panic!("smart object") };
sm.transform = Affine::translate(2.0, 3.0);
sm.smart_filters.push(SmartFilter {
command: "filter.invert".into(),
params: json!({}),
blend: photocraft_color::BlendMode::Normal,
opacity: 0.5,
visible: true,
});
sm.filters_enabled = false;
sm.filter_mask = Some(photocraft_doc::LayerMask {
surface: photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8),
enabled: true,
linked: true,
density: 0.5,
feather: 1.0,
});
let duplicate = layer.id;
doc.layers.push(layer);
duplicate
};
let before = s.active().unwrap().doc.clone();
let preserved = smart(&before, duplicate).unwrap().clone();
let revision = s.active().unwrap().revision;
let params = request(&mut s, id, BLUE);
s.execute(UPDATE, params).unwrap();
assert_eq!(s.active().unwrap().revision, revision + 1);
assert_eq!(source(&s, id), source(&s, duplicate));
let edited = smart(&s.active().unwrap().doc, duplicate).unwrap();
assert_eq!(edited.transform, preserved.transform);
assert_eq!(edited.smart_filters, preserved.smart_filters);
assert_eq!(edited.filter_mask, preserved.filter_mask);
assert_eq!(edited.filters_enabled, preserved.filters_enabled);
assert_eq!(edited.contents_id, preserved.contents_id);
assert_eq!(edited.cache.as_ref().unwrap().pixel(4, 4), vec![0.0, 0.0, 1.0, 1.0]);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(source(&s, id), BLUE.as_bytes());
}
#[test]
fn invalid_oversized_and_external_resources_are_bounded_atomic() {
let (mut s, id) = session(8);
for svg in ["garbage", "<svg>", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"99999\" height=\"99999\"><rect width=\"99999\" height=\"99999\"/></svg>"]
{
let params = request(&mut s, id, svg);
fails_without_edit(&mut s, params);
}
let huge = " ".repeat(crate::vector_paste_cmds::MAX_VECTOR_CLIPBOARD_BYTES + 1);
let params = request(&mut s, id, &huge);
fails_without_edit(&mut s, params);
// External images cannot trigger ambient filesystem or network access.
let external = r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><image href="file:///secret.png" width="8" height="6"/></svg>"#;
let params = request(&mut s, id, external);
let before = s.active().unwrap().doc.clone();
match s.execute(UPDATE, params) {
Err(_) => assert_eq!(s.active().unwrap().doc, before),
Ok(_) => assert_eq!(smart(&s.active().unwrap().doc, id).unwrap().cache.as_ref().unwrap().pixel(15, 11), vec![0.0; 4]),
}
}
#[test]
fn stale_hash_content_identity_wrong_parent_deleted_and_locked_reject() {
let (mut s, id) = session(8);
let params = request(&mut s, id, BLUE);
for (key, value) in [("fingerprint", json!("0".repeat(64))), ("contentsId", json!(0)), ("document", json!(DocId::fresh().0)), ("layer", json!("wrong"))] {
let mut bad = params.clone();
bad[key] = value;
fails_without_edit(&mut s, bad);
}
let mut missing = params.clone();
missing.as_object_mut().unwrap().remove("document");
fails_without_edit(&mut s, missing);
Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks = Locks { all: true, ..Default::default() };
fails_without_edit(&mut s, params.clone());
Arc::make_mut(&mut s.active_mut().unwrap().doc).layer_mut(id).unwrap().locks = Default::default();
s.execute(UPDATE, params.clone()).unwrap();
fails_without_edit(&mut s, params.clone());
Arc::make_mut(&mut s.active_mut().unwrap().doc).remove(id).unwrap();
fails_without_edit(&mut s, params);
}
#[test]
fn linked_sources_and_internal_editor_conflicts_reject_without_reads() {
let (mut s, id) = session(8);
let params = request(&mut s, id, BLUE);
let parent = s.active().unwrap().doc.id;
let contents_id = smart(&s.active().unwrap().doc, id).unwrap().contents_id;
s.smart_links.push(crate::smart_cmds::SmartLink { child: DocId::fresh(), parent, layer: id, contents_id });
fails_without_edit(&mut s, params.clone());
assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err());
s.smart_links.clear();
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
sm.source = SmartSource::Linked { path: "C:/not-authorized/source.svg".into() };
fails_without_edit(&mut s, params);
assert!(s.execute(EXPORT, json!({"layer":id.0})).is_err());
}
#[test]
fn inherited_shared_instance_lock_and_unbounded_placement_reject() {
let (mut s, id) = session(8);
let params = request(&mut s, id, BLUE);
{
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let mut duplicate = doc.layer(id).unwrap().duplicate();
duplicate.locks.pixels = true;
doc.layers.push(duplicate);
}
fails_without_edit(&mut s, params.clone());
{
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
doc.layers.pop();
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
sm.transform = Affine { m: [10000.0, 0.0, 0.0, 10000.0, 0.0, 0.0] };
}
fails_without_edit(&mut s, params);
}
#[test]
fn diverged_shared_source_is_never_overwritten() {
let (mut s, id) = session(8);
let params = request(&mut s, id, BLUE);
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let mut duplicate = doc.layer(id).unwrap().duplicate();
let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart object") };
sm.source = SmartSource::Embedded { file_name: "Other.svg".into(), bytes: Arc::new(GREEN.as_bytes().to_vec()) };
doc.layers.push(duplicate);
fails_without_edit(&mut s, params);
}
#[test]
fn failed_second_instance_render_does_not_commit_first_instance() {
let (mut s, id) = session(8);
{
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let mut duplicate = doc.layer(id).unwrap().duplicate();
let LayerContent::Smart(sm) = &mut duplicate.content else { panic!("smart object") };
sm.transform = Affine { m: [100.0, 0.0, 0.0, 0.001, 0.0, 0.0] };
doc.layers.push(duplicate);
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
sm.transform = Affine { m: [0.001, 0.0, 0.0, 0.001, 0.0, 0.0] };
}
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="2000" height="2000"><rect width="2000" height="2000" fill="blue"/></svg>"#;
let params = request(&mut s, id, svg);
fails_without_edit(&mut s, params);
}
#[test]
fn inherited_parent_lock_refuses_content_edits() {
let (mut s, id) = session(8);
let params = request(&mut s, id, BLUE);
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let layer = doc.remove(id).unwrap();
let mut group = Layer::new("Locked parent", LayerContent::Group(photocraft_doc::Group { children: vec![layer], expanded: true, artboard: None }));
group.locks.pixels = true;
doc.layers.push(group);
fails_without_edit(&mut s, params);
}
#[test]
fn perspective_and_warp_geometry_survive_an_update() {
for warp in [false, true] {
let (mut s, id) = session(8);
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
sm.perspective = Some([1.0, 0.0, 12.0, 0.0, 1.0, 9.0, 0.01, 0.0, 1.0]);
if warp {
sm.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 8.0, 6.0]));
}
let before = sm.clone();
let params = request(&mut s, id, BLUE);
s.execute(UPDATE, params).unwrap();
let after = smart(&s.active().unwrap().doc, id).unwrap();
assert_eq!(after.warp, before.warp);
assert_eq!(after.perspective, before.perspective);
assert_eq!(after.transform, before.transform);
assert_eq!(source(&s, id), BLUE.as_bytes());
}
}
#[test]
fn perspective_crossing_zero_is_rejected_before_rendering() {
let (mut s, id) = session(8);
let params = request(&mut s, id, BLUE);
let doc = Arc::make_mut(&mut s.active_mut().unwrap().doc);
let LayerContent::Smart(sm) = &mut doc.layer_mut(id).unwrap().content else { panic!("smart object") };
sm.perspective = Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, -0.5, 0.0, 1.0]);
fails_without_edit(&mut s, params);
}
#[test]
fn bad_params_are_errors_for_both_commands() {
let (mut s, _) = session(8);
for p in [Value::Null, json!([]), json!({"layer":-1}), json!({"layer":"invalid"})] {
assert!(s.execute(EXPORT, p.clone()).is_err());
fails_without_edit(&mut s, p);
}
fails_without_edit(&mut s, json!({}));
}
+295 -6
View File
@@ -45,6 +45,188 @@ fn whole_layer_bounds(layer: &Layer) -> Rect {
if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content }
}
/// Stages a shape's affine transform without changing the session or history. The normal
/// compositor retains its layer order, clipping, blend mode, effects and linked masks.
pub fn preview_shape_transform(doc: &Document, id: LayerId, affine: Affine, interpolation: &str) -> Result<Document> {
let mut shown = preview_shape_transform_geometry(doc, id, affine, interpolation)?;
if let Some(Layer { content: LayerContent::Shape(shape), .. }) = shown.layer_mut(id) {
crate::vector_cmds::refresh_shape(doc, shape);
}
Ok(shown)
}
/// Stages a shape's four-corner transform with the same vector projection as the command.
pub fn preview_shape_quad_transform(doc: &Document, id: LayerId, rect: [f64; 4], quad: [[f64; 2]; 4], interpolation: &str) -> Result<Document> {
let h = Homography::rect_to_quad(rect, quad).ok_or_else(|| bad("degenerate quad"))?;
if !h.0.iter().all(|v| v.is_finite()) || h.inverse().is_none() {
return Err(bad("the transform collapses the layer"));
}
let m = h.0;
if m[6].abs() < 1e-12 && m[7].abs() < 1e-12 {
let affine = Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] };
return preview_shape_transform(doc, id, affine, interpolation);
}
let group = group_locks(doc, id);
let mut shown = doc.clone();
let layer = shown.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(layer.content, LayerContent::Shape(_)) {
return Err(bad("preview target is not a shape layer"));
}
transform_layer(None, group, layer, &h, None, Interp::parse(interpolation))?;
if let LayerContent::Shape(shape) = &mut layer.content {
crate::vector_cmds::refresh_shape(doc, shape);
}
Ok(shown)
}
/// Stages geometry and linked masks without rasterizing the shape. Reduced previews supply
/// their own bounded cache; the committed command still refreshes at document resolution.
pub fn preview_shape_transform_geometry(doc: &Document, id: LayerId, affine: Affine, interpolation: &str) -> Result<Document> {
preview_shape_geometry(doc, id, affine, interpolation, None)
}
/// Bounded live preview: linked pixel-mask pixels come from a source-revision cache.
pub fn preview_shape_transform_geometry_clipped(
doc: &Document,
id: LayerId,
affine: Affine,
interpolation: &str,
clip: Rect,
mask: Option<&PreparedShapeMask>,
) -> Result<Document> {
preview_shape_geometry(doc, id, affine, interpolation, Some((clip, mask)))
}
fn preview_shape_geometry(
doc: &Document,
id: LayerId,
affine: Affine,
interpolation: &str,
clipped: Option<(Rect, Option<&PreparedShapeMask>)>,
) -> Result<Document> {
if clipped.is_some_and(|(r, _)| u64::from(r.width()) * u64::from(r.height()) > 2048 * 2048) {
return Err(bad("preview mask destination exceeds the bounded cache"));
}
if !affine.m.iter().all(|v| v.is_finite()) || affine.inverse().is_none() {
return Err(bad("the transform collapses the layer"));
}
let group = group_locks(doc, id);
let mut shown = doc.clone();
let layer = shown.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(layer.content, LayerContent::Shape(_)) {
return Err(bad("preview target is not a shape layer"));
}
let [a, b, c, d, e, f] = affine.m;
let h = Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]);
let moving_mask = if clipped.is_some() && layer.mask.as_ref().is_some_and(|m| m.linked) { layer.mask.take() } else { None };
transform_layer(None, group, layer, &h, Some(affine), Interp::parse(interpolation))?;
if let Some(mut moving) = moving_mask {
let Some((clip, Some(prepared))) = clipped else { return Err(bad("linked preview mask has no prepared source")) };
moving.surface = prepared.warp(&h, Interp::parse(interpolation), clip)?;
layer.mask = Some(moving);
}
if let LayerContent::Shape(shape) = &mut layer.content {
photocraft_vector::flatten::validate_shape(&shape.path, shape.stroke.as_ref()).map_err(bad)?;
shape.cache = None;
}
Ok(shown)
}
/// A gray mask's alpha-bearing source, prepared once for a reduced preview revision.
/// Power-of-two pre-reductions reuse the normal bicubic warp's exact sampling.
pub struct PreparedShapeMask {
format: PixelFormat,
default: f32,
source: Surface,
bounds: Rect,
reduced: std::sync::Mutex<std::collections::BTreeMap<u64, std::sync::Arc<Surface>>>,
}
impl PreparedShapeMask {
pub fn new(mask: &Surface) -> Result<Self> {
let format = mask.format();
let bounds = mask.content_bounds();
if format.alpha || format.channels() != 1 || u64::from(bounds.width()) * u64::from(bounds.height()) > 2048 * 2048 {
return Err(bad("preview mask source exceeds the bounded gray-mask cache"));
}
let mut source = Surface::new(PixelFormat::new(format.mode, format.sample, true));
if !bounds.is_empty() {
let pixels = crate::allocation::read_region(mask, bounds, "preparing a preview mask")?;
let mut lifted =
crate::allocation::filled(pixels.len().checked_mul(2).ok_or_else(|| bad("the preview mask is too large"))?, 0.0, "preparing a preview mask")?;
for (g, pair) in pixels.iter().zip(lifted.as_chunks_mut::<2>().0) {
pair.copy_from_slice(&[*g, 1.0]);
}
source.try_write_region(bounds, &lifted)?;
}
Ok(Self {
format,
default: mask.default_pixel().first().copied().unwrap_or(0.0),
source,
bounds,
reduced: std::sync::Mutex::new(std::collections::BTreeMap::new()),
})
}
pub fn warp_clipped(&self, affine: Affine, interpolation: &str, clip: Rect) -> Result<Surface> {
let [a, b, c, d, e, f] = affine.m;
self.warp(&Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::parse(interpolation), clip)
}
fn warp(&self, h: &Homography, interpolation: Interp, clip: Rect) -> Result<Surface> {
let mut out = Surface::with_default(self.format, &[self.default]);
if self.bounds.is_empty() || clip.is_empty() || u64::from(clip.width()) * u64::from(clip.height()) > 2048 * 2048 {
return Ok(out);
}
let center = ((f64::from(self.bounds.x0) + f64::from(self.bounds.x1)) / 2.0, (f64::from(self.bounds.y0) + f64::from(self.bounds.y1)) / 2.0);
let scale = h.local_scale(center.0, center.1);
let warped = if interpolation == Interp::Bicubic && scale > 0.0 && scale < 0.5 {
let factor = 2f64.powf(scale.log2().ceil()).min(1.0);
let reduced = {
let mut cache = self.reduced.lock().unwrap_or_else(|e| e.into_inner());
if let Some(existing) = cache.get(&factor.to_bits()) {
existing.clone()
} else {
// Normal document-sized masks use at most eleven levels; cap hostile
// subpixel scale sequences without retaining hundreds of tiny tiles.
if cache.len() >= 32 {
cache.pop_first();
}
let reduced = std::sync::Arc::new(photocraft_algo::resample::resize_surface(
&self.source,
factor,
factor,
photocraft_algo::resample::Resample::Bicubic,
));
cache.insert(factor.to_bits(), reduced.clone());
reduced
}
};
let h = h.mul(&Homography([1.0 / factor, 0.0, 0.0, 0.0, 1.0 / factor, 0.0, 0.0, 0.0, 1.0]));
photocraft_algo::transform::warp_surface_clipped(
&reduced,
photocraft_algo::resample::scaled_rect(self.bounds, factor, factor),
&h,
interpolation,
clip,
)
} else {
photocraft_algo::transform::warp_surface_clipped(&self.source, self.bounds, h, interpolation, clip)
}?;
let area = warped.content_bounds().intersect(&clip);
if !area.is_empty() {
let pixels = crate::allocation::read_region(&warped, area, "flattening a preview mask")?;
let mut values = crate::allocation::filled(pixels.len() / 2, 0.0, "flattening a preview mask")?;
for (p, value) in pixels.as_chunks::<2>().0.iter().zip(&mut values) {
*value = p[0] * p[1] + self.default * (1.0 - p[1]);
}
out.try_write_region(area, &values)?;
}
out.prune();
Ok(out)
}
}
/// Warp a surface whose pixels outside the content read as `default` (masks, selections):
/// warp the content with alpha, then flatten back onto the default value.
pub(crate) fn warp_gray(s: &Surface, h: &Homography, interp: Interp) -> Result<Surface> {
@@ -359,11 +541,16 @@ fn apply_transform(
};
t.transform = a.mul(&t.transform);
}
LayerContent::Shape(sh) => match affine {
Some(a) => crate::vector_cmds::transform_shape(sh, &a),
// Distort and Perspective map the path and keep the shape (#3009).
None => crate::vector_cmds::map_shape(sh, Some(h), None)?,
},
LayerContent::Shape(sh) => {
if let Some(a) = affine {
crate::vector_cmds::transform_shape(sh, &a);
} else {
sh.path = project_path(&sh.path, h)?;
photocraft_vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(bad)?;
sh.live = None;
sh.psd_raw = None;
}
}
LayerContent::Smart(sm) => {
// Smart objects keep the transform and re-render from their source afterwards
// (`refresh_text`), so repeated transforms don't degrade the pixels. Distort and
@@ -401,6 +588,17 @@ fn apply_transform(
if let Some(m) = l.mask.as_mut()
&& m.linked
{
if affine.is_none()
&& let Some(bounds) = mask_projection_bounds(&m.surface)
{
if (bounds.2 - bounds.0) * (bounds.3 - bounds.1) > 64.0 * 1024.0 * 1024.0 {
return Err(bad("mask source exceeds the 64M pixel transform budget"));
}
let (bounds, _) = projective_bounds(h, bounds)?;
if (bounds.2 - bounds.0 + 2.0).ceil() * (bounds.3 - bounds.1 + 2.0).ceil() > 64.0 * 1024.0 * 1024.0 {
return Err(bad("projected mask exceeds the 64M pixel transform budget"));
}
}
m.surface = match mask_sel {
Some(sel) => warp_gray_selected(&m.surface, sel, h, interp)?,
None => warp_gray(&m.surface, h, interp)?,
@@ -411,12 +609,103 @@ fn apply_transform(
{
vm.path = match affine {
Some(a) => vm.path.transform(&a),
None => crate::vector_cmds::map_path(&vm.path, Some(h), None)?,
None => project_path(&vm.path, h)?,
};
}
Ok(())
}
fn mask_projection_bounds(surface: &Surface) -> Option<(f64, f64, f64, f64)> {
let b = surface.content_bounds();
(!b.is_empty()).then_some((f64::from(b.x0), f64::from(b.y0), f64::from(b.x1), f64::from(b.y1)))
}
/// Bounds and a conservative Jacobian norm over a rectangle that cannot cross the horizon.
fn projective_bounds(h: &Homography, bounds: (f64, f64, f64, f64)) -> Result<((f64, f64, f64, f64), f64)> {
let m = h.0;
if !m.iter().all(|v| v.is_finite()) {
return Err(bad("projective transform must be finite"));
}
let (x0, y0, x1, y1) = bounds;
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
let weights = corners.map(|(x, y)| m[6] * x + m[7] * y + m[8]);
let sign = weights[0].is_sign_positive();
if weights.iter().any(|w| !w.is_finite() || w.abs() < 1e-12 || w.is_sign_positive() != sign) {
return Err(bad("projective transform crosses the path horizon"));
}
let min_weight = weights.iter().map(|w| w.abs()).fold(f64::INFINITY, f64::min);
let mut derivative = [0.0f64; 4];
let mut projected = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for (x, y) in corners {
let (u, v) = h.apply(x, y);
if !u.is_finite() || !v.is_finite() || u.abs() > 1_000_000.0 || v.abs() > 1_000_000.0 {
return Err(bad("projected coordinates must be finite and within ±1000000 px"));
}
projected = (projected.0.min(u), projected.1.min(v), projected.2.max(u), projected.3.max(v));
let numerators = [
(m[0] * m[7] - m[6] * m[1]) * y + m[0] * m[8] - m[6] * m[2],
(m[1] * m[6] - m[7] * m[0]) * x + m[1] * m[8] - m[7] * m[2],
(m[3] * m[7] - m[6] * m[4]) * y + m[3] * m[8] - m[6] * m[5],
(m[4] * m[6] - m[7] * m[3]) * x + m[4] * m[8] - m[7] * m[5],
];
for (maximum, value) in derivative.iter_mut().zip(numerators) {
*maximum = maximum.max(value.abs());
}
}
let stretch = derivative.iter().map(|v| v * v).sum::<f64>().sqrt() / min_weight.powi(2);
if !stretch.is_finite() {
return Err(bad("projective curve precision exceeds the bounded path budget"));
}
Ok((projected, stretch))
}
/// A homography maps cubic curves to rational curves. Keep vector edges with at most 0.25 px
/// deviation by flattening the source finely enough for the mapping's bounded stretch.
pub(crate) fn project_path(path: &photocraft_doc::Path, h: &Homography) -> Result<photocraft_doc::Path> {
use photocraft_doc::{Knot, Subpath};
if !h.0.iter().all(|v| v.is_finite()) {
return Err(bad("projective transform must be finite"));
}
photocraft_vector::flatten::validate_shape(path, None).map_err(bad)?;
let Some(bounds) = path.control_bounds() else { return Ok(path.clone()) };
let (_, stretch) = projective_bounds(h, bounds)?;
let tolerance = 0.25 / stretch.max(1.0);
if tolerance < 1e-4 && path.subpaths.iter().flat_map(|s| &s.knots).any(|k| k.in_ctrl != k.anchor || k.out_ctrl != k.anchor) {
return Err(bad("projective curve precision exceeds the bounded path budget"));
}
let mut result = path.clone();
let mut total = 0;
for (source, target) in path.subpaths.iter().zip(&mut result.subpaths) {
target.knots.clear();
if let Some(first) = source.knots.first() {
let (x, y) = h.apply(first.anchor.x, first.anchor.y);
target.knots.push(Knot::corner(x, y));
total += 1;
}
let segments = source.segments();
for (i, &[p0, c1, c2, p1]) in segments.iter().enumerate() {
let segment = Subpath {
knots: vec![Knot { anchor: p0, in_ctrl: p0, out_ctrl: c1, smooth: false }, Knot { anchor: p1, in_ctrl: c2, out_ctrl: p1, smooth: false }],
..Default::default()
};
// One source segment bounds the temporary allocation even when the projected
// path would exceed its total knot budget.
let flattened = photocraft_vector::flatten::flatten_subpath(&segment, tolerance);
let end = flattened.pts.len().saturating_sub(usize::from(source.closed && i + 1 == segments.len()));
total += end.saturating_sub(1);
if total > 4096 {
return Err(bad("projected shape exceeds 4096 path knots"));
}
for &(x, y) in flattened.pts.iter().take(end).skip(1) {
let (x, y) = h.apply(x, y);
target.knots.push(Knot::corner(x, y));
}
}
}
photocraft_vector::flatten::validate_shape(&result, None).map_err(bad)?;
Ok(result)
}
/// Split a layer surface by a selection: (selected pixels, everything else), both with alpha.
pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface)> {
let fmt = surf.format();
+14 -4
View File
@@ -241,10 +241,8 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM
Ok(())
}
fn apply_data_set(s: &mut Session, p: &Value) -> Result<Value> {
let (set, idx) = resolve_set(s, p, "image.applyDataSet")?;
let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone();
// Pixel Replacement reads image files, so an untrusted session's gate judges each path.
/// Pixel Replacement reads image files, so an untrusted session's gate judges each path.
fn authorize_data_set(s: &Session, set: &DataSet) -> Result<()> {
if let Some(auth) = s.authorize {
for dv in &set.values {
if let VarValue::Pixels(path) = &dv.value {
@@ -252,6 +250,13 @@ fn apply_data_set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
Ok(())
}
fn apply_data_set(s: &mut Session, p: &Value) -> Result<Value> {
let (set, idx) = resolve_set(s, p, "image.applyDataSet")?;
let vars = s.active().ok_or(EngineError::NoDocument)?.doc.variables.clone();
authorize_data_set(s, &set)?;
s.edit(&format!("Apply Data Set \"{}\"", set.name), |doc, _| apply_to_doc(doc, &vars, &set))?;
set_vars(s, |v| v.active = Some(idx))?;
Ok(json!({"applied": set.name, "index": idx}))
@@ -424,6 +429,11 @@ pub fn export_data_sets_with(s: &Session, p: &Value, mut write: impl FnMut(&str,
}
names.push(name);
}
// Authorize the whole selected batch before rendering or writing its first variation.
// A later Pixel Replacement must not leave earlier exports behind if its read is refused.
for set in &sets {
authorize_data_set(s, set)?;
}
let mut written = Vec::new();
for (set, name) in sets.iter().zip(&names) {
let mut doc = (*original).clone();
+80
View File
@@ -389,6 +389,86 @@ fn export_data_sets_rejects_collisions_before_writing() {
}
}
fn refuse_pixel_replacement(id: &str, params: &Value) -> Result<()> {
assert_eq!(id, "image.applyDataSet");
assert_eq!(params, &json!({"path":"forbidden-replacement.png"}));
Err(EngineError::Other("pixel replacement read refused".into()))
}
#[test]
fn export_data_sets_authorizes_all_selected_pixels_before_any_write() {
for earlier_row in [false, true] {
let mut s = visibility_export_session(&["allowed", "forbidden"]);
s.execute(
"image.variables.dataSets",
json!({"dataSets":[
{"name":"allowed", "values":[{"variable":"show", "kind":"visibility", "value":true}]},
{"name":"forbidden", "values":[{"variable":"replacement", "kind":"pixels", "value":"forbidden-replacement.png"}]}
]}),
)
.unwrap();
let layer = doc(&s).layers[0].id;
s.execute(
"image.variables.define",
json!({"defs":[
{"name":"show", "layer":layer.0, "type":"visibility"},
{"name":"replacement", "layer":layer.0, "type":"pixelReplacement"}
]}),
)
.unwrap();
s.authorize = Some(refuse_pixel_replacement);
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let history = s.active().unwrap().history.entries();
let past = s.active().unwrap().history.past_len();
let mut writes = 0;
let params = if earlier_row { json!({}) } else { json!({"dataSets":["forbidden"]}) };
let error = export_data_sets_with(&s, &params, |_, _| {
writes += 1;
Ok(())
})
.unwrap_err();
assert_eq!(error.to_string(), "pixel replacement read refused");
assert_eq!(writes, 0, "preflight must precede even an earlier nonpixel row");
let after = s.active().unwrap();
assert!(Arc::ptr_eq(&before, &after.doc));
assert_eq!(after.revision, revision);
assert_eq!(after.history.entries(), history);
assert_eq!(after.history.past_len(), past);
}
}
#[test]
fn export_data_sets_does_not_authorize_unselected_pixel_rows() {
let mut s = visibility_export_session(&["allowed"]);
let layer = doc(&s).layers[0].id;
s.execute(
"image.variables.define",
json!({"defs":[
{"name":"show", "layer":layer.0, "type":"visibility"},
{"name":"replacement", "layer":layer.0, "type":"pixelReplacement"}
]}),
)
.unwrap();
s.execute(
"image.variables.dataSets",
json!({"append":true,"dataSets":[
{"name":"forbidden","values":[{"variable":"replacement","kind":"pixels","value":"forbidden-replacement.png"}]}
]}),
)
.unwrap();
s.authorize = Some(refuse_pixel_replacement);
let mut writes = Vec::new();
let names = export_data_sets_with(&s, &json!({"dataSets":["allowed"]}), |name, bytes| {
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
writes.push(name.to_owned());
Ok(())
})
.unwrap();
assert_eq!(names, vec!["allowed.png"]);
assert_eq!(writes, names);
}
#[test]
fn export_data_sets_unique_paths_preserve_variations_and_document() {
for (label, names, naming, selected, expected) in [
+463 -223
View File
@@ -78,21 +78,31 @@ fn can_paste_shape_stroke(s: &Session) -> std::result::Result<(), String> {
}
fn copy_shape_fill(s: &mut Session, _p: &Value) -> Result<Value> {
let fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?;
let mut fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?;
let pattern = clipboard_pattern(s, &fill, None)?;
set_clipboard_pattern_id(&mut fill, pattern.as_ref());
s.path_fill_clipboard = Some(fill);
s.path_fill_pattern = pattern;
Ok(Value::Null)
}
fn copy_shape_stroke(s: &mut Session, _p: &Value) -> Result<Value> {
let stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?;
let mut stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?;
let pattern = clipboard_pattern(s, &stroke.paint, None)?;
set_clipboard_pattern_id(&mut stroke.paint, pattern.as_ref());
s.path_stroke_clipboard = Some(stroke);
s.path_stroke_pattern = pattern;
Ok(Value::Null)
}
fn paste_shape_fill(s: &mut Session, p: &Value) -> Result<Value> {
let fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?;
let mut fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?;
let pattern = clipboard_pattern(s, &fill, s.path_fill_pattern.as_ref())?;
set_clipboard_pattern_id(&mut fill, pattern.as_ref());
let patterns: Vec<_> = pattern.into_iter().collect();
check_clipboard_pattern_collision(s, &patterns)?;
let id = layer_id(s, p)?;
with_shape(s, id, "Paste Fill", |shape, _| {
with_shape_patterns(s, id, "Paste Fill", &patterns, |shape, _| {
shape.fill = Some(fill);
Ok(())
})?;
@@ -100,15 +110,51 @@ fn paste_shape_fill(s: &mut Session, p: &Value) -> Result<Value> {
}
fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?;
let mut stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?;
let pattern = clipboard_pattern(s, &stroke.paint, s.path_stroke_pattern.as_ref())?;
set_clipboard_pattern_id(&mut stroke.paint, pattern.as_ref());
let patterns: Vec<_> = pattern.into_iter().collect();
check_clipboard_pattern_collision(s, &patterns)?;
let id = layer_id(s, p)?;
with_shape(s, id, "Paste Complete Stroke", |shape, _| {
with_shape_patterns(s, id, "Paste Complete Stroke", &patterns, |shape, _| {
shape.stroke = Some(stroke);
Ok(())
})?;
shape_info(s, id)
}
fn set_clipboard_pattern_id(fill: &mut Fill, pattern: Option<&photocraft_doc::Pattern>) {
if let (Fill::Pattern { id, .. }, Some(pattern)) = (fill, pattern) {
*id = pattern.id.clone();
}
}
fn clipboard_pattern(s: &Session, fill: &Fill, snapshot: Option<&photocraft_doc::Pattern>) -> Result<Option<photocraft_doc::Pattern>> {
let Fill::Pattern { id, name, .. } = fill else { return Ok(None) };
let find = |patterns| photocraft_doc::pattern::find(patterns, id, name).cloned();
let pattern = snapshot
.and_then(|p| find(std::slice::from_ref(p)))
.or_else(|| s.active().and_then(|st| find(&st.doc.patterns)))
.or_else(|| find(&s.patterns.items))
.ok_or_else(|| EngineError::Other(format!("no copied pattern \"{name}\" (see pattern.list)")))?;
if pattern.is_empty() {
return Err(EngineError::Other("the copied pattern is empty".into()));
}
Ok(Some(pattern))
}
fn check_clipboard_pattern_collision(s: &Session, patterns: &[photocraft_doc::Pattern]) -> Result<()> {
let st = s.active().ok_or(EngineError::NoDocument)?;
for pattern in patterns {
if let Some(existing) = st.doc.patterns.iter().find(|p| p.id == pattern.id)
&& (existing.width != pattern.width || existing.height != pattern.height || existing.surface != pattern.surface)
{
return Err(EngineError::Other("A different pattern with the copied pattern's id already exists in this document".into()));
}
}
Ok(())
}
pub(crate) fn layer_id(s: &Session, p: &Value) -> Result<LayerId> {
match p.get("layer").and_then(Value::as_u64) {
Some(id) => Ok(LayerId(id)),
@@ -246,65 +292,213 @@ pub fn path_json(p: &Path) -> Value {
fn fill_json(f: &Fill) -> Value {
match f {
Fill::Solid(c) => json!(hex(c)),
Fill::Gradient { stops, angle, scale, style, reverse, .. } => json!({ "gradient": {
"stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::<Vec<_>>(),
"angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse } }),
Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }),
Fill::Solid(c) => paint_color_json(c),
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": {
"stops": stops.iter().map(|(t, c)| json!([t, paint_color_json(c)])).collect::<Vec<_>>(),
"angle": angle, "scale": f64::from(*scale) * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse,
"opacityStops": opacity_stops, "midpoints": midpoints, "offset": offset, "dither": dither, "align": align } }),
Fill::Pattern { name, scale, id, angle, link, phase } => json!({ "pattern": name, "scale": f64::from(*scale) * 100.0,
"id":id, "angle":angle, "link":link, "phase":phase }),
}
}
/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"],…],"angle","scale","style","reverse"}}` /
/// `{"pattern":name,"scale"}`. `null` → `Ok(None)` (no fill).
fn paint_color_json(c: &Color) -> Value {
if c.mode != photocraft_color::ColorMode::Rgb || c.c[3] != 0.0 {
return json!({"color":c});
}
if c.alpha == 1.0 && c.c.iter().take(3).all(|v| (0.0..=1.0).contains(v) && (v * 255.0).round() / 255.0 == *v) {
json!(hex(c))
} else {
json!([c.c[0], c.c[1], c.c[2], c.alpha])
}
}
fn fill_number(v: &Value, label: &str, lo: f64, hi: f64) -> std::result::Result<f32, String> {
v.as_f64()
.filter(|n| n.is_finite() && (lo..=hi).contains(n))
.map(|n| n as f32)
.ok_or_else(|| format!("{label} must be a finite number between {lo} and {hi}"))
}
fn fill_bool(v: &Value, key: &str, default: bool) -> std::result::Result<bool, String> {
v.get(key).map_or(Ok(default), |v| v.as_bool().ok_or_else(|| format!("{key} must be a boolean")))
}
fn fill_pair(v: &Value, key: &str) -> std::result::Result<(f32, f32), String> {
let Some(value) = v.get(key) else { return Ok((0.0, 0.0)) };
let Some(values) = value.as_array().filter(|a| a.len() == 2) else { return Err(format!("{key} must contain two numbers")) };
let mut values = values.iter();
let a = values.next().ok_or_else(|| format!("{key} needs an x value"))?;
let b = values.next().ok_or_else(|| format!("{key} needs a y value"))?;
Ok((fill_number(a, key, -1_000_000.0, 1_000_000.0)?, fill_number(b, key, -1_000_000.0, 1_000_000.0)?))
}
fn fill_scale(v: &Value) -> std::result::Result<f32, String> {
let Some(value) = v.get("scale") else { return Ok(1.0) };
let n = value.as_f64().filter(|n| n.is_finite() && *n > 0.0 && *n <= 1_000_000.0).ok_or("scale must be greater than 0 and at most 1000000 percent")?;
let scale = (n / 100.0) as f32;
if scale <= 0.0 {
return Err("scale is too small".into());
}
Ok(scale)
}
fn paint_color(v: &Value) -> std::result::Result<Color, String> {
let c = if let Some(raw) = v.get("color") {
serde_json::from_value::<Color>(raw.clone()).map_err(|e| format!("invalid model colour: {e}"))?
} else {
if let Some(values) = v.as_array() {
if !matches!(values.len(), 3 | 4) {
return Err("colour must contain three or four numbers".into());
}
for value in values.iter().take(3) {
fill_number(value, "colour component", -1_000_000.0, 1_000_000.0)?;
}
if let Some(alpha) = values.get(3) {
fill_number(alpha, "colour alpha", 0.0, 1.0)?;
}
}
color(v).ok_or("colour must be hex, RGB(A) numbers or a model colour object")?
};
if !c.c.iter().all(|n| n.is_finite() && n.abs() <= 1_000_000.0) || !c.alpha.is_finite() || !(0.0..=1.0).contains(&c.alpha) {
return Err("colour components must be finite and within ±1000000; alpha must be 0..1".into());
}
Ok(c)
}
/// Legacy colour/gradient/pattern forms plus complete geometry and opacity metadata.
/// Gradient locations, opacity stops and midpoints are fractions; scale is percent.
fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
if v.is_null() {
return Ok(None);
}
if let Some(c) = color(v) {
return Ok(Some(Fill::Solid(c)));
if v.is_array() || v.is_string() || v.get("color").is_some() {
return Ok(Some(Fill::Solid(paint_color(v)?)));
}
if let Some(g) = v.get("gradient") {
let stops: Vec<(f32, Color)> = g
.get("stops")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect())
.unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]);
let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") {
if !g.is_object() {
return Err("gradient must be an object".into());
}
let stops = match g.get("stops") {
None => vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
Some(Value::Array(stops)) if stops.len() <= 4096 => stops
.iter()
.map(|stop| {
let values = stop.as_array().filter(|a| a.len() == 2).ok_or("gradient stop must contain a location and colour")?;
let t = values.first().ok_or("missing stop location")?;
let c = values.get(1).ok_or("missing stop colour")?;
Ok((fill_number(t, "stop location", 0.0, 1.0)?, paint_color(c)?))
})
.collect::<std::result::Result<Vec<_>, String>>()?,
_ => return Err("gradient stops must contain at most 4096 location/colour pairs".into()),
};
let style = match g.get("style").map_or(Some("linear"), Value::as_str).ok_or("gradient style must be a string")? {
"linear" => GradientStyle::Linear,
"radial" => GradientStyle::Radial,
"angle" => GradientStyle::Angle,
"reflected" => GradientStyle::Reflected,
"diamond" => GradientStyle::Diamond,
_ => GradientStyle::Linear,
_ => return Err("unknown gradient style".into()),
};
return Ok(Some(Fill::gradient(
let opacity_stops = match g.get("opacityStops") {
None => Vec::new(),
Some(Value::Array(stops)) if stops.len() <= 4096 => stops
.iter()
.map(|stop| {
let values = stop.as_array().filter(|a| a.len() == 2).ok_or("opacity stop must contain a location and opacity")?;
Ok((
fill_number(values.first().ok_or("missing opacity location")?, "opacity location", 0.0, 1.0)?,
fill_number(values.get(1).ok_or("missing opacity value")?, "opacity value", 0.0, 1.0)?,
))
})
.collect::<std::result::Result<Vec<_>, String>>()?,
_ => return Err("opacityStops must contain at most 4096 location/opacity pairs".into()),
};
let midpoints = match g.get("midpoints") {
None => Vec::new(),
Some(Value::Array(values)) if values.len() <= 4096 => {
values.iter().map(|v| fill_number(v, "midpoint", 0.0, 1.0)).collect::<std::result::Result<Vec<_>, _>>()?
}
_ => return Err("midpoints must contain at most 4096 fractions".into()),
};
return Ok(Some(Fill::Gradient {
stops,
f64p(g, "angle").unwrap_or(90.0) as f32,
f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0,
angle: g.get("angle").map_or(Ok(90.0), |v| fill_number(v, "angle", -1_000_000.0, 1_000_000.0))?,
scale: fill_scale(g)?,
style,
g.get("reverse").and_then(Value::as_bool).unwrap_or(false),
)));
}
if let Some(name) = v.get("pattern").and_then(Value::as_str) {
return Ok(Some(Fill::Pattern {
name: name.into(),
scale: f64p(v, "scale").unwrap_or(100.0) as f32 / 100.0,
id: String::new(),
angle: 0.0,
link: true,
phase: (0.0, 0.0),
reverse: fill_bool(g, "reverse", false)?,
opacity_stops,
midpoints,
offset: fill_pair(g, "offset")?,
dither: fill_bool(g, "dither", false)?,
align: fill_bool(g, "align", true)?,
}));
}
Err(format!("unrecognised fill {v}"))
if let Some(pattern) = v.get("pattern") {
let name = pattern.as_str().filter(|s| s.len() <= 65536).ok_or("pattern must be a string of at most 65536 bytes")?;
let id = v.get("id").map_or(Some(""), Value::as_str).filter(|s| s.len() <= 65536).ok_or("pattern id must be a string of at most 65536 bytes")?;
return Ok(Some(Fill::Pattern {
name: name.into(),
scale: fill_scale(v)?,
id: id.into(),
angle: v.get("angle").map_or(Ok(0.0), |v| fill_number(v, "angle", -1_000_000.0, 1_000_000.0))?,
link: fill_bool(v, "link", true)?,
phase: fill_pair(v, "phase")?,
}));
}
Err("unrecognised fill".into())
}
fn stroke_json(s: &ShapeStroke) -> Value {
json!({
"width": s.width, "fill": fill_json(&s.paint), "opacity": s.opacity * 100.0,
"width": s.width, "fill": fill_json(&s.paint), "opacity": f64::from(s.opacity) * 100.0,
"align": format!("{:?}", s.align).to_ascii_lowercase(), "cap": format!("{:?}", s.cap).to_ascii_lowercase(),
"join": format!("{:?}", s.join).to_ascii_lowercase(), "miterLimit": s.miter_limit, "dashes": s.dashes, "dashOffset": s.dash_offset,
})
}
/// Shape-tool creation uses the paint displayed for the active shape, including gradients.
pub fn shape_paint_params(fill: Option<&Fill>, stroke: Option<&ShapeStroke>) -> Value {
json!({"fill":fill.map_or(Value::Null,fill_json),"stroke":stroke.map_or(Value::Null,stroke_json)})
}
fn pattern_for_fill(s: &Session, fill: &mut Fill) -> Result<Option<photocraft_doc::Pattern>> {
let Fill::Pattern { name, id, .. } = fill else { return Ok(None) };
let pattern = if id.is_empty() {
crate::pattern_cmds::resolve(s, name)
} else {
let by_id = s.active().and_then(|st| st.doc.patterns.iter().find(|p| p.id == *id)).or_else(|| s.patterns.items.iter().find(|p| p.id == *id));
let by_name = s
.active()
.and_then(|st| photocraft_doc::pattern::find(&st.doc.patterns, "", name))
.or_else(|| photocraft_doc::pattern::find(&s.patterns.items, "", name));
if let (Some(a), Some(b)) = (by_id, by_name)
&& a.id != b.id
{
return Err(EngineError::Other("pattern id and name refer to different tiles".into()));
}
by_id.or(by_name).cloned()
}
.ok_or_else(|| EngineError::Other(format!("no pattern \"{name}\" (see pattern.list)")))?;
if pattern.width == 0 || pattern.height == 0 {
return Err(EngineError::Other("the pattern is empty".into()));
}
if id.is_empty() {
*name = pattern.name.clone();
}
*id = pattern.id.clone();
Ok(Some(pattern))
}
/// Validate a shape paint without changing the document, resolving catalog pattern references.
pub fn shape_fill(s: &Session, value: &Value) -> Result<Option<Fill>> {
let mut fill = parse_fill(value).map_err(EngineError::Other)?;
if let Some(fill) = &mut fill {
pattern_for_fill(s, fill)?;
}
Ok(fill)
}
/// Applies stroke keys onto `base` (`null` → no stroke).
pub fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result::Result<Option<ShapeStroke>, String> {
if v.is_null() || v.as_bool() == Some(false) {
@@ -390,6 +584,14 @@ fn live_json(l: &LiveShape) -> Value {
/// Builds a live shape from `kind` + geometry params.
fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) -> Result<LiveShape> {
if p.get("smoothIndents").is_some_and(|v| !v.is_boolean()) {
return Err(bad(cmd, "smoothIndents must be a boolean"));
}
if let Some(v) = p.get("cornerRadius")
&& !v.as_f64().is_some_and(|v| (0.0..=1_000_000.0).contains(&v))
{
return Err(bad(cmd, "cornerRadius must be a finite number between 0 and 1000000"));
}
let prev_rect = match prev {
Some(LiveShape::Rect { rect, .. } | LiveShape::Ellipse { rect } | LiveShape::Polygon { rect, .. }) => Some(*rect),
_ => None,
@@ -418,17 +620,46 @@ fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>)
};
let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32);
let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0));
LiveShape::Polygon { rect: rect()?, sides, star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio } }
LiveShape::Polygon {
rect: rect()?,
sides,
star_ratio: ratio,
corner_radius: f64p(p, "cornerRadius").unwrap_or(match prev {
Some(LiveShape::Polygon { corner_radius, .. }) => *corner_radius,
_ => 0.0,
}),
smooth_indents: p.get("smoothIndents").and_then(Value::as_bool).unwrap_or(match prev {
Some(LiveShape::Polygon { smooth_indents, .. }) => *smooth_indents,
_ => false,
}),
}
}
"line" => {
let (pf, pto, pw) = match prev {
Some(LiveShape::Line { from, to, weight }) => (Some(*from), Some(*to), *weight),
Some(LiveShape::Line { from, to, weight, .. }) => (Some(*from), Some(*to), *weight),
_ => (None, None, 1.0),
};
LiveShape::Line {
from: nums::<2>(p, "from").or(pf).ok_or_else(|| bad(cmd, "`from`: [x, y] is required"))?,
to: nums::<2>(p, "to").or(pto).ok_or_else(|| bad(cmd, "`to`: [x, y] is required"))?,
weight: f64p(p, "weight").unwrap_or(pw).max(0.0),
arrowheads: match p.get("arrowheads") {
Some(Value::Null) => None,
Some(v) => {
let a: photocraft_doc::vector::LineArrowheads = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))?;
if ![a.width, a.length].iter().all(|v| v.is_finite() && (0.0..=10000.0).contains(v)) {
return Err(bad(cmd, "Arrowhead dimensions must be 0..10000"));
}
if !a.concavity.is_finite() || !(-50.0..=50.0).contains(&a.concavity) {
return Err(bad(cmd, "Arrowhead concavity must be -50..50 percent"));
}
Some(a)
}
None => match prev {
Some(LiveShape::Line { arrowheads, .. }) => arrowheads.clone(),
_ => None,
},
},
}
}
o => return Err(bad(cmd, format!("unknown kind `{o}`"))),
@@ -441,6 +672,11 @@ pub fn shape_path(p: &Value) -> Result<Path> {
Ok(vector::shapes::live_path(&live_from_params("shape.create", kind, p, None)?))
}
/// Live geometry shared by the shape-tool path mode's editing cache.
pub fn shape_live(p: &Value) -> Result<LiveShape> {
live_from_params("shape.create", p.get("kind").and_then(Value::as_str).unwrap_or("rect"), p, None)
}
fn live_kind(l: &LiveShape) -> &'static str {
match l {
LiveShape::Rect { radii, .. } if radii.iter().any(|r| *r > 0.0) => "roundedRect",
@@ -454,7 +690,7 @@ fn live_kind(l: &LiveShape) -> &'static str {
/// Re-renders a shape layer's cache for `doc` (canvas-clipped, document pixel format).
pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) {
sh.cache = Some(vector::render_shape(sh, doc.pixel_format(), doc.bounds()));
sh.cache = Some(vector::render_shape_with_patterns(sh, doc.pixel_format(), doc.bounds(), &doc.patterns));
}
/// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`.
@@ -500,29 +736,51 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) {
sh.path = sh.path.transform(a);
let [m0, m1, m2, m3, tx, ty] = a.m;
let axis_aligned = m1.abs() < 1e-12 && m2.abs() < 1e-12 && m0 > 0.0 && m3 > 0.0;
let line = |from: [f64; 2], to: [f64; 2], weight: f64, arrowheads: Option<photocraft_doc::vector::LineArrowheads>| {
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
return None;
}
// A zero-length line uses a vertical width fallback, without an orientation
// parameter. A quarter turn cannot be reconstructed by its live metadata.
if from == to && m0.abs() < 1e-12 && m3.abs() < 1e-12 {
return None;
}
// An anisotropic diagonal bar becomes a sheared parallelogram; a live
// line's perpendicular width cannot describe it. Keep the exact path.
let dimensions = if sx == sy || (to[1] - from[1]).abs() < 1e-12 {
Some((sx, sy))
} else if (to[0] - from[0]).abs() < 1e-12 {
Some((sy, sx))
} else {
None
};
let pt = |p: [f64; 2]| [m0 * p[0] + m2 * p[1] + tx, m1 * p[0] + m3 * p[1] + ty];
dimensions.map(|(length_scale, width_scale)| LiveShape::Line {
from: pt(from),
to: pt(to),
weight: weight * width_scale,
arrowheads: arrowheads.map(|mut heads| {
heads.width *= width_scale;
heads.length *= length_scale;
heads
}),
})
};
sh.live = match (sh.live.take(), axis_aligned) {
(Some(l), true) => {
let r = |r: [f64; 4]| [r[0] * m0 + tx, r[1] * m3 + ty, r[2] * m0, r[3] * m3];
let pt = |p: [f64; 2]| [p[0] * m0 + tx, p[1] * m3 + ty];
Some(match l {
match l {
LiveShape::Rect { rect, radii } => {
let k = m0.min(m3);
LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) }
Some(LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) })
}
LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) },
LiveShape::Polygon { rect, sides, star_ratio } => LiveShape::Polygon { rect: r(rect), sides, star_ratio },
LiveShape::Line { from, to, weight } => {
// Live-shape edits regenerate the line's filled bar from its parameters.
// Keep the weight in sync with a positive scale of the path, or an
// unchanged endpoint edit unexpectedly restores the old thickness (#3063).
// A line's width scales perpendicular to its direction.
let (dx, dy) = (to[0] - from[0], to[1] - from[1]);
let old_length = dx.hypot(dy);
let new_length = (dx * m0).hypot(dy * m3);
let width_scale = if old_length > 0.0 && new_length > 0.0 { m0 * m3 * old_length / new_length } else { m0.min(m3) };
LiveShape::Line { from: pt(from), to: pt(to), weight: weight * width_scale }
LiveShape::Ellipse { rect } => Some(LiveShape::Ellipse { rect: r(rect) }),
LiveShape::Polygon { rect, sides, star_ratio, corner_radius, smooth_indents } => {
Some(LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3), smooth_indents })
}
})
LiveShape::Line { from, to, weight, arrowheads } => line(from, to, weight, arrowheads),
}
}
// Flips and 90° turns keep rectangles, ellipses and lines live (Image Rotation): the new
// live box is the mapped box. Polygons/stars would change orientation, so they don't.
@@ -538,7 +796,7 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) {
Some(LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) })
}
LiveShape::Ellipse { rect } => Some(LiveShape::Ellipse { rect: r(rect) }),
LiveShape::Line { from, to, weight } => Some(LiveShape::Line { from: pt(from), to: pt(to), weight: weight * k }),
LiveShape::Line { from, to, weight, arrowheads } => line(from, to, weight, arrowheads),
_ => None,
}
}
@@ -552,9 +810,9 @@ fn finite_path(path: &Path) -> bool {
path.subpaths.iter().flat_map(|s| &s.knots).all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|p| p.x.is_finite() && p.y.is_finite()))
}
/// `path` through Distort / Perspective (`h`, a projective map of its points: exact for straight
/// segments, the end tangents of curves exact) or, with `warp`, Warp (split where the warp bends
/// it). Refused when the map sends a point to infinity (past a perspective's horizon).
/// `path` through Distort / Perspective (`h`, a bounded approximation of its projected curves)
/// or, with `warp`, Warp (split where the warp bends it). Refused when the map sends part of
/// the path through a perspective's horizon or exceeds the editable path budget.
pub(crate) fn map_path(path: &Path, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result<Path> {
let out = match (h, warp) {
(_, Some(w)) => vector::map::map_path(
@@ -565,10 +823,7 @@ pub(crate) fn map_path(path: &Path, h: Option<&photocraft_algo::transform::Homog
},
0.1,
),
(Some(h), None) => vector::map::map_points(path, |p| {
let (x, y) = h.apply(p.x, p.y);
Point::new(x, y)
}),
(Some(h), None) => crate::transform_cmds::project_path(path, h)?,
(None, None) => path.clone(),
};
if !finite_path(&out) {
@@ -581,7 +836,9 @@ pub(crate) fn map_path(path: &Path, h: Option<&photocraft_algo::transform::Homog
/// mapped ([`map_path`]), as in Photoshop. A live rectangle, ellipse, polygon or line becomes a
/// plain path, as its parameters can't describe the distorted outline.
pub(crate) fn map_shape(sh: &mut ShapeLayer, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result<()> {
sh.path = map_path(&sh.path, h, warp)?;
let path = map_path(&sh.path, h, warp)?;
vector::flatten::validate_shape(&path, sh.stroke.as_ref()).map_err(|error| EngineError::Other(error.into()))?;
sh.path = path;
sh.live = None;
sh.psd_raw = None;
Ok(())
@@ -607,6 +864,16 @@ fn shape_info(s: &Session, id: LayerId) -> Result<Value> {
}
pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>) -> Result<R> {
with_shape_patterns(s, id, label, &[], f)
}
fn with_shape_patterns<R>(
s: &mut Session,
id: LayerId,
label: &str,
patterns: &[photocraft_doc::Pattern],
f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>,
) -> Result<R> {
if let Some(st) = s.active() {
let locks = st.doc.effective_locks(id);
if locks.all || locks.pixels {
@@ -614,6 +881,9 @@ pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl F
}
}
s.edit(label, |doc, _| {
for pattern in patterns {
crate::pattern_cmds::ensure_in_doc(doc, pattern);
}
let snapshot = doc.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if !matches!(l.content, LayerContent::Shape(_)) {
@@ -645,6 +915,12 @@ fn default_name(kind: &str) -> &'static str {
fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "shape.create";
if p.get("addTo").is_some_and(|v| !v.is_u64()) {
return Err(bad(CMD, "addTo must be a layer id"));
}
if p.get("op").is_some_and(|v| !v.is_string()) {
return Err(bad(CMD, "op must be a path operation string"));
}
let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect");
let (path, live) = if kind == "path" {
let v = p.get("path").ok_or_else(|| bad(CMD, "kind \"path\" needs `path`"))?;
@@ -655,24 +931,35 @@ fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
};
let fg = s.tools.foreground;
let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]);
let fill = match p.get("fill") {
let mut fill = match p.get("fill") {
Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?,
None => Some(Fill::Solid(fgc)),
};
let stroke = match p.get("stroke") {
let mut stroke = match p.get("stroke") {
Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?,
None => None,
};
let mut patterns = Vec::new();
if let Some(fill) = &mut fill
&& let Some(pattern) = pattern_for_fill(s, fill)?
{
patterns.push(pattern);
}
if let Some(stroke) = &mut stroke
&& let Some(pattern) = pattern_for_fill(s, &mut stroke.paint)?
{
patterns.push(pattern);
}
vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?;
// Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode).
if let Some(target) = p.get("addTo").and_then(Value::as_u64) {
let op = p.get("op").and_then(Value::as_str).map_or(Some(PathOp::Combine), op_from).ok_or_else(|| bad(CMD, "unknown `op`"))?;
let id = LayerId(target);
if s.active().is_some_and(|st| st.doc.effective_locks(id).position) {
return Err(bad(CMD, "shape layer position is locked"));
}
with_shape(s, id, "Add Shape", |sh, _| {
for mut sp in path.subpaths {
sp.op = op;
sh.path.subpaths.push(sp);
}
sh.path = append_operand(CMD, &sh.path, path, op)?;
sh.live = None;
sh.psd_raw = None;
vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?;
@@ -683,6 +970,9 @@ fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let mut sh = ShapeLayer { path, fill, stroke, live, ..Default::default() };
let id = s.edit("New Shape Layer", |doc, active| {
for pattern in &patterns {
crate::pattern_cmds::ensure_in_doc(doc, pattern);
}
refresh_shape(doc, &mut sh);
let name = name.unwrap_or_else(|| doc.next_layer_name(default_name(kind)));
let id = doc.insert_above(*active, Layer::new(name, LayerContent::Shape(sh)));
@@ -698,13 +988,27 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
let fg = s.tools.foreground;
let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]);
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
with_shape(s, id, "Edit Shape", |sh, layer| {
let fill = p.get("fill").map(|value| shape_fill(s, value)).transpose()?;
let base = s.active().and_then(|st| st.doc.layer(id)).and_then(|l| if let LayerContent::Shape(sh) = &l.content { sh.stroke.clone() } else { None });
let mut stroke = p.get("stroke").map(|value| parse_stroke(value, base, fgc).map_err(|e| bad(CMD, e))).transpose()?;
let mut patterns = Vec::new();
if let Some(Some(mut fill)) = fill.clone()
&& let Some(pattern) = pattern_for_fill(s, &mut fill)?
{
patterns.push(pattern);
}
if let Some(Some(stroke)) = &mut stroke
&& let Some(pattern) = pattern_for_fill(s, &mut stroke.paint)?
{
patterns.push(pattern);
}
with_shape_patterns(s, id, "Edit Shape", &patterns, |sh, layer| {
if let Some(v) = p.get("path") {
sh.path = parse_path(v).map_err(|e| bad(CMD, e))?;
sh.live = None;
sh.psd_raw = None;
}
let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "from", "to", "weight"];
let geom_keys = ["kind", "rect", "radii", "radius", "cornerRadius", "sides", "starRatio", "smoothIndents", "from", "to", "weight", "arrowheads"];
if geom_keys.iter().any(|k| p.get(*k).is_some()) {
let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string()));
let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?;
@@ -738,11 +1042,11 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(r) = p.get("fillRule").and_then(Value::as_str) {
sh.path.fill_rule = if r.eq_ignore_ascii_case("evenodd") { FillRule::EvenOdd } else { FillRule::NonZero };
}
if let Some(v) = p.get("fill") {
sh.fill = parse_fill(v).map_err(|e| bad(CMD, e))?;
if let Some(fill) = fill {
sh.fill = fill;
}
if let Some(v) = p.get("stroke") {
sh.stroke = parse_stroke(v, sh.stroke.clone(), fgc).map_err(|e| bad(CMD, e))?;
if let Some(stroke) = stroke {
sh.stroke = stroke;
}
if let Some(n) = name {
layer.name = n;
@@ -785,7 +1089,7 @@ pub(crate) fn is_work(name: Option<&str>) -> bool {
/// The path a command targets: `"work"`/absent → the work path (falling back to the active
/// layer's shape path or vector mask when there is none), `"layer"` → the active layer's,
/// otherwise a saved path by name.
fn resolve_path(s: &Session, name: Option<&str>) -> Result<Path> {
pub(crate) fn resolve_path(s: &Session, name: Option<&str>) -> Result<Path> {
let d = s.active().ok_or(EngineError::NoDocument)?;
let layer_path = || -> Option<Path> {
let l = d.doc.layer(d.active_layer?)?;
@@ -835,30 +1139,68 @@ fn paths_list(s: &Session) -> Result<Value> {
}))
}
/// Append one filled operand, keeping its holes and disconnected pieces grouped.
fn append_operand(cmd: &str, old: &Path, mut new: Path, op: PathOp) -> Result<Path> {
vector::flatten::validate_shape(old, None).map_err(|e| bad(cmd, e))?;
vector::flatten::validate_shape(&new, None).map_err(|e| bad(cmd, e))?;
if old.inverted || new.inverted {
return Err(bad(cmd, "Cannot append inverted shape paths"));
}
if old.subpaths.is_empty() {
return Ok(if matches!(op, PathOp::Subtract | PathOp::Intersect) { Path::default() } else { new });
}
if new.subpaths.is_empty() {
return Ok(if op == PathOp::Intersect { Path::default() } else { old.clone() });
}
let mut result = old.clone();
// A compound incoming path is one operand, rather than a sequence of copies of `op`.
// Trace only when its internal operations or winding rule cannot share the result's rule.
if new.components().len() > 1 || new.fill_rule != result.fill_rule {
new = vector::components::merge(&new, 0.1, 16_000_000).map_err(|e| bad(cmd, e))?;
if result.fill_rule != FillRule::NonZero {
result = vector::components::merge(&result, 0.1, 16_000_000).map_err(|e| bad(cmd, e))?;
for (i, subpath) in result.subpaths.iter_mut().enumerate() {
subpath.op = if i == 0 { PathOp::Combine } else { PathOp::Join };
}
}
}
if new.subpaths.is_empty() {
return Ok(if op == PathOp::Intersect { Path::default() } else { result });
}
if result.subpaths.is_empty() {
return Ok(if matches!(op, PathOp::Subtract | PathOp::Intersect) { Path::default() } else { new });
}
for (i, subpath) in new.subpaths.iter_mut().enumerate() {
subpath.op = if i == 0 { op } else { PathOp::Join };
}
result.subpaths.extend(new.subpaths);
vector::flatten::validate_shape(&result, None).map_err(|e| bad(cmd, e))?;
Ok(result)
}
fn path_set(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.set";
let path = parse_path(p.get("path").ok_or_else(|| bad(CMD, "missing `path`"))?).map_err(|e| bad(CMD, e))?;
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?;
let append = p
.get("op")
.map(|v| {
let op = v.as_str().ok_or_else(|| bad(CMD, "op must be a path operation string"))?;
op_from(op).ok_or_else(|| bad(CMD, format!("unknown op `{op}`")))
})
.transpose()?;
s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| {
let merge = |old: Option<&Path>, new: Path| match (old, append) {
(Some(o), Some(op)) => {
let mut o = o.clone();
o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| {
sp.op = op;
sp
}));
o
}
_ => new,
(Some(o), Some(op)) => append_operand(CMD, o, new, op),
_ => Ok(new),
};
if is_work(name.as_deref()) {
doc.work_path = Some(merge(doc.work_path.as_ref(), path));
doc.work_path = Some(merge(doc.work_path.as_ref(), path)?);
} else {
let n = name.clone().unwrap_or_default();
match doc.paths.iter_mut().find(|q| q.name == n) {
Some(q) => {
q.path = merge(Some(&q.path), path);
q.path = merge(Some(&q.path), path)?;
}
None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }),
}
@@ -1167,13 +1509,14 @@ fn path_fill(s: &mut Session, p: &Value) -> Result<Value> {
let lock = locks.transparency;
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Fill Path needs a pixel layer".into()))?;
paint_coverage(surf, area, &cov, src, opacity, mode, lock);
surf.prune();
Ok(())
})?;
Ok(Value::Null)
}
/// Composites `src` × coverage onto a surface over `area` (any pixel format).
fn paint_coverage(surf: &mut photocraft_raster::Surface, area: Rect, cov: &[f32], src: [f32; 4], opacity: f32, mode: BlendMode, lock_alpha: bool) {
pub(crate) fn paint_coverage(surf: &mut photocraft_raster::Surface, area: Rect, cov: &[f32], src: [f32; 4], opacity: f32, mode: BlendMode, lock_alpha: bool) {
let fmt = surf.format();
let ch = fmt.channels();
let mut vals = surf.read_region(area);
@@ -1189,7 +1532,6 @@ fn paint_coverage(surf: &mut photocraft_raster::Surface, area: Rect, cov: &[f32]
photocraft_raster::from_rgba_into(&fmt, out, px);
}
surf.write_region(area, &vals);
surf.prune();
}
/// Stroke Path's brush-based tools besides the Brush, Pencil and Eraser it renders itself: each
@@ -1523,121 +1865,25 @@ fn toggle_vector_mask(s: &mut Session, p: &Value, key: &str) -> Result<Value> {
vector_mask_edit(s, &json!({ "layer": id.0, key: v }))
}
/// Layer selection combines into one shape; an explicit layer/component keeps its old behavior.
fn combine_shapes(s: &mut Session, p: &Value, op: PathOp, name: &str, label: &str) -> Result<Value> {
if let Some(id) = crate::shape_combine_cmds::selected(s, p, op, false, label)? { shape_info(s, id) } else { shape_edit(s, &with(p, "op", json!(name))) }
}
/// Traces the exact coverage of the shape's path back into plain outlines.
fn merge_components(s: &mut Session, p: &Value) -> Result<Value> {
let tol = f64p(p, "tolerance").unwrap_or(0.1).clamp(0.05, 10.0);
if let Some(ids) = combine_targets(s, p, &["layer"])? {
return merge_shape_layers(s, &ids, None, "Merge Shape Components", Some(tol));
if let Some(id) = crate::shape_combine_cmds::selected(s, p, PathOp::Combine, true, "Merge Shape Components")? {
return shape_info(s, id);
}
let id = layer_id(s, p)?;
with_shape(s, id, "Merge Shape Components", |sh, _| bake_components(sh, tol))?;
shape_info(s, id)
}
/// Bakes `sh`'s path operations into plain combined outlines, traced from the exact coverage.
fn bake_components(sh: &mut ShapeLayer, tol: f64) -> Result<()> {
// Trace at 4× resolution so the fitted outline follows the anti-aliased edge closely.
const K: f64 = 4.0;
let up = sh.path.transform(&Affine::scale(K));
let r = vector::fill_rasterizer(&up, vector::DEFAULT_TOLERANCE);
let Some(b) = r.pixel_bounds() else {
sh.path.subpaths.clear();
return Ok(());
};
let b = b.inflate(2);
if u64::from(b.width()) * u64::from(b.height()) > 400_000_000 {
return Err(EngineError::Other("shape too large to merge".into()));
}
let cov = r.render(b);
let traced = vector::trace::trace_mask(&cov, b, 0.0, tol * K);
sh.path = traced.transform(&Affine::scale(1.0 / K));
sh.live = None;
sh.psd_raw = None;
Ok(())
}
/// The shape layers Layer › Combine Shapes merges into one: with none of the `narrow` params
/// given and two or more shape layers selected, those layers bottom-to-top (other selected layers
/// are left alone). `None` keeps the single-layer behaviour (acting on one layer's components).
fn combine_targets(s: &Session, p: &Value, narrow: &[&str]) -> Result<Option<Vec<LayerId>>> {
if narrow.iter().any(|k| p.get(*k).is_some()) {
return Ok(None);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let sel = d.selected_layers();
let ids = crate::layer_multi_cmds::top_level(&d.doc, &sel);
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)))).collect();
Ok((ids.len() > 1).then_some(ids))
}
/// Layer › Combine Shapes with several shape layers selected: Photoshop merges them into the
/// top-most one. The bottom layer's components keep their operations; every component of the
/// layers above takes `op` (front shapes subtract, intersect… in stacking order), or, with no
/// `op` (Merge Shape Components), keeps its own with each layer's first component combined, and
/// the result is then baked with tolerance `bake`. The other layers are removed; one history step.
fn merge_shape_layers(s: &mut Session, ids: &[LayerId], op: Option<PathOp>, label: &str, bake: Option<f64>) -> Result<Value> {
let (&keep, _) = ids.split_last().ok_or_else(|| EngineError::Other("Combine Shapes needs two or more shape layers".into()))?;
if let Some(st) = s.active() {
for id in ids {
let locks = st.doc.effective_locks(*id);
if locks.all || locks.pixels {
return Err(EngineError::Other("a selected shape layer is locked".into()));
}
}
}
s.edit(label, |doc, active| {
let mut path: Option<Path> = None;
for id in ids {
let l = doc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
let LayerContent::Shape(sh) = &l.content else {
return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0)));
};
let Some(acc) = path.as_mut() else {
path = Some(sh.path.clone());
continue;
};
for (j, sp) in sh.path.subpaths.iter().enumerate() {
let mut sp = sp.clone();
sp.op = match op {
Some(op) => op,
None if j == 0 => PathOp::Combine,
None => sp.op,
};
acc.subpaths.push(sp);
}
}
let path = path.ok_or_else(|| EngineError::Other("Combine Shapes needs two or more shape layers".into()))?;
let snapshot = doc.clone();
let l = doc.layer_mut(keep).ok_or(EngineError::NoLayer(keep))?;
let LayerContent::Shape(sh) = &mut l.content else {
return Err(EngineError::Other(format!("layer {} is not a shape layer", keep.0)));
};
let mut merged = ShapeLayer { path, live: None, psd_raw: None, cache: None, ..sh.clone() };
if let Some(tol) = bake {
bake_components(&mut merged, tol)?;
}
vector::flatten::validate_shape(&merged.path, merged.stroke.as_ref()).map_err(|e| bad(label, e))?;
refresh_shape(&snapshot, &mut merged);
*sh = merged;
for id in ids.iter().filter(|id| **id != keep) {
doc.remove(*id);
}
*active = Some(keep);
let tol = f64p(p, "tolerance").unwrap_or(0.1).clamp(0.05, 10.0);
with_shape(s, id, "Merge Shape Components", |sh, _| {
sh.path = vector::components::merge(&sh.path, tol, 400_000_000).map_err(|e| bad("layer.combineShapes.mergeShapeComponents", e))?;
sh.live = None;
sh.psd_raw = None;
Ok(())
})?;
crate::layer_multi_cmds::reselect(s, vec![keep], Some(keep));
shape_info(s, keep)
}
/// Layer › Combine Shapes › Unite / Subtract Front Shape / Intersect / Exclude: merges the
/// selected shape layers with `op` ([`merge_shape_layers`]), or with one layer (or an explicit
/// `layer`/`subpath`) sets the operation of that layer's components (`shape.edit`'s `op`).
fn combine_shapes(s: &mut Session, p: &Value, op: &str, label: &str) -> Result<Value> {
if let Some(ids) = combine_targets(s, p, &["layer", "subpath"])? {
let op = op_from(op).ok_or_else(|| EngineError::Other(format!("unknown op `{op}`")))?;
return merge_shape_layers(s, &ids, Some(op), label, None);
}
shape_edit(s, &with(p, "op", json!(op)))
shape_info(s, id)
}
fn selection_to_shape(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1660,7 +1906,7 @@ fn selection_to_shape(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------------------------------------------------------------------
/// Params of the Layer › Combine Shapes operations.
const COMBINE_PARAMS: &str = r##"{"layer":id?,"subpath":index? (default all but the first)} (no layer/subpath and two or more shape layers selected: merges them into the top-most, whose components above the bottom layer's take the operation)"##;
const COMBINE_PARAMS: &str = r##"{"layer":id?,"subpath":index? (default all but the first)} (no layer/subpath and two or more shape layers selected: merges them into the top-most, each selected shape is one boolean operand; other selected layers are left alone)"##;
pub(crate) const PATH_FORM: &str = r##"path: {"subpaths":[{"closed":bool=true,"op":"combine|subtract|intersect|exclude","knots":[[x,y] | {"anchor":[x,y],"in":[x,y],"out":[x,y],"smooth":bool}]}],"fillRule":"nonzero|evenodd","inverted":bool}"##;
@@ -1676,14 +1922,14 @@ pub(crate) fn leak(s: String) -> &'static str {
pub fn specs() -> Vec<CommandSpec> {
let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##;
let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool}}|{"pattern":name}|null (no fill)"##;
let fill = r##""fill":"#rrggbb[aa]"|[r,g,b,a]|{"color":{mode,c:[4 channels],alpha}}|{"gradient":{"stops":[[0..1,colour]],"angle":deg,"scale":%>0,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[0..1,0..1]],"midpoints":[0..1],"offset":[fractionX,fractionY],"dither":bool,"align":bool}}|{"pattern":name|id,"id":str?,"scale":%>0,"angle":deg,"link":bool,"phase":[px,px]}|null (no fill)"##;
vec![
spec!(
"shape.create",
"New Shape Layer",
["Layer", "New"],
leak(format!(
r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"##
r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":0..1000000=0,"smoothIndents":bool=false (polygon/star),"arrowheads":{{"start":bool,"end":bool,"width":0..10000,"length":0..10000,"concavity":-50..50=0}}? (line),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"##
)),
has_doc,
shape_create
@@ -1693,7 +1939,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Shape",
[],
leak(format!(
r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"##
r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":r?,"smoothIndents":bool?,"arrowheads":{{"start":bool,"end":bool,"width":px,"length":px,"concavity":-50..50}}|null?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"##
)),
has_layer,
shape_edit
@@ -1870,29 +2116,23 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("layer.rasterize.shape", "Rasterize Shape", [], r##"{"layer":id?} (no layer: every selected shape layer)"##, has_layer, |s, p| {
if p.get("layer").is_some() { shape_rasterize(s, p) } else { crate::extra_cmds::rasterize(s, p, Some("shape")) }
}),
// With two or more shape layers selected (and no `layer`/`subpath`), these merge them into
// the top-most one, as in Photoshop; with one, they set the operation of its components.
spec!("layer.combineShapes.unite", "Unite Shapes", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(s, p, "combine", "Unite Shapes")),
spec!("layer.combineShapes.subtractFrontShape", "Subtract Front Shape", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(
s,
p,
"subtract",
"Subtract Front Shape"
)),
spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(
s,
p,
"intersect",
"Intersect Shape Areas"
)),
spec!("layer.combineShapes.unite", "Unite Shapes", [], COMBINE_PARAMS, has_layer, |s, p| {
combine_shapes(s, p, PathOp::Combine, "combine", "Unite Shapes")
}),
spec!("layer.combineShapes.subtractFrontShape", "Subtract Front Shape", [], COMBINE_PARAMS, has_layer, |s, p| {
combine_shapes(s, p, PathOp::Subtract, "subtract", "Subtract Front Shape")
}),
spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], COMBINE_PARAMS, has_layer, |s, p| {
combine_shapes(s, p, PathOp::Intersect, "intersect", "Intersect Shape Areas")
}),
spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], COMBINE_PARAMS, has_layer, |s, p| {
combine_shapes(s, p, "exclude", "Exclude Overlapping Shapes")
combine_shapes(s, p, PathOp::Exclude, "exclude", "Exclude Overlapping Shapes")
}),
spec!(
"layer.combineShapes.mergeShapeComponents",
"Merge Shape Components",
[],
r##"{"layer":id?,"tolerance":px=0.1} (bakes the path operations into plain combined outlines, traced from the exact coverage; no layer and several shape layers selected: merges them into the top-most first)"##,
r##"{"layer":id?,"tolerance":px=0.1} (merge selected shapes when no layer, leaving other selected layers alone; bake operations into contours, bounded 16M coverage pixels at 4x)"##,
has_layer,
merge_components
),
+349
View File
@@ -0,0 +1,349 @@
//! Explicit, bounded SVG clipboard import. Preparation never changes a document; confirmation
//! applies one edit using the existing SVG importer and cross-document colour conversion.
use std::sync::Arc;
use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Layer, LayerContent, MAX_GROUP_DEPTH, NamedPath, Path, SmartObject, SmartSource};
use photocraft_geom::{Affine, Size};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const CMD: &str = "edit.pasteVector";
pub const MAX_VECTOR_CLIPBOARD_BYTES: usize = 16 << 20;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PasteMode {
Layers,
SmartObject,
Pixels,
Path,
Shape,
}
impl PasteMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Layers => "layers",
Self::SmartObject => "smartObject",
Self::Pixels => "pixels",
Self::Path => "path",
Self::Shape => "shape",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Layers => "Layers",
Self::SmartObject => "Smart Object",
Self::Pixels => "Pixels",
Self::Path => "Path",
Self::Shape => "Shape Layer",
}
}
fn parse(value: &Value) -> Result<Self> {
match value.as_str() {
Some("layers") => Ok(Self::Layers),
Some("smartObject") => Ok(Self::SmartObject),
Some("pixels") => Ok(Self::Pixels),
Some("path") => Ok(Self::Path),
Some("shape") => Ok(Self::Shape),
_ => Err(bad("`mode` must be layers, smartObject, pixels, path or shape")),
}
}
}
/// An immutable parsed snapshot. Reusing it does not share imported layer identities.
#[derive(Clone, Debug)]
pub struct PreparedSvg {
source: Arc<Vec<u8>>,
document: Document,
warnings: Vec<String>,
path_capability: std::result::Result<(), String>,
shape_capability: std::result::Result<(), String>,
}
impl PreparedSvg {
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub fn size(&self) -> [u32; 2] {
[self.document.size.width, self.document.size.height]
}
pub fn capability(&self, mode: PasteMode) -> std::result::Result<(), String> {
match mode {
PasteMode::Layers => Ok(()),
PasteMode::Pixels | PasteMode::SmartObject => {
if self.document.size.area() > 24_000_000 {
Err("The SVG is too large to rasterise (24 million pixel limit)".into())
} else {
Ok(())
}
}
PasteMode::Path => self.path_capability.clone(),
PasteMode::Shape => self.shape_capability.clone(),
}
}
/// Destination-dependent availability for ordinary centred paste. Confirmation checks the
/// actual requested centre again, so an explicit placement cannot bypass the budget.
pub fn capability_on_canvas(&self, mode: PasteMode, size: Size) -> std::result::Result<(), String> {
self.capability_at(mode, size, [f64::from(size.width) / 2.0, f64::from(size.height) / 2.0])
}
/// Availability for a captured placement, computed once before showing the dialog.
pub fn capability_at(&self, mode: PasteMode, size: Size, center: [f64; 2]) -> std::result::Result<(), String> {
self.capability(mode)?;
if matches!(mode, PasteMode::Layers | PasteMode::Shape) {
let dx = (center[0] - f64::from(self.document.size.width) / 2.0).round() as i32;
let dy = (center[1] - f64::from(self.document.size.height) / 2.0).round() as i32;
destination_cache_budget(self, mode, size, dx, dy)?;
}
Ok(())
}
}
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
pub fn prepare_svg(bytes: &[u8]) -> Result<PreparedSvg> {
if bytes.is_empty() || bytes.len() > MAX_VECTOR_CLIPBOARD_BYTES {
return Err(bad("SVG clipboard data must contain 1 byte to 16 MiB"));
}
if !photocraft_io::svg::is_svg(bytes) {
return Err(bad("clipboard data is not an SVG document"));
}
let mut imported = photocraft_io::svg::import_svg_bounded("Clipboard.svg", bytes, 24_000_000)
.map_err(|e| EngineError::Other(format!("can't import clipboard SVG: {e}")))?;
if imported.document.layers.is_empty() {
return Err(bad("the SVG contains no drawable objects"));
}
// SVG paints and resvg pixels are sRGB, independent of the user's working RGB space.
imported.document.icc_profile = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes());
if bytes.split(|b| *b == b'<').skip(1).any(|element| {
let name = element.split(|b| b.is_ascii_whitespace() || matches!(b, b'>' | b'/')).next().unwrap_or_default();
name.rsplit(|b| *b == b':').next() == Some(b"text".as_slice())
}) {
imported.warnings.push("SVG: text is imported as outlines, not editable text".into());
}
let path_capability = combined_path(&imported.document).map(|_| ());
let shape_capability = single_shape(&imported.document).map(|_| ());
Ok(PreparedSvg { source: Arc::new(bytes.to_vec()), document: imported.document, warnings: imported.warnings, path_capability, shape_capability })
}
fn destination_cache_budget(prepared: &PreparedSvg, _mode: PasteMode, size: Size, dx: i32, dy: i32) -> std::result::Result<(), String> {
check_destination_shapes(&prepared.document.layers, Affine::translate(f64::from(dx), f64::from(dy)), size)
}
/// Preflight transformed SVG shape caches before an operation refreshes their raster caches.
pub(crate) fn check_destination_shapes(layers: &[Layer], transform: Affine, size: Size) -> std::result::Result<(), String> {
let canvas = photocraft_geom::Rect::from_size(size);
let mut pixels = 0u64;
let mut pending: Vec<&Layer> = layers.iter().collect();
while let Some(layer) = pending.pop() {
pending.extend(layer.children().into_iter().flatten());
let LayerContent::Shape(shape) = &layer.content else { continue };
let mut shape = shape.clone();
crate::vector_cmds::transform_shape(&mut shape, &transform);
let compiled = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, canvas);
let area = compiled.bounds().map_or(0, |bounds| bounds.intersect(&canvas).size().area());
pixels = pixels.checked_add(area).ok_or("The pasted shape caches exceed the clipboard cache budget; use Pixels or Smart Object")?;
if pixels > 24_000_000 {
return Err("The pasted shape caches exceed the clipboard cache budget; use Pixels or Smart Object".into());
}
}
Ok(())
}
fn combined_path(doc: &Document) -> std::result::Result<Path, String> {
let mut result: Option<Path> = None;
for (_, _, layer) in doc.walk() {
match &layer.content {
LayerContent::Group(_) => {}
LayerContent::Shape(shape) => {
if let Some(path) = &mut result {
if path.fill_rule != shape.path.fill_rule || path.inverted != shape.path.inverted {
return Err("The SVG has incompatible fill rules; use Layers or Smart Object".into());
}
path.subpaths.extend(shape.path.subpaths.clone());
} else {
result = Some(shape.path.clone());
}
}
_ => return Err("The SVG contains rasterised objects without editable paths".into()),
}
}
result.filter(|p| !p.is_empty()).ok_or_else(|| "The SVG contains no editable paths".into())
}
fn single_shape(doc: &Document) -> std::result::Result<&Layer, String> {
let mut found = None;
for (_, _, layer) in doc.walk() {
match &layer.content {
LayerContent::Group(_)
if layer.opacity == 1.0 && layer.vector_mask.is_none() && matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough) => {}
LayerContent::Shape(_) if found.is_none() => found = Some(layer),
_ => return Err("Shape Layer requires one shape without group blending; use Layers to preserve this SVG".into()),
}
}
found.ok_or_else(|| "The SVG contains no editable shape".into())
}
fn enabled(s: &Session) -> std::result::Result<(), String> {
let state = s.active().ok_or("no document open")?;
if s.tools.mask_targeted
|| state.doc.quick_mask.is_some()
|| state.channel_view.layer_mask.is_some()
|| state.channel_view.target != crate::channel_cmds::ChannelTarget::Composite
{
return Err("vector paste requires the layer composite target".into());
}
if !crate::layer_copy_cmds::layered(state.doc.mode) {
return Err("vector paste requires a layered RGB, Grayscale, CMYK or Lab document".into());
}
if s.job_on(state.doc.id).is_some() {
return Err("the document has a running background job".into());
}
Ok(())
}
fn params(s: &Session, p: &Value) -> Result<[f64; 2]> {
if !p.is_object() {
return Err(bad("parameters must be an object"));
}
if let Some(v) = p.get("document") {
let id = v.as_u64().ok_or_else(|| bad("`document` must be an integer document id"))?;
if s.active().map(|state| state.doc.id.0) != Some(id) {
return Err(bad("the destination document is no longer active"));
}
}
if p.get("target").is_some() {
return Err(bad("vector paste accepts only the current layer composite target"));
}
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
match p.get("center") {
None => Ok([f64::from(doc.size.width) / 2.0, f64::from(doc.size.height) / 2.0]),
Some(v) => {
let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]));
xy.filter(|a| a.iter().all(|n| n.is_finite() && n.abs() <= 1e7))
.ok_or_else(|| bad("`center` must contain two finite coordinates within ±10 million pixels"))
}
}
}
/// Confirm a prepared clipboard snapshot, preserving masks, channels and the existing work path.
pub fn paste_svg(s: &mut Session, prepared: &PreparedSvg, mode: PasteMode, p: &Value) -> Result<Value> {
let center = params(s, p)?;
enabled(s).map_err(EngineError::Other)?;
prepared.capability(mode).map_err(EngineError::Other)?;
let state = s.active().ok_or(EngineError::NoDocument)?;
let dest = state.doc.clone();
let [w, h] = prepared.size();
let (dx, dy) = ((center[0] - f64::from(w) / 2.0).round() as i32, (center[1] - f64::from(h) / 2.0).round() as i32);
if matches!(mode, PasteMode::Layers | PasteMode::Shape) {
destination_cache_budget(prepared, mode, dest.size, dx, dy).map_err(EngineError::Other)?;
}
if mode == PasteMode::Path {
let path = combined_path(&prepared.document).map_err(EngineError::Other)?.transform(&Affine::translate(f64::from(dx), f64::from(dy)));
let name = (1..=dest.paths.len().saturating_add(1))
.map(|i| format!("Pasted Path {i}"))
.find(|name| !dest.paths.iter().any(|p| &p.name == name))
.ok_or_else(|| EngineError::Other("no free path name".into()))?;
s.edit("Paste Vector Path", |doc, _| {
doc.paths.push(NamedPath { name: name.clone(), path, psd_raw: None });
Ok(())
})?;
return Ok(json!({"mode":mode.as_str(),"path":name,"warnings":prepared.warnings(),"offset":[dx,dy]}));
}
// Insertion is beside the active layer. Its own lock (e.g. Background) does not prevent a
// new sibling, but an enclosing locked group must not be modified.
let parent = state
.active_layer
.and_then(|id| dest.path_of(id))
.map(|mut path| {
path.pop();
path
})
.unwrap_or_default();
let locks = dest.locks_at(&parent);
if locks.all || locks.pixels || locks.position {
return Err(EngineError::Other("the destination group is locked".into()));
}
let mut copies = prepared.document.clone();
copies.layers = match mode {
PasteMode::Layers => copies.layers.iter().map(Layer::duplicate).collect(),
PasteMode::Shape => vec![single_shape(&prepared.document).map_err(EngineError::Other)?.duplicate()],
PasteMode::Pixels | PasteMode::SmartObject => {
let image = crate::smart_cmds::svg_source_image(&prepared.source, PixelFormat::new(ColorMode::Rgb, SampleType::F32, true), 1.0)?;
copies.depth = SampleType::F32;
vec![Layer::new("Pasted SVG", LayerContent::Raster((*image.surface).clone()))]
}
PasteMode::Path => return Err(bad("path insertion requires a path target")),
};
if copies.walk().into_iter().any(|(path, _, _)| parent.len().saturating_add(path.len()) > MAX_GROUP_DEPTH.saturating_add(1)) {
return Err(EngineError::Other("pasting these groups would exceed the document nesting limit".into()));
}
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &dest)?;
// Moving vectors re-renders their caches. It must use the destination canvas and depth,
// not clip centred shapes against the clipboard's smaller source canvas.
copies.size = dest.size;
copies.depth = dest.depth;
crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy);
if dx == 0 && dy == 0 && matches!(mode, PasteMode::Layers | PasteMode::Shape) {
let snapshot = copies.clone();
for layer in &mut copies.layers {
crate::vector_cmds::translate_vectors(&snapshot, layer, 0.0, 0.0);
}
}
if mode == PasteMode::SmartObject {
let cache = copies.layers.first().and_then(Layer::surface).cloned().ok_or_else(|| EngineError::Other("the SVG could not be rendered".into()))?;
let source = SmartSource::Embedded { file_name: "Clipboard.svg".into(), bytes: prepared.source.clone() };
let mut smart = SmartObject::new(source, Affine::translate(f64::from(dx), f64::from(dy)), Some(cache));
if let Some(rendered) = crate::smart_cmds::render(&dest, &smart)? {
smart.cache = Some(rendered);
}
copies.layers = vec![Layer::new("Pasted SVG", LayerContent::Smart(smart))];
}
let ids = s.edit("Paste Vector", |doc, active| {
let mut above = *active;
let mut ids = Vec::with_capacity(copies.layers.len());
for layer in copies.layers {
let id = doc.insert_above(above, layer);
above = Some(id);
ids.push(id);
}
*active = above;
Ok(ids)
})?;
crate::layer_multi_cmds::reselect(s, ids.clone(), ids.last().copied());
Ok(json!({"mode":mode.as_str(),"layers":ids.iter().map(|id|id.0).collect::<Vec<_>>(),"warnings":prepared.warnings(),"offset":[dx,dy]}))
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let mode = PasteMode::parse(p.get("mode").ok_or_else(|| bad("missing `mode`"))?)?;
let svg = p.get("svg").and_then(Value::as_str).ok_or_else(|| bad("`svg` must be an SVG string"))?;
let prepared = prepare_svg(svg.as_bytes())?;
paste_svg(s, &prepared, mode, p)
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: CMD,
label: "Paste Vector",
menu: &[],
shortcut: None,
params: r##"{"svg":"<svg …>…</svg>","mode":"layers|smartObject|pixels|path|shape","document":id? (must be active),"center":[x,y]?=canvas centre} → {mode,layers?|path?,warnings,offset}"##,
enabled,
run: paste,
journal: true,
}]
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,408 @@
use super::*;
use photocraft_doc::{LayerId, Locks, Subpath};
use photocraft_geom::Rect;
const RECT: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect id="red" width="8" height="6" fill="#ff0000"/></svg>"##;
const GROUP: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="6"><g id="pair" opacity="0.5"><rect id="red" width="6" height="6" fill="red"/><rect id="blue" x="6" width="6" height="6" fill="blue"/></g></svg>"##;
const FILTER: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><defs><filter id="blur"><feGaussianBlur stdDeviation="1"/></filter></defs><rect width="8" height="6" fill="red" filter="url(#blur)"/></svg>"##;
#[test]
fn pasted_shape_preserves_svg_paints_and_uses_shared_shape_parity_controls() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect x="1" y="1" width="6" height="4" fill="#ff0000" stroke="#0000ff" stroke-width="1"/></svg>"##;
for depth in [8, 16, 32] {
let mut s = session(depth);
s.tools.foreground = [0.0, 1.0, 0.0, 1.0];
let result = s.execute("edit.pasteVector", json!({"svg":svg,"mode":"shape"})).unwrap();
let layer = inserted(&s, &result);
let LayerContent::Shape(imported) = &layer.content else { panic!("native shape") };
let before = s.active().unwrap().doc.clone();
let original_path = imported.path.clone();
let info = s.execute("shape.info", json!({"layer":layer.id.0})).unwrap();
let photocraft_doc::Fill::Solid(fill) = imported.fill.as_ref().unwrap() else { panic!("solid SVG fill") };
let photocraft_doc::Fill::Solid(stroke) = &imported.stroke.as_ref().unwrap().paint else { panic!("solid SVG stroke") };
for (actual, expected) in fill.to_rgb().into_iter().zip([1.0, 0.0, 0.0]) {
assert!((actual - expected).abs() < 1e-5);
}
for (actual, expected) in stroke.to_rgb().into_iter().zip([0.0, 0.0, 1.0]) {
assert!((actual - expected).abs() < 1e-5);
}
s.execute("shape.edit", json!({"layer":layer.id.0,"stroke":{"dashes":[3,1],"cap":"round","join":"bevel","align":"inside"}})).unwrap();
let edited = s.execute("shape.info", json!({"layer":layer.id.0})).unwrap();
assert_eq!(edited["fill"], info["fill"]);
assert_eq!(edited["stroke"]["fill"], info["stroke"]["fill"]);
assert_eq!(edited["stroke"]["dashes"], json!([3.0, 1.0]));
assert_eq!(edited["stroke"]["cap"], "round");
assert_eq!(edited["stroke"]["join"], "bevel");
assert_eq!(edited["stroke"]["align"], "inside");
let LayerContent::Shape(shape) = &s.active().unwrap().doc.layer(layer.id).unwrap().content else { panic!("native shape") };
assert_eq!(shape.path, original_path);
assert_eq!(shape.cache.as_ref().unwrap().format(), s.active().unwrap().doc.pixel_format());
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
s.execute("shape.edit", json!({"layer":layer.id.0,"transform":[2,0,0,3,0,0]})).unwrap();
let LayerContent::Shape(shape) = &s.active().unwrap().doc.layer(layer.id).unwrap().content else { panic!("native shape") };
let (x0, y0, x1, y1) = original_path.control_bounds().unwrap();
assert_eq!(shape.path.control_bounds(), Some((x0 * 2.0, y0 * 3.0, x1 * 2.0, y1 * 3.0)));
assert_eq!(shape.fill, imported.fill);
assert_eq!(shape.stroke.as_ref().unwrap().paint, imported.stroke.as_ref().unwrap().paint);
}
}
#[test]
fn zero_offset_refreshes_shape_caches_on_the_destination_canvas() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect width="24" height="24" fill="red"/></svg>"#;
let prepared = prepare_svg(svg.as_bytes()).unwrap();
for mode in [PasteMode::Layers, PasteMode::Shape] {
let mut s = session(16);
let result = paste_svg(&mut s, &prepared, mode, &json!({"center":[0.5,0.5]})).unwrap();
let layer = inserted(&s, &result);
assert_eq!(layer.surface().unwrap().pixel(20, 20), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(layer.surface().unwrap().format(), s.active().unwrap().doc.pixel_format());
}
}
#[test]
fn scaled_svg_smart_object_cannot_unpack_excessive_destination_caches() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect width="300000" height="300000"/></svg>"#;
let mut s = Session::new();
s.execute("file.new", json!({"width":12000,"height":12000})).unwrap();
s.execute("edit.pasteVector", json!({"svg":svg,"mode":"smartObject","center":[0.5,0.5]})).unwrap();
s.execute("edit.transform", json!({"matrix":[2,0,0,2,0,0]})).unwrap();
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
assert!(s.execute("layer.smartObjects.convertToLayers", json!({})).unwrap_err().to_string().contains("cache budget"));
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().revision, revision);
}
fn session(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap();
s
}
fn inserted(s: &Session, result: &Value) -> Layer {
let id = LayerId(result["layers"][0].as_u64().unwrap());
s.active().unwrap().doc.layer(id).unwrap().clone()
}
#[test]
fn each_mode_has_distinct_content_and_one_undo_step() {
let prepared = prepare_svg(RECT.as_bytes()).unwrap();
assert_eq!(prepared.size(), [8, 6]);
for mode in [PasteMode::Layers, PasteMode::SmartObject, PasteMode::Pixels, PasteMode::Path, PasteMode::Shape] {
let mut s = session(8);
let before = s.active().unwrap().doc.clone();
let result = paste_svg(&mut s, &prepared, mode, &json!({})).unwrap();
assert_eq!(result["mode"], mode.as_str());
assert_eq!(result["offset"], json!([12, 9]));
let doc = &s.active().unwrap().doc;
match mode {
PasteMode::Path => {
assert_eq!(doc.layers, before.layers);
assert_eq!(doc.paths.len(), 1);
assert_eq!(doc.paths[0].path.control_bounds(), Some((12.0, 9.0, 20.0, 15.0)));
}
_ => {
let layer = inserted(&s, &result);
match (&layer.content, mode) {
(LayerContent::Shape(_), PasteMode::Layers | PasteMode::Shape) => {}
(LayerContent::Raster(_), PasteMode::Pixels) => {}
(LayerContent::Smart(sm), PasteMode::SmartObject) => {
assert_eq!(sm.transform, Affine::translate(12.0, 9.0));
assert!(
matches!(&sm.source, SmartSource::Embedded {file_name,bytes} if file_name == "Clipboard.svg" && bytes.as_slice() == RECT.as_bytes())
);
}
_ => panic!("wrong content for {mode:?}"),
}
let pixel = layer.surface().unwrap().read_region(Rect::new(15, 11, 16, 12));
assert_eq!(pixel, vec![1.0, 0.0, 0.0, 1.0]);
}
}
assert!(s.undo());
assert_eq!(*s.active().unwrap().doc, *before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc.paths.len(), usize::from(mode == PasteMode::Path));
}
}
#[test]
fn repeated_import_preserves_groups_order_opacity_and_assigns_fresh_ids() {
let prepared = prepare_svg(GROUP.as_bytes()).unwrap();
let mut s = session(8);
let a = paste_svg(&mut s, &prepared, PasteMode::Layers, &json!({})).unwrap();
let b = paste_svg(&mut s, &prepared, PasteMode::Layers, &json!({})).unwrap();
let (a, b) = (inserted(&s, &a), inserted(&s, &b));
assert_ne!(a.id, b.id);
assert_eq!(a.name, "pair");
assert_eq!(a.opacity, 0.5);
let (ac, bc) = (a.children().unwrap(), b.children().unwrap());
assert_eq!(ac.iter().map(|l| l.name.as_str()).collect::<Vec<_>>(), ["red", "blue"]);
assert!(ac.iter().zip(bc).all(|(x, y)| x.id != y.id));
assert!(prepared.capability(PasteMode::Shape).is_err());
assert!(prepared.capability(PasteMode::Path).is_ok());
}
#[test]
fn nested_svg_clips_require_layers_and_preserve_both_masks() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="8">
<defs>
<clipPath id="outerClip"><rect width="4" height="8"/></clipPath>
<clipPath id="innerClip"><rect width="12" height="4"/></clipPath>
</defs>
<g id="outer" clip-path="url(#outerClip)">
<g id="inner" clip-path="url(#innerClip)"><rect width="12" height="8" fill="red"/></g>
</g></svg>"##;
let prepared = prepare_svg(svg.as_bytes()).unwrap();
assert_eq!(prepared.document.walk().iter().filter(|(_, _, layer)| layer.vector_mask.is_some()).count(), 2);
assert!(prepared.capability(PasteMode::Shape).is_err());
assert!(prepared.capability(PasteMode::Layers).is_ok());
let mut s = session(8);
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
assert!(paste_svg(&mut s, &prepared, PasteMode::Shape, &json!({"center":[6,4]})).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().revision, revision);
let result = paste_svg(&mut s, &prepared, PasteMode::Layers, &json!({"center":[6,4]})).unwrap();
let after = s.active().unwrap().doc.clone();
let mut pasted = (*after).clone();
pasted.layers = vec![inserted(&s, &result)];
assert_eq!(pasted.walk().iter().filter(|(_, _, layer)| layer.vector_mask.is_some()).count(), 2);
let pixels = photocraft_compose::flatten(&pasted);
assert_eq!(pixels.get(3, 3), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(pixels.get(6, 2), [0.0, 0.0, 0.0, 0.0]);
assert_eq!(pixels.get(2, 6), [0.0, 0.0, 0.0, 0.0]);
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
}
#[test]
fn rasterised_elements_preserve_warnings_and_disable_geometry_modes() {
let prepared = prepare_svg(FILTER.as_bytes()).unwrap();
assert!(prepared.warnings().iter().any(|w| w.contains("filter")));
assert!(prepared.capability(PasteMode::Path).is_err());
assert!(prepared.capability(PasteMode::Shape).is_err());
let mut s = session(8);
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
assert!(paste_svg(&mut s, &prepared, PasteMode::Path, &json!({})).is_err());
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(*s.active().unwrap().doc, *before);
let result = paste_svg(&mut s, &prepared, PasteMode::Layers, &json!({})).unwrap();
assert_eq!(result["warnings"], json!(prepared.warnings()));
}
#[test]
fn aggregate_clipboard_cache_budget_warns_and_disables_geometry_modes() {
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"256\" height=\"256\">{}</svg>",
"<rect width=\"256\" height=\"256\" fill=\"red\"/>".repeat(400)
);
let prepared = prepare_svg(svg.as_bytes()).unwrap();
assert!(prepared.warnings().iter().any(|w| w.contains("cache budget")));
assert_eq!(prepared.document.layers.len(), 1);
for mode in [PasteMode::Path, PasteMode::Shape] {
assert!(prepared.capability(mode).is_err());
}
for mode in [PasteMode::Layers, PasteMode::Pixels, PasteMode::SmartObject] {
assert!(prepared.capability(mode).is_ok());
}
let oversized = br#"<svg xmlns="http://www.w3.org/2000/svg" width="5000" height="5000"><rect width="5000" height="5000"/></svg>"#;
assert!(prepare_svg(oversized).unwrap_err().to_string().contains("cache budget"));
}
#[test]
fn destination_preflight_bounds_geometry_without_allocating_large_caches() {
let size = Size::new(12_000, 12_000);
let tiny = prepare_svg(RECT.as_bytes()).unwrap();
assert!(tiny.capability_on_canvas(PasteMode::Layers, size).is_ok(), "a small shape on a large canvas fits the cache budget");
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect width="300000" height="300000"/></svg>"#;
let prepared = prepare_svg(svg).unwrap();
for mode in [PasteMode::Layers, PasteMode::Shape] {
assert!(prepared.capability(mode).is_ok(), "source cache contains only one pixel");
assert!(prepared.capability_on_canvas(mode, size).unwrap_err().contains("cache budget"));
let mut s = Session::new();
s.add_document(Document::new("large", size, ColorMode::Rgb, SampleType::U8), None);
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
assert!(paste_svg(&mut s, &prepared, mode, &json!({})).unwrap_err().to_string().contains("cache budget"));
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().revision, revision);
assert!(!s.active().unwrap().history.can_undo());
}
assert!(prepared.capability_on_canvas(PasteMode::Pixels, size).is_ok());
assert!(prepared.capability_on_canvas(PasteMode::SmartObject, size).is_ok());
}
#[test]
fn compound_path_holes_and_existing_work_path_are_preserved() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><path fill-rule="evenodd" d="M0 0H10V10H0Z M3 3H7V7H3Z"/></svg>"#;
let prepared = prepare_svg(svg.as_bytes()).unwrap();
let mut s = session(8);
let work = Path::new(vec![Subpath::polyline(&[(1.0, 2.0), (3.0, 4.0)])]);
s.edit("work", |doc, _| {
doc.work_path = Some(work.clone());
Ok(())
})
.unwrap();
let a = paste_svg(&mut s, &prepared, PasteMode::Path, &json!({"center":[5,5]})).unwrap();
let b = paste_svg(&mut s, &prepared, PasteMode::Path, &json!({"center":[5,5]})).unwrap();
assert_ne!(a["path"], b["path"]);
let doc = &s.active().unwrap().doc;
assert_eq!(doc.work_path, Some(work));
let path = &doc.paths[0].path;
assert_eq!(path.subpaths.len(), 2);
assert_eq!(path.subpaths[1].op, photocraft_doc::PathOp::Join);
assert_eq!(path.fill_rule, photocraft_doc::FillRule::EvenOdd);
let coverage = photocraft_vector::path_coverage(path, Rect::new(0, 0, 10, 10));
assert!(coverage[11] > 0.99);
assert!(coverage[44] < 0.01, "hole remains empty");
}
#[test]
fn incompatible_fill_rules_are_not_silently_merged() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><path fill-rule="evenodd" d="M0 0H5V5H0Z"/><path fill-rule="nonzero" d="M5 5H10V10H5Z"/></svg>"#;
let prepared = prepare_svg(svg.as_bytes()).unwrap();
assert!(prepared.capability(PasteMode::Path).unwrap_err().contains("fill rules"));
assert!(prepared.capability(PasteMode::Layers).is_ok());
}
#[test]
fn pixel_and_smart_caches_convert_depth_and_colour_model() {
let prepared = prepare_svg(RECT.as_bytes()).unwrap();
for depth in [8, 16, 32] {
for mode in [PasteMode::Pixels, PasteMode::SmartObject] {
let mut s = session(depth);
s.execute("image.mode.grayscale", json!({})).unwrap();
let result = paste_svg(&mut s, &prepared, mode, &json!({})).unwrap();
let layer = inserted(&s, &result);
let surface = layer.surface().unwrap();
assert_eq!(surface.format(), s.active().unwrap().doc.pixel_format());
let pixel = surface.read_region(Rect::new(15, 11, 16, 12));
assert!(pixel[0] > 0.1 && pixel[0] < 0.9, "{depth} {mode:?} {pixel:?}");
assert_eq!(pixel[1], 1.0);
}
}
}
#[test]
fn editable_shapes_render_on_destination_canvas_at_every_depth() {
let prepared = prepare_svg(RECT.as_bytes()).unwrap();
for depth in [8, 16, 32] {
for mode in [PasteMode::Layers, PasteMode::Shape] {
let mut s = session(depth);
let result = paste_svg(&mut s, &prepared, mode, &json!({})).unwrap();
let layer = inserted(&s, &result);
let surface = layer.surface().unwrap();
assert_eq!(surface.format(), s.active().unwrap().doc.pixel_format());
assert_eq!(surface.read_region(Rect::new(15, 11, 16, 12)), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(surface.read_region(Rect::new(0, 0, 1, 1)), vec![0.0, 0.0, 0.0, 0.0]);
}
}
}
#[test]
fn bad_parameters_stale_document_and_non_layer_targets_do_not_edit() {
let prepared = prepare_svg(RECT.as_bytes()).unwrap();
let mut s = session(8);
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let stale_id = s.active().unwrap().doc.id.0.saturating_add(1);
for params in [
json!(null),
json!({"center":[]}),
json!({"center":[1,"2"]}),
json!({"center":[1e20,0]}),
json!({"document":stale_id}),
json!({"document":0.5}),
json!({"target":"quickMask"}),
] {
assert!(paste_svg(&mut s, &prepared, PasteMode::Layers, &params).is_err(), "{params}");
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().revision, revision);
}
s.tools.mask_targeted = true;
assert!(paste_svg(&mut s, &prepared, PasteMode::Layers, &json!({})).is_err());
s.tools.mask_targeted = false;
s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::Color(0);
assert!(paste_svg(&mut s, &prepared, PasteMode::Layers, &json!({})).is_err());
assert_eq!(*s.active().unwrap().doc, *before);
}
#[test]
fn locked_enclosing_group_rejects_new_layers_but_locked_background_accepts_siblings() {
let prepared = prepare_svg(RECT.as_bytes()).unwrap();
let mut s = session(8);
assert!(paste_svg(&mut s, &prepared, PasteMode::Pixels, &json!({})).is_ok());
let child = Layer::raster("child", s.active().unwrap().doc.pixel_format());
let child_id = child.id;
let mut group = Layer::group("locked", vec![child]);
group.locks = Locks { all: true, ..Default::default() };
s.edit("group", |doc, active| {
doc.layers.push(group);
*active = Some(child_id);
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
assert!(paste_svg(&mut s, &prepared, PasteMode::Layers, &json!({})).is_err());
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().revision, revision);
}
#[test]
fn headless_paste_in_mask_view_does_not_mutate_document_or_history() {
let mut s = session(8);
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("view.layerMask", json!({"mode":"gray"})).unwrap();
s.tools.mask_targeted = false;
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let undo = s.active().unwrap().history.can_undo();
let spec = specs().remove(0);
assert!((spec.run)(&mut s, &json!({"svg":RECT,"mode":"pixels"})).is_err());
assert_eq!(*s.active().unwrap().doc, *before);
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.active().unwrap().history.can_undo(), undo);
assert!(s.active().unwrap().channel_view.layer_mask.is_some());
}
#[test]
fn untrusted_svg_budget_empty_and_external_images_are_rejected_safely() {
for bytes in [b"".as_slice(), b"not SVG", b"<svg", br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"/>"#] {
assert!(prepare_svg(bytes).is_err());
}
assert!(prepare_svg(&vec![b' '; MAX_VECTOR_CLIPBOARD_BYTES + 1]).is_err());
let external = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><image href="file:///not-accessed.png" width="8" height="6"/></svg>"#;
assert!(prepare_svg(external).is_err());
}
#[test]
fn outlined_text_warning_includes_namespaced_text_elements() {
for svg in [
r#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6"/><text y="4">A</text></svg>"#,
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="8" height="6"><rect width="8" height="6"/><svg:text y="4">A</svg:text></svg>"#,
] {
let prepared = prepare_svg(svg.as_bytes()).unwrap();
assert!(prepared.warnings().iter().any(|w| w.contains("outlines")));
}
}
#[test]
fn headless_command_requires_explicit_svg_and_mode() {
let mut s = session(8);
let spec = specs().remove(0);
for p in [json!({}), json!({"svg":RECT}), json!({"svg":RECT,"mode":false}), json!({"svg":RECT,"mode":"unknown"}), json!({"svg":32,"mode":"pixels"})] {
assert!((spec.run)(&mut s, &p).is_err());
}
let doc_id = s.active().unwrap().doc.id.0;
let result = (spec.run)(&mut s, &json!({"svg":RECT,"mode":"pixels","document":doc_id,"center":[4,3]})).unwrap();
assert_eq!(result["offset"], json!([0, 0]));
}
@@ -0,0 +1,187 @@
//! Selected-layer Boolean operations preserve holes, history and hierarchy (#2496).
use photocraft_doc::{LayerContent, LayerId, PathOp};
use photocraft_engine::Session;
use serde_json::{Value, json};
fn session(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
s
}
fn shape(s: &mut Session, params: Value) -> LayerId {
LayerId(s.execute("shape.create", params).unwrap()["layer"].as_u64().unwrap())
}
fn select(s: &mut Session, ids: &[LayerId]) {
for (i, id) in ids.iter().enumerate() {
s.execute("layer.select", json!({"layer":id.0,"mode":if i == 0 {"replace"} else {"add"}})).unwrap();
}
}
fn rgba(s: &mut Session, x: i32, y: i32) -> [f32; 4] {
serde_json::from_value(s.execute("document.pixel", json!({"x":x,"y":y})).unwrap()).unwrap()
}
#[test]
fn compound_and_evenodd_holes_survive_as_either_boolean_operand() {
for depth in [8, 16, 32] {
for outer_reverse in [false, true] {
for (rule, hole_op, opposite_winding) in [
("nonzero", "subtract", false),
("nonzero", "subtract", true),
("nonzero", "join", true),
("evenodd", "join", false),
("evenodd", "join", true),
] {
for hole_in_front in [false, true] {
let mut s = session(depth);
let mut outer = vec![[8, 8], [56, 8], [56, 40], [8, 40]];
let mut inner = vec![[20, 16], [44, 16], [44, 32], [20, 32]];
if outer_reverse {
outer.reverse();
}
if outer_reverse != opposite_winding {
inner.reverse();
}
let hole = json!({"kind":"path","fill":"#0000ff","path":{"fillRule":rule,"subpaths":[
{"closed":true,"knots":outer}, {"closed":true,"op":hole_op,"knots":inner}
]}});
let solid = json!({"rect":[4,4,56,40],"fill":"#0000ff"});
let back = shape(&mut s, if hole_in_front { solid.clone() } else { hole.clone() });
let front = shape(&mut s, if hole_in_front { hole } else { solid });
select(&mut s, &[front, back]);
s.execute("layer.combineShapes.intersectShapeAreas", json!({})).unwrap();
for (x, filled) in [(12, true), (32, false), (52, true), (60, false)] {
let pixel = rgba(&mut s, x, 24);
assert_eq!(
pixel[3] > 0.9,
filled,
"depth={depth}, rule={rule}, hole_op={hole_op}, outer_reverse={outer_reverse}, opposite_winding={opposite_winding}, hole_in_front={hole_in_front}, x={x}: {pixel:?}"
);
}
}
}
}
}
}
#[test]
fn merge_shape_components_merges_selected_layers_and_bakes_the_union_in_one_step() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let back = shape(&mut s, json!({"rect":[4,4,24,24],"fill":"#ff0000"}));
let front = shape(&mut s, json!({"rect":[18,4,24,24],"fill":"#0000ff"}));
select(&mut s, &[front, back]);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("layer.combineShapes.mergeShapeComponents", json!({})).unwrap();
let st = s.active().unwrap();
assert_eq!(st.history.past_len(), history + 1);
assert_eq!(st.selected_layers(), [front]);
assert!(st.doc.layer(back).is_none());
let LayerContent::Shape(shape) = &st.doc.layer(front).unwrap().content else { panic!("merged shape") };
assert_eq!(shape.path.components().len(), 1);
assert_eq!(shape.path.subpaths[0].op, PathOp::Combine);
assert!(shape.live.is_none());
for x in [8, 20, 36] {
let pixel = rgba(&mut s, x, 12);
assert!(pixel[2] > 0.99 && pixel[3] > 0.99 && pixel[0] < 0.01, "x={x}: {pixel:?}");
}
assert_eq!(rgba(&mut s, 50, 12)[3], 0.0);
let after = s.active().unwrap().doc.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().selected_layers(), [back, front]);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
assert_eq!(s.active().unwrap().selected_layers(), [front]);
}
}
#[test]
fn three_layer_intersection_stays_empty_after_any_empty_operand() {
for empty_index in [None, Some(0), Some(1), Some(2)] {
let mut s = session(8);
let ids: Vec<_> = [4, 12, 20].into_iter().map(|x| shape(&mut s, json!({"rect":[x,4,32,24],"fill":"#0000ff"}))).collect();
if let Some(i) = empty_index {
s.execute("shape.edit", json!({"layer":ids[i].0,"path":{"subpaths":[]}})).unwrap();
}
select(&mut s, &ids);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("layer.combineShapes.intersectShapeAreas", json!({})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
assert_eq!(s.active().unwrap().selected_layers(), [ids[2]]);
for (x, covered) in [(12, false), (24, empty_index.is_none()), (40, false)] {
let pixel = rgba(&mut s, x, 12);
assert_eq!(pixel[3] > 0.99, covered, "empty_index={empty_index:?}, x={x}: {pixel:?}");
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().selected_layers(), ids);
}
}
#[test]
fn merging_shapes_from_separate_groups_keeps_front_parent_slot_and_layer_style() {
let mut s = session(16);
let back = shape(&mut s, json!({"rect":[4,4,24,24],"fill":"#ff0000"}));
let back_group = LayerId(s.execute("layer.groupLayers", json!({"layer":back.0,"name":"Back"})).unwrap()["layer"].as_u64().unwrap());
let front = shape(&mut s, json!({"rect":[18,4,24,24],"fill":"#0000ff","stroke":{"width":2,"color":"#ffffff","dashes":[2,1]}}));
let front_group = LayerId(s.execute("layer.groupLayers", json!({"layer":front.0,"name":"Front"})).unwrap()["layer"].as_u64().unwrap());
s.execute("layer.setProps", json!({"layer":front.0,"name":"Retained shape","opacity":0.4,"fill":0.7,"blend":"multiply"})).unwrap();
let before_layer = s.active().unwrap().doc.layer(front).unwrap().clone();
let before_slot = s.active().unwrap().doc.path_of(front).unwrap();
let before_root: Vec<_> = s.active().unwrap().doc.layers.iter().map(|l| l.id).collect();
let LayerContent::Shape(before_shape) = &before_layer.content else { panic!("shape") };
let before_paint = (before_shape.fill.clone(), before_shape.stroke.clone());
select(&mut s, &[back, front]);
let before = s.active().unwrap().doc.clone();
s.execute("layer.combineShapes.unite", json!({})).unwrap();
let doc = &s.active().unwrap().doc;
assert_eq!(doc.path_of(front), Some(before_slot));
assert_eq!(doc.layers.iter().map(|l| l.id).collect::<Vec<_>>(), before_root);
assert!(doc.layer(back_group).unwrap().children().unwrap().is_empty());
assert_eq!(doc.layer(front_group).unwrap().children().unwrap().iter().map(|l| l.id).collect::<Vec<_>>(), [front]);
let after_layer = doc.layer(front).unwrap();
let LayerContent::Shape(after_shape) = &after_layer.content else { panic!("merged shape") };
assert_eq!((after_shape.fill.clone(), after_shape.stroke.clone()), before_paint);
let mut expected_layer = before_layer;
expected_layer.content = after_layer.content.clone();
assert_eq!(*after_layer, expected_layer, "all frontmost layer metadata must stay unchanged");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn combining_inverted_operands_rejects_atomically_for_every_operation() {
for inverted_front in [false, true] {
let mut s = session(32);
let back = shape(&mut s, json!({"rect":[4,4,24,24]}));
let front = shape(&mut s, json!({"rect":[18,4,24,24]}));
s.execute(
"shape.edit",
json!({"layer":if inverted_front {front.0} else {back.0},"path":{
"inverted":true,"subpaths":[{"closed":true,"knots":[[8,8],[40,8],[40,32],[8,32]]}]
}}),
)
.unwrap();
select(&mut s, &[back, front]);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
for command in [
"layer.combineShapes.unite",
"layer.combineShapes.subtractFrontShape",
"layer.combineShapes.intersectShapeAreas",
"layer.combineShapes.excludeOverlappingShapes",
"layer.combineShapes.mergeShapeComponents",
] {
let error = s.execute(command, json!({})).unwrap_err();
assert!(error.to_string().contains("inverted"), "{command}: {error}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
assert_eq!(s.active().unwrap().selected_layers(), [back, front]);
}
}
}
@@ -0,0 +1,421 @@
use photocraft_algo::transform::Homography;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Fill, FillRule, GradientStyle, LayerContent, LayerId, LayerMask, Path, PathOp, ShapeLayer, ShapeStroke, Subpath, VectorMask};
use photocraft_engine::{Session, transform_cmds::preview_shape_quad_transform, vector_cmds::shape_paint_params};
use photocraft_geom::{Point, Rect};
use photocraft_raster::Surface;
use serde_json::json;
const FRAME: [f64; 4] = [20.0, 20.0, 100.0, 80.0];
const QUAD: [[f64; 2]; 4] = [[30.0, 18.0], [110.0, 28.0], [94.0, 96.0], [12.0, 80.0]];
fn session(depth: u32, kind: &str) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width":140,"height":120,"depth":depth,"background":"transparent"})).unwrap();
let id = LayerId(s.execute("shape.create", json!({"kind":kind,"rect":[20,20,80,60],"fill":"#fdca22"})).unwrap()["layer"].as_u64().unwrap());
(s, id)
}
fn shape(s: &Session, id: LayerId) -> &ShapeLayer {
let LayerContent::Shape(shape) = &s.active().unwrap().doc.layer(id).unwrap().content else { panic!("shape stays vector") };
shape
}
fn distance_to_segment(p: (f64, f64), a: Point, b: Point) -> f64 {
let (dx, dy) = (b.x - a.x, b.y - a.y);
let denominator = dx * dx + dy * dy;
let t = if denominator == 0.0 { 0.0 } else { ((p.0 - a.x) * dx + (p.1 - a.y) * dy) / denominator }.clamp(0.0, 1.0);
(p.0 - a.x - t * dx).hypot(p.1 - a.y - t * dy)
}
#[test]
fn path_transform_projects_curves_for_work_saved_shape_and_mask_targets() {
use photocraft_doc::Knot;
let curve = Path::new(vec![Subpath {
knots: vec![
Knot { anchor: Point::new(0.0, 0.0), in_ctrl: Point::new(0.0, 0.0), out_ctrl: Point::new(0.0, 100.0), smooth: false },
Knot { anchor: Point::new(100.0, 0.0), in_ctrl: Point::new(100.0, 100.0), out_ctrl: Point::new(100.0, 0.0), smooth: false },
],
..Default::default()
}]);
let frame = [0.0, 0.0, 100.0, 100.0];
let quad = [[0.0, 0.0], [50.0, 0.0], [50.0, 50.0], [0.0, 100.0]];
let h = Homography::rect_to_quad(frame, quad).unwrap();
let midpoint = h.apply(50.0, 75.0);
assert!((midpoint.0 - 100.0 / 3.0).abs() < 1e-10 && (midpoint.1 - 50.0).abs() < 1e-10);
for depth in [8, 16, 32] {
for route in ["work", "saved", "shape", "mask"] {
let (mut s, shape_id) = session(depth, "rect");
s.execute("path.set", json!({"name":"work","path":curve})).unwrap();
s.execute("path.set", json!({"name":"saved","path":curve})).unwrap();
s.execute("shape.edit", json!({"layer":shape_id.0,"path":curve})).unwrap();
let mask_id = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
s.edit("mask fixture", |doc, _| {
doc.layer_mut(mask_id).unwrap().vector_mask = Some(VectorMask::new(curve.clone()));
Ok(())
})
.unwrap();
let target = match route {
"shape" => json!({"name":"layer","layer":shape_id.0}),
"mask" => json!({"name":"layer","layer":mask_id.0}),
name => json!({"name":name}),
};
let path = |s: &Session| match route {
"work" => s.active().unwrap().doc.work_path.as_ref().unwrap().clone(),
"saved" => s.active().unwrap().doc.paths.iter().find(|p| p.name == "saved").unwrap().path.clone(),
"shape" => shape(s, shape_id).path.clone(),
_ => s.active().unwrap().doc.layer(mask_id).unwrap().vector_mask.as_ref().unwrap().path.clone(),
};
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let mut params = target.clone();
params["rect"] = json!(frame);
params["quad"] = json!(quad);
s.execute("path.transform", params).unwrap();
let projected = path(&s);
assert!(projected.subpaths[0].knots.len() > 2, "projected curves need subdivision: {route}");
let edges = projected.subpaths[0].segments();
for i in 0..=1024 {
let t = f64::from(i) / 1024.0;
let u = 1.0 - t;
let q = h.apply(300.0 * u * t * t + 100.0 * t.powi(3), 300.0 * u * t);
let error = edges.iter().map(|edge| distance_to_segment(q, edge[0], edge[3])).fold(f64::INFINITY, f64::min);
assert!(error <= 0.250001, "depth {depth}, {route}: projected curve error {error}");
}
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let after = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
let revision = s.active().unwrap().revision;
let mut invalid = target;
invalid["rect"] = json!(frame);
invalid["quad"] = json!([[0, 0], [100, 100], [100, 0], [0, 100]]);
assert!(s.execute("path.transform", invalid).is_err(), "a horizon-crossing distortion is refused: {route}");
assert_eq!(s.active().unwrap().doc, after);
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
}
}
}
#[test]
fn distorted_rectangle_keeps_exact_corners_paint_and_one_undo_at_every_depth() {
for depth in [8, 16, 32] {
let (mut s, id) = session(depth, "rect");
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let preview = preview_shape_quad_transform(&before, id, FRAME, QUAD, "bicubic").unwrap();
assert_eq!(s.active().unwrap().doc, before, "preview never edits the session");
assert_eq!(s.active().unwrap().history.past_len(), history);
s.execute("edit.transform", json!({"layer":id.0,"rect":FRAME,"quad":QUAD})).unwrap();
let after = shape(&s, id);
assert!(after.live.is_none());
assert_eq!(after.path.subpaths[0].knots.len(), 4);
let h = Homography::rect_to_quad(FRAME, QUAD).unwrap();
let LayerContent::Shape(original) = &before.layer(id).unwrap().content else { panic!() };
for (a, b) in original.path.subpaths[0].knots.iter().zip(&after.path.subpaths[0].knots) {
let expected = h.apply(a.anchor.x, a.anchor.y);
assert!((b.anchor.x - expected.0).hypot(b.anchor.y - expected.1) < 1e-10);
assert_eq!(b.anchor, b.in_ctrl);
assert_eq!(b.anchor, b.out_ctrl);
}
assert_eq!(after.fill, original.fill);
assert!(after.cache.as_ref().unwrap().rgba(60, 50)[3] > 0.99);
assert_eq!(s.active().unwrap().doc, std::sync::Arc::new(preview));
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let committed = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, committed);
}
}
#[test]
fn projected_curves_meet_destination_tolerance_and_keep_paint_and_path_operations() {
for depth in [8, 16, 32] {
let (mut s, id) = session(depth, "ellipse");
let pattern = s.patterns.items[0].clone();
let fill = Fill::Gradient {
stops: vec![(0.0, Color::rgba(0.1, 0.2, 0.9, 1.0)), (1.0, Color::rgba(0.9, 0.7, 0.1, 1.0))],
angle: 37.0,
scale: 0.8,
style: GradientStyle::Linear,
reverse: true,
opacity_stops: vec![],
midpoints: vec![0.3],
offset: (-0.1, 0.2),
dither: true,
align: false,
};
let stroke = ShapeStroke {
paint: Fill::Pattern { name: pattern.name.clone(), id: pattern.id.clone(), scale: 1.3, angle: 22.0, link: false, phase: (3.0, -4.0) },
opacity: 0.7,
width: 2.0,
..Default::default()
};
let mut paint = shape_paint_params(Some(&fill), Some(&stroke));
paint["layer"] = json!(id.0);
s.execute("shape.edit", paint).unwrap();
s.edit("compound path", |doc, _| {
let LayerContent::Shape(sh) = &mut doc.layer_mut(id).unwrap().content else { panic!() };
sh.path.fill_rule = FillRule::EvenOdd;
sh.path.inverted = true;
sh.path.subpaths.push(Subpath::polygon(&[(45.0, 40.0), (65.0, 40.0), (65.0, 60.0), (45.0, 60.0)]).with_op(PathOp::Subtract));
Ok(())
})
.unwrap();
let source = shape(&s, id).clone();
s.execute("edit.transform", json!({"rect":FRAME,"quad":QUAD})).unwrap();
let projected = shape(&s, id);
assert_eq!(projected.fill, Some(fill));
assert_eq!(projected.stroke, Some(stroke));
assert_eq!(projected.path.fill_rule, source.path.fill_rule);
assert_eq!(projected.path.inverted, source.path.inverted);
assert_eq!(projected.path.subpaths.len(), source.path.subpaths.len());
assert!(projected.live.is_none());
assert!(projected.path.subpaths.iter().map(|p| p.knots.len()).sum::<usize>() <= 4096);
let h = Homography::rect_to_quad(FRAME, QUAD).unwrap();
for (original, actual) in source.path.subpaths.iter().zip(&projected.path.subpaths) {
assert_eq!((actual.closed, actual.op), (original.closed, original.op));
let edges: Vec<_> = actual.segments().into_iter().map(|s| (s[0], s[3])).collect();
// Sample the original cubic independently of the production flattener.
for [p0, c1, c2, p1] in original.segments() {
for i in 0..=1024 {
let t = f64::from(i) / 1024.0;
let u = 1.0 - t;
let x = u.powi(3) * p0.x + 3.0 * u.powi(2) * t * c1.x + 3.0 * u * t.powi(2) * c2.x + t.powi(3) * p1.x;
let y = u.powi(3) * p0.y + 3.0 * u.powi(2) * t * c1.y + 3.0 * u * t.powi(2) * c2.y + t.powi(3) * p1.y;
let q = h.apply(x, y);
let error = edges.iter().map(|&(a, b)| distance_to_segment(q, a, b)).fold(f64::INFINITY, f64::min);
assert!(error <= 0.250001, "depth {depth}, projected curve error {error}");
}
}
}
assert!(projected.cache.as_ref().unwrap().tiles().next().is_some());
let preserved_path = projected.path.clone();
s.execute("shape.edit", json!({"layer":id.0,"fill":null})).unwrap();
assert_eq!(shape(&s, id).path, preserved_path, "paint editing never regenerates the former live ellipse");
}
}
#[test]
fn linked_and_unlinked_pixel_and_vector_masks_follow_the_shape_controls() {
for (depth, sample) in [(8, SampleType::U8), (16, SampleType::U16), (32, SampleType::F32)] {
for linked in [false, true] {
let (mut s, id) = session(depth, "rect");
s.edit("masks", |doc, _| {
let l = doc.layer_mut(id).unwrap();
let mut surface = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[1.0]);
surface.fill_rect(Rect::new(30, 30, 50, 60), &[0.0]);
l.mask = Some(LayerMask { surface, enabled: true, linked, density: 0.6, feather: 2.0 });
let mut vector = VectorMask::new(Path::new(vec![Subpath::polygon(&[(20.0, 20.0), (65.0, 20.0), (65.0, 80.0), (20.0, 80.0)])]));
vector.linked = linked;
vector.density = 0.7;
vector.feather = 1.0;
l.vector_mask = Some(vector);
Ok(())
})
.unwrap();
let before = s.active().unwrap().doc.clone();
let preview = preview_shape_quad_transform(&before, id, FRAME, QUAD, "nearest").unwrap();
s.execute("edit.transform", json!({"rect":FRAME,"quad":QUAD,"interpolation":"nearest"})).unwrap();
assert_eq!(s.active().unwrap().doc.as_ref(), &preview);
let original = before.layer(id).unwrap();
let actual = s.active().unwrap().doc.layer(id).unwrap();
let old_mask = original.mask.as_ref().unwrap();
let mask = actual.mask.as_ref().unwrap();
assert_eq!((mask.enabled, mask.linked, mask.density, mask.feather), (old_mask.enabled, linked, old_mask.density, old_mask.feather));
assert_eq!(mask.surface.format(), old_mask.surface.format());
assert_eq!(mask.surface.default_pixel(), old_mask.surface.default_pixel());
let old_vector = original.vector_mask.as_ref().unwrap();
let vector = actual.vector_mask.as_ref().unwrap();
assert_eq!((vector.enabled, vector.linked, vector.density, vector.feather), (old_vector.enabled, linked, old_vector.density, old_vector.feather));
if linked {
let h = Homography::rect_to_quad(FRAME, QUAD).unwrap();
let q = h.apply(40.5, 45.5);
assert!(mask.surface.sample_channel(q.0.floor() as i32, q.1.floor() as i32, 0) < 0.01);
for (a, b) in old_vector.path.subpaths[0].knots.iter().zip(&vector.path.subpaths[0].knots) {
let q = h.apply(a.anchor.x, a.anchor.y);
assert!((b.anchor.x - q.0).hypot(b.anchor.y - q.1) < 1e-10);
}
assert_ne!(vector.path, old_vector.path);
} else {
assert_eq!(mask, old_mask);
assert_eq!(vector, old_vector);
}
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
}
}
}
fn unchanged_after_error(s: &mut Session, params: serde_json::Value) -> String {
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let history = s.active().unwrap().history.past_len();
let error = s.execute("edit.transform", params).unwrap_err().to_string();
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.active().unwrap().history.past_len(), history);
error
}
#[test]
fn projection_horizon_precision_budget_and_locks_fail_atomically() {
let (mut empty, id) = session(8, "rect");
empty
.edit("empty path", |doc, _| {
let LayerContent::Shape(shape) = &mut doc.layer_mut(id).unwrap().content else { panic!() };
shape.path = Path::default();
shape.live = None;
shape.cache = None;
Ok(())
})
.unwrap();
let original = empty.active().unwrap().doc.clone();
let mut nonfinite = QUAD;
nonfinite[0][0] = f64::NAN;
assert!(preview_shape_quad_transform(&original, id, FRAME, nonfinite, "bicubic").is_err());
assert_eq!(empty.active().unwrap().doc, original);
assert!(
unchanged_after_error(&mut empty, json!({"rect":FRAME,"quad":[[0,0],[1e308,0],[9e307,1e308],[0,1e308]]})).contains("finite"),
"empty paths still reject nonfinite homographies produced by extreme JSON coordinates"
);
let (mut s, _) = session(8, "ellipse");
for quad in [
json!([[20, 20], [100, 80], [100, 20], [20, 80]]),
json!([[0, 0], [1e100, 0], [9e99, 1e100], [0, 1e100]]),
json!([[0, 0], [600000, 0], [500000, 600000], [0, 600000]]),
] {
unchanged_after_error(&mut s, json!({"rect":FRAME,"quad":quad}));
}
s.execute("layer.setProps", json!({"locks":{"position":true}})).unwrap();
assert!(unchanged_after_error(&mut s, json!({"rect":FRAME,"quad":QUAD})).contains("locked"));
s.execute("layer.setProps", json!({"locks":{"position":false}})).unwrap();
s.edit("many curves", |doc, active| {
let LayerContent::Shape(sh) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!() };
sh.path.subpaths = vec![sh.path.subpaths[0].clone(); 128];
Ok(())
})
.unwrap();
assert!(
unchanged_after_error(&mut s, json!({"rect":FRAME,"quad":[[20,20],[1500,20],[1400,1200],[20,1100]]})).contains("4096"),
"reject projected knot growth before raster allocation"
);
let (mut s, id) = session(8, "rect");
s.edit("sparse mask", |doc, _| {
let mut surface = Surface::new(PixelFormat::GRAY8);
surface.write_pixel(0, 0, &[1.0]);
surface.write_pixel(100_000, 100_000, &[1.0]);
doc.layer_mut(id).unwrap().mask = Some(LayerMask { surface, enabled: true, linked: true, density: 1.0, feather: 0.0 });
Ok(())
})
.unwrap();
assert!(unchanged_after_error(&mut s, json!({"rect":FRAME,"quad":QUAD})).contains("mask source"));
s.edit("small mask", |doc, _| {
let mut surface = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
surface.fill_rect(Rect::new(30, 30, 50, 60), &[0.0]);
doc.layer_mut(id).unwrap().mask.as_mut().unwrap().surface = surface;
Ok(())
})
.unwrap();
assert!(
unchanged_after_error(&mut s, json!({"rect":FRAME,"quad":[[20,20],[40000,20],[35000,35000],[20,35000]]})).contains("projected mask"),
"reject a huge warped mask before allocating destination pixels"
);
s.edit("mask across horizon", |doc, _| {
let mut surface = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
surface.write_pixel(0, 0, &[0.0]);
surface.write_pixel(200, 200, &[0.0]);
doc.layer_mut(id).unwrap().mask.as_mut().unwrap().surface = surface;
Ok(())
})
.unwrap();
assert!(
unchanged_after_error(&mut s, json!({"rect":FRAME,"quad":[[20,20],[100,20],[140,80],[-20,80]]})).contains("horizon"),
"a valid shape quad must still reject a linked mask that crosses its horizon"
);
}
#[test]
fn warp_that_exceeds_editable_shape_knot_budget_rejects_atomically() {
use photocraft_geom::warp::{Warp, WarpStyle};
for depth in [8, 16, 32] {
let (mut s, id) = session(depth, "rect");
// Coincident joined outlines keep one inexpensive component, but exactly
// reach the supported input limit. Arc Warp must add curved-edge anchors.
let outline = json!({"closed":true,"op":"join","knots":[[20,20],[100,20],[100,80],[20,80]]});
let mut subpaths = vec![outline; 1024];
subpaths[0]["op"] = json!("combine");
s.execute("shape.edit", json!({"layer":id.0,"path":{"subpaths":subpaths}})).unwrap();
let source = shape(&s, id);
let original_path = source.path.clone();
assert_eq!(source.path.subpaths.iter().map(|subpath| subpath.knots.len()).sum::<usize>(), 4096);
photocraft_vector::flatten::validate_shape(&source.path, source.stroke.as_ref()).unwrap();
let warp = Warp::preset(WarpStyle::Arc, 50.0, FRAME);
let mapped = photocraft_vector::map::map_path(
&source.path,
|point| {
let (x, y) = warp.map(point.x, point.y);
Point::new(x, y)
},
0.1,
);
assert!(mapped.subpaths.iter().map(|subpath| subpath.knots.len()).sum::<usize>() > 4096, "fixture must exceed the editable output budget");
let before = s.active().unwrap().doc.clone();
let revision = s.active().unwrap().revision;
let history = s.active().unwrap().history.past_len();
let before_pixels = photocraft_compose::render(&before, before.bounds());
let error = s.execute("edit.transform.warp", json!({"layer":id.0,"warp":warp})).unwrap_err().to_string();
assert!(error.contains("4096"), "depth {depth}: {error}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.active().unwrap().history.past_len(), history);
assert_eq!(photocraft_compose::render(&s.active().unwrap().doc, before.bounds()), before_pixels);
// Rejection leaves a shape ordinary Properties editing can still use.
s.execute("shape.edit", json!({"layer":id.0,"fill":"#fa22cc"})).unwrap();
assert_eq!(shape(&s, id).path, original_path);
}
}
#[test]
#[ignore = "release measurement; 36MP full-resolution projective shape preview, no GPU or presentation"]
fn shape_distort_preview_36mp_bench() {
use std::time::Instant;
let mut s = Session::new();
s.execute("file.new", json!({"width":7200,"height":5000,"background":"transparent"})).unwrap();
let id = LayerId(s.execute("shape.create", json!({"rect":[800,400,4000,4000],"fill":"#fdca22"})).unwrap()["layer"].as_u64().unwrap());
let source = s.active().unwrap().doc.clone();
let frame = [800.0, 400.0, 4800.0, 4400.0];
for (n, quad) in
[[[820.0, 400.0], [4810.0, 440.0], [4790.0, 4450.0], [760.0, 4380.0]], [[880.0, 380.0], [4860.0, 470.0], [4760.0, 4490.0], [710.0, 4330.0]]]
.into_iter()
.enumerate()
{
let started = Instant::now();
let preview = preview_shape_quad_transform(&source, id, frame, quad, "bicubic").unwrap();
let preview_ms = started.elapsed().as_secs_f64() * 1000.0;
let LayerContent::Shape(shape) = &preview.layer(id).unwrap().content else { panic!() };
assert!(shape.live.is_none());
assert_eq!(shape.path.subpaths[0].knots.len(), 4);
assert!((shape.path.subpaths[0].knots[0].anchor.x - quad[0][0]).abs() < 1e-9);
assert!((shape.path.subpaths[0].knots[0].anchor.y - quad[0][1]).abs() < 1e-9);
assert!(shape.cache.as_ref().unwrap().tiles().next().is_some());
let started = Instant::now();
let composite = photocraft_compose::render(&preview, preview.bounds());
let compose_ms = started.elapsed().as_secs_f64() * 1000.0;
std::hint::black_box(composite);
println!(
"shape_distort36mp changed_frame={n} project_and_raster_ms={preview_ms:.3} cpu_compose_36mp_ms={compose_ms:.3}; GPU/upload/presentation excluded"
);
assert_eq!(s.active().unwrap().doc, source);
}
}
+54
View File
@@ -0,0 +1,54 @@
use photocraft_engine::Session;
use serde_json::json;
#[test]
fn star_indents_and_arrowhead_concavity_edit_and_undo_at_every_depth() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":96,"height":64,"depth":depth,"background":"transparent"})).unwrap();
for (params, edit, probe, expected) in [
(json!({"kind":"star","rect":[10,10,50,40],"sides":5,"starRatio":0.4}), json!({"smoothIndents":true}), "/live/smooth_indents", json!(true)),
(
json!({"kind":"line","from":[10,30],"to":[70,30],"weight":3,"arrowheads":{"start":true,"end":true,"width":12,"length":10}}),
json!({"arrowheads":{"start":true,"end":true,"width":12,"length":10,"concavity":35}}),
"/live/arrowheads/concavity",
json!(35.0),
),
] {
s.execute("shape.create", params).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let info = s.execute("shape.edit", edit).unwrap();
assert_eq!(info.pointer(probe), Some(&expected));
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let after = s.active().unwrap().doc.clone();
assert_ne!(before, after);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
s.execute("shape.edit", json!({"transform":[2,0,0,1,0,0]})).unwrap();
assert_eq!(s.execute("shape.info", json!({})).unwrap().pointer(probe), Some(&expected));
}
}
}
#[test]
fn invalid_geometry_options_fail_without_document_or_history_changes() {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48})).unwrap();
s.execute("shape.create", json!({"kind":"star","rect":[5,5,30,30]})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
for params in [json!({"smoothIndents":"true"}), json!({"smoothIndents":null}), json!({"smoothIndents":1})] {
assert!(s.execute("shape.edit", params.clone()).is_err(), "{params}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
for concavity in [json!(-50.1), json!(50.1), json!(1e100), json!("0"), json!(null)] {
let params = json!({"kind":"line","from":[0,20],"to":[60,20],"arrowheads":{"end":true,"width":10,"length":20,"concavity":concavity}});
assert!(s.execute("shape.create", params.clone()).is_err(), "{params}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
+150
View File
@@ -0,0 +1,150 @@
use photocraft_doc::{LayerContent, LayerId, LiveShape, Path};
use photocraft_engine::Session;
use photocraft_geom::Affine;
use serde_json::json;
fn shape(session: &Session, id: LayerId) -> &photocraft_doc::ShapeLayer {
match &session.active().unwrap().doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => sh,
_ => panic!("expected shape"),
}
}
fn same_outline(a: &Path, b: &Path) {
assert_eq!(a.subpaths.len(), b.subpaths.len());
for (a, b) in a.subpaths.iter().zip(&b.subpaths) {
assert_eq!(a.knots.len(), b.knots.len());
// Reflections reverse the two sides of a symmetric live arrow's contour.
for knot in &a.knots {
assert!(
b.knots.iter().any(|q| (knot.anchor.x - q.anchor.x).abs() < 1e-8 && (knot.anchor.y - q.anchor.y).abs() < 1e-8),
"missing transformed knot {:?}",
knot.anchor
);
}
}
}
#[test]
fn live_line_dimensions_follow_scale_and_remain_stable_on_next_edit() {
for depth in [8, 16, 32] {
for (from, to, m, length_scale, width_scale) in [
([20.0, 30.0], [60.0, 30.0], [2.0, 0.0, 0.0, 2.0, 10.0, 10.0], 2.0, 2.0),
([20.0, 30.0], [60.0, 50.0], [2.0, 0.0, 0.0, 2.0, 10.0, 10.0], 2.0, 2.0),
([20.0, 30.0], [60.0, 30.0], [2.0, 0.0, 0.0, 3.0, 10.0, 10.0], 2.0, 3.0),
([20.0, 20.0], [20.0, 60.0], [3.0, 0.0, 0.0, 2.0, 10.0, 10.0], 2.0, 3.0),
([20.0, 30.0], [60.0, 50.0], [-2.0, 0.0, 0.0, 2.0, 200.0, 10.0], 2.0, 2.0),
([20.0, 30.0], [60.0, 30.0], [-2.0, 0.0, 0.0, 3.0, 200.0, 10.0], 2.0, 3.0),
([20.0, 20.0], [20.0, 60.0], [0.0, 3.0, -2.0, 0.0, 200.0, 10.0], 2.0, 3.0),
] {
let mut s = Session::new();
s.execute("file.new", json!({"width":256,"height":256,"depth":depth,"background":"transparent"})).unwrap();
let id=LayerId(s.execute("shape.create",json!({"kind":"line","from":from,"to":to,"weight":4,"fill":"#fdca22","arrowheads":{"start":true,"end":true,"width":12,"length":10,"concavity":35}})).unwrap()["layer"].as_u64().unwrap());
let original = shape(&s, id).path.clone();
s.execute("shape.edit", json!({"layer":id.0,"transform":m})).unwrap();
let before = shape(&s, id).clone();
let LiveShape::Line { from: transformed_from, to: transformed_to, weight, arrowheads } = before.live.as_ref().unwrap() else {
panic!("line remains editable")
};
assert_eq!(*weight, 4.0 * width_scale);
let heads = arrowheads.as_ref().unwrap();
assert_eq!(heads.width, 12.0 * width_scale);
assert_eq!(heads.length, 10.0 * length_scale);
assert_eq!(heads.concavity, 35.0);
assert!(heads.start && heads.end);
let pt = |p: [f64; 2]| [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]];
assert_eq!(*transformed_from, pt(from));
assert_eq!(*transformed_to, pt(to));
assert_eq!(before.path, original.transform(&Affine { m }));
same_outline(&before.path, &photocraft_vector::shapes::live_path(before.live.as_ref().unwrap()));
s.execute(
"shape.edit",
json!({"layer":id.0,"arrowheads":{"start":true,"end":true,"width":heads.width,"length":heads.length,"concavity":heads.concavity}}),
)
.unwrap();
let after = shape(&s, id);
same_outline(&before.path, &after.path);
let a = before.cache.as_ref().unwrap();
let b = after.cache.as_ref().unwrap();
assert_eq!(a.content_bounds(), b.content_bounds(), "no bounds shrink: depth {depth}, matrix {m:?}");
assert_eq!(a.format(), b.format());
let area = a.content_bounds();
let pixels_a = a.read_region(area);
let pixels_b = b.read_region(area);
for (i, (a, b)) in pixels_a.as_chunks::<4>().0.iter().zip(pixels_b.as_chunks::<4>().0).enumerate() {
// Tiny coverage can quantize to zero alpha while retaining straight RGB.
// Compare all visible channels and alpha, as the compositor consumes them.
for channel in 0..4 {
let va = if channel == 3 { a[3] } else { a[channel] * a[3] };
let vb = if channel == 3 { b[3] } else { b[channel] * b[3] };
assert!((va - vb).abs() < 1e-5, "no visible pixel change: depth {depth}, matrix {m:?}, pixel {i}, channel {channel}: {a:?} vs {b:?}");
}
}
// A real concavity edit changes only the head notch and remains one undo step.
let prior = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("shape.edit", json!({"layer":id.0,"arrowheads":{"start":true,"end":true,"width":heads.width,"length":heads.length,"concavity":-20}}))
.unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let edited = shape(&s, id);
let LiveShape::Line { weight, arrowheads, .. } = edited.live.as_ref().unwrap() else { panic!("line remains live") };
assert_eq!(*weight, 4.0 * width_scale);
assert_eq!(arrowheads.as_ref().unwrap().width, heads.width);
assert_eq!(arrowheads.as_ref().unwrap().length, heads.length);
let changed = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, prior);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, changed);
}
}
}
#[test]
fn anisotropic_diagonal_lines_keep_exact_path_without_inaccurate_live_metadata() {
let live = LiveShape::Line {
from: [20.0, 30.0],
to: [60.0, 50.0],
weight: 4.0,
arrowheads: Some(photocraft_doc::vector::LineArrowheads { start: true, end: true, width: 12.0, length: 10.0, concavity: 35.0 }),
};
let path = photocraft_vector::shapes::live_path(&live);
for m in [[2.0, 0.0, 0.0, 3.0, 10.0, 10.0], [-2.0, 0.0, 0.0, 3.0, 200.0, 10.0], [0.0, 3.0, -2.0, 0.0, 200.0, 10.0], [0.0, 0.0, 0.0, 2.0, 0.0, 0.0]] {
let mut sh = photocraft_doc::ShapeLayer { path: path.clone(), live: Some(live.clone()), ..Default::default() };
photocraft_engine::vector_cmds::transform_shape(&mut sh, &Affine { m });
assert!(sh.live.is_none(), "unrepresentable transform {m:?}");
assert_eq!(sh.path, path.transform(&Affine { m }));
}
}
#[test]
fn quarter_turn_of_zero_length_stroked_line_keeps_path_and_rejects_live_geometry_edit() {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":128,"height":128,"depth":depth,"background":"transparent"})).unwrap();
let id = LayerId(
s.execute("shape.create", json!({"kind":"line","from":[40,40],"to":[40,40],"weight":4,"fill":"#fdca22","stroke":{"width":2,"fill":"#000000"}}))
.unwrap()["layer"]
.as_u64()
.unwrap(),
);
let original = shape(&s, id).path.clone();
let m = [0.0, 1.0, -1.0, 0.0, 120.0, 10.0];
s.execute("shape.edit", json!({"layer":id.0,"transform":m})).unwrap();
let transformed = shape(&s, id);
assert!(transformed.live.is_none());
assert_eq!(transformed.path, original.transform(&Affine { m }));
let cache = transformed.cache.as_ref().unwrap();
assert!(
cache.read_region(cache.content_bounds()).chunks_exact(cache.format().channels()).any(|p| p.last().copied().unwrap() > 0.5),
"degenerate line stroke remains visible at depth {depth}"
);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
for edit in [json!({"weight":4}), json!({"arrowheads":{"end":true,"width":12,"length":10}})] {
assert!(s.execute("shape.edit", edit).is_err(), "requires an explicit kind or path after orientation is lost");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use photocraft_doc::{ColorMode, LayerContent, LayerMask, PixelFormat, SampleType};
use photocraft_engine::Session;
use photocraft_engine::transform_cmds::{PreparedShapeMask, preview_shape_transform, preview_shape_transform_geometry_clipped};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::json;
#[test]
fn prepared_shape_mask_matches_exact_preview_formats_defaults_and_downscales() {
for (depth, sample) in [(8, SampleType::U8), (16, SampleType::U16), (32, SampleType::F32)] {
for default in [0.0, 0.35, 1.0] {
let mut session = Session::new();
session.execute("file.new", json!({"width":128,"height":128,"depth":depth})).unwrap();
session.execute("shape.create", json!({"kind":"rect","rect":[8,8,64,64],"fill":"#ff0000"})).unwrap();
let id = session.active().unwrap().active_layer.unwrap();
session
.edit("mask", |doc, _| {
let mut surface = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[default]);
surface.fill_rect(Rect::new(12, 16, 52, 56), &[0.2]);
surface.fill_rect(Rect::new(28, 24, 40, 44), &[0.8]);
doc.layer_mut(id).unwrap().mask = Some(LayerMask { surface, enabled: true, linked: true, density: 0.47, feather: 5.5 });
Ok(())
})
.unwrap();
let source = session.active().unwrap().doc.clone();
let mask = source.layer(id).unwrap().mask.as_ref().unwrap();
let prepared = PreparedShapeMask::new(&mask.surface).unwrap();
assert!(preview_shape_transform_geometry_clipped(&source, id, Affine::IDENTITY, "bicubic", Rect::new(0, 0, 2049, 2049), Some(&prepared)).is_err());
let clip = Rect::new(0, 0, 96, 96);
for interpolation in ["nearest", "bilinear", "bicubic"] {
for affine in [
Affine { m: [1.2, 0.3, -0.3, 1.2, 7.0, -5.0] },
Affine { m: [0.125, 0.0, 0.0, 0.125, 24.0, 30.0] },
Affine { m: [0.125, 0.0, 0.0, 0.125, 30.0, 32.0] },
] {
let exact = preview_shape_transform(&source, id, affine, interpolation).unwrap();
let shown = preview_shape_transform_geometry_clipped(&source, id, affine, interpolation, clip, Some(&prepared)).unwrap();
let actual = shown.layer(id).unwrap().mask.as_ref().unwrap();
let expected = exact.layer(id).unwrap().mask.as_ref().unwrap();
assert_eq!(actual.surface.format(), mask.surface.format());
assert_eq!((actual.enabled, actual.linked, actual.density, actual.feather), (mask.enabled, mask.linked, mask.density, mask.feather));
assert_eq!(actual.surface.default_pixel(), mask.surface.default_pixel());
assert_eq!(
actual.surface.read_region(clip),
expected.surface.read_region(clip),
"depth {depth}, default {default}, {interpolation}, {affine:?}"
);
assert!(clip.contains_rect(&actual.surface.content_bounds()));
assert!(actual.surface.tiles().count() <= clip.tiles().count());
assert!(matches!(&shown.layer(id).unwrap().content,LayerContent::Shape(shape) if shape.cache.is_none()));
}
}
}
}
}
#[test]
fn prepared_mask_source_is_immutable_and_capped() {
let mut mask = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::U16, false), &[1.0]);
mask.fill_rect(Rect::new(8, 8, 12, 12), &[0.0]);
let prepared = PreparedShapeMask::new(&mask).unwrap();
mask.fill_rect(Rect::new(8, 8, 12, 12), &[1.0]);
let clip = Rect::new(0, 0, 64, 64);
let affine = Affine { m: [10_000.0, 0.0, 0.0, 10_000.0, -99_968.0, -99_968.0] };
let output = prepared.warp_clipped(affine, "bicubic", clip).unwrap();
assert_eq!(output.pixel(32, 32), vec![0.0]);
assert_eq!(output.default_pixel(), vec![1.0]);
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
let mut sparse = Surface::new(PixelFormat::GRAY8);
sparse.write_pixel(0, 0, &[1.0]);
sparse.write_pixel(10_000, 10_000, &[1.0]);
assert!(PreparedShapeMask::new(&sparse).is_err(), "reject before reading an enormous sparse bounding box");
assert!(PreparedShapeMask::new(&Surface::new(PixelFormat::RGBA8)).is_err());
let rejected = prepared.warp_clipped(Affine::IDENTITY, "bicubic", Rect::new(0, 0, 2049, 2049)).unwrap();
assert!(rejected.tiles().next().is_none());
assert_eq!(rejected.default_pixel(), vec![1.0]);
}
@@ -0,0 +1,152 @@
//! Incoming compound geometry is one Boolean operand for Shape and Path modes.
use photocraft_doc::{LayerContent, LayerId, Path};
use photocraft_engine::{Session, vector_cmds::path_json};
use photocraft_geom::Rect;
use serde_json::{Value, json};
fn square(x: i32, y: i32, w: i32, h: i32) -> Value {
json!({"closed":true,"knots":[[x,y],[x+w,y],[x+w,y+h],[x,y+h]]})
}
fn session(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
s
}
fn incoming(kind: &str) -> Value {
match kind {
"ring" => {
let mut hole = square(24, 20, 16, 8);
hole["op"] = json!("subtract");
json!({"subpaths":[square(12,12,40,24),hole]})
}
"evenoddRing" => {
let mut hole = square(24, 20, 16, 8);
hole["op"] = json!("join");
json!({"fillRule":"evenodd","subpaths":[square(12,12,40,24),hole]})
}
"disconnected" => json!({"subpaths":[square(12,12,8,24),square(44,12,8,24)]}),
_ => panic!("test operand kind"),
}
}
fn setup(s: &mut Session, target: &str) -> Option<LayerId> {
let path = json!({"subpaths":[square(4,16,24,16)]});
if target == "shape" {
Some(LayerId(s.execute("shape.create", json!({"kind":"path","path":path})).unwrap()["layer"].as_u64().unwrap()))
} else {
s.execute("path.set", json!({"name":target,"path":path})).unwrap();
None
}
}
fn apply(s: &mut Session, target: &str, id: Option<LayerId>, path: Value, op: Value) -> photocraft_engine::Result<Value> {
if let Some(id) = id {
s.execute("shape.create", json!({"kind":"path","path":path,"op":op,"addTo":id.0}))
} else {
s.execute("path.set", json!({"name":target,"path":path,"op":op}))
}
}
fn result_path(s: &Session, target: &str, id: Option<LayerId>) -> Path {
let doc = &s.active().unwrap().doc;
if let Some(id) = id {
let LayerContent::Shape(shape) = &doc.layer(id).unwrap().content else { panic!("shape") };
shape.path.clone()
} else if target == "work" {
doc.work_path.clone().unwrap()
} else {
doc.paths.iter().find(|p| p.name == target).unwrap().path.clone()
}
}
#[test]
fn rings_and_disconnected_components_are_single_operands_for_all_operations() {
let samples = [(8, true, false), (16, true, true), (26, true, false), (48, false, true), (34, false, false)];
for depth in [8, 16, 32] {
for target in ["shape", "work", "Saved operand path"] {
for kind in ["ring", "evenoddRing", "disconnected"] {
for op in ["combine", "subtract", "intersect", "exclude"] {
let mut s = session(depth);
let id = setup(&mut s, target);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
apply(&mut s, target, id, incoming(kind), json!(op)).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let path = result_path(&s, target, id);
for (x, a, b) in samples {
let expected = match op {
"combine" => a || b,
"subtract" => a && !b,
"intersect" => a && b,
"exclude" => a != b,
_ => panic!(),
};
let coverage = photocraft_vector::path_coverage(&path, Rect::new(x, 24, x + 1, 25))[0];
assert_eq!(
coverage > 0.9,
expected,
"depth={depth}, target={target}, kind={kind}, op={op}, x={x}, coverage={coverage}, path={}",
path_json(&path)
);
}
let after = s.active().unwrap().doc.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
}
}
#[test]
fn empty_operands_and_empty_accumulators_follow_boolean_semantics() {
for target in ["shape", "work"] {
for op in ["combine", "subtract", "intersect", "exclude"] {
for empty_base in [false, true] {
let mut s = session(8);
let id = setup(&mut s, target);
let empty = json!({"subpaths":[]});
if empty_base {
if let Some(id) = id {
s.execute("shape.edit", json!({"layer":id.0,"path":empty})).unwrap();
} else {
s.execute("path.set", json!({"name":target,"path":empty})).unwrap();
}
}
let path = if empty_base { incoming("ring") } else { empty };
apply(&mut s, target, id, path, json!(op)).unwrap();
let path = result_path(&s, target, id);
let coverage = photocraft_vector::path_coverage(&path, Rect::new(16, 24, 17, 25))[0];
let expected = if empty_base { matches!(op, "combine" | "exclude") } else { op != "intersect" };
assert_eq!(coverage > 0.9, expected, "target={target}, op={op}, empty_base={empty_base}");
}
}
}
}
#[test]
fn invalid_or_excessive_compound_operands_fail_atomically() {
for target in ["shape", "work", "Saved operand path"] {
let mut s = session(8);
let id = setup(&mut s, target);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let mut huge_hole = square(100, 100, 1000, 1000);
huge_hole["op"] = json!("subtract");
for (path, op) in [
(incoming("ring"), json!("unknown")),
(incoming("ring"), json!(1)),
(json!({"inverted":true,"subpaths":[square(12,12,40,24)]}), json!("combine")),
(json!({"subpaths":[square(0,0,1200,1200),huge_hole]}), json!("combine")),
(json!({"subpaths":[{"knots":vec![json!([0,0]);4097]}]}), json!("combine")),
] {
assert!(apply(&mut s, target, id, path, op).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Shape paint JSON preserves model colours and all gradient/pattern placement fields.
use photocraft_color::{Color, ColorMode};
use photocraft_doc::{Fill, GradientStyle, LayerContent, LayerId, ShapeLayer, ShapeStroke};
use photocraft_engine::{
Session,
vector_cmds::{shape_fill, shape_paint_params},
};
use serde_json::json;
fn session(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
s
}
fn active_shape(s: &Session) -> &ShapeLayer {
let st = s.active().unwrap();
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape layer") };
shape
}
#[test]
fn exact_paints_roundtrip_through_create_edit_and_undo() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let pattern = s.patterns.items[0].clone();
let mut fills = vec![
Fill::Solid(Color::rgba(0.1234567, 0.3737, 0.919191, 0.4737)),
Fill::Solid(Color { mode: ColorMode::Grayscale, c: [0.314159, 0.0, 0.0, 0.0], alpha: 0.3737 }),
Fill::Solid(Color { mode: ColorMode::Cmyk, c: [0.17, 0.28, 0.39, 0.41], alpha: 0.73 }),
Fill::Solid(Color { mode: ColorMode::Lab, c: [57.3, -14.8, 23.9, 0.0], alpha: 0.63 }),
Fill::Pattern { name: "Stored pattern alias".into(), id: pattern.id, scale: 0.3737, angle: 27.3, link: false, phase: (-7.3, 4.1) },
];
for style in [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond] {
fills.push(Fill::Gradient {
stops: vec![(0.1737, Color::rgba(0.12345, 0.34567, 0.56789, 0.61)), (0.9137, Color::rgba(0.9191, 0.8181, 0.7171, 0.82))],
angle: 37.3,
scale: 0.3737,
style,
reverse: true,
opacity_stops: vec![(0.07, 0.23), (0.91, 0.79)],
midpoints: vec![0.27],
offset: (-0.13, 0.21),
dither: true,
align: false,
});
}
for fill in fills {
let stroke = ShapeStroke { paint: fill.clone(), opacity: 0.3737, width: 2.3, dashes: vec![2.1, 1.3], ..Default::default() };
let before_parse = s.active().unwrap().doc.clone();
let paint = shape_paint_params(Some(&fill), Some(&stroke));
assert_eq!(shape_fill(&s, &paint["fill"]).unwrap(), Some(fill.clone()));
assert_eq!(s.active().unwrap().doc, before_parse, "view-only paint validation edited the document");
let mut create = paint.clone();
create["rect"] = json!([8, 8, 40, 24]);
s.execute("shape.create", create).unwrap();
assert_eq!(active_shape(&s).fill, Some(fill.clone()));
assert_eq!(active_shape(&s).stroke, Some(stroke.clone()));
s.execute("shape.edit", json!({"fill":"#0000ff","stroke":null})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("shape.edit", paint).unwrap();
assert_eq!(active_shape(&s).fill, Some(fill));
assert_eq!(active_shape(&s).stroke, Some(stroke));
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let after = s.active().unwrap().doc.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
#[test]
fn solid_alpha_and_library_patterns_render_and_embed_in_the_same_history_step() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let library = s.patterns.items.clone();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let id = LayerId(s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":[0.17,0.31,0.43,0.3737]})).unwrap()["layer"].as_u64().unwrap());
let alpha: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap();
assert!((alpha[3] - 0.3737).abs() < 0.005, "depth={depth}: {alpha:?}");
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert!(s.active().unwrap().doc.layer(id).is_some());
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("shape.edit", json!({"fill":{"pattern":library[0].name},"stroke":{"width":4,"fill":{"pattern":library[1].id}}})).unwrap();
assert_eq!(s.active().unwrap().doc.patterns.len(), 2);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let pixel: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap();
assert!(pixel[3] > 0.99 && pixel[0] > 0.1, "library pattern did not render: {pixel:?}");
let stroke_pixel: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":7,"y":20})).unwrap()).unwrap();
assert!(stroke_pixel[3] > 0.99, "library pattern stroke did not render: {stroke_pixel:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
assert!(s.active().unwrap().doc.patterns.is_empty());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.patterns.len(), 2);
// A freshly created patterned shape carries its own tile too.
let mut fresh = session(depth);
fresh.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":library[0].id}})).unwrap();
assert_eq!(fresh.active().unwrap().doc.patterns.len(), 1);
let pixel: [f32; 4] = serde_json::from_value(fresh.execute("document.pixel", json!({"x":24,"y":20})).unwrap()).unwrap();
assert!(pixel[3] > 0.99);
fresh.execute("edit.undo", json!({})).unwrap();
assert!(fresh.active().unwrap().doc.patterns.is_empty());
}
}
#[test]
fn imported_document_pattern_resolves_without_a_global_library_entry() {
let mut s = session(16);
let imported = s.patterns.items[0].clone();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(imported.clone());
s.patterns.items.clear();
let fill = Fill::Pattern { name: "Imported name preserved".into(), id: imported.id, scale: 0.3737, angle: -17.3, link: false, phase: (3.1, -2.7) };
let paint = shape_paint_params(Some(&fill), None);
let before = s.active().unwrap().doc.clone();
assert_eq!(shape_fill(&s, &paint["fill"]).unwrap(), Some(fill.clone()));
assert_eq!(s.active().unwrap().doc, before);
let mut create = paint;
create["rect"] = json!([8, 8, 40, 24]);
s.execute("shape.create", create).unwrap();
assert_eq!(active_shape(&s).fill, Some(fill));
assert_eq!(s.active().unwrap().doc.patterns.len(), 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn legacy_defaults_and_malformed_paint_inputs_are_explicit_and_atomic() {
let mut s = session(8);
assert_eq!(
shape_fill(&s, &json!({"gradient":{}})).unwrap(),
Some(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 90.0, 1.0, GradientStyle::Linear, false))
);
assert_eq!(shape_fill(&s, &json!("#ff000080")).unwrap(), Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 128.0 / 255.0))));
s.execute("shape.create", json!({"rect":[8,8,40,24]})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
for fill in [
json!([0, "bad", 1, 0.5]),
json!([0, 0, 0, 2]),
json!([0, 0, 0, 1, 1]),
json!({"gradient":null}),
json!({"gradient":{"stops":[[0,"#000000"],["bad","#ffffff"]]}}),
json!({"gradient":{"stops":[[2,"#000000"]]}}),
json!({"gradient":{"opacityStops":[[0,2]]}}),
json!({"gradient":{"midpoints":[-0.1]}}),
json!({"gradient":{"offset":[0]}}),
json!({"gradient":{"scale":0}}),
json!({"gradient":{"scale":1e100}}),
json!({"gradient":{"align":"false"}}),
json!({"gradient":{"dither":null}}),
json!({"gradient":{"style":"unknown"}}),
json!({"pattern":1}),
json!({"pattern":"missing pattern"}),
json!({"pattern":"Checkerboard","link":0}),
json!({"pattern":"Checkerboard","phase":[0,1e100]}),
json!({"pattern":"Checkerboard","id":1}),
json!({"color":{"mode":"Rgb","c":[0,0,0,0],"alpha":-1}}),
json!({"gradient":{"stops":vec![json!([0,"#000000"]);4097]}}),
] {
assert!(shape_fill(&s, &fill).is_err(), "{fill}");
assert!(s.execute("shape.edit", json!({"fill":fill.clone()})).is_err(), "{fill}");
assert!(s.execute("shape.create", json!({"rect":[0,0,5,5],"stroke":{"fill":fill}})).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
@@ -0,0 +1,222 @@
//! Vector style clipboards retain the pattern tile after its source document closes.
use photocraft_doc::{Color, Fill, LayerContent, Pattern, PixelFormat, ShapeLayer, ShapeStroke};
use photocraft_engine::{Session, vector_cmds::shape_fill};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn imported_pattern() -> Pattern {
let mut surface = Surface::new(PixelFormat::RGBA16);
surface.fill_rect(Rect::new(0, 0, 4, 4), &[0.13, 0.27, 0.91, 1.0]);
Pattern::new("$$$/Patterns/Test/Blue=Imported Blue", surface, 4, 4)
}
fn new_doc(s: &mut Session, depth: u32) {
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
}
fn shape(s: &Session) -> &ShapeLayer {
let st = s.active().unwrap();
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape") };
sh
}
#[test]
fn patterned_fill_and_stroke_clipboards_keep_cow_tiles_after_source_closes() {
for depth in [8, 16, 32] {
for imported in [false, true] {
let mut s = Session::new();
new_doc(&mut s, depth);
let pattern = if imported {
let p = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone());
s.patterns.items.clear();
p
} else {
s.patterns.items[0].clone()
};
let paint = json!({"pattern":pattern.name,"id":pattern.id,"scale":100,"angle":0,"link":false,"phase":[0,0]});
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":paint,"stroke":{"width":4,"fill":paint}})).unwrap();
let copied_fill = shape(&s).fill.clone();
let copied_stroke = shape(&s).stroke.clone();
let fill_pixel = shape(&s).cache.as_ref().unwrap().rgba(24, 20);
let stroke_pixel = shape(&s).cache.as_ref().unwrap().rgba(7, 20);
let original = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("path.style.copyFill", json!({})).unwrap();
s.execute("path.style.copyStroke", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, original);
assert_eq!(s.active().unwrap().history.past_len(), history);
// Editing the source tile after Copy must not change the copied COW snapshot.
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns[0].surface.fill_rect(pattern.rect(), &[1.0, 0.0, 0.0, 1.0]);
s.patterns.items.clear();
assert!(s.close(0).is_some());
for (paste, sample, expected) in [("path.style.pasteFill", (24, 20), fill_pixel), ("path.style.pasteStroke", (7, 20), stroke_pixel)] {
new_doc(&mut s, depth);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
assert!(s.active().unwrap().doc.patterns.is_empty());
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute(paste, json!({})).unwrap();
let after = s.active().unwrap().doc.clone();
assert_eq!(after.patterns, vec![pattern.clone()]);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
if paste == "path.style.pasteFill" {
assert_eq!(shape(&s).fill, copied_fill);
} else {
assert_eq!(shape(&s).stroke, copied_stroke);
}
let pixel = shape(&s).cache.as_ref().unwrap().rgba(sample.0, sample.1);
assert!(pixel[3] > 0.99, "{paste}: copied pattern remained transparent");
assert!(
pixel.iter().zip(expected).all(|(a, b)| (*a - b).abs() < 0.005),
"{paste}, imported={imported}, depth={depth}: {pixel:?} != {expected:?}"
);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
assert!(s.active().unwrap().doc.patterns.is_empty());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
}
#[test]
fn replacing_public_clipboards_with_plain_paints_ignores_old_pattern_snapshots() {
let mut s = Session::new();
new_doc(&mut s, 8);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":"Checkerboard"},"stroke":{"width":4,"fill":{"pattern":"Checkerboard"}}})).unwrap();
s.execute("path.style.copyFill", json!({})).unwrap();
s.execute("path.style.copyStroke", json!({})).unwrap();
new_doc(&mut s, 8);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
let fill = Fill::Solid(Color::rgba(0.13, 0.27, 0.91, 0.73));
let stroke = ShapeStroke { paint: fill.clone(), width: 4.0, ..Default::default() };
s.path_fill_clipboard = Some(fill.clone());
s.path_stroke_clipboard = Some(stroke.clone());
s.execute("path.style.pasteFill", json!({})).unwrap();
s.execute("path.style.pasteStroke", json!({})).unwrap();
assert_eq!(shape(&s).fill, Some(fill));
assert_eq!(shape(&s).stroke, Some(stroke));
assert!(s.active().unwrap().doc.patterns.is_empty());
}
#[test]
fn legacy_pattern_clipboard_without_a_snapshot_resolves_the_library() {
let mut s = Session::new();
let p = s.patterns.items[0].clone();
for old_id in ["", "obsolete-library-id"] {
new_doc(&mut s, 16);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
s.path_fill_clipboard = Some(Fill::Pattern { name: p.name.clone(), id: old_id.into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) });
s.execute("path.style.pasteFill", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.patterns, vec![p.clone()]);
assert_eq!(shape(&s).fill, Some(Fill::Pattern { name: p.name.clone(), id: p.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }));
assert!(shape(&s).cache.as_ref().unwrap().rgba(24, 20)[3] > 0.99);
}
}
#[test]
fn copied_name_only_and_obsolete_ids_bind_to_the_snapshot_despite_destination_conflicts() {
for old_id in ["", "obsolete-imported-id"] {
for stroke in [false, true] {
let mut s = Session::new();
new_doc(&mut s, 16);
let pattern = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(pattern.clone());
let paint = json!({"pattern":pattern.name,"id":pattern.id,"scale":37.37,"angle":17.3,"link":false,"phase":[-2.7,3.1]});
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":paint,"stroke":{"width":4,"fill":paint}})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
let LayerContent::Shape(source) = &mut doc.layer_mut(id).unwrap().content else { panic!("source shape") };
if let Some(Fill::Pattern { id, .. }) = &mut source.fill {
*id = old_id.into();
}
if let Some(ShapeStroke { paint: Fill::Pattern { id, .. }, .. }) = &mut source.stroke {
*id = old_id.into();
}
let mut expected_fill = shape(&s).fill.clone().unwrap();
let mut expected_stroke = shape(&s).stroke.clone().unwrap();
if let Fill::Pattern { id, .. } = &mut expected_fill {
*id = pattern.id.clone();
}
if let Fill::Pattern { id, .. } = &mut expected_stroke.paint {
*id = pattern.id.clone();
}
let original = s.active().unwrap().doc.clone();
s.execute(if stroke { "path.style.copyStroke" } else { "path.style.copyFill" }, json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, original, "normalization changes the clipboard, not imported source metadata");
if stroke {
assert_eq!(s.path_stroke_clipboard, Some(expected_stroke.clone()));
} else {
assert_eq!(s.path_fill_clipboard, Some(expected_fill.clone()));
}
s.patterns.items.clear();
new_doc(&mut s, 16);
let mut conflicting = pattern.clone();
conflicting.id = if old_id.is_empty() { "destination-pattern-id".into() } else { old_id.into() };
conflicting.surface.fill_rect(pattern.rect(), &[1.0, 0.0, 0.0, 1.0]);
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(conflicting.clone());
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute(if stroke { "path.style.pasteStroke" } else { "path.style.pasteFill" }, json!({})).unwrap();
let after = s.active().unwrap().doc.clone();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
assert_eq!(after.patterns, vec![conflicting, pattern.clone()]);
if stroke {
assert_eq!(shape(&s).stroke, Some(expected_stroke));
} else {
assert_eq!(shape(&s).fill, Some(expected_fill));
}
let pixel = shape(&s).cache.as_ref().unwrap().rgba(if stroke { 7 } else { 24 }, 20);
assert!(
pixel[2] > 0.85 && pixel[0] < 0.2 && pixel[3] > 0.99,
"stroke={stroke}, old_id={old_id:?}: destination pattern replaced copied blue: {pixel:?}"
);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
#[test]
fn unresolved_and_conflicting_pattern_clipboards_fail_atomically() {
let mut s = Session::new();
new_doc(&mut s, 16);
let p = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone());
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":{"pattern":p.name,"id":p.id}})).unwrap();
s.execute("path.style.copyFill", json!({})).unwrap();
new_doc(&mut s, 16);
s.execute("shape.create", json!({"rect":[8,8,40,24],"fill":null})).unwrap();
let mut conflicting = p.clone();
conflicting.surface.fill_rect(p.rect(), &[1.0, 0.0, 0.0, 1.0]);
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(conflicting);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let error = s.execute("path.style.pasteFill", json!({})).unwrap_err();
assert!(error.to_string().contains("different pattern"));
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
s.path_fill_clipboard = Some(Fill::Pattern { name: "Missing".into(), id: "missing".into(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) });
assert!(s.execute("path.style.pasteFill", json!({})).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
#[test]
fn pattern_parser_falls_back_to_imported_display_names_and_rejects_conflicting_ids() {
let mut s = Session::new();
new_doc(&mut s, 16);
let p = imported_pattern();
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).patterns.push(p.clone());
let before = s.active().unwrap().doc.clone();
let fill = shape_fill(&s, &json!({"pattern":"Imported Blue","id":"obsolete-id","angle":17,"phase":[3,4]})).unwrap().unwrap();
assert_eq!(fill, Fill::Pattern { name: "Imported Blue".into(), id: p.id.clone(), scale: 1.0, angle: 17.0, link: true, phase: (3.0, 4.0) });
assert!(shape_fill(&s, &json!({"pattern":s.patterns.items[0].name,"id":p.id})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
+74
View File
@@ -0,0 +1,74 @@
use photocraft_doc::{LayerContent, LayerId, Pattern, PixelFormat};
use photocraft_engine::Session;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::json;
fn library_pattern() -> Pattern {
let mut surface = Surface::new(PixelFormat::RGBA16);
surface.fill_rect(Rect::new(0, 0, 2, 2), &[0.1, 0.2, 0.9, 1.0]);
surface.fill_rect(Rect::new(1, 0, 2, 2), &[0.9, 0.7, 0.1, 1.0]);
Pattern::new("Shape tile", surface, 2, 2)
}
#[test]
fn patterned_shapes_embed_render_and_undo_in_one_command_at_every_depth() {
for depth in [8, 16, 32] {
let mut session = Session::new();
session.execute("file.new", json!({"width":32,"height":32,"depth":depth})).unwrap();
let p = library_pattern();
session.patterns.items.push(p.clone());
let history = session.active().unwrap().history.past_len();
let f = json!({"pattern":p.name,"id":p.id,"scale":100,"angle":0,"link":true});
let id = LayerId(
session.execute("shape.create", json!({"kind":"rect","rect":[8,8,16,16],"fill":f,"stroke":{"width":4,"fill":f}})).unwrap()["layer"]
.as_u64()
.unwrap(),
);
let doc = session.active().unwrap().doc.clone();
assert_eq!(session.active().unwrap().history.past_len(), history + 1);
assert_eq!(doc.patterns, vec![p.clone()]);
let surface = doc.layer(id).unwrap().surface().unwrap();
let q = surface.rgba(10, 10);
assert!(q[2] > 0.85 && q[0] < 0.15 && q[3] > 0.99, "pattern fill is visible: {q:?}");
let q = surface.rgba(6, 12);
assert!(q[2] > 0.85 && q[3] > 0.99, "pattern stroke is visible: {q:?}");
assert!(session.undo());
assert!(session.active().unwrap().doc.patterns.is_empty());
assert!(session.active().unwrap().doc.layer(id).is_none());
assert!(session.redo());
assert_eq!(session.active().unwrap().doc, doc);
let saved = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
let native = photocraft_format::load_from_bytes(&saved).unwrap();
assert_eq!(&native, doc.as_ref());
let bytes = photocraft_io::export(&doc, "psd", &Default::default()).unwrap().bytes;
let mut imported = photocraft_io::import("shape-pattern.psd", &bytes).unwrap().document;
assert_eq!(imported.patterns.len(), 1);
let imported_id = imported.layers.iter().find(|l| matches!(l.content, LayerContent::Shape(_))).unwrap().id;
// Prove editable paint survives, rather than relying on the PSD's cached pixels.
let source = imported.clone();
if let LayerContent::Shape(sh) = &mut imported.layer_mut(imported_id).unwrap().content {
photocraft_engine::vector_cmds::refresh_shape(&source, sh);
}
let q = imported.layer(imported_id).unwrap().surface().unwrap().rgba(10, 10);
assert!(q[2] > 0.8 && q[0] < 0.2 && q[3] > 0.99, "PSD pattern remains renderable after refresh: {q:?}");
}
}
#[test]
fn editing_a_solid_shape_to_pattern_embeds_and_undoes_the_tile() {
let mut session = Session::new();
session.execute("file.new", json!({"width":32,"height":32})).unwrap();
let p = library_pattern();
session.patterns.items.push(p.clone());
let id = session.execute("shape.create", json!({"kind":"rect","rect":[8,8,16,16],"fill":"#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let original = session.active().unwrap().doc.clone();
session.execute("shape.edit", json!({"layer":id,"fill":{"pattern":p.name,"id":p.id}})).unwrap();
assert_eq!(session.active().unwrap().doc.patterns, vec![p]);
let edited = session.active().unwrap().doc.clone();
assert!(session.undo());
assert_eq!(session.active().unwrap().doc, original);
assert!(session.redo());
assert_eq!(session.active().unwrap().doc, edited);
}
+264
View File
@@ -0,0 +1,264 @@
use photocraft_doc::{LayerContent, LayerId, LiveShape};
use photocraft_engine::Session;
use serde_json::json;
fn session(depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
s
}
fn rgba(s: &Session, id: LayerId, x: i32, y: i32) -> [f32; 4] {
let surface = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
let fmt = surface.format();
photocraft_raster::to_rgba(&fmt, &surface.read_region(photocraft_geom::Rect::new(x, y, x + 1, y + 1)))
}
#[test]
fn pixel_shapes_respect_selection_opacity_blend_and_undo_at_every_depth() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let id = s.active().unwrap().active_layer.unwrap();
let original = s.active().unwrap().doc.clone();
s.execute("select.rect", json!({"x":10,"y":5,"width":12,"height":20})).unwrap();
let history = s.active().unwrap().history.past_len();
s.execute("shape.drawPixels", json!({"rect":[5,5,25,20],"color":"#ff0000","opacity":50})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
assert!(rgba(&s, id, 15, 10)[0] > 0.99);
assert!((rgba(&s, id, 15, 10)[3] - 0.5).abs() < 0.005);
assert_eq!(rgba(&s, id, 6, 10)[3], 0.0);
let painted = s.active().unwrap().doc.clone();
s.execute("shape.drawPixels", json!({"rect":[5,5,25,20],"color":"#00ff00","mode":"multiply"})).unwrap();
assert!(rgba(&s, id, 15, 10)[0] < 0.01);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, painted);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(rgba(&s, id, 15, 10)[3], 0.0);
assert_eq!(s.active().unwrap().doc.layers.len(), original.layers.len());
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, painted);
}
}
#[test]
fn pixel_antialias_and_transparency_lock_are_observable() {
let mut s = session(32);
let id = s.active().unwrap().active_layer.unwrap();
s.execute("shape.drawPixels", json!({"kind":"ellipse","rect":[5.2,5.4,20.3,20.6],"antiAlias":true})).unwrap();
let surface = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
assert!(surface.read_region(photocraft_geom::Rect::new(0, 0, 32, 32)).chunks_exact(surface.format().channels()).any(|p| p[3] > 0.0 && p[3] < 1.0));
s.execute("edit.undo", json!({})).unwrap();
s.execute("shape.drawPixels", json!({"kind":"ellipse","rect":[5.2,5.4,20.3,20.6],"antiAlias":false})).unwrap();
let surface = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap();
assert!(surface.read_region(photocraft_geom::Rect::new(0, 0, 32, 32)).chunks_exact(surface.format().channels()).all(|p| p[3] == 0.0 || p[3] == 1.0));
s.execute("layer.setProps", json!({"locks":{"transparency":true}})).unwrap();
s.execute("shape.drawPixels", json!({"rect":[0,0,40,40],"color":"#ff0000"})).unwrap();
assert_eq!(rgba(&s, id, 1, 1)[3], 0.0);
}
#[test]
fn pixel_mask_target_and_bad_params_preserve_document_on_failure() {
let mut s = session(16);
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("shape.drawPixels", json!({"rect":[3,3,20,20],"target":"mask","color":"#000000"})).unwrap();
let layer = s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap();
assert!(layer.mask.as_ref().unwrap().surface.sample_channel(10, 10, 0) < 0.01);
s.execute("layer.setProps", json!({"locks":{"all":true}})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
for params in [
json!({}),
json!({"rect":[0,0,10,10],"target":"mask"}),
json!({"rect":[0,0,10,10],"opacity":101}),
json!({"rect":[0,0,10,10],"mode":"unknown"}),
json!({"rect":[0,0,10,10],"antiAlias":"true"}),
json!({"rect":[1e100,0,10,10]}),
json!({"kind":"path","path":null}),
] {
assert!(s.execute("shape.drawPixels", params.clone()).is_err(), "{params}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
fn compound(s: &mut Session) {
s.execute(
"path.set",
json!({"path":{"subpaths":[
{"closed":true,"knots":[[5,5],[15,5],[15,15],[5,15]]},
{"closed":true,"op":"subtract","knots":[[20,10],[30,10],[30,20],[20,20]]},
{"closed":true,"knots":[[40,25],[50,25],[50,35],[40,35]]}
]}}),
)
.unwrap();
}
#[test]
fn align_distribute_arrange_work_saved_and_explicit_layer_targets() {
let mut s = session(8);
compound(&mut s);
let original = s.active().unwrap().doc.work_path.clone().unwrap();
s.execute("path.align", json!({"action":"top"})).unwrap();
assert!(s.active().unwrap().doc.work_path.as_ref().unwrap().subpaths.iter().all(|sp| sp.knots[0].anchor.y == 5.0));
s.execute("edit.undo", json!({})).unwrap();
s.execute("path.align", json!({"action":"distributeX"})).unwrap();
assert_eq!(s.active().unwrap().doc.work_path.as_ref().unwrap().subpaths[1].knots[0].anchor.x, 22.5);
s.execute("edit.undo", json!({})).unwrap();
s.execute("path.arrange", json!({"components":[1],"action":"front"})).unwrap();
let path = s.active().unwrap().doc.work_path.as_ref().unwrap();
assert_eq!(path.subpaths[2], original.subpaths[1]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&original));
s.execute("path.set", json!({"name":"saved","path":photocraft_engine::vector_cmds::path_json(&original)})).unwrap();
s.execute("path.align", json!({"name":"saved","action":"right","to":"canvas","components":[0]})).unwrap();
assert_eq!(s.active().unwrap().doc.paths[0].path.subpaths[0].knots[1].anchor.x, 64.0);
let first =
s.execute("shape.create", json!({"kind":"path","path":photocraft_engine::vector_cmds::path_json(&original)})).unwrap()["layer"].as_u64().unwrap();
let other = s.execute("shape.create", json!({"rect":[2,2,10,10]})).unwrap()["layer"].as_u64().unwrap();
s.execute("path.align", json!({"name":"layer","layer":first,"action":"bottom","to":"canvas","components":[2]})).unwrap();
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(LayerId(first)).unwrap().content else { panic!() };
assert_eq!(sh.path.subpaths[2].knots[2].anchor.y, 48.0);
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(LayerId(other)).unwrap().content else { panic!() };
assert!(sh.live.is_some());
}
#[test]
fn component_actions_reject_invalid_indices_locks_and_insufficient_distribution() {
let mut s = session(8);
compound(&mut s);
let before = s.active().unwrap().doc.clone();
for (cmd, p) in [
("path.align", json!({"action":"bad"})),
("path.align", json!({"action":"top","components":[999]})),
("path.align", json!({"action":"distributeX","components":[0,1]})),
("path.arrange", json!({"action":"front","components":[]})),
("path.arrange", json!({"action":"bad"})),
("path.arrange", json!({"action":"front","components":[1e100]})),
] {
assert!(s.execute(cmd, p).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
let id = s.execute("shape.create", json!({"rect":[4,4,10,10]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"locks":{"position":true}})).unwrap();
assert!(s.execute("path.align", json!({"name":"layer","layer":id,"action":"top","to":"canvas"})).is_err());
}
#[test]
fn aligning_a_whole_live_shape_keeps_geometry_editable_and_undoable() {
let mut s = session(8);
for params in [
json!({"kind":"roundedRect","rect":[4,5,20,15],"radii":3}),
json!({"kind":"polygon","rect":[4,5,20,15],"sides":5,"cornerRadius":2}),
json!({"kind":"line","from":[4,5],"to":[24,20],"weight":2,"arrowheads":{"end":true,"width":8,"length":6}}),
] {
let id = s.execute("shape.create", params).unwrap()["layer"].as_u64().unwrap();
// Imported paths may have slightly different precision from their live metadata.
let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc);
let LayerContent::Shape(sh) = &mut doc.layer_mut(LayerId(id)).unwrap().content else { panic!() };
sh.path = sh.path.transform(&photocraft_geom::Affine::translate(0.001, 0.003));
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute("path.align", json!({"name":"layer","layer":id,"action":"right","to":"canvas"})).unwrap();
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content else { panic!() };
assert!(sh.live.is_some());
assert!((sh.path.control_bounds().unwrap().2 - 64.0).abs() < 1e-8);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
let patch = match s.execute("shape.info", json!({"layer":id})).unwrap()["live"]["kind"].as_str().unwrap() {
"rect" => json!({"layer":id,"radii":5}),
"polygon" => json!({"layer":id,"cornerRadius":5}),
_ => json!({"layer":id,"weight":5}),
};
s.execute("shape.edit", patch).unwrap();
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content else { panic!() };
match &sh.live {
Some(LiveShape::Rect { radii, .. }) => assert_eq!(*radii, [5.0; 4]),
Some(LiveShape::Polygon { corner_radius, .. }) => assert_eq!(*corner_radius, 5.0),
Some(LiveShape::Line { weight, .. }) => assert_eq!(*weight, 5.0),
_ => panic!("aligned shape lost its live parameters"),
}
}
}
#[test]
fn merging_path_components_preserves_coverage_and_is_one_undo_step() {
let mut s = session(8);
compound(&mut s);
let path = s.active().unwrap().doc.work_path.clone().unwrap();
let history = s.active().unwrap().history.past_len();
s.execute("path.merge", json!({})).unwrap();
let merged = s.active().unwrap().doc.work_path.clone().unwrap();
let area = photocraft_geom::Rect::new(0, 0, 64, 48);
let before = photocraft_vector::fill_rasterizer(&path, 0.01).render(area);
let after = photocraft_vector::fill_rasterizer(&merged, 0.01).render(area);
assert!(before.iter().zip(after).map(|(a, b)| (a - b).abs()).sum::<f32>() < 3.0);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&path));
assert!(s.execute("path.merge", json!({"tolerance":1e100})).is_err());
}
#[test]
fn rounded_polygons_and_arrowheads_keep_live_properties_and_undo() {
for depth in [8, 16, 32] {
let mut s = session(depth);
let id =
s.execute("shape.create", json!({"kind":"star","sides":5,"rect":[5,5,40,30],"cornerRadius":2,"starRatio":0.5})).unwrap()["layer"].as_u64().unwrap();
s.execute("shape.edit", json!({"layer":id,"sides":6})).unwrap();
let info = s.execute("shape.info", json!({"layer":id})).unwrap();
assert_eq!(info["live"]["star_ratio"], 0.5);
assert_eq!(info["live"]["corner_radius"], 2.0);
s.execute("shape.edit", json!({"layer":id,"cornerRadius":0})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(s.execute("shape.edit", json!({"layer":id,"cornerRadius":-1})).is_err());
let id = s
.execute("shape.create", json!({"kind":"line","from":[5,20],"to":[55,20],"weight":2,"arrowheads":{"start":true,"end":true,"width":10,"length":12}}))
.unwrap()["layer"]
.as_u64()
.unwrap();
let LayerContent::Shape(sh) = &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content else { panic!() };
assert_eq!(sh.path.subpaths.len(), 1);
assert_eq!(sh.path.subpaths[0].knots.len(), 10);
assert!(matches!(sh.live, Some(LiveShape::Line { arrowheads: Some(_), .. })));
s.execute("shape.edit", json!({"layer":id,"weight":4})).unwrap();
assert!(s.execute("shape.edit", json!({"layer":id,"arrowheads":{"width":1e100}})).is_err());
}
}
#[test]
fn pixels_draw_in_grayscale_without_format_assumptions() {
let mut s = session(16);
s.execute("image.mode.grayscale", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("shape.drawPixels", json!({"rect":[4,4,12,12],"color":"#ff0000"})).unwrap();
let p = rgba(&s, id, 8, 8);
assert!((p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6 && p[3] > 0.99);
}
#[test]
#[ignore = "release performance measurement on a 36 MP canvas"]
fn pixel_shape_36mp() {
let mut s = Session::new();
s.execute("file.new", json!({"width":7200,"height":5000,"background":"transparent"})).unwrap();
let start = std::time::Instant::now();
s.execute("shape.drawPixels", json!({"rect":[0,0,7200,5000],"color":"#ff0000"})).unwrap();
eprintln!("36 MP pixel shape: {:?}", start.elapsed());
let id = s.active().unwrap().active_layer.unwrap();
assert!(rgba(&s, id, 7199, 4999)[0] > 0.99);
}
#[test]
fn huge_pixel_shape_rejects_before_allocation_without_changing_document_or_history() {
let mut s = Session::new();
s.execute("file.new", json!({"width":300000,"height":300000,"depth":32,"background":"transparent"})).unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let err = s.execute("shape.drawPixels", json!({"rect":[0,0,300000,300000]})).unwrap_err();
assert!(err.to_string().contains("drawing memory limit"));
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
// Large sparse canvases still support small local shapes.
s.execute("shape.drawPixels", json!({"rect":[2,2,8,8],"color":"#ff0000"})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
assert!(rgba(&s, id, 4, 4)[0] > 0.99);
}
+168
View File
@@ -0,0 +1,168 @@
//! Behavioral regressions from the expanded Shape-tools tracking checklist (#2496).
use photocraft_engine::Session;
use serde_json::json;
fn shape(s: &mut Session, rect: [i32; 4], color: &str) -> u64 {
s.execute("shape.create", json!({"rect":rect,"fill":color})).unwrap()["layer"].as_u64().unwrap()
}
fn select_pair(s: &mut Session, back: u64, front: u64) {
s.execute("layer.select", json!({"layer":back})).unwrap();
s.execute("layer.select", json!({"layer":front,"mode":"toggle"})).unwrap();
}
#[test]
fn combine_selected_shape_layers_is_atomic_and_preserves_front_style() {
for (command, samples) in [
("layer.combineShapes.unite", [true, true, true]),
("layer.combineShapes.subtractFrontShape", [true, false, false]),
("layer.combineShapes.intersectShapeAreas", [false, true, false]),
("layer.combineShapes.excludeOverlappingShapes", [true, false, true]),
] {
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
let back = shape(&mut s, [4, 4, 20, 20], "#ff0000");
let front = shape(&mut s, [14, 4, 20, 20], "#0000ff");
select_pair(&mut s, back, front);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
s.execute(command, json!({})).unwrap();
let st = s.active().unwrap();
assert!(st.doc.layer(photocraft_doc::LayerId(back)).is_none(), "{command}: selected layers merge");
assert!(matches!(st.doc.layer(photocraft_doc::LayerId(front)).unwrap().content, photocraft_doc::LayerContent::Shape(_)));
assert_eq!(st.selected_layers(), [photocraft_doc::LayerId(front)]);
assert_eq!(st.history.past_len(), history + 1);
for (x, filled) in [8, 18, 28].into_iter().zip(samples) {
let p = s.execute("document.pixel", json!({"x":x,"y":10})).unwrap();
let rgba: [f64; 4] = serde_json::from_value(p.clone()).unwrap();
assert_eq!(rgba[3] > 0.9, filled, "{command}, depth {depth}, x={x}: {p}");
if filled {
assert!(rgba[2] > 0.99 && rgba[0] < 0.01, "frontmost fill retained: {p}");
}
}
let after = s.active().unwrap().doc.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
}
}
}
#[test]
fn intersection_treats_a_compound_front_layer_as_one_operand() {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"background":"transparent"})).unwrap();
let back = shape(&mut s, [4, 4, 56, 20], "#ff0000");
let front = shape(&mut s, [14, 4, 10, 20], "#0000ff");
s.execute("shape.create", json!({"rect":[40,4,10,20],"addTo":front,"op":"combine"})).unwrap();
select_pair(&mut s, back, front);
s.execute("layer.combineShapes.intersectShapeAreas", json!({})).unwrap();
for (x, covered) in [(8, false), (18, true), (30, false), (45, true), (55, false)] {
let p: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":x,"y":10})).unwrap()).unwrap();
assert_eq!(p[3] > 0.9, covered, "x={x}: {p:?}");
}
}
#[test]
fn combining_layers_handles_empty_operands() {
for (command, empty_back, covered) in [
("layer.combineShapes.unite", true, true),
("layer.combineShapes.subtractFrontShape", true, false),
("layer.combineShapes.intersectShapeAreas", true, false),
("layer.combineShapes.intersectShapeAreas", false, false),
("layer.combineShapes.subtractFrontShape", false, true),
("layer.combineShapes.excludeOverlappingShapes", true, true),
] {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"background":"transparent"})).unwrap();
let back = shape(&mut s, [4, 4, 20, 20], "#ff0000");
let front = shape(&mut s, [4, 4, 20, 20], "#0000ff");
let empty = if empty_back { back } else { front };
s.execute("shape.edit", json!({"layer":empty,"path":{"subpaths":[]}})).unwrap();
select_pair(&mut s, back, front);
s.execute(command, json!({})).unwrap();
let p: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":10,"y":10})).unwrap()).unwrap();
assert_eq!(p[3] > 0.9, covered, "{command}, empty_back={empty_back}: {p:?}");
}
}
#[test]
fn combining_locked_shape_layers_fails_without_changes() {
for lock in ["all", "pixels", "position"] {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"background":"transparent"})).unwrap();
let back = shape(&mut s, [4, 4, 20, 20], "#ff0000");
let front = shape(&mut s, [14, 4, 20, 20], "#0000ff");
s.execute("layer.setProps", json!({"layer":back,"locks":{lock:true}})).unwrap();
select_pair(&mut s, back, front);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
assert!(s.execute("layer.combineShapes.unite", json!({})).is_err(), "{lock}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
}
#[test]
fn mixed_selection_with_one_shape_uses_single_layer_components_and_preserves_raster() {
use photocraft_doc::{LayerContent, LayerId, PathOp};
for depth in [8, 16, 32] {
let mut s = Session::new();
s.execute("file.new", json!({"width":64,"height":48,"depth":depth,"background":"transparent"})).unwrap();
let raster = s.active().unwrap().active_layer.unwrap();
let front = shape(&mut s, [4, 4, 20, 20], "#0000ff");
s.execute("shape.create", json!({"rect":[14,4,10,20],"addTo":front,"op":"subtract"})).unwrap();
select_pair(&mut s, raster.0, front);
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let original_layer = before.layer(LayerId(front)).unwrap();
let LayerContent::Shape(original) = &original_layer.content else { panic!("shape") };
assert_eq!(original.path.subpaths.len(), 2);
assert_eq!(original.path.subpaths[1].op, PathOp::Subtract);
let mut expected_path = original.path.clone();
expected_path.subpaths[1].op = PathOp::Combine;
let cutout: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":20,"y":10})).unwrap()).unwrap();
assert!(cutout[3] < 0.01, "depth {depth}: the subtracting component starts transparent: {cutout:?}");
assert_eq!(s.execute("layer.combineShapes.unite", json!({})).unwrap()["layer"], front);
let after = s.active().unwrap().doc.clone();
assert_eq!(after.layer(raster), before.layer(raster), "depth {depth}: Raster is left untouched");
assert_eq!(after.walk().len(), before.walk().len());
let edited_layer = after.layer(LayerId(front)).unwrap();
let LayerContent::Shape(edited) = &edited_layer.content else { panic!("shape") };
assert_eq!(edited.path, expected_path, "only the component operation changes");
assert_eq!(edited.fill, original.fill);
assert_eq!(edited.stroke, original.stroke);
assert_eq!(edited_layer.name, original_layer.name);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
let pixel: [f32; 4] = serde_json::from_value(s.execute("document.pixel", json!({"x":20,"y":10})).unwrap()).unwrap();
assert!(pixel[3] > 0.99 && pixel[2] > 0.99 && pixel[0] < 0.01, "depth {depth}: Unite restores blue coverage: {pixel:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc, after);
s.execute("edit.undo", json!({})).unwrap();
s.execute("layer.combineShapes.unite", json!({"layer":front})).unwrap();
assert_eq!(s.active().unwrap().doc, after, "mixed implicit selection matches an explicit single-layer operation");
}
}
#[test]
fn curved_shape_bounds_do_not_include_coverage_residue() {
for depth in [8, 16, 32] {
for (rect, bounds) in [
([0.0, 0.0, 100.0, 100.0], [0, 0, 100, 100]),
([660.0, 240.0, 600.0, 600.0], [660, 240, 600, 600]),
([10.5, 10.0, 100.0, 100.0], [10, 10, 101, 100]),
] {
let mut s = Session::new();
s.execute("file.new", json!({"width":1400,"height":900,"depth":depth,"background":"transparent"})).unwrap();
let result = s.execute("shape.create", json!({"kind":"ellipse","rect":rect,"fill":"#0000ff"})).unwrap();
assert_eq!(result["bounds"], json!(bounds), "depth {depth}, ellipse {rect:?}");
}
}
}
+22
View File
@@ -11,6 +11,28 @@ use photocraft_raster::Rgba8Image;
const MODES: [ColorMode; 4] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab];
#[test]
fn rounded_polygon_and_line_arrowheads_roundtrip() {
for mode in MODES {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = rich_doc(mode, depth);
for live in [
photocraft_doc::LiveShape::Polygon { rect: [5.0, 5.0, 40.0, 30.0], sides: 3, star_ratio: 1.0, corner_radius: 7.0, smooth_indents: false },
photocraft_doc::LiveShape::Polygon { rect: [5.0, 5.0, 40.0, 30.0], sides: 5, star_ratio: 0.4, corner_radius: 7.0, smooth_indents: true },
photocraft_doc::LiveShape::Line {
from: [5.0, 20.0],
to: [50.0, 20.0],
weight: 3.0,
arrowheads: Some(photocraft_doc::vector::LineArrowheads { start: true, end: true, width: 12.0, length: 20.0, concavity: 32.5 }),
},
] {
d.layers.push(Layer::new("Live shape", LayerContent::Shape(photocraft_doc::ShapeLayer { live: Some(live), ..Default::default() })));
}
assert_eq!(load_from_bytes(&save_to_bytes(&d, &SaveOptions::default()).unwrap()).unwrap(), d);
}
}
}
#[test]
fn custom_shape_stroke_roundtrip_keeps_every_style_field() {
for mode in MODES {
+5 -2
View File
@@ -720,7 +720,8 @@ impl<'a> Planner<'a> {
if let LayerContent::Shape(sh) = &layer.content
&& !visible_clipped.is_empty()
&& let Some((fill, stroke)) = photocraft_compose::shape_split::split(sh, self.cx.canvas, self.cx.depth)
&& let Some((fill, stroke)) =
photocraft_compose::shape_split::split_with_patterns(sh, self.cx.canvas, self.cx.depth, self.cx.patterns, self.cx.mode)
{
// The vector stroke goes above the clipped layers: the fill is the clipping base,
// the stroke is laid over the clipped result, then the masks apply to both
@@ -997,7 +998,9 @@ impl<'a> Planner<'a> {
// A stroked shape's vector stroke goes above its clipped layers and interior effects
// (compose::split_parts): the fill and the stroke unmasked, the masks applied after.
let split = match &layer.content {
LayerContent::Shape(sh) if sh.stroke.is_some() => photocraft_compose::shape_split::split(sh, canvas, self.cx.depth),
LayerContent::Shape(sh) if sh.stroke.is_some() => {
photocraft_compose::shape_split::split_with_patterns(sh, canvas, self.cx.depth, self.cx.patterns, self.cx.mode)
}
_ => None,
};
let (mut content, vstroke) = match split {
+34
View File
@@ -1232,6 +1232,40 @@ fn checker_pattern() -> Pattern {
Pattern::new("checker", s, 6, 5)
}
#[test]
fn patterned_shape_fill_and_stroke_clip_and_effects_match_cpu() {
let Some(mut g) = gpu() else { return };
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk] {
for effects in [false, true] {
let mut d = Document::new("pattern shape", Size::new(90, 70), mode, depth);
let p = checker_pattern();
let fill = Fill::Pattern { id: p.id.clone(), name: p.name.clone(), scale: 1.3, angle: 23.0, link: true, phase: (2.0, -3.0) };
d.patterns.push(p);
let mut sh = photocraft_doc::ShapeLayer {
path: photocraft_vector::shapes::rounded_rect(12.0, 12.0, 60.0, 40.0, [4.0; 4]),
fill: Some(fill.clone()),
stroke: Some(photocraft_doc::ShapeStroke { width: 4.0, paint: fill, align: photocraft_doc::StrokeAlign::Outside, ..Default::default() }),
..Default::default()
};
sh.cache = Some(photocraft_vector::render_shape_with_patterns(&sh, d.pixel_format(), d.bounds(), &d.patterns));
assert!(sh.cache.as_ref().unwrap().rgba(30, 30)[3] > 0.5, "pattern fill must be visible before parity");
assert!(sh.cache.as_ref().unwrap().rgba(9, 30)[3] > 0.5, "pattern stroke must be visible before parity");
let mut layer = Layer::new("pattern", LayerContent::Shape(sh));
if effects {
layer.mask = Some(mask(d.bounds(), 7, 0.6));
layer.effects.items.push(Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 120.0, 4.0, 3.0, 0.0)));
}
d.layers.push(layer);
let mut clipped = noise_layer("clipped", d.pixel_format(), Rect::new(28, 0, 50, 70), 9, 0.5);
clipped.clipped = true;
d.layers.push(clipped);
fx_check(&mut g, &d, &format!("pattern shape {depth:?}/{mode:?}, effects {effects}"));
}
}
}
}
/// Every effect kind with several option combinations, one effect stack per case.
fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
let pat = checker_pattern();
+2
View File
@@ -39,6 +39,8 @@ blake3 = { version = "1.8", default-features = false, features = ["pure"] }
flate2 = { workspace = true }
# ora.rs reads OpenRaster's stack.xml (already in the tree through usvg).
roxmltree = "0.20"
# Reuse usvg's locked header reader to bound embedded SVG raster data before decoding.
imagesize = "0.13"
# SVG import (svg.rs): usvg parses, resvg rasterises what the shape model can't hold. Both are
# already in the tree through the UI's icon loader; text shaping needs the fonts feature set.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+447 -14
View File
@@ -10,7 +10,7 @@
//! stops and direction in the layer's own gradient fill, with a warning that the exact geometry
//! is not kept.
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use photocraft_color::blend::BlendMode;
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
@@ -80,6 +80,7 @@ fn options(fontdb: Arc<usvg::fontdb::Database>) -> usvg::Options<'static> {
}
/// A parsed drawing.
#[derive(Debug)]
struct Parsed {
tree: usvg::Tree,
/// The drawing has text but no font at all was available to shape it. `usvg` then drops the
@@ -110,18 +111,260 @@ fn parse_with(bytes: &[u8], fonts: fn() -> Arc<usvg::fontdb::Database>) -> Resul
Ok(Parsed { tree, fontless_text })
}
const MAX_BOUNDED_SVG_BYTES: u64 = 16 << 20;
const MAX_EMBEDDED_SVG_DEPTH: usize = 32;
struct ImageBudget {
pixels: u64,
xml_bytes: u64,
svg_images: u64,
fontless_text: bool,
error: Option<String>,
}
fn image_error(budget: &Mutex<ImageBudget>, error: impl Into<String>) {
let mut budget = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if budget.error.is_none() {
budget.error = Some(error.into());
}
}
fn charge_image(budget: &Mutex<ImageBudget>, width: usize, height: usize) -> bool {
let pixels = u64::try_from(width).ok().zip(u64::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h));
let mut budget = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
match pixels.filter(|pixels| *pixels != 0).and_then(|pixels| budget.pixels.checked_sub(pixels)) {
Some(remaining) => {
budget.pixels = remaining;
true
}
None => {
budget.error.get_or_insert_with(|| "embedded SVG raster images exceed the decoded pixel budget".into());
false
}
}
}
// A GIF's first frame can exceed its logical screen. resvg allocates the frame's RGBA buffer,
// so inspect its descriptor too, without reading any LZW pixels.
fn gif_frame_size(bytes: &[u8]) -> Option<(usize, usize)> {
let packed = *bytes.get(10)?;
let table = if packed & 0x80 != 0 { 3usize.checked_mul(1usize.checked_shl(u32::from((packed & 7) + 1))?)? } else { 0 };
let mut at = 13usize.checked_add(table)?;
loop {
match *bytes.get(at)? {
0x2c => {
let word = |offset: usize| -> Option<usize> {
let pair: [u8; 2] = bytes.get(at.checked_add(offset)?..at.checked_add(offset)?.checked_add(2)?)?.try_into().ok()?;
Some(usize::from(u16::from_le_bytes(pair)))
};
return Some((word(5)?, word(7)?));
}
0x21 => {
at = at.checked_add(2)?;
loop {
let length = usize::from(*bytes.get(at)?);
at = at.checked_add(1)?;
if length == 0 {
break;
}
bytes.get(at..at.checked_add(length)?)?;
at = at.checked_add(length)?;
}
}
_ => return None,
}
}
}
// VP8X declares a container canvas, while a malformed lossy VP8 chunk may declare a larger
// frame which the downstream decoder allocates before comparing it with that canvas.
fn webp_frame_sizes(chunks: &[u8], dimensions: &mut (usize, usize), nested: bool) -> Option<()> {
let mut at = 0usize;
while at < chunks.len() {
let header = chunks.get(at..at.checked_add(8)?)?;
let length = usize::try_from(u32::from_le_bytes(header.get(4..8)?.try_into().ok()?)).ok()?;
let from = at.checked_add(8)?;
let end = from.checked_add(length)?;
let payload = chunks.get(from..end)?;
match header.get(..4)? {
b"VP8 " | b"VP8L" => {
let mut probe = b"RIFF\0\0\0\0WEBP".to_vec();
probe.extend_from_slice(header);
probe.extend_from_slice(payload.get(..payload.len().min(10))?);
let size = imagesize::blob_size(&probe).ok()?;
dimensions.0 = dimensions.0.max(size.width);
dimensions.1 = dimensions.1.max(size.height);
}
b"ANMF" if !nested => {
let word = |at: usize| -> Option<usize> {
let bytes: [u8; 3] = payload.get(at..at.checked_add(3)?)?.try_into().ok()?;
usize::try_from(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], 0]).checked_add(1)?).ok()
};
dimensions.0 = dimensions.0.max(word(6)?);
dimensions.1 = dimensions.1.max(word(9)?);
webp_frame_sizes(payload.get(16..)?, dimensions, true)?;
}
b"ANMF" => return None,
_ => {}
}
at = end.checked_add(length & 1)?;
if at > chunks.len() {
return None;
}
}
Some(())
}
fn bounded_data_image(mime: &str, data: Arc<Vec<u8>>, options: &usvg::Options, budget: &Arc<Mutex<ImageBudget>>, depth: usize) -> Option<usvg::ImageKind> {
if budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.is_some() {
return None;
}
let format = photocraft_codecs::detect(&data);
if mime == "image/svg+xml" || (mime == "text/plain" && format.is_none()) {
if depth >= MAX_EMBEDDED_SVG_DEPTH {
image_error(budget, "embedded SVG images exceed the nesting limit");
return None;
}
match parse_image_bounded(&data, budget.clone(), depth + 1) {
Ok(tree) => {
let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
counter.svg_images = counter.svg_images.saturating_add(1);
Some(usvg::ImageKind::SVG(tree))
}
Err(e) => {
image_error(budget, format!("can't parse embedded SVG image: {e}"));
None
}
}
} else {
let supported = matches!(
format,
Some(photocraft_codecs::Format::Png | photocraft_codecs::Format::Jpeg | photocraft_codecs::Format::Gif | photocraft_codecs::Format::WebP)
);
if !supported {
image_error(budget, "embedded SVG image has unsupported or invalid raster data");
return None;
}
let dimensions = match imagesize::blob_size(&data) {
Ok(size) => (size.width, size.height),
Err(e) => {
image_error(budget, format!("can't read embedded SVG image dimensions: {e}"));
return None;
}
};
let mut dimensions = if format == Some(photocraft_codecs::Format::Gif) {
let Some(frame) = gif_frame_size(&data) else {
image_error(budget, "can't read embedded GIF frame dimensions");
return None;
};
(dimensions.0.max(frame.0), dimensions.1.max(frame.1))
} else {
dimensions
};
if format == Some(photocraft_codecs::Format::WebP) && data.get(12..).and_then(|chunks| webp_frame_sizes(chunks, &mut dimensions, false)).is_none() {
image_error(budget, "can't read embedded WebP frame dimensions");
return None;
}
if !charge_image(budget, dimensions.0, dimensions.1) {
return None;
}
let image = (usvg::ImageHrefResolver::default_data_resolver())(mime, data, options);
if image.is_none() {
image_error(budget, "embedded SVG raster image has an unsupported MIME type");
}
image
}
}
fn parse_image_bounded(bytes: &[u8], budget: Arc<Mutex<ImageBudget>>, depth: usize) -> Result<usvg::Tree, IoError> {
parse_image_bounded_with(bytes, budget, depth, fonts)
}
fn parse_image_bounded_with(
bytes: &[u8],
budget: Arc<Mutex<ImageBudget>>,
depth: usize,
fonts: fn() -> Arc<usvg::fontdb::Database>,
) -> Result<usvg::Tree, IoError> {
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > MAX_BOUNDED_SVG_BYTES {
return Err(IoError::Svg("embedded SVG source exceeds the 16 MiB byte limit".into()));
}
let xml = inflate_with_limit(bytes, MAX_BOUNDED_SVG_BYTES)?;
if nesting_depth(&xml, MAX_XML_DEPTH) > MAX_XML_DEPTH {
return Err(IoError::Svg(format!("elements are nested more than {MAX_XML_DEPTH} levels deep")));
}
{
let mut counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
counter.xml_bytes = counter
.xml_bytes
.checked_sub(u64::try_from(xml.len()).unwrap_or(u64::MAX))
.ok_or_else(|| IoError::Svg("embedded SVG XML exceeds the decoded byte budget".into()))?;
}
let has_text = xml.windows(4).any(|w| w == b"text");
let fontdb = if has_text { fonts() } else { Arc::new(usvg::fontdb::Database::new()) };
if has_text && fontdb.faces().next().is_none() {
budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).fontless_text = true;
}
let mut opt = options(fontdb);
let shared = budget.clone();
opt.image_href_resolver.resolve_data = Box::new(move |mime, data, options| bounded_data_image(mime, data, options, &shared, depth));
let tree = usvg::Tree::from_data(&xml, &opt);
if let Some(error) = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner).error.clone() {
return Err(IoError::Svg(error));
}
tree.map_err(|e| IoError::Svg(e.to_string()))
}
fn parse_bounded(bytes: &[u8], max_pixels: u64) -> Result<Parsed, IoError> {
parse_bounded_inner(bytes, max_pixels).map(|(tree, _)| tree)
}
fn parse_bounded_inner(bytes: &[u8], max_pixels: u64) -> Result<(Parsed, u64), IoError> {
parse_bounded_inner_with(bytes, max_pixels, fonts)
}
fn parse_bounded_inner_with(bytes: &[u8], max_pixels: u64, fonts: fn() -> Arc<usvg::fontdb::Database>) -> Result<(Parsed, u64), IoError> {
let budget = Arc::new(Mutex::new(ImageBudget {
pixels: max_pixels.min(MAX_RASTER_PIXELS),
xml_bytes: MAX_BOUNDED_SVG_BYTES,
svg_images: 0,
fontless_text: false,
error: None,
}));
let tree = parse_image_bounded_with(bytes, budget.clone(), 0, fonts)?;
let counter = budget.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// resvg renders each embedded SVG through a temporary pixmap as large as its destination.
if counter.svg_images != 0 {
let area = canvas(&tree, &mut Vec::new()).size.area();
if area.checked_mul(counter.svg_images.saturating_add(1)).is_none_or(|pixels| pixels > max_pixels.min(MAX_RASTER_PIXELS)) {
return Err(IoError::Svg("embedded SVG intermediate images exceed the SVG cache budget".into()));
}
}
Ok((Parsed { tree, fontless_text: counter.fontless_text }, counter.svg_images))
}
/// The plain XML of `bytes`: itself, or the inflated content of a gzip `.svgz`, refused past
/// [`MAX_INFLATED_BYTES`] (a decompression bomb) or when the stream is corrupt.
fn inflate(bytes: &[u8]) -> Result<std::borrow::Cow<'_, [u8]>, IoError> {
use std::io::Read;
if !bytes.starts_with(&[0x1f, 0x8b]) {
return Ok(std::borrow::Cow::Borrowed(bytes));
}
inflate_with_limit(bytes, MAX_INFLATED_BYTES)
}
fn inflate_with_limit(bytes: &[u8], limit: u64) -> Result<std::borrow::Cow<'_, [u8]>, IoError> {
use std::io::Read;
if !bytes.starts_with(&[0x1f, 0x8b]) {
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
return Err(IoError::Svg(format!("the SVG exceeds the {limit} byte limit")));
}
return Ok(std::borrow::Cow::Borrowed(bytes));
}
let mut out = Vec::new();
let mut reader = flate2::read::MultiGzDecoder::new(bytes).take(MAX_INFLATED_BYTES.saturating_add(1));
let mut reader = flate2::read::MultiGzDecoder::new(bytes).take(limit.saturating_add(1));
reader.read_to_end(&mut out).map_err(|e| IoError::Svg(format!("the compressed SVG couldn't be inflated: {e}")))?;
if u64::try_from(out.len()).unwrap_or(u64::MAX) > MAX_INFLATED_BYTES {
return Err(IoError::Svg(format!("the compressed SVG inflates past {} MB", MAX_INFLATED_BYTES >> 20)));
if u64::try_from(out.len()).unwrap_or(u64::MAX) > limit {
return Err(IoError::Svg(format!("the compressed SVG inflates past {} MB", limit >> 20)));
}
Ok(std::borrow::Cow::Owned(out))
}
@@ -208,23 +451,81 @@ pub fn import_svg(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
/// Opens an SVG while rasterising any subtrees deeper than `max_group_depth` into single pixel
/// layers. The document's hard nesting limit remains [`MAX_GROUP_DEPTH`].
pub fn import_svg_with_group_depth(name: &str, bytes: &[u8], max_group_depth: usize) -> Result<ImportResult, IoError> {
import_parsed(name, parse(bytes)?, max_group_depth)
import_svg_inner(name, bytes, max_group_depth, None, false)
}
fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result<ImportResult, IoError> {
/// Opens an SVG with a conservative budget for eagerly rendered layer caches. A canvas above
/// the budget (or the rasterisation limit) is rejected before any layer cache is allocated.
/// When the canvas fits but its elements' combined caches would exceed the budget, the whole
/// drawing becomes one pixel layer, with an explicit editability warning.
pub fn import_svg_bounded(name: &str, bytes: &[u8], max_cached_pixels: u64) -> Result<ImportResult, IoError> {
import_svg_inner(name, bytes, MAX_GROUP_DEPTH, Some(max_cached_pixels), false)
}
/// The same cache bound without a lossy aggregate-budget fallback. Smart-object operations
/// use this variant so editing contents or changing Stack Mode fails before excessive caches
/// are rendered, rather than silently changing the source's layer structure.
pub fn import_svg_bounded_layers(name: &str, bytes: &[u8], max_cached_pixels: u64) -> Result<ImportResult, IoError> {
import_svg_inner(name, bytes, MAX_GROUP_DEPTH, Some(max_cached_pixels), true)
}
fn import_svg_inner(
name: &str,
bytes: &[u8],
max_group_depth: usize,
max_cached_pixels: Option<u64>,
reject_cache_overflow: bool,
) -> Result<ImportResult, IoError> {
let parsed = match max_cached_pixels {
Some(limit) => parse_bounded(bytes, limit)?,
None => parse(bytes)?,
};
import_parsed(name, parsed, max_group_depth, max_cached_pixels, reject_cache_overflow)
}
fn import_parsed(
name: &str,
parsed: Parsed,
max_group_depth: usize,
max_cached_pixels: Option<u64>,
reject_cache_overflow: bool,
) -> Result<ImportResult, IoError> {
let Parsed { tree, fontless_text } = parsed;
let mut warnings = Vec::new();
if fontless_text {
warnings.push("SVG: the text needs a font and none is installed, so it was dropped".to_string());
}
let cv = canvas(&tree, &mut warnings);
let nodes = count_nodes(tree.root(), max_cached_pixels.is_some());
let over_cache_budget = if let Some(budget) = max_cached_pixels {
let area = cv.size.area();
if area > budget.min(MAX_RASTER_PIXELS) {
return Err(IoError::Svg(format!(
"{} × {} px exceeds the SVG layer cache budget of {} pixels",
cv.size.width,
cv.size.height,
budget.min(MAX_RASTER_PIXELS)
)));
}
let count = u64::try_from(nodes.min(MAX_NODES)).unwrap_or(u64::MAX);
area.checked_mul(count).is_none_or(|pixels| pixels > budget)
} else {
false
};
if over_cache_budget && reject_cache_overflow {
return Err(IoError::Svg("the source's editable layer caches exceed the SVG cache budget".into()));
}
let mut doc = Document::new(name, cv.size, ColorMode::Rgb, SampleType::U8);
let fmt = doc.pixel_format();
let global = tiny_skia::Transform::from_scale(cv.scale, cv.scale);
let max_group_depth = (max_group_depth != 0).then_some(max_group_depth.clamp(1, MAX_GROUP_DEPTH));
let mut conv = Conv { fmt, canvas: doc.bounds(), global, max_group_depth, warnings, noted: Vec::new(), counts: [0; 4] };
if count_nodes(tree.root()) > MAX_NODES {
conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes"));
if nodes > MAX_NODES || over_cache_budget {
if over_cache_budget {
conv.warnings.push("SVG: combined layer caches exceed the clipboard cache budget; opened as one pixel layer instead of editable shapes".into());
} else {
conv.warnings.push(format!("SVG: more than {MAX_NODES} elements; opened as one pixel layer instead of shapes"));
}
let (w, h) = (cv.size.width, cv.size.height);
if let Some(mut pixmap) = pixmap(w, h) {
resvg::render(&tree, global, &mut pixmap.as_mut());
@@ -243,12 +544,33 @@ fn import_parsed(name: &str, parsed: Parsed, max_group_depth: usize) -> Result<I
/// the origin. Smart objects use it to stay sharp at any placement.
pub fn rasterize(bytes: &[u8], scale: f32) -> Result<photocraft_compose::Buffer, IoError> {
let tree = parse(bytes)?.tree;
let cv = canvas(&tree, &mut Vec::new());
rasterize_tree(&tree, scale)
}
/// Render untrusted embedded SVG with intrinsic raster-image and aggregate decode budgets.
pub fn rasterize_bounded(bytes: &[u8], scale: f32, max_decoded_pixels: u64) -> Result<photocraft_compose::Buffer, IoError> {
let (parsed, images) = parse_bounded_inner(bytes, max_decoded_pixels)?;
let tree = parsed.tree;
if images != 0 {
let cv = canvas(&tree, &mut Vec::new());
let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0);
let width = ((tree.size().width() * k).ceil() as u32).max(1);
let height = ((tree.size().height() * k).ceil() as u32).max(1);
if (u64::from(width) * u64::from(height)).checked_mul(images.saturating_add(1)).is_none_or(|pixels| pixels > max_decoded_pixels.min(MAX_RASTER_PIXELS))
{
return Err(IoError::Svg("embedded SVG intermediate images exceed the rendered pixel budget".into()));
}
}
rasterize_tree(&tree, scale)
}
fn rasterize_tree(tree: &usvg::Tree, scale: f32) -> Result<photocraft_compose::Buffer, IoError> {
let cv = canvas(tree, &mut Vec::new());
let k = cv.scale * scale.clamp(1.0 / 64.0, 64.0);
let side = |v: f32| ((v * k).ceil() as u32).max(1);
let (w, h) = (side(tree.size().width()), side(tree.size().height()));
let mut pixmap = pixmap(w, h).ok_or_else(|| IoError::Svg(format!("{w} × {h} px is too large to rasterise")))?;
resvg::render(&tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut());
resvg::render(tree, tiny_skia::Transform::from_scale(k, k), &mut pixmap.as_mut());
let rect = Rect::new(0, 0, w as i32, h as i32);
let px = pixmap.pixels().iter().map(|p| rgba(p.demultiply())).collect();
Ok(photocraft_compose::Buffer { rect, px })
@@ -282,7 +604,7 @@ fn surface(pixmap: &tiny_skia::Pixmap, rect: Rect, fmt: PixelFormat) -> Surface
}
/// Drawable elements in the tree (iterative: the nesting can be anything).
fn count_nodes(root: &usvg::Group) -> usize {
fn count_nodes(root: &usvg::Group, include_text: bool) -> usize {
let mut n = 0;
let mut stack: Vec<&usvg::Group> = vec![root];
while let Some(g) = stack.pop() {
@@ -291,6 +613,9 @@ fn count_nodes(root: &usvg::Group) -> usize {
if let usvg::Node::Group(child) = node {
stack.push(child);
}
if include_text && let usvg::Node::Text(text) = node {
stack.push(text.flattened());
}
if n > MAX_NODES {
return n;
}
@@ -667,6 +992,80 @@ fn knots(path: &tiny_skia::Path) -> Vec<Subpath> {
mod tests {
use super::*;
fn embedded_image(mime: &str, bytes: &[u8]) -> String {
let data = bytes.iter().map(|byte| format!("%{byte:02X}")).collect::<String>();
format!("<image width=\"1\" height=\"1\" href=\"data:{mime},{data}\"/>")
}
fn image_svg(images: &str) -> String {
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\">{images}</svg>")
}
fn png_dimensions(width: u32, height: u32) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&height.to_be_bytes());
bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
bytes
}
#[test]
fn embedded_raster_intrinsic_sizes_are_rejected_before_pixel_decode() {
let png = png_dimensions(32768, 32768);
let jpeg = vec![0xff, 0xd8, 0xff, 0xc0, 0, 17, 8, 0x80, 0, 0x80, 0, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0];
let mut gif = b"GIF89a\x01\0\x01\0\0\0\0\x2c\0\0\0\0".to_vec();
gif.extend_from_slice(&[0, 0x80, 0, 0x80, 0]);
let mut webp = b"RIFF\x16\0\0\0WEBPVP8X\x0a\0\0\0\0\0\0\0".to_vec();
webp.extend_from_slice(&[0xff, 0x7f, 0, 0xff, 0x7f, 0]);
for (mime, bytes) in [("image/png", png), ("image/jpeg", jpeg), ("image/gif", gif), ("image/webp", webp)] {
// These are dimension headers, not valid compressed pixel streams. The budget
// error proves no downstream image decoder was asked to allocate their pixels.
let svg = image_svg(&embedded_image(mime, &bytes));
let error = import_svg_bounded("clipboard", svg.as_bytes(), MAX_RASTER_PIXELS).unwrap_err();
assert!(error.to_string().contains("decoded pixel budget"), "{mime}: {error}");
assert!(rasterize_bounded(svg.as_bytes(), 1.0, MAX_RASTER_PIXELS).unwrap_err().to_string().contains("decoded pixel budget"));
}
}
#[test]
fn embedded_images_share_budget_and_nested_svg_cannot_hide_raster_bombs() {
let image = embedded_image("image/png", &png_dimensions(2, 2));
let aggregate = image_svg(&image.repeat(2));
assert!(parse_bounded(aggregate.as_bytes(), 7).unwrap_err().to_string().contains("decoded pixel budget"));
let oversized = image_svg(&embedded_image("image/png", &png_dimensions(u32::MAX, u32::MAX)));
let nested = image_svg(&embedded_image("image/svg+xml", oversized.as_bytes()));
assert!(parse_bounded(nested.as_bytes(), MAX_RASTER_PIXELS).unwrap_err().to_string().contains("decoded pixel budget"));
let malformed = image_svg(&embedded_image("image/png", b"\x89PNG\r\n\x1a\n"));
assert!(parse_bounded(malformed.as_bytes(), MAX_RASTER_PIXELS).unwrap_err().to_string().contains("dimensions"));
assert_eq!(gif_frame_size(b"GIF89a\x01\0\x01\0\0\0\0\x21\xfe\xff"), None, "truncated extension has no frame");
}
#[test]
fn webp_container_cannot_hide_a_larger_lossy_frame() {
let mut webp = b"RIFF\x28\0\0\0WEBPVP8X\x0a\0\0\0\0\0\0\0\0\0\0\0\0\0".to_vec();
webp.extend_from_slice(b"VP8 \x0a\0\0\0\0\0\0\x9d\x01\x2a");
webp.extend_from_slice(&8000u16.to_le_bytes());
webp.extend_from_slice(&8000u16.to_le_bytes());
assert_eq!(imagesize::blob_size(&webp).unwrap().width, 1, "container claims one pixel");
let svg = image_svg(&embedded_image("image/webp", &webp));
assert!(parse_bounded(svg.as_bytes(), MAX_RASTER_PIXELS).unwrap_err().to_string().contains("decoded pixel budget"));
}
#[test]
fn bounded_images_keep_small_pixels_and_external_references_stay_disabled() {
let image = photocraft_codecs::Image::from_u8(1, 1, photocraft_codecs::ChannelLayout::Rgba, vec![255, 0, 0, 255]).unwrap();
let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
let inner = image_svg(&embedded_image("image/png", &bytes));
let nested = image_svg(&embedded_image("image/svg+xml", inner.as_bytes()));
for svg in [&inner, &nested] {
let rendered = rasterize_bounded(svg.as_bytes(), 1.0, 16).unwrap();
assert_eq!(rendered.px, vec![[1.0, 0.0, 0.0, 1.0]]);
}
assert!(rasterize_bounded(nested.as_bytes(), 32.0, 16).unwrap_err().to_string().contains("rendered pixel budget"));
let external = image_svg("<rect width=\"1\" height=\"1\" fill=\"blue\"/><image href=\"file:///never-read.png\" width=\"1\" height=\"1\"/>");
assert_eq!(rasterize_bounded(external.as_bytes(), 1.0, 16).unwrap().px, vec![[0.0, 0.0, 1.0, 1.0]]);
}
#[test]
fn sniffing_accepts_svg_and_rejects_other_xml() {
assert!(is_svg(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"));
@@ -709,14 +1108,20 @@ mod tests {
let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"60\"><text x=\"5\" y=\"45\" font-size=\"48\">II</text></svg>";
let parsed = parse_with(svg, no_fonts).unwrap();
assert!(parsed.fontless_text);
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap();
assert!(r.document.layers.is_empty());
assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings);
// Clipboard/import paths use the bounded parser and retain the same diagnostic.
let (parsed, _) = parse_bounded_inner_with(svg, MAX_RASTER_PIXELS, no_fonts).unwrap();
assert!(parsed.fontless_text);
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, Some(MAX_RASTER_PIXELS), false).unwrap();
assert!(r.document.layers.is_empty());
assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings);
// A drawing without text never loads or misses fonts.
let parsed =
parse_with(b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><rect width=\"10\" height=\"10\"/></svg>", no_fonts).unwrap();
assert!(!parsed.fontless_text);
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH).unwrap();
let r = import_parsed("t", parsed, MAX_GROUP_DEPTH, None, false).unwrap();
assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings);
}
@@ -776,4 +1181,32 @@ mod tests {
assert_eq!((cv.size.width, cv.size.height), (MAX_SIDE, MAX_SIDE / 2));
assert_eq!(w.len(), 1);
}
#[test]
fn bounded_import_preserves_shapes_at_budget_and_rasterises_aggregate_overflow() {
let bytes = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="red"/><rect x="4" width="4" height="8" fill="blue"/></svg>"#;
let at_limit = import_svg_bounded("bounded", bytes, 128).unwrap();
assert_eq!(at_limit.document.layers.len(), 2);
assert!(at_limit.document.layers.iter().all(|l| matches!(l.content, LayerContent::Shape(_))));
let over = import_svg_bounded("bounded", bytes, 127).unwrap();
assert!(import_svg_bounded_layers("bounded", bytes, 127).unwrap_err().to_string().contains("cache budget"));
assert_eq!(over.document.layers.len(), 1);
assert!(matches!(over.document.layers[0].content, LayerContent::Raster(_)));
assert!(over.warnings.iter().any(|w| w.contains("cache budget") && w.contains("editable shapes")));
let surface = over.document.layers[0].surface().unwrap();
assert_eq!(surface.read_region(Rect::new(1, 1, 2, 2)), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(surface.read_region(Rect::new(6, 1, 7, 2)), vec![0.0, 0.0, 1.0, 1.0]);
// Ordinary file imports retain their existing editable-layer behavior.
assert_eq!(import_svg("unbounded", bytes).unwrap().document.layers.len(), 2);
}
#[test]
fn bounded_import_rejects_canvas_before_rendering_and_respects_raster_limit() {
let bytes = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8"/></svg>"#;
assert!(import_svg_bounded("bounded", bytes, 0).is_err());
assert!(import_svg_bounded("bounded", bytes, 63).is_err());
assert!(import_svg_bounded("bounded", bytes, 64).is_ok());
let oversized = br#"<svg xmlns="http://www.w3.org/2000/svg" width="5000" height="5000"><rect width="5000" height="5000"/></svg>"#;
assert!(import_svg_bounded("bounded", oversized, u64::MAX).unwrap_err().to_string().contains("cache budget"));
}
}
+79 -9
View File
@@ -383,6 +383,21 @@ pub fn live_from_vogk(data: &[u8]) -> Option<LiveShape> {
from: point_of(o, "keyOriginLineStart")?,
to: point_of(o, "keyOriginLineEnd")?,
weight: num(o.get("keyOriginLineWeight")).unwrap_or(1.0),
arrowheads: if matches!(o.get("keyOriginLineArrowSt"), Some(Value::Boolean(true)))
|| matches!(o.get("keyOriginLineArrowEnd"), Some(Value::Boolean(true)))
|| num(o.get("keyOriginLineArrWdth")).is_some_and(|v| v > 0.0)
|| num(o.get("keyOriginLineArrLngth")).is_some_and(|v| v > 0.0)
{
Some(photocraft_doc::vector::LineArrowheads {
start: matches!(o.get("keyOriginLineArrowSt"), Some(Value::Boolean(true))),
end: matches!(o.get("keyOriginLineArrowEnd"), Some(Value::Boolean(true))),
width: num(o.get("keyOriginLineArrWdth")).unwrap_or(10.0),
length: num(o.get("keyOriginLineArrLngth")).unwrap_or(20.0),
concavity: num(o.get("keyOriginLineArrConc")).unwrap_or(0.0),
})
} else {
None
},
}),
_ => None,
}
@@ -421,7 +436,7 @@ pub fn vogk_bytes(s: &LiveShape, dpi: f32) -> Option<Vec<u8>> {
)
.with("keyOriginShapeBBox", bbox(*rect)),
LiveShape::Ellipse { rect } => base(5).with("keyOriginShapeBBox", bbox(*rect)),
LiveShape::Line { from, to, weight } => {
LiveShape::Line { from, to, weight, arrowheads } => {
let pnt = |p: [f64; 2]| Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(p[0])).with("Vrtc", Value::Double(p[1])));
let r = [from[0].min(to[0]), from[1].min(to[1]), (from[0] - to[0]).abs(), (from[1] - to[1]).abs()];
base(4)
@@ -429,11 +444,11 @@ pub fn vogk_bytes(s: &LiveShape, dpi: f32) -> Option<Vec<u8>> {
.with("keyOriginLineEnd", pnt(*to))
.with("keyOriginLineStart", pnt(*from))
.with("keyOriginLineWeight", Value::Double(*weight))
.with("keyOriginLineArrowSt", Value::Boolean(false))
.with("keyOriginLineArrowEnd", Value::Boolean(false))
.with("keyOriginLineArrWdth", Value::Double(0.0))
.with("keyOriginLineArrLngth", Value::Double(0.0))
.with("keyOriginLineArrConc", Value::Integer(0))
.with("keyOriginLineArrowSt", Value::Boolean(arrowheads.as_ref().is_some_and(|a| a.start)))
.with("keyOriginLineArrowEnd", Value::Boolean(arrowheads.as_ref().is_some_and(|a| a.end)))
.with("keyOriginLineArrWdth", Value::Double(arrowheads.as_ref().map_or(0.0, |a| a.width)))
.with("keyOriginLineArrLngth", Value::Double(arrowheads.as_ref().map_or(0.0, |a| a.length)))
.with("keyOriginLineArrConc", Value::Double(arrowheads.as_ref().map_or(0.0, |a| a.concavity)))
}
LiveShape::Polygon { .. } => return None,
};
@@ -666,12 +681,67 @@ mod tests {
LiveShape::Rect { rect: [1.0, 2.0, 30.0, 40.0], radii: [0.0; 4] },
LiveShape::Rect { rect: [1.0, 2.0, 30.0, 40.0], radii: [1.0, 2.0, 3.0, 4.0] },
LiveShape::Ellipse { rect: [5.0, 6.0, 7.0, 8.0] },
LiveShape::Line { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0 },
LiveShape::Line { from: [0.0, 10.0], to: [20.0, 0.0], weight: 3.0, arrowheads: None },
LiveShape::Line {
from: [0.0, 10.0],
to: [20.0, 0.0],
weight: 3.0,
arrowheads: Some(photocraft_doc::vector::LineArrowheads { start: true, end: true, width: 12.0, length: 10.0, concavity: -32.5 }),
},
] {
let b = vogk_bytes(&s, 72.0).unwrap();
assert_eq!(live_from_vogk(&b), Some(s));
}
assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0 }, 72.0).is_none());
assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0, corner_radius: 0.0, smooth_indents: false }, 72.0).is_none());
}
#[test]
fn vogk_arrowhead_concavity_keeps_fractional_values_and_defaults_missing_values() {
for concavity in [-50.0, -12.5, 0.0, 32.5, 50.0] {
let live = LiveShape::Line {
from: [5.0, 10.0],
to: [75.0, 20.0],
weight: 3.0,
arrowheads: Some(photocraft_doc::vector::LineArrowheads { start: false, end: true, width: 12.0, length: 10.0, concavity }),
};
assert_eq!(live_from_vogk(&vogk_bytes(&live, 72.0).unwrap()), Some(live));
}
let point = |x| Value::Descriptor(Descriptor::new("Pnt ").with("Hrzn", Value::Double(x)).with("Vrtc", Value::Double(10.0)));
let origin = Descriptor::new("null")
.with("keyOriginType", Value::Integer(4))
.with("keyOriginLineStart", point(5.0))
.with("keyOriginLineEnd", point(75.0))
.with("keyOriginLineArrowEnd", Value::Boolean(true));
let descriptor = Descriptor::new("null").with("keyDescriptorList", Value::List(vec![Value::Descriptor(origin)]));
let mut data = 1u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor::new(descriptor).to_bytes());
let Some(LiveShape::Line { arrowheads: Some(heads), .. }) = live_from_vogk(&data) else { panic!("line origination") };
assert_eq!(heads.concavity, 0.0);
}
#[test]
fn smooth_star_psd_keeps_curve_geometry_without_inventing_origination_metadata() {
let live = LiveShape::Polygon { rect: [5.0, 5.0, 100.0, 70.0], sides: 5, star_ratio: 0.4, corner_radius: 3.0, smooth_indents: true };
let shape = ShapeLayer { path: photocraft_vector::shapes::live_path(&live), live: Some(live), ..Default::default() };
let mut raw = Vec::new();
shape_blocks(&shape, &mut raw, 128, 128, 72.0);
let lookup = |k: &[u8; 4]| raw.iter().find(|(key, _)| key == k).map(|(_, data)| data.clone());
let mut back = ShapeLayer::default();
shape_from_blocks(&mut back, &lookup, 128, 128, 72.0);
assert!(back.live.is_none());
assert_eq!(shape.path.subpaths.len(), back.path.subpaths.len());
for (a, b) in shape.path.subpaths.iter().zip(&back.path.subpaths) {
assert_eq!(a.knots.len(), b.knots.len());
assert_eq!((a.closed, a.op), (b.closed, b.op));
for (a, b) in a.knots.iter().zip(&b.knots) {
for (a, b) in [a.anchor, a.in_ctrl, a.out_ctrl].into_iter().zip([b.anchor, b.in_ctrl, b.out_ctrl]) {
assert!((a.x - b.x).abs() < 0.00001 && (a.y - b.y).abs() < 0.00001);
}
}
}
let before = raw.clone();
shape_blocks(&back, &mut raw, 128, 128, 72.0);
assert_eq!(raw, before, "unchanged imported curves must keep original PSD bytes");
}
#[test]
@@ -699,7 +769,7 @@ mod tests {
// Edit: path changes → vmsk regenerated, live dropped when polygon.
let mut edited = sh.clone();
edited.path.subpaths.pop();
edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0 });
edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0, corner_radius: 0.0, smooth_indents: false });
shape_blocks(&edited, &mut raw, 64, 64, 72.0);
assert!(!raw.iter().any(|(k, _)| k == b"vogk"));
let (p, _) = path_from_vmsk(&raw[0].1, 64, 64).unwrap();
+336 -144
View File
@@ -610,7 +610,7 @@ pub(crate) fn eyedropper_ring_colors(app: &mut PhotocraftApp, x: f64, y: f64) ->
/// Precise-cursor preference wants the plain crosshair.
fn eyedropper_ring(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, p: Pos2, held: bool) -> Option<egui::CursorIcon> {
// The options bar's Show Sampling Ring turns it off (#1649).
if !held || !app.ui.tool_options.eyedropper_ring || app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
if !held || !app.ui.tool_options.eyedropper_ring || precise_other_cursor(app) {
return None;
}
let [x, y] = xf.to_doc(p);
@@ -1089,11 +1089,8 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
if let Some(l) = live_stroke(app, idx) {
return (l.stroke.doc().clone(), l.display_key());
}
if let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview)
&& app.session.active_index() == Some(idx)
&& st.doc.layer(photocraft_doc::LayerId(t.layer)).is_some()
{
return (pv.doc.clone(), (1 << 40) + pv.session);
if let Some(shown) = crate::transform_tool::display_doc(app, idx) {
return shown;
}
// Layer Style dialog: show its effects live (Cancel just drops the preview).
let style = app.ui.dialogs.iter().find(|d| d.kind == crate::state::DialogKind::LayerStyle);
@@ -1336,6 +1333,12 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no
if seen.1 == now.1 ^ display_key && seen.0 + 1 == now.0 {
return last_damage;
}
if seen.0 == now.0
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::transform_tool::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
if seen.0 == now.0
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::type_transform::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
@@ -1741,6 +1744,65 @@ fn ensure_adjust_proxy(app: &mut PhotocraftApp, idx: usize, zoom: f32) -> Option
Some((frame.k, key, size))
}
fn ensure_shape_proxy(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> {
crate::transform_tool::follow_edits(app);
let frame = crate::transform_tool::shape_proxy(app, idx)?;
let gpu = app.gpu.clone()?;
if !gpu.supports(&frame.doc) {
return None;
}
let key = frame.doc.id.0;
let size = [frame.doc.size.width, frame.doc.size.height];
if frame.stale || !gpu.has(key, size) {
let original = app.session.documents().get(idx)?.doc.clone();
let (display, _) = canvas_display(app, &original, None);
let damage = gpu.has(key, size).then_some(frame.region);
let r = gpu.refresh(key, &frame.doc, damage, display.as_deref());
app.perf.record("shape-proxy", r.px, r.composite_ms, r.upload_ms);
crate::transform_tool::shape_proxy_drawn(app, frame.key);
}
Some((frame.k, key, size))
}
/// CPU, mirrored views and unsupported GPU documents use the same bounded proxy frame.
pub(crate) fn paint_shape_proxy(
app: &mut PhotocraftApp,
idx: usize,
ctx: &egui::Context,
painter: &egui::Painter,
xf: &ViewXform,
output: Option<u32>,
) -> bool {
crate::transform_tool::follow_edits(app);
let Some(frame) = crate::transform_tool::shape_proxy(app, idx) else { return false };
if frame.region.is_empty() {
return true;
}
let Some(original) = app.session.documents().get(idx).map(|st| st.doc.clone()) else { return false };
let (display, display_key) = canvas_display(app, &original, output);
let tex = crate::transform_tool::shape_proxy_texture(app, frame.key, display_key).unwrap_or_else(|| {
let started = crate::gpu_canvas::now_ms();
let buffer = photocraft_compose::render(&frame.doc, frame.region);
let texture = ctx.load_texture("shape-transform-proxy", display_image(display.as_deref(), &buffer), TextureOptions::LINEAR);
let id = texture.id();
crate::transform_tool::set_shape_proxy_texture(app, frame.key, display_key, texture);
app.perf.span("shape proxy composite", crate::gpu_canvas::now_ms() - started);
id
});
let r = frame.region;
let k = frame.k as f32;
let points = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| xf.to_screen(x as f32 * k, y as f32 * k));
let uvs = [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)];
let mut mesh = Mesh::with_texture(tex);
for (pos, uv) in points.into_iter().zip(uvs) {
mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
true
}
/// If a live adjustment preview is active on a large document, composite it on the proxy and upload
/// it under its own GPU key. Returns (factor, gpu key, dimensions) when the proxy should be drawn.
fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64, [u32; 2])> {
@@ -1800,6 +1862,11 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) {
// retaining only document ids freed the previews every frame (blank canvas while previewing).
let mut live: Vec<u64> = app.session.documents().iter().flat_map(|st| [st.doc.id.0, st.doc.id.0 ^ (1u64 << 61), st.doc.id.0 ^ (1u64 << 62)]).collect();
live.extend(crate::adjust_preview::gpu_keys(app));
if let Some(idx) = app.session.active_index()
&& let Some(frame) = crate::transform_tool::shape_proxy(app, idx)
{
live.push(frame.doc.id.0);
}
gpu.retain(&live);
}
// Upload markers must not outlive the resources they describe: native reopen can reuse
@@ -2676,6 +2743,9 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f
/// Draw one canvas view and handle its input. `primary` = main window (tools active).
pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View {
let ctx = ui.ctx().clone();
if primary {
crate::shape_canvas::cancel_if_left(app);
}
// The camera as the app holds it before this frame's input. The canvas works on its own copy
// (`view`) and writes it back at the end; a tool that moves the camera itself meanwhile (the
// Crop tool's default mode pans and turns the view with the image, `crop_mode`) must not be
@@ -2763,8 +2833,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation, aspect);
if app.gpu.is_some()
&& gpu_ok
&& let Some((k, key, preview_size)) =
ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx))
&& let Some((k, key, preview_size)) = ensure_shape_proxy(app, idx)
.or_else(|| ensure_adjust_proxy(app, idx, view.zoom))
.or_else(|| ensure_filter_preview(app, idx, &ctx))
.or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)];
@@ -2790,7 +2862,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
offset: [0.0, 0.0],
};
paint_gpu_view(&painter, rect, params, &pattern);
} else if gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) {
} else if crate::transform_tool::shape_proxy(app, idx).is_none() && gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) {
on_gpu = true;
app.perf.gpu = true;
// Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs).
@@ -2818,10 +2890,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
let rotated = view.rotation.abs() > 0.05;
let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square));
let tex = ensure_texture(app, &ctx, idx, output).map(|(tex, _scale)| tex);
crate::transform_tool::follow_edits(app);
let shape_proxy = crate::transform_tool::shape_proxy(app, idx).is_some();
let tex = if shape_proxy { None } else { ensure_texture(app, &ctx, idx, output).map(|(tex, _scale)| tex) };
paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex);
if shape_proxy {
paint_shape_proxy(app, idx, &ctx, &painter, &xf, output);
}
for &c in &pattern {
paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex);
let copy_xf = pattern_xf(&xf, c, size);
paint_cpu_document(&painter, &copy_xf, doc.bounds(), rotated, checkerboard, tex);
if shape_proxy {
paint_shape_proxy(app, idx, &ctx, &painter, &copy_xf, output);
}
}
}
// Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs).
@@ -3041,6 +3122,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
None if middle => Tool::Hand,
None => app.ui.tool,
};
if primary {
crate::shape_canvas::end_if_navigation(app, tool);
}
// ⌘ held: this frame's gestures go to the Move tool (`PhotocraftApp::active_tool`). The Hand
// and Zoom never reach `tool_event`, so only the Move needs the state machine to know.
app.tool_override = (temporary == Some(crate::hold_keys::Temporary::Move)).then_some(Tool::Move);
@@ -3076,7 +3160,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
} else if free_hover && (space_pan || hand) {
ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, false));
} else if picking && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) {
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
if precise_other_cursor(app) {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
} else {
ctx.set_cursor_icon(pipette_cursor(&ctx, p));
@@ -3140,7 +3224,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
if response.hovered()
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
{
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
if precise_other_cursor(app) {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
} else {
ctx.set_cursor_icon(pipette_cursor(&ctx, p));
@@ -3258,7 +3342,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
// A drag is only recognised once the pointer has moved past egui's click distance: the
// gesture starts where the button went down, not where it is now (#123).
let gesture_active_before = app.drag.is_some();
let gesture_active_before = app.drag.is_some() || crate::shape_canvas::dragging(app);
let shape_press = egui::Id::new("shape-pointer-press-modifiers");
if crate::shape_canvas::supports_tool(tool)
&& response.is_pointer_button_down_on()
&& ui.input(|i| i.pointer.primary_pressed())
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p))
{
let press_mods = ui.input(|i| pointer_button_modifiers(&i.events, PointerButton::Primary)).unwrap_or(mods);
if crate::vector_ui::is_shape_tool(tool) {
ctx.data_mut(|d| d.insert_temp(shape_press, press_mods));
}
let d = xf.to_doc(p);
crate::shape_canvas::pointer(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods);
}
let radius_drag = crate::shape_canvas::dragging(app);
// Stroke timing: egui's events carry no timestamps, so a press takes the frame time and
// the moves below are spread between the previous point and this frame (`spread_ms`).
let now_ms = ui.input(|i| i.time) * 1000.0;
@@ -3279,23 +3377,29 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
app.press_stroke = app.drag.is_some();
}
let press_stroke = app.press_stroke;
if press_stroke {
if press_stroke || radius_drag {
buttons.started = false;
}
if buttons.started
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos())
{
if tool == Tool::Move && app.ui.transform.is_none() {
if app.ui.transform.is_none() {
begin_transform_controls_at(app, &ctx, &xf, p);
}
let d = xf.to_doc(p);
let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods };
let press_mods = if capture_press {
ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods)
} else if crate::vector_ui::is_shape_tool(tool) {
ctx.data(|d| d.get_temp(shape_press)).unwrap_or(mods)
} else {
mods
};
tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods);
}
// A stroke started on the press takes its moves from the press on, not only once egui
// recognises a drag (past its click distance): otherwise the first few points are lost
// and the stroke starts with a straight segment and no pressure ramp.
let press_stroke_moving = press_stroke && app.drag.is_some();
let press_stroke_moving = (press_stroke && app.drag.is_some()) || radius_drag;
if buttons.dragged || buttons.stopped || press_stroke_moving {
// Feed every pointer move the OS delivered this frame, not just the latest position, so
// a fast stroke is sampled densely and renders as a smooth curve instead of a coarse
@@ -3356,7 +3460,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// The live painting press already created the stroke and this release commits it.
// egui may also report clicked on the same release; replaying that click paints a
// second full-pressure dab over the real (possibly light-pressure) pen dab.
if press_stroke {
if press_stroke || radius_drag {
buttons.clicked = false;
}
let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]])));
@@ -3367,11 +3471,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
// A stroke started on the press ends with the release, whether egui saw a drag, a click,
// or neither (a long press that never moved).
if press_stroke && !buttons.stopped && ui.input(|i| i.pointer.primary_released()) {
if (press_stroke || radius_drag) && !buttons.stopped && ui.input(|i| i.pointer.primary_released()) {
buttons.clicked = false;
let p = response.interact_pointer_pos().map(|p| xf.to_doc(p)).or_else(|| app.drag.as_ref().and_then(|d| d.points.last().map(|q| [q[0], q[1]])));
if let Some(d) = p
&& app.drag.is_some()
&& (app.drag.is_some() || crate::shape_canvas::dragging(app))
{
tap_at_peak_pressure(app);
tool_event(app, ToolEvent::Up { x: d[0], y: d[1] }, mods);
@@ -3384,7 +3488,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
&& let Some(p) = response.interact_pointer_pos()
{
let click_button = if app.secondary_erase { PointerButton::Secondary } else { PointerButton::Primary };
let click_mods = ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods);
let click_mods = if crate::vector_ui::is_shape_tool(tool) {
ctx.data(|d| d.get_temp(shape_press)).unwrap_or_else(|| ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods))
} else {
ui.input(|i| pointer_button_modifiers(&i.events, click_button)).unwrap_or(mods)
};
let d = xf.to_doc(p);
match tool {
Tool::Zoom => {
@@ -3403,7 +3511,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// The first click placed (or grabbed) the point; the second toggles it.
Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]),
_ => {
if tool == Tool::Move && app.ui.transform.is_none() {
if app.ui.transform.is_none() {
begin_transform_controls_at(app, &ctx, &xf, p);
}
// If this tool was not handled on pointer-down, fall back to a
@@ -3497,7 +3605,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
crate::zoom_tool::draw(&ctx, &painter);
let resizing = crate::brush_resize::draw(app, &painter, &xf);
draw_transform_controls(app, &painter, &xf);
if !matches!(tool, Tool::Hand | Tool::Zoom | Tool::RotateView) {
draw_transform_controls(app, &painter, &xf);
}
crate::move_hover::draw(app, &painter, &xf, idx, response.hover_pos());
crate::object_finder::draw(app, &painter, &xf, idx, response.hover_pos());
crate::layer_pick_ui::show(app, &ctx);
@@ -3525,6 +3635,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::perspective_crop_ui::draw_overlay(app, &painter, &xf);
crate::retouch_ui::draw_source_marker(app, &painter, &xf);
crate::vector_ui::draw_overlay(app, &painter, &xf, &doc);
if crate::shape_canvas::supports_tool(tool) {
crate::shape_canvas::draw_overlay(app, &painter, &xf);
}
crate::symmetry_ui::draw(app, &painter, &xf);
crate::analysis_ui::draw_overlay(app, &painter, &xf);
crate::gradient_ui::draw_overlay(app, &painter, &xf);
@@ -3539,10 +3652,28 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
command_guide_at(app, tool, d, crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)))
});
if let Some((vertical, _)) = guide_hover {
if let Some(c) = response.hover_pos().filter(|_| crate::shape_canvas::supports_tool(tool)).and_then(|p| crate::shape_canvas::cursor(app, xf.to_doc(p)))
{
ui.ctx().set_cursor_icon(c);
} else if let Some(c) = response.hover_pos().and_then(|p| crate::cam_transform::cursor(app, &ctx, &xf, p)) {
ui.ctx().set_cursor_icon(c);
} else if let Some((vertical, _)) = guide_hover {
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
} else if let Some(c) =
response.hover_pos().filter(|_| !matches!(tool, Tool::Hand | Tool::Zoom | Tool::RotateView)).and_then(|p| transform_controls_cursor(app, &xf, p))
{
if c == egui::CursorIcon::None {
ui.ctx().set_cursor_icon(c);
if let Some(p) = response.hover_pos()
&& let Some(toward) = transform_controls_turn(app, &xf, p)
{
crate::crop_ui::draw_turn_cursor(&ctx, p, toward);
}
} else {
ui.ctx().set_cursor_icon(c);
}
} else if let Some(p) = response.hover_pos()
&& let Some(c) = crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command))
&& let Some(c) = transform_cursor_in_view(app, &xf, p, ui.input(|i| i.modifiers.alt || i.modifiers.command))
{
ui.ctx().set_cursor_icon(c);
// CursorIcon::None hides the pointer; the curved arrow has to be drawn or the
@@ -3552,17 +3683,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let toward = xf.to_screen((d[0] + k[0] * 16.0) as f32, (d[1] + k[1] * 16.0) as f32) - p;
crate::crop_ui::draw_turn_cursor(&ctx, p, toward);
}
} else if let Some(c) = response.hover_pos().and_then(|p| crate::cam_transform::cursor(app, &ctx, &xf, p)) {
ui.ctx().set_cursor_icon(c);
} else if let Some((r, p, c)) =
response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c))))
{
// The Move tool draws handles without opening a Free Transform session.
// Give them their resize/rotate cursors before the first pointer press (#2871).
ui.ctx().set_cursor_icon(c);
if let Some(toward) = transform_controls_turn(r, p) {
crate::crop_ui::draw_turn_cursor(&ctx, p, toward);
}
} else if let Some(c) = response.hover_pos().filter(|_| tool.is_type()).and_then(|p| {
let mods = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers));
crate::type_transform::cursor(app, &ctx, &xf, p, mods)
@@ -3616,7 +3736,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// While it samples (button held), the comparison ring replaces the cursor (#213).
if let Some(icon) = eyedropper_ring(app, &painter, &xf, p, response.is_pointer_button_down_on()) {
icon
} else if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
} else if precise_other_cursor(app) {
egui::CursorIcon::Crosshair
} else {
pipette_cursor(ui.ctx(), p)
@@ -3685,7 +3805,28 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
pipette_cursor(ui.ctx(), p)
}
}
// The Color Sampler: a pipette, or the crosshair for precise Other Cursors.
// Over the first anchor, use close-path cursor before ordinary pen cursors.
Tool::Pen | Tool::CurvaturePen
if {
let [x, y] = xf.to_doc(p);
crate::vector_ui::closes_path_at(app, tool, x, y)
} =>
{
crate::tool_cursor::close_path(&painter, p)
}
// Pen tools keep their tool glyphs; Pen uses native cursor image where supported.
Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => {
if precise_other_cursor(app) {
egui::CursorIcon::Crosshair
} else if tool == Tool::Pen && crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)).command {
// Ctrl/Command temporarily selects the path instead of drawing it.
egui::CursorIcon::Default
} else if tool == Tool::Pen {
crate::tool_cursor::pen(&painter, p)
} else {
pen_cursor(ui.ctx(), tool, p)
}
}
Tool::ColorSampler => {
if precise_other_cursor(app) {
egui::CursorIcon::Crosshair
@@ -3701,24 +3842,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
paint_bucket_cursor(ui.ctx(), p)
}
}
// Over the first anchor, the close-path pointer (#4596), before the pens' own cursors.
Tool::Pen | Tool::CurvaturePen
if {
let [x, y] = xf.to_doc(p);
crate::vector_ui::closes_path_at(app, tool, x, y)
} =>
{
crate::tool_cursor::close_path(&painter, p)
}
// The pen tools: their own icon with its nib on the point, or the crosshair for
// precise Other Cursors (#4639).
Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint => {
if precise_other_cursor(app) {
egui::CursorIcon::Crosshair
} else {
pen_cursor(ui.ctx(), tool, p)
}
}
// Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool.
Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise =>
@@ -4334,18 +4457,47 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option<photoc
if b.is_empty() {
return None;
}
let (dx, dy) = crate::move_ui::shown_offset(app, st.doc.id, st.revision, id).unwrap_or((0, 0));
let (dx, dy) = crate::move_ui::shown_layer_offset(app, st.doc.id, st.revision, id).unwrap_or((0, 0));
Some(b.translate(dx, dy))
}
/// Move tool › Show Transform Controls: the active layer's transform bounds.
fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option<Rect> {
if app.ui.tool != Tool::Move || !app.ui.tool_options.move_show_transform || app.ui.transform.is_some() || app.drag.is_some() {
#[derive(Debug, PartialEq)]
struct TransformControls {
bounds: Rect,
corners: [Pos2; 4],
}
impl TransformControls {
fn handles(&self) -> [Pos2; 8] {
let q = self.corners;
[q[0], q[0].lerp(q[1], 0.5), q[1], q[1].lerp(q[2], 0.5), q[2], q[2].lerp(q[3], 0.5), q[3], q[3].lerp(q[0], 0.5)]
}
fn handle_at(&self, p: Pos2, grab: f32) -> Option<usize> {
self.handles().into_iter().enumerate().map(|(i, h)| (i, h.distance(p))).filter(|(_, d)| *d < grab).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
}
pub(crate) fn shape_transform_controls_target(app: &PhotocraftApp) -> bool {
app.session.active().is_some_and(|st| st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))))
}
fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option<TransformControls> {
let tool = app.active_tool();
let shape = shape_transform_controls_target(app);
let visible = if shape {
app.ui.tool_options.shape.show_transform && (tool == Tool::Move || (crate::vector_ui::is_shape_tool(tool) && app.ui.tool_options.shape.mode == "shape"))
} else {
tool == Tool::Move && app.ui.tool_options.move_show_transform
};
if !visible || paint_target(app) != "pixels" || app.ui.mask_target || app.ui.vector_mask_target || app.ui.transform.is_some() || app.drag.is_some() {
return None;
}
let st = app.session.active()?;
let l = st.active_layer.and_then(|id| st.doc.layer(id))?;
if crate::doc_props_ui::is_background(&st.doc, l) {
let locks = st.doc.effective_locks(l.id);
if crate::doc_props_ui::is_background(&st.doc, l) || (tool != Tool::Move && (locks.all || locks.position)) {
return None;
}
let b = photocraft_engine::transform_cmds::transform_bounds(&st.doc, l);
@@ -4354,59 +4506,94 @@ fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option<Rect>
if b.is_empty() {
return None;
}
Some(Rect::from_two_pos(xf.to_screen(b.x0 as f32, b.y0 as f32), xf.to_screen(b.x1 as f32, b.y1 as f32)))
}
/// The Move tool shows transform handles even before a Free Transform session exists.
/// Use the same hit areas for pointer-down and hover, so a control never behaves like
/// resize/rotate while showing the ordinary four-arrow Move cursor (#2871).
fn transform_controls_cursor(r: Rect, p: Pos2) -> Option<egui::CursorIcon> {
let handles = [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()];
let grab = crate::transform_tool::HANDLE_PX as f32;
for (i, handle) in handles.into_iter().enumerate() {
if handle.distance(p) <= grab {
return Some(match i {
0 | 4 => egui::CursorIcon::ResizeNwSe,
2 | 6 => egui::CursorIcon::ResizeNeSw,
1 | 5 => egui::CursorIcon::ResizeVertical,
_ => egui::CursorIcon::ResizeHorizontal,
});
}
}
// The rotation band: the pointer gives way to the curved rotate arrow (`transform_controls_turn`).
in_rotation_band(r, p).then_some(egui::CursorIcon::None)
}
fn in_rotation_band(r: Rect, p: Pos2) -> bool {
r.expand(18.0).contains(p) && !r.expand(5.0).contains(p)
}
/// In the rotation band just outside the Move tool's transform controls, the screen direction the
/// curved rotate arrow bends round: into the box along the nearest corner's bisector, as Free
/// Transform draws it (#4658). Before, the band showed a plain alias pointer, so nothing said a
/// drag there rotates.
fn transform_controls_turn(r: Rect, p: Pos2) -> Option<Vec2> {
if !in_rotation_band(r, p)
|| [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()]
.into_iter()
.any(|h| h.distance(p) <= crate::transform_tool::HANDLE_PX as f32)
{
return None;
}
let corner = pos2(if p.x < r.center().x { r.left() } else { r.right() }, if p.y < r.center().y { r.top() } else { r.bottom() });
Some(vec2((r.center().x - corner.x).signum(), (r.center().y - corner.y).signum()).normalized() * 16.0)
Some(TransformControls {
bounds: Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)),
corners: [[b.x0, b.y0], [b.x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]].map(|p| xf.to_screen(p[0] as f32, p[1] as f32)),
})
}
/// A visible handle starts scaling; the narrow band just outside the box starts rotation.
/// The interior stays the normal Move-tool drag target.
fn transform_controls_hit(r: Rect, p: Pos2) -> bool {
transform_controls_cursor(r, p).is_some()
fn transform_controls_hit(r: &TransformControls, xf: &ViewXform, p: Pos2) -> bool {
let d = xf.to_doc(p);
let p_doc = pos2(d[0] as f32, d[1] as f32);
let zoom = xf.zoom.max(0.01);
r.handle_at(p, crate::transform_tool::HANDLE_PX as f32).is_some()
|| (r.bounds.expand(18.0 / zoom).contains(p_doc) && !r.bounds.expand(5.0 / zoom).contains(p_doc))
}
fn transform_controls_cursor(app: &PhotocraftApp, xf: &ViewXform, p: Pos2) -> Option<egui::CursorIcon> {
let r = transform_controls_rect(app, xf)?;
let shape_tool = crate::vector_ui::is_shape_tool(app.active_tool());
let grab = if shape_tool { 6.0 } else { crate::transform_tool::HANDLE_PX as f32 };
if let Some(i) = r.handle_at(p, grab) {
let handles = r.handles();
let direction = *handles.get(i)? - *handles.get((i + 4) % 8)?;
return Some(transform_resize_cursor(direction));
}
(!shape_tool && transform_controls_hit(&r, xf, p)).then_some(egui::CursorIcon::None)
}
fn transform_controls_turn(app: &PhotocraftApp, xf: &ViewXform, p: Pos2) -> Option<Vec2> {
let r = transform_controls_rect(app, xf)?;
let d = xf.to_doc(p);
let p_doc = pos2(d[0] as f32, d[1] as f32);
let zoom = xf.zoom.max(0.01);
if !r.bounds.expand(18.0 / zoom).contains(p_doc)
|| r.bounds.expand(5.0 / zoom).contains(p_doc)
|| r.handle_at(p, crate::transform_tool::HANDLE_PX as f32).is_some()
{
return None;
}
let corner = pos2(
if p_doc.x < r.bounds.center().x { r.bounds.left() } else { r.bounds.right() },
if p_doc.y < r.bounds.center().y { r.bounds.top() } else { r.bounds.bottom() },
);
let toward = vec2((r.bounds.center().x - corner.x).signum(), (r.bounds.center().y - corner.y).signum()) * 16.0;
Some(xf.to_screen(d[0] as f32 + toward.x, d[1] as f32 + toward.y) - p)
}
fn transform_resize_cursor(direction: Vec2) -> egui::CursorIcon {
let angle = direction.y.atan2(direction.x).rem_euclid(std::f32::consts::PI);
match ((angle / std::f32::consts::FRAC_PI_4).round() as u32) % 4 {
0 => egui::CursorIcon::ResizeHorizontal,
1 => egui::CursorIcon::ResizeNwSe,
2 => egui::CursorIcon::ResizeVertical,
_ => egui::CursorIcon::ResizeNeSw,
}
}
fn transform_cursor_in_view(app: &PhotocraftApp, xf: &ViewXform, p: Pos2, alt: bool) -> Option<egui::CursorIcon> {
let d = xf.to_doc(p);
let cursor = crate::transform_tool::cursor(app, d, alt)?;
let shown = crate::transform_tool::shown_shape_transform(app);
let t = shown.as_ref().or(app.ui.transform.as_ref())?;
if t.warp.is_some() {
return Some(cursor);
}
let q = t.quad.map(|p| xf.to_screen(p[0] as f32, p[1] as f32));
let direction = match crate::transform_tool::hit(t, d, crate::transform_tool::handle_tolerance(app)) {
crate::transform_tool::Hit::Corner(i) => *q.get(i)? - *q.get((i + 2) % 4)?,
crate::transform_tool::Hit::Edge(i) if t.mode != crate::state::TransformMode::Distort => {
q.get(i)?.lerp(*q.get((i + 1) % 4)?, 0.5) - q.get((i + 2) % 4)?.lerp(*q.get((i + 3) % 4)?, 0.5)
}
_ => return Some(cursor),
};
Some(transform_resize_cursor(direction))
}
/// Enter the existing Free Transform session when a Move-tool transform control is pressed.
fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool {
pub(crate) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool {
if crate::shape_canvas::cursor(app, xf.to_doc(p)).is_some() {
return false;
}
let Some(r) = transform_controls_rect(app, xf) else { return false };
if !transform_controls_hit(r, p) {
// Shape-tool drags outside a visible grip still draw new components (Shift/Alt).
// Move retains its generous grip area and rotation band.
if crate::vector_ui::is_shape_tool(app.active_tool()) && r.handle_at(p, 6.0).is_none() {
return false;
}
if !transform_controls_hit(&r, xf, p) {
return false;
}
match crate::transform_tool::begin(app, ctx) {
@@ -4422,8 +4609,8 @@ fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf:
fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
let Some(r) = transform_controls_rect(app, xf) else { return };
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle);
for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] {
painter.add(egui::Shape::closed_line(r.corners.to_vec(), Stroke::new(1.0, accent)));
for p in r.handles() {
let h = Rect::from_center_size(p, vec2(7.0, 7.0));
painter.rect_filled(h, 0.0, Color32::WHITE);
painter.rect_stroke(h, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Inside);
@@ -4701,6 +4888,9 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
let armed = std::mem::take(&mut app.brush_resize_armed);
crate::transform_tool::end_if_left(app);
let mods = crate::workspace_ui::sticky_mods(app, mods);
if crate::shape_canvas::pointer(app, ev, mods) {
return;
}
if app.ui.transform.is_none() && crate::type_transform::pointer(app, ev, mods) {
return;
}
@@ -5366,8 +5556,8 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
// Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead.
t if crate::vector_ui::is_shape_tool(t)
&& crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom())
&& crate::shape_dialog::open(app, t, d.start).is_some() => {}
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live),
&& crate::shape_dialog::open_gesture(app, t, d.start, d.modifiers).is_some() => {}
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape_gesture(app, t, d.start, [end[0], end[1]], d.modifiers, d.live),
Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]),
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]),
Tool::Brush | Tool::Pencil | Tool::Eraser => commit_brush_stroke(app, &d),
@@ -7331,10 +7521,10 @@ mod transform_controls_tests {
let xf =
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 };
let r = transform_controls_rect(&app, &xf).expect("shape layers have transform bounds");
assert!(transform_controls_hit(r, r.right_bottom()));
assert!(transform_controls_hit(&r, &xf, r.corners[2]));
let ctx = egui::Context::default();
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.corners[2]));
let t = app.ui.transform.as_ref().expect("control press starts Free Transform");
assert_eq!(t.rect, [20.0, 30.0, 100.0, 70.0]);
}
@@ -7351,7 +7541,7 @@ mod transform_controls_tests {
assert!(crate::move_ui::float_selected(&mut app, false, 100.0, 50.0));
let xf =
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [200.0, 150.0], flip: false, rotation: 0.0, aspect: 1.0 };
let r = transform_controls_rect(&app, &xf).unwrap();
let r = transform_controls_rect(&app, &xf).unwrap().bounds;
assert_eq!(r, Rect::from_min_max(pos2(200.0, 150.0), pos2(300.0, 210.0)));
let ctx = egui::Context::default();
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
@@ -7394,7 +7584,7 @@ mod transform_controls_tests {
let xf =
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 };
let r = transform_controls_rect(&app, &xf).unwrap();
assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, r.right_bottom()));
assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, r.corners[2]));
assert!(app.ui.transform.is_none());
assert_eq!(errors(&app), [crate::transform_tool::LOCKED]);
}
@@ -7413,50 +7603,52 @@ mod transform_controls_tests {
let r = transform_controls_rect(&app, &xf).expect("visible shape controls");
assert!(app.ui.transform.is_none(), "no transform session is needed for hover");
for (p, expected) in [
(r.left_top(), egui::CursorIcon::ResizeNwSe),
(r.right_bottom(), egui::CursorIcon::ResizeNwSe),
(r.right_top(), egui::CursorIcon::ResizeNeSw),
(r.left_bottom(), egui::CursorIcon::ResizeNeSw),
(r.center_top(), egui::CursorIcon::ResizeVertical),
(r.center_bottom(), egui::CursorIcon::ResizeVertical),
(r.right_center(), egui::CursorIcon::ResizeHorizontal),
(r.left_center(), egui::CursorIcon::ResizeHorizontal),
// Between handles: the rotation band (a point above the top-centre handle is that handle),
// where the curved rotate arrow replaces the pointer (#4658).
(pos2(r.left() + r.width() * 0.25, r.top() - 12.0), egui::CursorIcon::None),
(r.corners[0], egui::CursorIcon::ResizeNwSe),
(r.corners[2], egui::CursorIcon::ResizeNwSe),
(r.corners[1], egui::CursorIcon::ResizeNeSw),
(r.corners[3], egui::CursorIcon::ResizeNeSw),
(r.handles()[1], egui::CursorIcon::ResizeVertical),
(r.handles()[5], egui::CursorIcon::ResizeVertical),
(r.handles()[3], egui::CursorIcon::ResizeHorizontal),
(r.handles()[7], egui::CursorIcon::ResizeHorizontal),
// Between handles: the pointer is hidden while the curved rotate arrow is drawn.
(pos2(r.bounds.left() + r.bounds.width() * 0.25, r.bounds.top() - 12.0), egui::CursorIcon::None),
] {
assert_eq!(transform_controls_cursor(r, p), Some(expected), "hover at {p:?}");
assert!(transform_controls_hit(r, p), "same region must start a transform");
assert_eq!(transform_controls_cursor(&app, &xf, p), Some(expected), "hover at {p:?}");
assert!(transform_controls_hit(&r, &xf, p), "same region must start a transform");
}
assert_eq!(transform_controls_cursor(r, r.center()), None, "interior stays Move");
assert_eq!(transform_controls_cursor(r, pos2(r.center().x, r.top() - 30.0)), None);
// The rotate arrow bends round the nearest corner, pointing into the box; handles and the
// interior have none.
let into = |p: Pos2| transform_controls_turn(r, p).map(|v| (v.x.signum(), v.y.signum()));
assert_eq!(into(pos2(r.left() + r.width() * 0.25, r.top() - 12.0)), Some((1.0, 1.0)), "top left");
assert_eq!(into(pos2(r.right() + 12.0, r.bottom() - r.height() * 0.25)), Some((-1.0, -1.0)), "bottom right");
assert_eq!(into(pos2(r.left() - 12.0, r.bottom() + 12.0)), Some((1.0, -1.0)), "beyond the bottom-left corner");
assert_eq!(into(r.left_top()), None);
assert_eq!(into(r.center()), None);
assert_eq!(transform_controls_cursor(&app, &xf, r.bounds.center()), None, "interior stays Move");
assert_eq!(transform_controls_cursor(&app, &xf, pos2(r.bounds.center().x, r.bounds.top() - 30.0)), None);
let turn_at = pos2(r.bounds.left() + r.bounds.width() * 0.25, r.bounds.top() - 12.0);
let into = |p: Pos2| transform_controls_turn(&app, &xf, p).map(|v| (v.x.signum(), v.y.signum()));
assert_eq!(into(turn_at), Some((1.0, 1.0)), "rotation arrow points toward top-left corner");
assert_eq!(into(r.corners[0]), None, "resize handle keeps its own cursor");
// Changing the active shape changes the hover bounds without requiring a drag first.
app.session.execute("shape.create", json!({"kind": "rect", "rect": [120, 100, 40, 50], "fill": "#00ff00"})).unwrap();
let next = transform_controls_rect(&app, &xf).expect("new active shape controls");
assert_ne!(r, next);
assert_eq!(transform_controls_cursor(next, next.right_bottom()), Some(egui::CursorIcon::ResizeNwSe));
assert_eq!(transform_controls_cursor(next, next.center()), None);
assert_eq!(transform_controls_cursor(&app, &xf, next.corners[2]), Some(egui::CursorIcon::ResizeNwSe));
assert_eq!(transform_controls_cursor(&app, &xf, next.bounds.center()), None);
}
#[test]
fn transform_control_hit_keeps_move_interior_free_and_has_rotation_band() {
let r = Rect::from_min_max(pos2(20.0, 30.0), pos2(100.0, 70.0));
assert!(transform_controls_hit(r, r.left_top()));
assert!(transform_controls_hit(r, pos2(60.0, 18.0)));
assert!(!transform_controls_hit(r, r.center()));
assert!(!transform_controls_hit(r, pos2(60.0, 4.0)));
let bounds = Rect::from_min_max(pos2(20.0, 30.0), pos2(100.0, 70.0));
let r = TransformControls { bounds, corners: [bounds.left_top(), bounds.right_top(), bounds.right_bottom(), bounds.left_bottom()] };
let xf =
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 };
assert!(transform_controls_hit(&r, &xf, r.corners[0]));
assert!(transform_controls_hit(&r, &xf, pos2(60.0, 18.0)));
assert!(!transform_controls_hit(&r, &xf, r.bounds.center()));
assert!(!transform_controls_hit(&r, &xf, pos2(60.0, 4.0)));
}
}
#[cfg(test)]
#[path = "shape_controls_tests.rs"]
mod shape_controls_tests;
#[cfg(test)]
mod layer_edges_tests;
+20 -2
View File
@@ -53,12 +53,30 @@ fn capslock_harness() -> Harness<'static, PhotocraftApp> {
struct CursorFp {
icon: CursorIcon,
image: Option<[u16; 2]>,
shapes: Vec<egui::epaint::ClippedShape>,
shapes: Vec<String>,
}
fn fp(h: &Harness<'_, PhotocraftApp>) -> CursorFp {
let out = h.output();
CursorFp { icon: out.platform_output.cursor_icon, image: out.platform_output.cursor_image.as_ref().map(|i| i.size), shapes: out.shapes.clone() }
let shapes = out.shapes.iter().map(|shape| stable_shape(&format!("{shape:?}"))).collect();
CursorFp { icon: out.platform_output.cursor_icon, image: out.platform_output.cursor_image.as_ref().map(|i| i.size), shapes }
}
fn stable_shape(debug: &str) -> String {
let mut stable = String::with_capacity(debug.len());
let mut remaining = debug;
while let Some(start) = remaining.find("Managed(") {
stable.push_str(&remaining[..start]);
let after = &remaining[start + "Managed(".len()..];
let Some(end) = after.find(')') else {
stable.push_str(&remaining[start..]);
return stable;
};
stable.push_str("Managed(_)");
remaining = &after[end + 1..];
}
stable.push_str(remaining);
stable
}
fn painting(h: &mut Harness<'_, PhotocraftApp>, value: &str) {
+16 -9
View File
@@ -13,6 +13,7 @@ use photocraft_engine::channel_cmds::{ChannelView, color_count};
use photocraft_engine::mask_view_cmds::MaskViewMode;
use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba, to_rgba};
use std::sync::{Arc, Weak};
use crate::PhotocraftApp;
@@ -22,10 +23,11 @@ const MAX_SIDE: u32 = 4096;
const MAX_COMPOSITE_CACHE_PIXELS: usize = 8 * 1024 * 1024;
struct SampledComposite {
doc: Weak<Document>,
revision: u64,
preview_key: u64,
factor: u32,
pixels: Vec<[f32; 4]>,
pixels: Arc<Vec<[f32; 4]>>,
}
struct CompositeSource<'a> {
@@ -270,12 +272,17 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio
// Never exceed what the GPU accepts (egui panics on oversized textures).
let max_side = (ctx.input(|i| i.max_texture_side) as u32).clamp(256, MAX_SIDE);
let factor = doc.size.width.max(doc.size.height).div_ceil(max_side).max(1);
// A live preview can mutate the same Arc in place. Revision and preview_key, not
// pointer identity, determine whether these flattened pixels are still current.
// Committed document edits replace the document Arc. A view-only revision bump leaves it
// unchanged, so keep its sampled pixels; live previews still require an exact revision.
if let Some(c) = app.channel_views.get_mut(&id)
&& c.source.as_ref().is_some_and(|s| s.factor != factor || s.revision != revision || s.preview_key != preview_key)
&& let Some(s) = c.source.as_mut()
{
c.source = None;
let same_doc = s.doc.upgrade().is_some_and(|cached_doc| Arc::ptr_eq(&cached_doc, &doc));
if s.factor != factor || !same_doc || s.preview_key != preview_key || (preview_key != 0 && s.revision != revision) {
c.source = None;
} else if preview_key == 0 {
s.revision = revision;
}
}
if let Some(c) = app.channel_views.get(&id)
&& c.key == key
@@ -325,12 +332,12 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio
c.factor = factor;
c.valid = true;
if let Some(pixels) = fresh.take() {
c.source = Some(SampledComposite { revision, preview_key, factor, pixels });
c.source = Some(SampledComposite { doc: Arc::downgrade(&doc), revision, preview_key, factor, pixels: Arc::new(pixels) });
}
}
_ => {
let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR);
let source = fresh.map(|pixels| SampledComposite { revision, preview_key, factor, pixels });
let source = fresh.map(|pixels| SampledComposite { doc: Arc::downgrade(&doc), revision, preview_key, factor, pixels: Arc::new(pixels) });
app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex, source });
}
}
@@ -393,12 +400,12 @@ mod tests {
let id = app.session.active().unwrap().doc.id.0;
let first = app.channel_views.get(&id).unwrap().source.as_ref().unwrap();
let original_sample = first.pixels[0];
let ptr = first.pixels.as_ptr();
let original_pixels = first.pixels.clone();
app.run("channel.setVisible", json!({"channel": "green", "visible": false})).unwrap();
assert!(ensure(&mut app, &ctx, 0).is_some());
let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap();
assert_eq!(ptr, reused.pixels.as_ptr(), "visibility does not recompose the pixels");
assert!(Arc::ptr_eq(&original_pixels, &reused.pixels), "visibility does not recompose the pixels");
app.run("edit.fill", json!({"color": "#e02010"})).unwrap();
assert!(ensure(&mut app, &ctx, 0).is_some());
+6 -1
View File
@@ -445,6 +445,11 @@ const VALUE_W: f32 = 42.0;
/// Draw the picker and its top-right actions. `Some(true)` confirms, `Some(false)` cancels.
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) -> Option<bool> {
body_with_swatches(ui, f, true)
}
/// Local paint editors do not manage the shared swatch library.
pub(crate) fn body_with_swatches(ui: &mut egui::Ui, f: &mut Map<String, Value>, add_swatch: bool) -> Option<bool> {
ui.set_width(CONTENT_WIDTH);
ui.spacing_mut().item_spacing.y = 4.0;
let mut outcome = None;
@@ -483,7 +488,7 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) -> Option<bool> {
}
ui.add_space(4.0);
// Handled by the dialog host, which has the app (`take_add_swatch`).
if widgets::secondary_button(ui, tl!("Add to Swatches"), button_width).clicked() {
if add_swatch && widgets::secondary_button(ui, tl!("Add to Swatches"), button_width).clicked() {
f.insert("__addSwatch".into(), json!(true));
}
});
+51 -2
View File
@@ -131,7 +131,7 @@ pub enum Outcome {
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
/// changed (#412).
pub const UI_SET_FIELDS: [&str; 35] = [
pub const UI_SET_FIELDS: [&str; 38] = [
"tool",
"panels",
"dock",
@@ -166,6 +166,9 @@ pub const UI_SET_FIELDS: [&str; 35] = [
"cropShield",
"shapeStroke",
"camTransformOnDrop",
"shapeOptions",
"shapeFillPaint",
"shapeStrokePaint",
"tileGrid",
];
@@ -480,7 +483,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
}
}
let o = &app.ui.tool_options;
let base = o.shape_stroke.stroke(o.stroke_width, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK));
let base = o.shape_stroke.stroke(
o.stroke_width,
o.shape_stroke_paint.clone().unwrap_or_else(|| {
let c = crate::shape_options::stroke_color(app);
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3]))
}),
);
Some(photocraft_engine::vector_cmds::parse_stroke(v, Some(base), photocraft_doc::Color::BLACK)?)
}
None => None,
@@ -534,6 +543,21 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
};
// The Crop tool's gear menu: Show Cropped Area and the shield (#1919).
let crop_shield = merged_object(&app.ui.tool_options.crop_shield, p.get("cropShield"), "cropShield")?;
let mut shape_patch = p.get("shapeOptions").cloned();
if let Some(patch) = shape_patch.as_mut() {
let heads = merged_object(&app.ui.tool_options.shape.arrowheads, patch.get("arrowheads"), "shapeOptions.arrowheads")?;
if let Some(heads) = heads {
patch["arrowheads"] = serde_json::to_value(heads).map_err(|e| e.to_string())?;
}
}
let shape_options = merged_object(&app.ui.tool_options.shape, shape_patch.as_ref(), "shapeOptions")?;
if let Some(o) = &shape_options {
o.validate()?;
}
let shape_fill_paint =
p.get("shapeFillPaint").map(|v| photocraft_engine::vector_cmds::shape_fill(&app.session, v)).transpose().map_err(|e| e.to_string())?;
let shape_stroke_paint =
p.get("shapeStrokePaint").map(|v| photocraft_engine::vector_cmds::shape_fill(&app.session, v)).transpose().map_err(|e| e.to_string())?;
if crop_shield.as_ref().is_some_and(|s| !(0.0..=100.0).contains(&s.opacity)) {
return Err("cropShield.opacity must be 0..100".into());
}
@@ -641,6 +665,20 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
app.ui.tool_options.stroke_width = 0.0;
}
}
if let Some(fill) = shape_fill_paint {
app.ui.tool_options.shape_fill = fill.is_some();
app.ui.tool_options.shape_fill_paint = fill;
}
if let Some(paint) = shape_stroke_paint {
app.ui.tool_options.stroke_width = if paint.is_none() {
0.0
} else if app.ui.tool_options.stroke_width <= 0.0 {
3.0
} else {
app.ui.tool_options.stroke_width
};
app.ui.tool_options.shape_stroke_paint = paint;
}
if let Some(t) = tool {
app.ui.tool = t;
// Each tool keeps its own brush (#218), so switch it in before `brushSize`
@@ -708,6 +746,10 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(v) = dock_tabs {
app.ui.dock_tabs = v;
}
if let Some(mut o) = shape_options {
o.path_geometry = app.ui.tool_options.shape.path_geometry.clone();
app.ui.tool_options.shape = o;
}
// Dock group order, heights and collapsed groups (see `dock::DockLayout`).
if let Some(v) = dock {
app.ui.dock = v;
@@ -1059,6 +1101,13 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(d) = app.drag.as_mut().filter(|d| crate::hold_keys::repositions(d.tool)) {
d.reposition = space;
}
if !space
&& app.ui.transform.is_none()
&& matches!(ev, ToolEvent::Down { .. })
&& let Some(xf) = crate::canvas::ViewXform::active(app)
{
crate::canvas::begin_transform_controls_at(app, ctx, &xf, xf.to_screen(x as f32, y as f32));
}
// A Crop drag in the default mode turns and pans the view with the image: its
// points are read as the view showed them at the press (`crop_mode`).
let ev = match ev {
+1 -1
View File
@@ -356,7 +356,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn F
Some(Drag::Edit { target, preview: Some(p), .. }) if *target == r => p.clone(),
_ => path,
};
crate::vector_ui::draw_outline(painter, &path, to_scr, accent);
crate::vector_ui::draw_outline(painter, &path, to_scr, accent, crate::shape_modes::path_display(app, painter.ctx()).1);
let ours = ds.target == Some(r);
for &(k, h) in if ours { shown_handles(ds, &path) } else { Vec::new() }.iter() {
if let Some(kn) = path.subpaths.get(k[0]).and_then(|s| s.knots.get(k[1])) {
+5 -3
View File
@@ -719,9 +719,11 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() {
assert!(!app.ui.status_error, "{}", app.ui.status);
assert_eq!(written.borrow().len(), 2);
assert_eq!(written.borrow()[0], "/out/boards.jpg");
// The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows).
let second = written.borrow()[1].replace('\\', "/");
assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow());
let paths = written.borrow();
let second = std::path::Path::new(&paths[1]);
assert_eq!(second.parent(), Some(std::path::Path::new("/out")), "the second export stays in the chosen directory");
let filename = second.file_name().unwrap().to_str().unwrap();
assert!(filename.starts_with("boards-2-") && filename.ends_with(".jpg"), "{paths:?}");
let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::<Vec<_>>();
got.sort_unstable();
assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %");
+67 -60
View File
@@ -77,6 +77,12 @@ pub struct LiveGradient {
preview: Option<(u64, Arc<Document>)>,
}
/// A draft would become stale if an external Smart Object update changed the document.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn editing(app: &PhotocraftApp) -> bool {
app.gradient.drag.is_some() || app.gradient.panel.is_some() || app.gradient.preset_stop.is_some()
}
/// Whether the Gradient tool is in its live (Gradient Fill layer) mode.
/// Painting into a layer mask, an alpha channel or the Quick Mask stays classic (pixels), except
/// that a selected gradient fill layer is always edited live (selecting it targets its mask).
@@ -786,14 +792,50 @@ fn preset_stop_color(app: &mut PhotocraftApp, i: usize) {
}
fn stops_editor_for(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
let key = strip_key(app, sink).with("selection");
if ui.data(|d| d.get_temp::<Marker>(key)).is_none() {
ui.data_mut(|d| d.insert_temp(key, Marker::Color(0)));
let key = strip_key(app, sink);
let selection_key = key.with("selection");
if ui.data(|d| d.get_temp::<Marker>(selection_key)).is_none() {
ui.data_mut(|d| d.insert_temp(selection_key, Marker::Color(0)));
}
let pending = match sink {
Sink::Layer(id) => app.gradient.panel.clone().filter(|(l, ..)| *l == id).map(|(_, _, p)| p),
Sink::Preset => app.gradient.preset_stop.clone(),
};
let colors = (app.session.tools.foreground, app.session.tools.background);
match stop_strip(ui, key, &f, sink.midpoints(), pending.as_ref(), colors) {
Some(StopAction::Preview(p)) => match sink {
Sink::Layer(id) => app.gradient.panel = Some((id, cmds::STOP, sink.params(p))),
Sink::Preset => app.gradient.preset_stop = Some(p),
},
Some(StopAction::Commit(p)) => {
match sink {
Sink::Layer(_) => app.gradient.panel = None,
Sink::Preset => app.gradient.preset_stop = None,
}
let selected = ui.data(|d| d.get_temp::<Marker>(selection_key));
let selection = selection_after_edit(&f, &p, selected);
if app.run(sink.cmd(), sink.params(p)).is_ok()
&& let Some(marker) = selection
{
ui.data_mut(|d| d.insert_temp(selection_key, marker));
}
}
Some(StopAction::Color(i)) => match sink {
Sink::Layer(id) => edit_stop_color(app, id, i),
Sink::Preset => preset_stop_color(app, i),
},
None => {}
}
stop_strip(app, ui, sink, f);
properties_panel::stop_fields(app, ui, sink);
}
/// A shared stop strip keeps previews local; its caller chooses the command or paint target.
pub(crate) enum StopAction {
Preview(Value),
Commit(Value),
Color(usize),
}
/// Keep selection on a stop after the engine sorts its new position.
fn selection_after_edit(f: &Fill, p: &Value, previous: Option<Marker>) -> Option<Marker> {
let Fill::Gradient { stops, opacity_stops, .. } = f else { return previous };
@@ -821,8 +863,15 @@ fn selection_after_edit(f: &Fill, p: &Value, previous: Option<Marker>) -> Option
Some(if opacity { Marker::Opacity(at) } else { Marker::Color(at) })
}
fn stop_strip(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
let Fill::Gradient { stops, .. } = &f else { return };
pub(crate) fn stop_strip(
ui: &mut egui::Ui,
key: egui::Id,
f: &Fill,
midpoints: bool,
pending: Option<&Value>,
colors: ([f32; 4], [f32; 4]),
) -> Option<StopAction> {
let Fill::Gradient { stops, .. } = f else { return None };
let t = Tokens::get(ui.ctx());
let w = ui.available_width().max(120.0);
let (outer, resp) = ui.allocate_exact_size(vec2(w, 58.0), Sense::click_and_drag());
@@ -833,12 +882,8 @@ fn stop_strip(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
let x_of = |loc: f32| bar.left() + loc.clamp(0.0, 1.0) * bar.width();
let loc_of = |x: f32| ((x - bar.left()) / bar.width().max(1.0)).clamp(0.0, 1.0);
// The ramp being shown (with a pending drag's edit applied).
let pending: Option<Value> = match sink {
Sink::Layer(id) => app.gradient.panel.clone().filter(|(l, ..)| *l == id).map(|(_, _, p)| p),
Sink::Preset => app.gradient.preset_stop.clone(),
};
let (fg, bg) = (app.session.tools.foreground, app.session.tools.background);
let shown = pending.as_ref().and_then(|p| cmds::apply_stop(&f, p, fg, bg).ok()).unwrap_or_else(|| f.clone());
let (fg, bg) = colors;
let shown = pending.and_then(|p| cmds::apply_stop(f, p, fg, bg).ok()).unwrap_or_else(|| f.clone());
let mut track_fill = shown.clone();
if let Fill::Gradient { reverse, .. } = &mut track_fill {
// All markers address un-reversed positions; Reverse applies to the canvas output.
@@ -856,7 +901,6 @@ fn stop_strip(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut ops: Vec<(f32, f32)> = if os.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { os.clone() };
ops.sort_by(|a, b| a.0.total_cmp(&b.0));
let key = strip_key(app, sink);
let selection_key = key.with("selection");
let selected: Option<Marker> = ui.data(|d| d.get_temp(selection_key));
let dragging: Option<Marker> = ui.data(|d| d.get_temp(key));
@@ -877,7 +921,7 @@ fn stop_strip(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
p.rect_filled(body, 1.0, color);
p.rect_stroke(body, 1.0, Stroke::new(1.0, if sel { t.accent } else { t.text_dim }), StrokeKind::Outside);
}
if sink.midpoints() {
if midpoints {
for i in 0..sorted.len().saturating_sub(1) {
let (a, b) = (sorted[i].0, sorted[i + 1].0);
if b - a > 1e-3 {
@@ -907,7 +951,7 @@ fn stop_strip(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
if let Some(i) = sorted.iter().position(|(l, _)| (x_of(*l) - pos.x).abs() <= 6.0 && pos.y >= bar.bottom() + 3.0) {
return Some(Marker::Color(i));
}
if !sink.midpoints() {
if !midpoints {
return None;
}
return (0..sorted.len().saturating_sub(1))
@@ -930,6 +974,7 @@ fn stop_strip(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
ui.data_mut(|d| d.insert_temp(selection_key, m));
}
let dragging: Option<Marker> = ui.data(|d| d.get_temp(key));
let mut preview = None;
if let (Some(m), Some(pos)) = (dragging, resp.interact_pointer_pos()) {
let off = if pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0 { 1.0 } else { 0.0 };
let loc = loc_of(pos.x);
@@ -944,70 +989,32 @@ fn stop_strip(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) {
}
};
if resp.dragged() {
match sink {
Sink::Layer(id) => app.gradient.panel = Some((id, cmds::STOP, sink.params(edit))),
Sink::Preset => app.gradient.preset_stop = Some(edit),
}
preview = Some(edit);
}
}
if resp.drag_stopped() {
ui.data_mut(|d| d.remove::<Marker>(key));
match sink {
Sink::Layer(_) => {
if let Some((_, cmd, p)) = app.gradient.panel.take() {
let selection = selection_after_edit(&f, &p, selected);
if app.run(cmd, p).is_ok()
&& let Some(marker) = selection
{
ui.data_mut(|d| d.insert_temp(selection_key, marker));
}
}
}
Sink::Preset => {
if let Some(p) = app.gradient.preset_stop.take() {
let selection = selection_after_edit(&f, &p, selected);
if app.run(sink.cmd(), p).is_ok()
&& let Some(marker) = selection
{
ui.data_mut(|d| d.insert_temp(selection_key, marker));
}
}
}
}
return;
return preview.or_else(|| pending.cloned()).map(StopAction::Commit);
}
if resp.double_clicked()
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some(Marker::Color(i)) = hit(pos)
{
ui.data_mut(|d| d.insert_temp(selection_key, Marker::Color(i)));
match sink {
Sink::Layer(id) => edit_stop_color(app, id, i),
Sink::Preset => preset_stop_color(app, i),
}
return;
return Some(StopAction::Color(i));
}
if resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
if let Some(marker) = hit(pos) {
ui.data_mut(|d| d.insert_temp(selection_key, marker));
return;
return None;
}
let loc = loc_of(pos.x);
let opacity = pos.y < bar.center().y;
let p = if opacity {
sink.params(json!({"action": "add", "kind": "opacity", "location": loc}))
} else {
sink.params(json!({"action": "add", "location": loc}))
};
let selection = selection_after_edit(&f, &p, selected);
if app.run(sink.cmd(), p).is_ok()
&& let Some(marker) = selection
{
ui.data_mut(|d| d.insert_temp(selection_key, marker));
}
let p = if pos.y < bar.center().y { json!({"action": "add", "kind": "opacity", "location": loc}) } else { json!({"action": "add", "location": loc}) };
return Some(StopAction::Commit(p));
}
preview.map(StopAction::Preview)
}
#[cfg(test)]
+28 -2
View File
@@ -2557,6 +2557,33 @@ fontWeight Italic Kurzíva
Quick Selection found nothing there Rychlý výběr zde nic nenašel
Object Selection found nothing there — try a tighter rectangle Výběr objektu zde nic nenašel — zkuste těsnější obdélník
No subject found — try a rectangle around the object Nebyl nalezen žádný objekt — zkuste obdélník kolem objektu
W Š
H V
Align to Canvas Zarovnat k plátnu
Arrowheads Hroty šipek
Bring Shape Forward Posunout tvar vpřed
Bring Shape to Front Přenést tvar do popředí
Constrain Proportions Zachovat proporce
Distribute Horizontally Rozmístit vodorovně
Distribute Vertically Rozmístit svisle
End Konec
New Path Nová cesta
Path Alignment Zarovnání cest
Path Arrangement Pořadí cest
Path Operations Operace s cestami
Path Options Volby cesty
Send Shape Backward Posunout tvar vzad
Send Shape to Back Přenést tvar do pozadí
Show Path Zobrazit cestu
Start Začátek
Align Edges Zarovnat hrany
Unconstrained Bez omezení
Proportional Proporcionální
Smooth Indents Vyhladit prohlubně
Concavity: Konkávnost:
Recent Colors Naposledy použité barvy
No recent colors Žádné nedávno použité barvy
Color opacity Krytí barvy
Auto Erase Automatické mazání
Click the first point or press {key} to close · {del} removes a point · Esc cancels Klikněte na první bod nebo stiskněte {key} pro uzavření · {del} odebere bod · Esc zruší
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klikněte a veďte podél okraje · kliknutím připevníte bod · {alt}-kliknutí pro rovný úsek · dvojklik uzavře
@@ -2609,8 +2636,6 @@ cameraData Lens Objektiv
Make Portrait Na výšku
Make Landscape Na šířku
Add New Artboard Přidat novou kreslicí plochu
W Š
H V
Freeform Pen Tool Nástroj pero od ruky
Curvature Pen Tool Nástroj pero křivosti
Curve Fit: Přizpůsobení křivky:
@@ -3508,6 +3533,7 @@ curves Smooth Vyhladit
Paint over hair or fur on the image; Alt paints the other mode Malujte přes vlasy nebo srst v obraze; Alt maluje v opačném režimu
Clear Brush Strokes Vymazat tahy štětce
Show rulers Zobrazit pravítka
Shape color selector Výběr barvy tvaru
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Kliknutím umístíte · Alt-kliknutím nebo přetažením mimo plátno odeberete
Can't render this file because it contains an unexpected character in line 6 and column 47.
+71 -14
View File
@@ -22,6 +22,33 @@
Masked Areas Maskierte Bereiche
Selected Areas Ausgewählte Bereiche
Ink Characteristics Farbeigenschaften
# Vector clipboard Paste As
vectorPaste Paste Einfügen
vectorPaste Paste as: Einfügen als:
vectorPaste Layers Ebenen
vectorPaste Smart Object Smart-Objekt
vectorPaste Pixels Pixel
vectorPaste Path Pfad
vectorPaste Shape Layer Formebene
vectorPaste SVG text is converted to outlines, not editable type. SVG-Text wird in Konturen umgewandelt und bleibt nicht als Text bearbeitbar.
vectorPaste Import warnings: Importhinweise:
vectorPaste The SVG is too large to rasterise (24 million pixel limit) Das SVG ist zum Rastern zu groß (maximal 24 Millionen Pixel).
vectorPaste The SVG has incompatible fill rules; use Layers or Smart Object Das SVG enthält unvereinbare Füllregeln. Verwenden Sie Ebenen oder ein Smart-Objekt.
vectorPaste The SVG contains rasterised objects without editable paths Das SVG enthält gerasterte Objekte ohne bearbeitbare Pfade.
vectorPaste The SVG contains no editable paths Das SVG enthält keine bearbeitbaren Pfade.
vectorPaste Shape Layer requires one shape without group blending; use Layers to preserve this SVG Eine Formebene benötigt eine einzelne Form ohne Gruppenmischung. Verwenden Sie Ebenen, um dieses SVG zu erhalten.
vectorPaste The SVG contains no editable shape Das SVG enthält keine bearbeitbare Form.
vectorPaste The pasted shape caches exceed the clipboard cache budget; use Pixels or Smart Object Die eingefügten Formen überschreiten das Speicherbudget der Zwischenablage. Verwenden Sie Pixel oder ein Smart-Objekt.
smartSvg Edit SVG Smart Object SVG-Smart-Objekt bearbeiten
smartSvg Choose the application used to edit this SVG: Wählen Sie die Anwendung zum Bearbeiten dieses SVG:
smartSvg Clipboard SVG does not reliably identify its source application. SVG aus der Zwischenablage identifiziert seine Herkunftsanwendung nicht zuverlässig.
smartSvg Other application Andere Anwendung
smartSvg PhotoCraft (internal editor) PhotoCraft (interner Editor)
smartSvg Choose executable… Programm auswählen…
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Speichern Sie die geöffnete SVG-Datei. Jede gespeicherte Änderung wird in PhotoCraft zu einem Rückgängig-Schritt.
smartSvg Updates wait while another document or an editing dialog is active. Aktualisierungen warten, während ein anderes Dokument oder ein Bearbeitungsdialog aktiv ist.
smartSvg Stop syncing this Smart Object Synchronisierung dieses Smart-Objekts beenden
smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG-Editor geöffnet. Speichern Sie das SVG, um dieses Smart-Objekt zu aktualisieren.
# Guide layout dialog
@@ -2597,6 +2624,33 @@ fontWeight Italic Kursiv
Quick Selection found nothing there Die Schnellauswahl hat dort nichts gefunden
Object Selection found nothing there — try a tighter rectangle Die Objektauswahl hat dort nichts gefunden — versuchen Sie ein engeres Rechteck
No subject found — try a rectangle around the object Kein Motiv gefunden — versuchen Sie ein Rechteck um das Objekt
Align to Canvas An Leinwand ausrichten
Arrowheads Pfeilspitzen
Bring Shape Forward Form nach vorne
Bring Shape to Front Form in den Vordergrund
Constrain Proportions Proportionen beibehalten
Distribute Horizontally Horizontal verteilen
Distribute Vertically Vertikal verteilen
End Ende
New Path Neuer Pfad
Path Alignment Pfadausrichtung
Path Arrangement Pfadanordnung
Path Operations Pfadoperationen
Path Options Pfadoptionen
Send Shape Backward Form nach hinten
Send Shape to Back Form in den Hintergrund
Show Path Pfad anzeigen
Start Anfang
Align Edges Kanten ausrichten
Unconstrained Unbeschränkt
Proportional Proportional
Smooth Indents Einbuchtungen glätten
Concavity: Einbuchtung:
Solid Color Volltonfarbe
Color Picker Farbwähler
Recent Colors Zuletzt verwendete Farben
No recent colors Noch keine Farben verwendet
Color opacity Farbdeckkraft
Click the first point or press {key} to close · {del} removes a point · Esc cancels Klicke auf den ersten Punkt oder drücke {key} zum Schließen · {del} entfernt einen Punkt · Esc bricht ab
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klicke und bewege dich entlang einer Kante · Klicken setzt einen Ankerpunkt · {alt}-Klick für ein gerades Segment · Doppelklick schließt
Follows only edges this close to the pointer ([ and ] change it) Folgt nur Kanten so nah am Zeiger ([ und ] ändern den Wert)
@@ -2701,6 +2755,23 @@ curves Smooth Glätten
Clear Brush Strokes Pinselstriche löschen
Show rulers Lineale anzeigen
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} Farbfeld nach {file} exportiert|{n} Farbfelder nach {file} exportiert
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} Pinsel aus {group} importiert|{n} Pinsel aus {group} importiert
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} Farbfeld aus {file} importiert|{n} Farbfelder aus {file} importiert
@plural Played {n} step|Played {n} steps {n} Schritt abgespielt|{n} Schritte abgespielt
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} auf dem Datenträger geändertes verknüpftes Smart-Objekt aktualisiert|{n} auf dem Datenträger geänderte verknüpfte Smart-Objekte aktualisiert
@plural Saved for Web: {n} file|Saved for Web: {n} files Für Web gespeichert: {n} Datei|Für Web gespeichert: {n} Dateien
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} in dieser Ebenenkomposition gespeicherte Ebene wurde gelöscht. Aktualisieren Sie die Ebenenkomposition oder löschen Sie die Warnung.|{n} in dieser Ebenenkomposition gespeicherte Ebenen wurden gelöscht. Aktualisieren Sie die Ebenenkomposition oder löschen Sie die Warnung.
@plural {n} plane|{n} planes {n} Fläche|{n} Flächen
@plural {n} preset|{n} presets {n} Vorgabe|{n} Vorgaben
@plural {n} sample point removed|{n} sample points removed {n} Messpunkt entfernt|{n} Messpunkte entfernt
@plural {n} slice|{n} slices {n} Slice|{n} Slices
@plural {n} step|{n} steps {n} Schritt|{n} Schritte
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Warnung zu Pinselvorgaben)|{warning} (+{n} weitere Warnungen zu Pinselvorgaben)
@plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen
Shape color selector Formfarbauswahl
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Klicken zum Platzieren · Alt-Klick oder Ziehen außerhalb der Arbeitsfläche zum Entfernen
No document open Kein Dokument geöffnet
@@ -2718,20 +2789,6 @@ curves Smooth Glätten
The layer or its mask changed. Drag the mask onto the Trash again. Die Ebene oder ihre Maske hat sich geändert. Ziehen Sie die Maske erneut in den Papierkorb.
Delete the vector mask? Vektormaske löschen?
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} Farbfeld nach {file} exportiert|{n} Farbfelder nach {file} exportiert
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} Pinsel aus {group} importiert|{n} Pinsel aus {group} importiert
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} Farbfeld aus {file} importiert|{n} Farbfelder aus {file} importiert
@plural Played {n} step|Played {n} steps {n} Schritt wiedergegeben|{n} Schritte wiedergegeben
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} verknüpftes Smart-Objekt aktualisiert, das auf dem Datenträger geändert wurde|{n} verknüpfte Smart-Objekte aktualisiert, die auf dem Datenträger geändert wurden
@plural Saved for Web: {n} file|Saved for Web: {n} files Für Web gespeichert: {n} Datei|Für Web gespeichert: {n} Dateien
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} von dieser Komposition erfasste Ebene wurde gelöscht. Aktualisiere die Komposition oder lösche die Warnung.|{n} von dieser Komposition erfasste Ebenen wurden gelöscht. Aktualisiere die Komposition oder lösche die Warnung.
@plural {n} plane|{n} planes {n} Fläche|{n} Flächen
@plural {n} preset|{n} presets {n} Vorgabe|{n} Vorgaben
@plural {n} sample point removed|{n} sample points removed {n} Messpunkt entfernt|{n} Messpunkte entfernt
@plural {n} slice|{n} slices {n} Slice|{n} Slices
@plural {n} step|{n} steps {n} Schritt|{n} Schritte
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Pinselvorgaben-Warnung)|{warning} (+{n} weitere Pinselvorgaben-Warnungen)
@plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen
# View › Guides › New Guide… › Color
Light Blue Hellblau
Light Red Hellrot
Can't render this file because it contains an unexpected character in line 6 and column 47.
+43 -16
View File
@@ -3659,6 +3659,32 @@ fontWeight Thin Πολύ λεπτά
Quick Selection found nothing there Η Γρήγορη επιλογή δεν βρήκε τίποτα εκεί
Object Selection found nothing there — try a tighter rectangle Η Επιλογή αντικειμένου δεν βρήκε τίποτα εκεί — δοκιμάστε ένα πιο στενό ορθογώνιο
No subject found — try a rectangle around the object Δεν βρέθηκε θέμα — δοκιμάστε ένα ορθογώνιο γύρω από το αντικείμενο
W Π
H Υ
Align to Canvas Στοίχιση στον καμβά
Arrowheads Αιχμές βελών
Bring Shape Forward Μεταφορά σχήματος εμπρός
Bring Shape to Front Μεταφορά σχήματος στο προσκήνιο
Constrain Proportions Διατήρηση αναλογιών
Distribute Horizontally Οριζόντια κατανομή
Distribute Vertically Κατακόρυφη κατανομή
End Τέλος
Path Alignment Στοίχιση διαδρομών
Path Arrangement Σειρά διαδρομών
Path Operations Λειτουργίες διαδρομής
Path Options Επιλογές διαδρομής
Send Shape Backward Μεταφορά σχήματος πίσω
Send Shape to Back Μεταφορά σχήματος στο παρασκήνιο
Show Path Εμφάνιση διαδρομής
Start Αρχή
Align Edges Ευθυγράμμιση άκρων
Unconstrained Χωρίς περιορισμούς
Proportional Αναλογικό
Smooth Indents Ομαλές εσοχές
Concavity: Κοιλότητα:
Recent Colors Πρόσφατα χρώματα
No recent colors Δεν υπάρχουν πρόσφατα χρώματα
Color opacity Αδιαφάνεια χρώματος
Appearance Settings… Ρυθμίσεις εμφάνισης…
Add to favorites Προσθήκη στα αγαπημένα
Remove from favorites Αφαίρεση από τα αγαπημένα
@@ -3700,8 +3726,6 @@ cameraData Lens Φακός
Make Portrait Κατακόρυφος προσανατολισμός
Make Landscape Οριζόντιος προσανατολισμός
Add New Artboard Προσθήκη νέου πίνακα σχεδίασης
W Π
H Υ
Freeform Pen Tool Πένα ελεύθερης σχεδίασης
Curvature Pen Tool Πένα καμπυλότητας
Curve Fit: Προσαρμογή καμπύλης:
@@ -3757,6 +3781,23 @@ curves Smooth Εξομάλυνση
Clear Brush Strokes Απαλοιφή πινελιών
Show rulers Εμφάνιση χαράκων
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Εξήχθη {n} δείγμα χρώματος στο {file}|Εξήχθησαν {n} δείγματα χρωμάτων στο {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Εισήχθη {n} πινέλο από την ομάδα {group}|Εισήχθησαν {n} πινέλα από την ομάδα {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Εισήχθη {n} δείγμα χρώματος από το {file}|Εισήχθησαν {n} δείγματα χρωμάτων από το {file}
@plural Played {n} step|Played {n} steps Εκτελέστηκε {n} βήμα|Εκτελέστηκαν {n} βήματα
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ενημερώθηκε {n} συνδεδεμένο έξυπνο αντικείμενο που άλλαξε στον δίσκο|Ενημερώθηκαν {n} συνδεδεμένα έξυπνα αντικείμενα που άλλαξαν στον δίσκο
@plural Saved for Web: {n} file|Saved for Web: {n} files Αποθηκεύτηκε για ιστό: {n} αρχείο|Αποθηκεύτηκαν για ιστό: {n} αρχεία
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Διαγράφηκε {n} επίπεδο που είχε καταγραφεί σε αυτή τη σύνθεση. Ενημερώστε τη σύνθεση ή διαγράψτε την προειδοποίηση.|Διαγράφηκαν {n} επίπεδα που είχαν καταγραφεί σε αυτή τη σύνθεση. Ενημερώστε τη σύνθεση ή διαγράψτε την προειδοποίηση.
@plural {n} plane|{n} planes {n} επίπεδο προοπτικής|{n} επίπεδα προοπτικής
@plural {n} preset|{n} presets {n} προεπιλογή|{n} προεπιλογές
@plural {n} sample point removed|{n} sample points removed Αφαιρέθηκε {n} σημείο δειγματοληψίας|Αφαιρέθηκαν {n} σημεία δειγματοληψίας
@plural {n} slice|{n} slices {n} τομή|{n} τομές
@plural {n} step|{n} steps {n} βήμα|{n} βήματα
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογής πινέλου)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων)
@plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις
Shape color selector Επιλογέας χρώματος σχήματος
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Κλικ για τοποθέτηση · Alt-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση
{n} of {max} sample points {n} από {max} σημεία δειγματοληψίας
@@ -3773,20 +3814,6 @@ curves Smooth Εξομάλυνση
The layer or its mask changed. Drag the mask onto the Trash again. Το επίπεδο ή η μάσκα του άλλαξε. Σύρετε ξανά τη μάσκα στον Κάδο.
Delete the vector mask? Διαγραφή της διανυσματικής μάσκας;
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Εξήχθη {n} δείγμα χρώματος στο {file}|Εξήχθησαν {n} δείγματα χρωμάτων στο {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Εισήχθη {n} πινέλο από την ομάδα {group}|Εισήχθησαν {n} πινέλα από την ομάδα {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Εισήχθη {n} δείγμα χρώματος από το {file}|Εισήχθησαν {n} δείγματα χρωμάτων από το {file}
@plural Played {n} step|Played {n} steps Αναπαράχθηκε {n} βήμα|Αναπαράχθηκαν {n} βήματα
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ενημερώθηκε {n} συνδεδεμένο έξυπνο αντικείμενο που άλλαξε στον δίσκο|Ενημερώθηκαν {n} συνδεδεμένα έξυπνα αντικείμενα που άλλαξαν στον δίσκο
@plural Saved for Web: {n} file|Saved for Web: {n} files Αποθήκευση για Web: {n} αρχείο|Αποθήκευση για Web: {n} αρχεία
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Διαγράφηκε {n} επίπεδο που καταγράφει αυτή η σύνθεση. Ενημερώστε τη σύνθεση ή καταργήστε την προειδοποίηση.|Διαγράφηκαν {n} επίπεδα που καταγράφει αυτή η σύνθεση. Ενημερώστε τη σύνθεση ή καταργήστε την προειδοποίηση.
@plural {n} plane|{n} planes {n} επίπεδο προοπτικής|{n} επίπεδα προοπτικής
@plural {n} preset|{n} presets {n} προεπιλογή|{n} προεπιλογές
@plural {n} sample point removed|{n} sample points removed Αφαιρέθηκε {n} σημείο δειγματοληψίας|Αφαιρέθηκαν {n} σημεία δειγματοληψίας
@plural {n} slice|{n} slices {n} τομή|{n} τομές
@plural {n} step|{n} steps {n} βήμα|{n} βήματα
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογών πινέλων)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων)
@plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις
# View › Guides › New Guide… › Color
Light Blue Ανοιχτό μπλε
Light Red Ανοιχτό κόκκινο
Can't render this file because it contains an unexpected character in line 1029 and column 18.
+44 -14
View File
@@ -2626,6 +2626,33 @@ fontWeight Italic Cursiva
Quick Selection found nothing there Selección rápida no encontró nada ahí
Object Selection found nothing there — try a tighter rectangle Selección de objetos no encontró nada ahí — pruebe un rectángulo más ajustado
No subject found — try a rectangle around the object No se encontró ningún sujeto — pruebe un rectángulo alrededor del objeto
Align to Canvas Alinear al lienzo
Arrowheads Puntas de flecha
Bring Shape Forward Adelantar forma
Bring Shape to Front Traer forma al frente
Constrain Proportions Mantener proporciones
Distribute Horizontally Distribuir horizontalmente
Distribute Vertically Distribuir verticalmente
End Fin
New Path Nuevo trazado
Path Alignment Alineación de trazados
Path Arrangement Orden de trazados
Path Operations Operaciones de trazado
Path Options Opciones de trazado
Send Shape Backward Retrasar forma
Send Shape to Back Enviar forma al fondo
Show Path Mostrar trazado
Start Inicio
Align Edges Alinear bordes
Unconstrained Sin restricciones
Proportional Proporcional
Smooth Indents Suavizar hendiduras
Concavity: Concavidad:
Solid Color Color uniforme
Color Picker Selector de color
Recent Colors Colores recientes
No recent colors No hay colores recientes
Color opacity Opacidad del color
Click the first point or press {key} to close · {del} removes a point · Esc cancels Haga clic en el primer punto o pulse {key} para cerrar · {del} elimina un punto · Esc cancela
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Haga clic y siga un borde · haga clic para fijar un punto · {alt}-clic para un segmento recto · doble clic para cerrar
Follows only edges this close to the pointer ([ and ] change it) Solo sigue bordes así de cerca del puntero ([ y ] lo cambian)
@@ -2742,6 +2769,23 @@ curves Smooth Suavizar
Clear Brush Strokes Borrar trazos de pincel
Show rulers Mostrar reglas
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Se ha exportado {n} muestra a {file}|Se han exportado {n} muestras a {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Se ha importado {n} pincel de {group}|Se han importado {n} pinceles de {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Se ha importado {n} muestra de {file}|Se han importado {n} muestras de {file}
@plural Played {n} step|Played {n} steps Se ha reproducido {n} paso|Se han reproducido {n} pasos
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Se ha actualizado {n} objeto inteligente enlazado modificado en el disco|Se han actualizado {n} objetos inteligentes enlazados modificados en el disco
@plural Saved for Web: {n} file|Saved for Web: {n} files Guardado para web: {n} archivo|Guardado para web: {n} archivos
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Se ha eliminado {n} capa registrada en esta composición. Actualice la composición o borre la advertencia.|Se han eliminado {n} capas registradas en esta composición. Actualice la composición o borre la advertencia.
@plural {n} plane|{n} planes {n} plano|{n} planos
@plural {n} preset|{n} presets {n} ajuste preestablecido|{n} ajustes preestablecidos
@plural {n} sample point removed|{n} sample points removed Se ha eliminado {n} punto de muestreo|Se han eliminado {n} puntos de muestreo
@plural {n} slice|{n} slices {n} sector|{n} sectores
@plural {n} step|{n} steps {n} paso|{n} pasos
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel)
@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias
Shape color selector Selector de color de forma
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Clic para colocar · Alt-clic o arrastrar fuera del lienzo para quitar
No document open Ningún documento abierto
@@ -2759,20 +2803,6 @@ curves Smooth Suavizar
The layer or its mask changed. Drag the mask onto the Trash again. La capa o su máscara ha cambiado. Arrastre de nuevo la máscara a la papelera.
Delete the vector mask? ¿Eliminar la máscara vectorial?
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Se exportó {n} muestra a {file}|Se exportaron {n} muestras a {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Se importó {n} pincel de {group}|Se importaron {n} pinceles de {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Se importó {n} muestra de {file}|Se importaron {n} muestras de {file}
@plural Played {n} step|Played {n} steps Se reprodujo {n} paso|Se reprodujeron {n} pasos
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Se actualizó {n} objeto inteligente enlazado modificado en el disco|Se actualizaron {n} objetos inteligentes enlazados modificados en el disco
@plural Saved for Web: {n} file|Saved for Web: {n} files Guardado para Web: {n} archivo|Guardado para Web: {n} archivos
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Se eliminó {n} capa registrada por esta composición. Actualice la composición o borre la advertencia.|Se eliminaron {n} capas registradas por esta composición. Actualice la composición o borre la advertencia.
@plural {n} plane|{n} planes {n} plano|{n} planos
@plural {n} preset|{n} presets {n} ajuste preestablecido|{n} ajustes preestablecidos
@plural {n} sample point removed|{n} sample points removed Se quitó {n} punto de muestreo|Se quitaron {n} puntos de muestreo
@plural {n} slice|{n} slices {n} sector|{n} sectores
@plural {n} step|{n} steps {n} paso|{n} pasos
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel)
@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias
# View › Guides › New Guide… › Color
Light Blue Azul claro
Light Red Rojo claro
Can't render this file because it contains an unexpected character in line 4 and column 47.
+43
View File
@@ -2974,6 +2974,32 @@ fontWeight Italic Italique
Quick Selection found nothing there La Sélection rapide n'a rien trouvé à cet endroit
Object Selection found nothing there — try a tighter rectangle La Sélection d'objet n'a rien trouvé à cet endroit — essayez un rectangle plus serré
No subject found — try a rectangle around the object Aucun sujet trouvé — essayez un rectangle autour de l'objet
Align to Canvas Aligner sur la zone de travail
Arrowheads Pointes de flèche
Bring Shape Forward Avancer la forme
Bring Shape to Front Mettre la forme au premier plan
Constrain Proportions Conserver les proportions
Distribute Horizontally Répartir horizontalement
Distribute Vertically Répartir verticalement
End Fin
Path Alignment Alignement des tracés
Path Arrangement Ordre des tracés
Path Operations Opérations sur les tracés
Path Options Options de tracé
Send Shape Backward Reculer la forme
Send Shape to Back Mettre la forme en arrière-plan
Show Path Afficher le tracé
Start Début
Align Edges Aligner les contours
Unconstrained Sans contrainte
Proportional Proportionnel
Smooth Indents Lisser les indentations
Concavity: Concavité :
Solid Color Couleur unie
Color Picker Sélecteur de couleurs
Recent Colors Couleurs récentes
No recent colors Aucune couleur récente
Color opacity Opacité de la couleur
Click the first point or press {key} to close · {del} removes a point · Esc cancels Cliquez sur le premier point ou appuyez sur {key} pour fermer · {del} supprime un point · Échap annule
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Cliquez, puis suivez un contour · cliquez pour fixer un point · {alt}-clic pour un segment droit · double-cliquez pour fermer
Follows only edges this close to the pointer ([ and ] change it) Ne suit que les contours aussi proches du pointeur ([ et ] le modifient)
@@ -3075,6 +3101,23 @@ curves Smooth Lisser
Clear Brush Strokes Effacer les traits de pinceau
Show rulers Afficher les règles
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} échantillon exporté vers {file}|{n} échantillons exportés vers {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pinceau importé depuis {group}|{n} pinceaux importés depuis {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} échantillon importé depuis {file}|{n} échantillons importés depuis {file}
@plural Played {n} step|Played {n} steps {n} étape exécutée|{n} étapes exécutées
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} objet intelligent lié modifié sur le disque mis à jour|{n} objets intelligents liés modifiés sur le disque mis à jour
@plural Saved for Web: {n} file|Saved for Web: {n} files Enregistré pour le Web : {n} fichier|Enregistré pour le Web : {n} fichiers
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} calque enregistré dans cette composition a été supprimé. Mettez à jour la composition ou effacez l’avertissement.|{n} calques enregistrés dans cette composition ont été supprimés. Mettez à jour la composition ou effacez l’avertissement.
@plural {n} plane|{n} planes {n} plan|{n} plans
@plural {n} preset|{n} presets {n} préréglage|{n} préréglages
@plural {n} sample point removed|{n} sample points removed {n} point d’échantillonnage supprimé|{n} points d’échantillonnage supprimés
@plural {n} slice|{n} slices {n} tranche|{n} tranches
@plural {n} step|{n} steps {n} étape|{n} étapes
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} avertissement supplémentaire de préréglage de pinceau)|{warning} (+{n} avertissements supplémentaires de préréglage de pinceau)
@plural {what} with {n} warning|{what} with {n} warnings {what} avec {n} avertissement|{what} avec {n} avertissements
Shape color selector Sélecteur de couleur de forme
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer
No document open Aucun document ouvert
Can't render this file because it contains an unexpected character in line 6 and column 47.
+53
View File
@@ -16,6 +16,59 @@
# letters; the trailing "…" of dialog commands is kept. Plural rule: a count is followed by the
# singular noun in Hungarian, so every @plural entry has a single form.
# Vector clipboard and shared shape controls
Align Edges Élek igazítása
Align to Canvas Igazítás a vászonhoz
Arrowheads Nyílhegyek
Bring Shape Forward Alakzat előrébb hozása
Bring Shape to Front Alakzat előrehozása legfelülre
Color opacity Szín átlátszatlansága
Concavity: Homorúság:
Constrain Proportions Arányok megtartása
Distribute Horizontally Vízszintes elosztás
Distribute Vertically Függőleges elosztás
End Vég
No recent colors Nincsenek legutóbbi színek
Path Alignment Útvonal igazítása
Path Arrangement Útvonalak sorrendje
Path Operations Útvonalműveletek
Path Options Útvonalbeállítások
Proportional Arányos
Recent Colors Legutóbbi színek
Send Shape Backward Alakzat hátrébb küldése
Send Shape to Back Alakzat hátraküldése legalulra
Shape color selector Alakzat színválasztója
Show Path Útvonal megjelenítése
Smooth Indents Bemélyedések simítása
Start Kezdet
Unconstrained Korlátozás nélkül
smartSvg Choose executable… Futtatható fájl kiválasztása…
smartSvg Choose the application used to edit this SVG: Válassza ki az SVG szerkesztésére használt alkalmazást:
smartSvg Clipboard SVG does not reliably identify its source application. A vágólapra másolt SVG nem azonosítja megbízhatóan a forrásalkalmazást.
smartSvg Edit SVG Smart Object SVG intelligens objektum szerkesztése
smartSvg Other application Másik alkalmazás
smartSvg PhotoCraft (internal editor) PhotoCraft (belső szerkesztő)
smartSvg SVG editor opened. Save the SVG to update this Smart Object. Az SVG-szerkesztő megnyílt. Mentse az SVG-t az intelligens objektum frissítéséhez.
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Mentse a megnyitott SVG-fájlba. Minden mentett módosítás külön visszavonási lépés lesz a PhotoCraftban.
smartSvg Stop syncing this Smart Object Az intelligens objektum szinkronizálásának leállítása
smartSvg Updates wait while another document or an editing dialog is active. A frissítések várakoznak, amíg másik dokumentum vagy szerkesztési párbeszédablak aktív.
vectorPaste Import warnings: Importálási figyelmeztetések:
vectorPaste Layers Rétegek
vectorPaste Paste Beillesztés
vectorPaste Paste as: Beillesztés mint:
vectorPaste Path Útvonal
vectorPaste Pixels Pixel
vectorPaste SVG text is converted to outlines, not editable type. Az SVG-szöveg körvonalakká alakul, nem szerkeszthető szöveggé.
vectorPaste Shape Layer Alakzatréteg
vectorPaste Shape Layer requires one shape without group blending; use Layers to preserve this SVG Az alakzatréteghez egyetlen alakzat szükséges csoportkeverés nélkül; az SVG megőrzéséhez használja a Rétegek módot
vectorPaste Smart Object Intelligens objektum
vectorPaste The SVG contains no editable paths Az SVG nem tartalmaz szerkeszthető útvonalakat
vectorPaste The SVG contains no editable shape Az SVG nem tartalmaz szerkeszthető alakzatot
vectorPaste The SVG contains rasterised objects without editable paths Az SVG raszterizált objektumokat tartalmaz szerkeszthető útvonalak nélkül
vectorPaste The SVG has incompatible fill rules; use Layers or Smart Object Az SVG kitöltési szabályai nem kompatibilisek; használja a Rétegek vagy az Intelligens objektum módot
vectorPaste The SVG is too large to rasterise (24 million pixel limit) Az SVG túl nagy a raszterizáláshoz (24 millió pixeles korlát)
vectorPaste The pasted shape caches exceed the clipboard cache budget; use Pixels or Smart Object A beillesztett alakzatok gyorsítótárai meghaladják a vágólap gyorsítótárkeretét; használja a Pixel vagy az Intelligens objektum módot
# Plural messages and command id overrides
# Custom paper dimensions in Print Settings.
Paper Width Papír szélessége
Can't render this file because it contains an unexpected character in line 6 and column 47.
+43
View File
@@ -2961,6 +2961,32 @@ fontWeight Italic Miring
Quick Selection found nothing there Seleksi Cepat tidak menemukan apa pun di sana
Object Selection found nothing there — try a tighter rectangle Seleksi Objek tidak menemukan apa pun di sana — coba persegi panjang yang lebih rapat
No subject found — try a rectangle around the object Subjek tidak ditemukan — coba persegi panjang di sekitar objek
Align to Canvas Ratakan ke Kanvas
Arrowheads Kepala Panah
Bring Shape Forward Majukan Bentuk
Bring Shape to Front Bawa Bentuk ke Depan
Constrain Proportions Pertahankan Proporsi
Distribute Horizontally Distribusikan Horizontal
Distribute Vertically Distribusikan Vertikal
End Akhir
Path Alignment Perataan Jalur
Path Arrangement Urutan Jalur
Path Operations Operasi Jalur
Path Options Opsi Jalur
Send Shape Backward Mundurkan Bentuk
Send Shape to Back Kirim Bentuk ke Belakang
Show Path Tampilkan Jalur
Start Awal
Align Edges Sejajarkan Tepi
Unconstrained Tanpa Batasan
Proportional Proporsional
Smooth Indents Haluskan Lekukan
Concavity: Kecekungan:
Solid Color Warna solid
Color Picker Pemilih warna
Recent Colors Warna yang baru digunakan
No recent colors Belum ada warna yang digunakan
Color opacity Opasitas warna
Click the first point or press {key} to close · {del} removes a point · Esc cancels Klik titik pertama atau tekan {key} untuk menutup · {del} menghapus titik · Esc membatalkan
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Klik, lalu gerakkan di sepanjang tepi · klik untuk menancapkan titik · {alt}-klik untuk segmen lurus · klik dua kali untuk menutup
Follows only edges this close to the pointer ([ and ] change it) Hanya mengikuti tepi sedekat ini dengan penunjuk ([ dan ] mengubahnya)
@@ -3062,6 +3088,23 @@ curves Smooth Haluskan
Clear Brush Strokes Hapus Goresan Kuas
Show rulers Tampilkan Penggaris
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} swatch diekspor ke {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} kuas diimpor dari {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} swatch diimpor dari {file}
@plural Played {n} step|Played {n} steps {n} langkah dijalankan
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} Smart Object tertaut yang berubah di disk diperbarui
@plural Saved for Web: {n} file|Saved for Web: {n} files Disimpan untuk Web: {n} berkas
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} layer yang direkam dalam Layer Comp ini telah dihapus. Perbarui Layer Comp atau hapus peringatan.
@plural {n} plane|{n} planes {n} bidang
@plural {n} preset|{n} presets {n} preset
@plural {n} sample point removed|{n} sample points removed {n} titik sampel dihapus
@plural {n} slice|{n} slices {n} slice
@plural {n} step|{n} steps {n} langkah
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} peringatan preset kuas lainnya)
@plural {what} with {n} warning|{what} with {n} warnings {what} dengan {n} peringatan
Shape color selector Pemilih warna bentuk
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Klik untuk menempatkan · Alt-klik atau seret ke luar kanvas untuk menghapus
No document open Tidak ada dokumen yang terbuka
Can't render this file because it contains an unexpected character in line 4 and column 47.
+41 -14
View File
@@ -2626,6 +2626,30 @@ fontWeight Italic Corsivo
Quick Selection found nothing there Selezione rapida non ha trovato nulla lì
Object Selection found nothing there — try a tighter rectangle Selezione oggetto non ha trovato nulla lì — prova un rettangolo più stretto
No subject found — try a rectangle around the object Nessun soggetto trovato — prova un rettangolo attorno all'oggetto
Align to Canvas Allinea alla tela
Arrowheads Punte delle frecce
Bring Shape Forward Porta forma avanti
Bring Shape to Front Porta forma in primo piano
Constrain Proportions Mantieni proporzioni
Distribute Horizontally Distribuisci orizzontalmente
Distribute Vertically Distribuisci verticalmente
End Fine
Path Alignment Allineamento tracciati
Path Arrangement Ordine dei tracciati
Path Operations Operazioni sui tracciati
Path Options Opzioni tracciato
Send Shape Backward Porta forma indietro
Send Shape to Back Porta forma sullo sfondo
Show Path Mostra tracciato
Start Inizio
Align Edges Allinea bordi
Unconstrained Senza vincoli
Proportional Proporzionale
Smooth Indents Smussa rientranze
Concavity: Concavità:
Recent Colors Colori recenti
No recent colors Nessun colore recente
Color opacity Opacità del colore
Click the first point or press {key} to close · {del} removes a point · Esc cancels Fai clic sul primo punto o premi {key} per chiudere · {del} rimuove un punto · Esc annulla
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Fai clic e segui un bordo · fai clic per fissare un punto · {alt}-clic per un segmento dritto · doppio clic per chiudere
Follows only edges this close to the pointer ([ and ] change it) Segue solo i bordi così vicini al puntatore ([ e ] lo cambiano)
@@ -2730,6 +2754,23 @@ curves Smooth Leviga
Clear Brush Strokes Cancella tracce del pennello
Show rulers Mostra righelli
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Esportato {n} campione in {file}|Esportati {n} campioni in {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importato {n} pennello da {group}|Importati {n} pennelli da {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importato {n} campione da {file}|Importati {n} campioni da {file}
@plural Played {n} step|Played {n} steps Eseguito {n} passaggio|Eseguiti {n} passaggi
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Aggiornato {n} oggetto avanzato collegato modificato su disco|Aggiornati {n} oggetti avanzati collegati modificati su disco
@plural Saved for Web: {n} file|Saved for Web: {n} files Salvato per Web: {n} file|Salvato per Web: {n} file
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. È stato eliminato {n} livello registrato in questa composizione. Aggiornare la composizione o cancellare l’avviso.|Sono stati eliminati {n} livelli registrati in questa composizione. Aggiornare la composizione o cancellare l’avviso.
@plural {n} plane|{n} planes {n} piano|{n} piani
@plural {n} preset|{n} presets {n} predefinito|{n} predefiniti
@plural {n} sample point removed|{n} sample points removed Rimosso {n} punto di campionamento|Rimossi {n} punti di campionamento
@plural {n} slice|{n} slices {n} sezione|{n} sezioni
@plural {n} step|{n} steps {n} passaggio|{n} passaggi
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso sui predefiniti pennello)|{warning} (+{n} altri avvisi sui predefiniti pennello)
@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi
Shape color selector Selettore colore forma
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dal quadro per rimuovere
No document open Nessun documento aperto
@@ -2747,20 +2788,6 @@ curves Smooth Leviga
The layer or its mask changed. Drag the mask onto the Trash again. Il livello o la sua maschera è cambiato. Trascina di nuovo la maschera nel cestino.
Delete the vector mask? Eliminare la maschera vettoriale?
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} campione esportato in {file}|{n} campioni esportati in {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pennello importato da {group}|{n} pennelli importati da {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} campione importato da {file}|{n} campioni importati da {file}
@plural Played {n} step|Played {n} steps {n} passaggio riprodotto|{n} passaggi riprodotti
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} oggetto avanzato collegato modificato su disco aggiornato|{n} oggetti avanzati collegati modificati su disco aggiornati
@plural Saved for Web: {n} file|Saved for Web: {n} files Salvato per il Web: {n} file|Salvato per il Web: {n} file
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} livello registrato da questa composizione è stato eliminato. Aggiorna la composizione o cancella l'avviso.|{n} livelli registrati da questa composizione sono stati eliminati. Aggiorna la composizione o cancella l'avviso.
@plural {n} plane|{n} planes {n} piano|{n} piani
@plural {n} preset|{n} presets {n} predefinito|{n} predefiniti
@plural {n} sample point removed|{n} sample points removed {n} punto di campionamento rimosso|{n} punti di campionamento rimossi
@plural {n} slice|{n} slices {n} sezione|{n} sezioni
@plural {n} step|{n} steps {n} passaggio|{n} passaggi
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso sui predefiniti pennello)|{warning} (+{n} altri avvisi sui predefiniti pennello)
@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi
# View › Guides › New Guide… › Color
Light Blue Blu chiaro
Light Red Rosso chiaro
Can't render this file because it contains an unexpected character in line 2637 and column 53.
+28
View File
@@ -3569,6 +3569,31 @@ fontWeight Italic 斜体
Quick Selection found nothing there クイック選択ではそこに何も見つかりませんでした
Object Selection found nothing there — try a tighter rectangle オブジェクト選択ではそこに何も見つかりませんでした — より狭い長方形で試してください
No subject found — try a rectangle around the object 被写体が見つかりません — オブジェクトを囲む長方形を試してください
Align to Canvas カンバスに整列
Arrowheads 矢印の先端
Bring Shape Forward シェイプを前面へ
Bring Shape to Front シェイプを最前面へ
Constrain Proportions 縦横比を固定
Distribute Horizontally 水平方向に分布
Distribute Vertically 垂直方向に分布
End 終点
Path Alignment パスの整列
Path Arrangement パスの順序
Path Operations パス操作
Path Options パスオプション
Send Shape Backward シェイプを背面へ
Send Shape to Back シェイプを最背面へ
Show Path パスを表示
Start 始点
Align Edges エッジを整列
Unconstrained 制約なし
Proportional 縦横比を指定
Smooth Indents くぼみを滑らかに
Concavity: 凹み:
Color Picker カラーピッカー
Recent Colors 最近使った色
No recent colors 最近使った色はありません
Color opacity 色の不透明度
Click the first point or press {key} to close · {del} removes a point · Esc cancels 最初の点をクリックするか {key} を押して閉じる · {del} で点を削除 · Esc でキャンセル
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close クリックしてエッジに沿って動かす · クリックで固定ポイント · {alt}-クリックで直線セグメント · ダブルクリックで閉じる
Follows only edges this close to the pointer ([ and ] change it) ポインターからこの距離内のエッジだけをたどる ([ と ] で変更)
@@ -3668,6 +3693,9 @@ curves Smooth スムーズ
Clear Brush Strokes ブラシストロークを消去
Show rulers 定規を表示
# Plural status messages and counts
Shape color selector シェイプのカラーピッカー
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove クリックで配置 · Alt キーを押しながらクリック、またはカンバス外へドラッグで削除
No document open ドキュメントが開かれていません
Can't render this file because it contains an unexpected character in line 6 and column 47.
+46 -2
View File
@@ -3135,6 +3135,35 @@ fontWeight Italic 기울임
Quick Selection found nothing there 빠른 선택으로 해당 위치에서 아무것도 찾지 못했습니다
Object Selection found nothing there — try a tighter rectangle 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요
No subject found — try a rectangle around the object 피사체를 찾을 수 없습니다 — 개체 주위에 사각형을 그려 보세요
W W
H H
Align to Canvas 캔버스에 정렬
Arrowheads 화살촉
Bring Shape Forward 모양 앞으로 이동
Bring Shape to Front 모양 맨 앞으로 이동
Constrain Proportions 비율 유지
Distribute Horizontally 가로로 분포
Distribute Vertically 세로로 분포
End 끝
Mask 마스크
New Path 새 패스
Path Alignment 패스 정렬
Path Arrangement 패스 순서
Path Operations 패스 작업
Path Options 패스 옵션
Send Shape Backward 모양 뒤로 이동
Send Shape to Back 모양 맨 뒤로 이동
Show Path 패스 표시
Start 시작
Align Edges 가장자리 정렬
Unconstrained 제한 없음
Proportional 비율 유지
Smooth Indents 들여쓰기 부드럽게
Concavity: 오목함:
Color Picker 색상 선택기
Recent Colors 최근 사용한 색상
No recent colors 최근 사용한 색상이 없습니다
Color opacity 색상 불투명도
Auto Erase 자동 지우기
Click the first point or press {key} to close · {del} removes a point · Esc cancels 첫 점을 클릭하거나 {key}를 눌러 닫기 · {del}로 점 제거 · Esc로 취소
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close 클릭한 다음 가장자리를 따라 이동 · 클릭하여 고정점 추가 · {alt}-클릭으로 직선 구간 · 두 번 클릭하여 닫기
@@ -3187,8 +3216,6 @@ cameraData Lens 렌즈
Make Portrait 세로 방향으로
Make Landscape 가로 방향으로
Add New Artboard 새 아트보드 추가
W 폭
H 높이
Freeform Pen Tool 자유 형태 펜 도구
Curvature Pen Tool 곡률 펜 도구
Curve Fit: 곡선 맞춤:
@@ -3247,6 +3274,23 @@ curves Smooth 매끄럽게
Clear Brush Strokes 브러시 획 지우기
Show rulers 눈금자 표시
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n}개의 색상 견본을 {file}로 내보냈습니다
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group}에서 {n}개의 브러시를 가져왔습니다
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}에서 {n}개의 색상 견본을 가져왔습니다
@plural Played {n} step|Played {n} steps {n}개의 단계를 실행했습니다
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 디스크에서 변경된 연결된 스마트 오브젝트 {n}개를 업데이트했습니다
@plural Saved for Web: {n} file|Saved for Web: {n} files 웹용으로 저장: 파일 {n}개
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 이 레이어 구성 요소에 기록된 레이어 {n}개가 삭제되었습니다. 레이어 구성 요소를 업데이트하거나 경고를 지우세요.
@plural {n} plane|{n} planes 평면 {n}개
@plural {n} preset|{n} presets 사전 설정 {n}개
@plural {n} sample point removed|{n} sample points removed 샘플 지점 {n}개를 제거했습니다
@plural {n} slice|{n} slices 분할 영역 {n}개
@plural {n} step|{n} steps 단계 {n}개
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (브러시 사전 설정 경고 {n}개 더 있음)
@plural {what} with {n} warning|{what} with {n} warnings {what} (경고 {n}개)
Shape color selector 도형 색상 선택기
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove 클릭하여 배치 · Alt-클릭 또는 캔버스 밖으로 드래그하여 제거
No document open 열린 문서 없음
Can't render this file because it contains an unexpected character in line 3150 and column 53.
+41 -14
View File
@@ -3725,6 +3725,30 @@ cameraRaw Whites Witten
Quick Selection found nothing there Snelle selectie heeft daar niets gevonden
Object Selection found nothing there — try a tighter rectangle Objectselectie heeft daar niets gevonden — probeer een krappere rechthoek
No subject found — try a rectangle around the object Geen onderwerp gevonden — probeer een rechthoek rond het object
Align to Canvas Uitlijnen op canvas
Arrowheads Pijlpunten
Bring Shape Forward Vorm naar voren
Bring Shape to Front Vorm op voorgrond
Constrain Proportions Verhoudingen behouden
Distribute Horizontally Horizontaal verdelen
Distribute Vertically Verticaal verdelen
End Einde
Path Alignment Paduitlijning
Path Arrangement Padvolgorde
Path Operations Padbewerkingen
Path Options Padopties
Send Shape Backward Vorm naar achteren
Send Shape to Back Vorm op achtergrond
Show Path Pad tonen
Start Begin
Align Edges Randen uitlijnen
Unconstrained Onbeperkt
Proportional Proportioneel
Smooth Indents Inkepingen vloeiend maken
Concavity: Holte:
Recent Colors Onlangs gebruikte kleuren
No recent colors Geen onlangs gebruikte kleuren
Color opacity Kleurdekking
Appearance Settings… Weergave-instellingen…
Add to favorites Toevoegen aan favorieten
Remove from favorites Verwijderen uit favorieten
@@ -3819,6 +3843,23 @@ curves Smooth Vloeiend maken
Scale: Schaal:
Snap to Origin Uitlijnen op oorsprong
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} staal geëxporteerd naar {file}|{n} stalen geëxporteerd naar {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} penseel geïmporteerd uit {group}|{n} penselen geïmporteerd uit {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} staal geïmporteerd uit {file}|{n} stalen geïmporteerd uit {file}
@plural Played {n} step|Played {n} steps {n} stap afgespeeld|{n} stappen afgespeeld
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} op schijf gewijzigd gekoppeld slim object bijgewerkt|{n} op schijf gewijzigde gekoppelde slimme objecten bijgewerkt
@plural Saved for Web: {n} file|Saved for Web: {n} files Opgeslagen voor web: {n} bestand|Opgeslagen voor web: {n} bestanden
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} laag die in deze laagcompositie is vastgelegd, is verwijderd. Werk de laagcompositie bij of wis de waarschuwing.|{n} lagen die in deze laagcompositie zijn vastgelegd, zijn verwijderd. Werk de laagcompositie bij of wis de waarschuwing.
@plural {n} plane|{n} planes {n} vlak|{n} vlakken
@plural {n} preset|{n} presets {n} voorinstelling|{n} voorinstellingen
@plural {n} sample point removed|{n} sample points removed {n} steekproefpunt verwijderd|{n} steekproefpunten verwijderd
@plural {n} slice|{n} slices {n} segment|{n} segmenten
@plural {n} step|{n} steps {n} stap|{n} stappen
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} extra waarschuwing over penseelvoorinstellingen)|{warning} (+{n} extra waarschuwingen over penseelvoorinstellingen)
@plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen
Shape color selector Kleurkiezer voor vorm
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Klik om te plaatsen · Alt-klik of sleep buiten het canvas om te verwijderen
{n} of {max} sample points {n} van {max} steekproefpunten
@@ -3835,20 +3876,6 @@ curves Smooth Vloeiend maken
The layer or its mask changed. Drag the mask onto the Trash again. De laag of het masker is gewijzigd. Sleep het masker opnieuw naar de prullenbak.
Delete the vector mask? Het vectormasker verwijderen?
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} staal geëxporteerd naar {file}|{n} stalen geëxporteerd naar {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} penseel geïmporteerd uit {group}|{n} penselen geïmporteerd uit {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} staal geïmporteerd uit {file}|{n} stalen geïmporteerd uit {file}
@plural Played {n} step|Played {n} steps {n} stap afgespeeld|{n} stappen afgespeeld
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} gekoppeld slim object bijgewerkt dat op schijf is gewijzigd|{n} gekoppelde slimme objecten bijgewerkt die op schijf zijn gewijzigd
@plural Saved for Web: {n} file|Saved for Web: {n} files Opgeslagen voor web: {n} bestand|Opgeslagen voor web: {n} bestanden
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} laag die deze compositie vastlegt, is verwijderd. Werk de compositie bij of wis de waarschuwing.|{n} lagen die deze compositie vastlegt, zijn verwijderd. Werk de compositie bij of wis de waarschuwing.
@plural {n} plane|{n} planes {n} vlak|{n} vlakken
@plural {n} preset|{n} presets {n} voorinstelling|{n} voorinstellingen
@plural {n} sample point removed|{n} sample points removed {n} steekproefpunt verwijderd|{n} steekproefpunten verwijderd
@plural {n} slice|{n} slices {n} segment|{n} segmenten
@plural {n} step|{n} steps {n} stap|{n} stappen
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} andere waarschuwing over penseelvoorinstellingen)|{warning} (+{n} andere waarschuwingen over penseelvoorinstellingen)
@plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen
# View › Guides › New Guide… › Color
Light Blue Lichtblauw
Light Red Lichtrood
Can't render this file because it contains an unexpected character in line 8 and column 34.
+42
View File
@@ -3571,6 +3571,31 @@ cameraRaw Roughness Chropowatość
Quick Selection found nothing there Szybkie zaznaczanie nic tam nie znalazło
Object Selection found nothing there — try a tighter rectangle Zaznaczanie obiektu nic tam nie znalazło — spróbuj ciaśniejszego prostokąta
No subject found — try a rectangle around the object Nie znaleziono obiektu — spróbuj prostokąta wokół obiektu
Align to Canvas Wyrównaj do płótna
Arrowheads Groty strzałek
Bring Shape Forward Przesuń kształt do przodu
Bring Shape to Front Przenieś kształt na wierzch
Constrain Proportions Zachowaj proporcje
Distribute Horizontally Rozmieść poziomo
Distribute Vertically Rozmieść pionowo
End Koniec
Path Alignment Wyrównanie ścieżek
Path Arrangement Kolejność ścieżek
Path Operations Operacje na ścieżkach
Path Options Opcje ścieżki
Send Shape Backward Przesuń kształt do tyłu
Send Shape to Back Przenieś kształt na spód
Show Path Pokaż ścieżkę
Start Początek
Align Edges Wyrównaj krawędzie
Unconstrained Bez ograniczeń
Proportional Proporcjonalny
Smooth Indents Wygładź wcięcia
Concavity: Wklęsłość:
Color Picker Próbnik kolorów
Recent Colors Ostatnio używane kolory
No recent colors Brak ostatnio używanych kolorów
Color opacity Krycie koloru
Click the first point or press {key} to close · {del} removes a point · Esc cancels Kliknij pierwszy punkt lub naciśnij {key}, aby zamknąć · {del} usuwa punkt · Esc anuluje
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Kliknij i prowadź wzdłuż krawędzi · kliknij, aby przypiąć punkt · {alt}-klik dla prostego odcinka · kliknij dwukrotnie, aby zamknąć
Fill Screen Wypełnij ekran
@@ -3671,6 +3696,23 @@ curves Smooth Wygładź
Clear Brush Strokes Wyczyść pociągnięcia pędzla
Show rulers Pokaż linijki
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Wyeksportowano {n} próbkę do {file}|Wyeksportowano {n} próbki do {file}|Wyeksportowano {n} próbek do {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Zaimportowano {n} pędzel z {group}|Zaimportowano {n} pędzle z {group}|Zaimportowano {n} pędzli z {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Zaimportowano {n} próbkę z {file}|Zaimportowano {n} próbki z {file}|Zaimportowano {n} próbek z {file}
@plural Played {n} step|Played {n} steps Odtworzono {n} krok|Odtworzono {n} kroki|Odtworzono {n} kroków
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Zaktualizowano {n} połączony obiekt inteligentny zmieniony na dysku|Zaktualizowano {n} połączone obiekty inteligentne zmienione na dysku|Zaktualizowano {n} połączonych obiektów inteligentnych zmienionych na dysku
@plural Saved for Web: {n} file|Saved for Web: {n} files Zapisano dla Internetu: {n} plik|Zapisano dla Internetu: {n} pliki|Zapisano dla Internetu: {n} plików
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Usunięto {n} warstwę zapisaną w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstwy zapisane w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstw zapisanych w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.
@plural {n} plane|{n} planes {n} płaszczyzna|{n} płaszczyzny|{n} płaszczyzn
@plural {n} preset|{n} presets {n} ustawienie wstępne|{n} ustawienia wstępne|{n} ustawień wstępnych
@plural {n} sample point removed|{n} sample points removed Usunięto {n} punkt próbkowania|Usunięto {n} punkty próbkowania|Usunięto {n} punktów próbkowania
@plural {n} slice|{n} slices {n} plasterek|{n} plasterki|{n} plasterków
@plural {n} step|{n} steps {n} krok|{n} kroki|{n} kroków
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} dodatkowe ostrzeżenie dotyczące ustawień wstępnych pędzla)|{warning} (+{n} dodatkowe ostrzeżenia dotyczące ustawień wstępnych pędzla)|{warning} (+{n} dodatkowych ostrzeżeń dotyczących ustawień wstępnych pędzla)
@plural {what} with {n} warning|{what} with {n} warnings {what} z {n} ostrzeżeniem|{what} z {n} ostrzeżeniami|{what} z {n} ostrzeżeniami
Shape color selector Selektor koloru kształtu
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Kliknij, aby umieścić · Alt-kliknij lub przeciągnij poza obszar roboczy, aby usunąć
No document open Brak otwartego dokumentu
Can't render this file because it contains an unexpected character in line 6 and column 47.
+41 -14
View File
@@ -2978,6 +2978,30 @@ fontWeight Italic Itálico
Quick Selection found nothing there A Seleção rápida não encontrou nada ali
Object Selection found nothing there — try a tighter rectangle A Seleção de objeto não encontrou nada ali — tente um retângulo mais justo
No subject found — try a rectangle around the object Nenhum assunto encontrado — tente um retângulo ao redor do objeto
Align to Canvas Alinhar à tela
Arrowheads Pontas de seta
Bring Shape Forward Avançar forma
Bring Shape to Front Trazer forma para a frente
Constrain Proportions Manter proporções
Distribute Horizontally Distribuir horizontalmente
Distribute Vertically Distribuir verticalmente
End Fim
Path Alignment Alinhamento de demarcadores
Path Arrangement Ordem de demarcadores
Path Operations Operações de demarcador
Path Options Opções de demarcador
Send Shape Backward Recuar forma
Send Shape to Back Enviar forma para trás
Show Path Mostrar demarcador
Start Início
Align Edges Alinhar bordas
Unconstrained Sem restrições
Proportional Proporcional
Smooth Indents Suavizar reentrâncias
Concavity: Concavidade:
Recent Colors Cores recentes
No recent colors Nenhuma cor recente
Color opacity Opacidade da cor
Click the first point or press {key} to close · {del} removes a point · Esc cancels Clique no primeiro ponto ou pressione {key} para fechar · {del} remove um ponto · Esc cancela
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Clique e siga uma borda · clique para fixar um ponto · {alt}-clique para um segmento reto · clique duas vezes para fechar
Follows only edges this close to the pointer ([ and ] change it) Segue só bordas a esta distância do ponteiro ([ e ] mudam o valor)
@@ -3080,6 +3104,23 @@ curves Smooth Suavizar
Clear Brush Strokes Limpar traços do pincel
Show rulers Mostrar réguas
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} amostra exportada para {file}|{n} amostras exportadas para {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pincel importado de {group}|{n} pincéis importados de {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} amostra importada de {file}|{n} amostras importadas de {file}
@plural Played {n} step|Played {n} steps {n} etapa reproduzida|{n} etapas reproduzidas
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} objeto inteligente vinculado alterado no disco atualizado|{n} objetos inteligentes vinculados alterados no disco atualizados
@plural Saved for Web: {n} file|Saved for Web: {n} files Salvo para Web: {n} arquivo|Salvo para Web: {n} arquivos
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} camada registrada nesta composição foi excluída. Atualize a composição ou limpe o aviso.|{n} camadas registradas nesta composição foram excluídas. Atualize a composição ou limpe o aviso.
@plural {n} plane|{n} planes {n} plano|{n} planos
@plural {n} preset|{n} presets {n} predefinição|{n} predefinições
@plural {n} sample point removed|{n} sample points removed {n} ponto de amostragem removido|{n} pontos de amostragem removidos
@plural {n} slice|{n} slices {n} fatia|{n} fatias
@plural {n} step|{n} steps {n} etapa|{n} etapas
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} aviso de predefinição de pincel adicional)|{warning} (+{n} avisos de predefinição de pincel adicionais)
@plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos
Shape color selector Seletor de cor da forma
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Clique para posicionar · Alt-clique ou arraste para fora da tela para remover
No document open Nenhum documento aberto
@@ -3865,20 +3906,6 @@ layerLabel No Color Sem cor
The layer or its mask changed. Drag the mask onto the Trash again. A camada ou a máscara mudou. Arraste a máscara para a Lixeira novamente.
Delete the vector mask? Excluir a máscara vetorial?
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} amostra exportada para {file}|{n} amostras exportadas para {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pincel importado de {group}|{n} pincéis importados de {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} amostra importada de {file}|{n} amostras importadas de {file}
@plural Played {n} step|Played {n} steps {n} etapa reproduzida|{n} etapas reproduzidas
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} objeto inteligente vinculado alterado no disco foi atualizado|{n} objetos inteligentes vinculados alterados no disco foram atualizados
@plural Saved for Web: {n} file|Saved for Web: {n} files Salvo para Web: {n} arquivo|Salvo para Web: {n} arquivos
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} camada registrada por esta composição foi excluída. Atualize a composição ou limpe o aviso.|{n} camadas registradas por esta composição foram excluídas. Atualize a composição ou limpe o aviso.
@plural {n} plane|{n} planes {n} plano|{n} planos
@plural {n} preset|{n} presets {n} predefinição|{n} predefinições
@plural {n} sample point removed|{n} sample points removed {n} ponto de amostragem removido|{n} pontos de amostragem removidos
@plural {n} slice|{n} slices {n} fatia|{n} fatias
@plural {n} step|{n} steps {n} etapa|{n} etapas
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} outro aviso de predefinição de pincel)|{warning} (+{n} outros avisos de predefinição de pincel)
@plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos
# View › Guides › New Guide… › Color
Light Blue Azul-claro
Light Red Vermelho-claro
Can't render this file because it contains an unexpected character in line 6 and column 47.
+32 -3
View File
@@ -2837,6 +2837,35 @@ fontWeight Italic Курсив
Missing delete command Отсутствует команда удаления
Invalid delete command Недопустимая команда удаления
Layer deleted, but confirmation preference could not be saved: {error} Слой удалён, но не удалось сохранить настройку подтверждения: {error}
Align to Canvas Выровнять по холсту
Arrowheads Наконечники стрелок
Bring Shape Forward Переместить фигуру вперёд
Bring Shape to Front Переместить фигуру на передний план
Constrain Proportions Сохранить пропорции
Distribute Horizontally Распределить по горизонтали
Distribute Vertically Распределить по вертикали
End Конец
Path Alignment Выравнивание контуров
Path Arrangement Порядок контуров
Path Operations Операции с контурами
Path Options Параметры контура
Send Shape Backward Переместить фигуру назад
Send Shape to Back Переместить фигуру на задний план
Show Path Показать контур
Start Начало
Align Edges Выровнять края
Unconstrained Без ограничений
Fixed Size Фиксированный размер
Proportional Пропорционально
Smooth Indents Сгладить впадины
Concavity: Вогнутость:
Link with Layer Связать со слоем
Solid Color Сплошной цвет
Color Picker Выбор цвета
Recent Colors Недавно использованные цвета
No recent colors Нет недавно использованных цветов
Color opacity Непрозрачность цвета
Show Transform Controls Показать элементы управления трансформацией
Add a layer style Добавить стиль слоя
Add a mask (from the selection; {key} inverts) Добавить маску (из выделения; {key} инвертирует)
Add an adjustment Добавить коррекцию
@@ -2875,7 +2904,6 @@ fontWeight Italic Курсив
Filter for smart objects Фильтр смарт-объектов
Filter for type layers Фильтр текстовых слоёв
Fixed Ratio Заданные пропорции
Fixed Size Заданный размер
Follows only edges this close to the pointer ([ and ] change it) Следует только за краями на таком расстоянии от указателя ([ и ] меняют его)
Hide Properties Скрыть свойства
Higher values follow only edges that contrast sharply with their surroundings Чем выше значение, тем только более контрастные края учитываются
@@ -2900,7 +2928,6 @@ fontWeight Italic Курсив
Reflected Gradient Зеркальный градиент
Reset to defaults Сбросить к значениям по умолчанию
Set additional smoothing options Дополнительные параметры сглаживания
Show Transform Controls Показать управляющие элементы трансформации
Subtract from selection ({key}) Вычесть из выделения ({key})
Swaps height and width Меняет местами высоту и ширину
Tolerance Допуск
@@ -2955,7 +2982,6 @@ cameraData Lens Объектив
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Клик: гладкая точка · двойной клик: угол · перетаскивание точки: переместить · клик по первой точке: замкнуть · {key} завершить
Scale: Масштаб:
Snap to Origin Привязать к началу координат
Link with Layer Связать со слоем
Rasterize Layer Style Растрировать стиль слоя
Search settings… Поиск настроек…
No matching settings Нет подходящих настроек
@@ -3004,6 +3030,9 @@ curves Smooth Сгладить
Enter a name for the crop preset Введите имя набора параметров кадрирования
There is no crop size or ratio to save Нет размера или соотношения сторон кадрирования для сохранения
# Plural status messages and counts
Shape color selector Выбор цвета фигуры
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Щёлкните, чтобы разместить · Alt-щелчок или перетаскивание за пределы холста для удаления
No document open Документ не открыт
Can't render this file because it contains an unexpected character in line 6 and column 47.
+53
View File
@@ -22,6 +22,59 @@
# are; the trailing "…" of dialog commands is kept. A count that is not an @plural entry is kept
# out of noun agreement ("Broj: {n}", "slojeva: {n}") so every number reads correctly.
# Vector clipboard, SVG Smart Object editor and shared shape controls
Align Edges Poravnaj ivice
Align to Canvas Poravnaj sa platnom
Arrowheads Vrhovi strelica
Bring Shape Forward Pomeri oblik napred
Bring Shape to Front Premesti oblik na vrh
Color opacity Neprozirnost boje
Concavity: Udubljenost:
Constrain Proportions Ograniči proporcije
Distribute Horizontally Raspodeli horizontalno
Distribute Vertically Raspodeli vertikalno
End Kraj
No recent colors Nema nedavnih boja
Path Alignment Poravnanje putanja
Path Arrangement Raspored putanja
Path Operations Operacije nad putanjama
Path Options Opcije putanje
Proportional Proporcionalno
Recent Colors Nedavne boje
Send Shape Backward Pomeri oblik nazad
Send Shape to Back Premesti oblik na dno
Shape color selector Birač boja oblika
Show Path Prikaži putanju
Smooth Indents Izgladi udubljenja
Start Početak
Unconstrained Bez ograničenja
smartSvg Choose executable… Izaberi izvršnu datoteku…
smartSvg Choose the application used to edit this SVG: Izaberite aplikaciju za uređivanje ovog SVG-a:
smartSvg Clipboard SVG does not reliably identify its source application. SVG iz ostave ne identifikuje pouzdano izvornu aplikaciju.
smartSvg Edit SVG Smart Object Uredi SVG pametni objekat
smartSvg Other application Druga aplikacija
smartSvg PhotoCraft (internal editor) PhotoCraft (interni uređivač)
smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG uređivač je otvoren. Sačuvajte SVG da biste ažurirali ovaj pametni objekat.
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Sačuvajte u otvorenu SVG datoteku. Svaka sačuvana izmena postaje jedan korak opoziva u PhotoCraft-u.
smartSvg Stop syncing this Smart Object Zaustavi sinhronizaciju ovog pametnog objekta
smartSvg Updates wait while another document or an editing dialog is active. Ažuriranja čekaju dok je aktivan drugi dokument ili dijalog za uređivanje.
vectorPaste Import warnings: Upozorenja pri uvozu:
vectorPaste Layers Slojevi
vectorPaste Paste Nalepi
vectorPaste Paste as: Nalepi kao:
vectorPaste Path Putanja
vectorPaste Pixels Pikseli
vectorPaste SVG text is converted to outlines, not editable type. SVG tekst se pretvara u konture, a ne u tekst koji se može uređivati.
vectorPaste Shape Layer Sloj oblika
vectorPaste Shape Layer requires one shape without group blending; use Layers to preserve this SVG Sloj oblika zahteva jedan oblik bez stapanja grupe; koristite Slojeve da biste sačuvali ovaj SVG
vectorPaste Smart Object Pametni objekat
vectorPaste The SVG contains no editable paths SVG ne sadrži putanje koje se mogu uređivati
vectorPaste The SVG contains no editable shape SVG ne sadrži oblik koji se može uređivati
vectorPaste The SVG contains rasterised objects without editable paths SVG sadrži rasterizovane objekte bez putanja koje se mogu uređivati
vectorPaste The SVG has incompatible fill rules; use Layers or Smart Object SVG sadrži nekompatibilna pravila popune; koristite Slojeve ili Pametni objekat
vectorPaste The SVG is too large to rasterise (24 million pixel limit) SVG je prevelik za rasterizaciju (ograničenje: 24 miliona piksela)
vectorPaste The pasted shape caches exceed the clipboard cache budget; use Pixels or Smart Object Keševi nalepljenih oblika premašuju ograničenje keša ostave; koristite Piksele ili Pametni objekat
# Guide layout dialog
Rows Redovi
Number Broj
Can't render this file because it contains an unexpected character in line 6 and column 47.
+52
View File
@@ -10,6 +10,58 @@
# Custom paper dimensions in Print Settings.
Paper Width Kağıt Genişliği
Paper Height Kağıt Yüksekliği
# Vector clipboard and shared shape controls
Align Edges Kenarları Hizala
Align to Canvas Kanvasa Hizala
Arrowheads Ok Uçları
Bring Shape Forward Şekli Öne Getir
Bring Shape to Front Şekli En Öne Getir
Color opacity Renk opaklığı
Concavity: İçbükeylik:
Constrain Proportions Oranları Koru
Distribute Horizontally Yatay Dağıt
Distribute Vertically Dikey Dağıt
End Bitiş
No recent colors Son kullanılan renk yok
Path Alignment Yol Hizalama
Path Arrangement Yol Sıralaması
Path Operations Yol İşlemleri
Path Options Yol Seçenekleri
Proportional Orantılı
Recent Colors Son Kullanılan Renkler
Send Shape Backward Şekli Arkaya Gönder
Send Shape to Back Şekli En Arkaya Gönder
Shape color selector Şekil rengi seçicisi
Show Path Yolu Göster
Smooth Indents Girintileri Yumuşat
Start Başlangıç
Unconstrained Kısıtlamasız
smartSvg Choose executable… Çalıştırılabilir dosya seç…
smartSvg Choose the application used to edit this SVG: Bu SVG'yi düzenlemek için kullanılacak uygulamayı seçin:
smartSvg Clipboard SVG does not reliably identify its source application. Panodaki SVG, kaynak uygulamasını güvenilir biçimde tanımlamaz.
smartSvg Edit SVG Smart Object SVG Akıllı Nesnesini Düzenle
smartSvg Other application Diğer uygulama
smartSvg PhotoCraft (internal editor) PhotoCraft (dahili düzenleyici)
smartSvg SVG editor opened. Save the SVG to update this Smart Object. SVG düzenleyicisi açıldı. Bu Akıllı Nesneyi güncellemek için SVG'yi kaydedin.
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Açılan SVG dosyasına kaydedin. Kaydedilen her değişiklik PhotoCraft'ta bir geri alma adımı olur.
smartSvg Stop syncing this Smart Object Bu Akıllı Nesnenin eşitlemesini durdur
smartSvg Updates wait while another document or an editing dialog is active. Başka bir belge veya düzenleme iletişim kutusu etkin olduğunda güncellemeler bekler.
vectorPaste Import warnings: İçe aktarma uyarıları:
vectorPaste Layers Katmanlar
vectorPaste Paste Yapıştır
vectorPaste Paste as: Yapıştırma biçimi:
vectorPaste Path Yol
vectorPaste Pixels Piksel
vectorPaste SVG text is converted to outlines, not editable type. SVG metni düzenlenebilir metin yerine konturlara dönüştürülür.
vectorPaste Shape Layer Şekil Katmanı
vectorPaste Shape Layer requires one shape without group blending; use Layers to preserve this SVG Şekil Katmanı için grup karıştırması olmayan tek bir şekil gerekir; bu SVG'yi korumak için Katmanlar'ı kullanın
vectorPaste Smart Object Akıllı Nesne
vectorPaste The SVG contains no editable paths SVG düzenlenebilir yol içermiyor
vectorPaste The SVG contains no editable shape SVG düzenlenebilir şekil içermiyor
vectorPaste The SVG contains rasterised objects without editable paths SVG, düzenlenebilir yolları olmayan rasterleştirilmiş nesneler içeriyor
vectorPaste The SVG has incompatible fill rules; use Layers or Smart Object SVG uyumsuz dolgu kuralları içeriyor; Katmanlar'ı veya Akıllı Nesne'yi kullanın
vectorPaste The SVG is too large to rasterise (24 million pixel limit) SVG rasterleştirmek için çok büyük (24 milyon piksel sınırı)
vectorPaste The pasted shape caches exceed the clipboard cache budget; use Pixels or Smart Object Yapıştırılan şekillerin önbellekleri pano önbellek bütçesini aşıyor; Piksel veya Akıllı Nesne'yi kullanın
Rows Satırlar
Number Sayı
Can't render this file because it contains an unexpected character in line 8 and column 34.
+42 -14
View File
@@ -3657,6 +3657,31 @@ cameraRaw Masking Маскування
Quick Selection found nothing there Швидке виділення нічого тут не знайшло
Object Selection found nothing there — try a tighter rectangle Виділення об'єктів нічого тут не знайшло — спробуйте вужчий прямокутник
No subject found — try a rectangle around the object Об'єкт не знайдено — спробуйте прямокутник навколо об'єкта
Align to Canvas Вирівняти за полотном
Arrowheads Наконечники стрілок
Bring Shape Forward Перемістити фігуру вперед
Bring Shape to Front Перемістити фігуру на передній план
Constrain Proportions Зберегти пропорції
Distribute Horizontally Розподілити горизонтально
Distribute Vertically Розподілити вертикально
End Кінець
Path Alignment Вирівнювання контурів
Path Arrangement Порядок контурів
Path Operations Операції з контурами
Path Options Параметри контуру
Send Shape Backward Перемістити фігуру назад
Send Shape to Back Перемістити фігуру на задній план
Show Path Показати контур
Start Початок
Align Edges Вирівняти краї
Unconstrained Без обмежень
Proportional Пропорційно
Smooth Indents Згладити западини
Concavity: Увігнутість:
Color Picker Вибір кольору
Recent Colors Нещодавно використані кольори
No recent colors Немає нещодавно використаних кольорів
Color opacity Непрозорість кольору
Click the first point or press {key} to close · {del} removes a point · Esc cancels Клацніть першу точку або натисніть {key}, щоб замкнути · {del} видаляє точку · Esc скасовує
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Клацніть і ведіть уздовж краю · клацання закріплює точку · {alt}-клацання — прямий відрізок · подвійне клацання замикає
Follows only edges this close to the pointer ([ and ] change it) Відстежує лише краї на такій відстані від вказівника ([ і ] змінюють її)
@@ -3757,6 +3782,23 @@ curves Smooth Згладити
Scale: Масштаб:
Snap to Origin Прив'язати до початку координат
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Експортовано {n} зразок кольору до {file}|Експортовано {n} зразки кольорів до {file}|Експортовано {n} зразків кольорів до {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Імпортовано {n} пензель із {group}|Імпортовано {n} пензлі з {group}|Імпортовано {n} пензлів із {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Імпортовано {n} зразок кольору з {file}|Імпортовано {n} зразки кольорів з {file}|Імпортовано {n} зразків кольорів з {file}
@plural Played {n} step|Played {n} steps Відтворено {n} крок|Відтворено {n} кроки|Відтворено {n} кроків
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Оновлено {n} зв’язаний смартоб'єкт, змінений на диску|Оновлено {n} зв’язані смартоб'єкти, змінені на диску|Оновлено {n} зв’язаних смартоб'єктів, змінених на диску
@plural Saved for Web: {n} file|Saved for Web: {n} files Збережено для вебу: {n} файл|Збережено для вебу: {n} файли|Збережено для вебу: {n} файлів
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Видалено {n} шар, записаний у цій композиції. Оновіть композицію або скиньте попередження.|Видалено {n} шари, записані у цій композиції. Оновіть композицію або скиньте попередження.|Видалено {n} шарів, записаних у цій композиції. Оновіть композицію або скиньте попередження.
@plural {n} plane|{n} planes {n} площина|{n} площини|{n} площин
@plural {n} preset|{n} presets {n} набір|{n} набори|{n} наборів
@plural {n} sample point removed|{n} sample points removed Видалено {n} точку вимірювання|Видалено {n} точки вимірювання|Видалено {n} точок вимірювання
@plural {n} slice|{n} slices {n} фрагмент|{n} фрагменти|{n} фрагментів
@plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} додаткове попередження щодо наборів пензлів)|{warning} (+{n} додаткові попередження щодо наборів пензлів)|{warning} (+{n} додаткових попереджень щодо наборів пензлів)
@plural {what} with {n} warning|{what} with {n} warnings {what} з {n} попередженням|{what} з {n} попередженнями|{what} з {n} попередженнями
Shape color selector Вибір кольору фігури
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove Клацніть, щоб розмістити · Alt-клацання або перетягування за межі полотна для видалення
No document open Документ не відкрито
@@ -3774,20 +3816,6 @@ curves Smooth Згладити
The layer or its mask changed. Drag the mask onto the Trash again. Шар або його маска змінилися. Перетягніть маску до кошика ще раз.
Delete the vector mask? Видалити векторну маску?
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Експортовано {n} зразок кольору до {file}|Експортовано {n} зразки кольорів до {file}|Експортовано {n} зразків кольорів до {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Імпортовано {n} пензель з {group}|Імпортовано {n} пензлі з {group}|Імпортовано {n} пензлів з {group}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Імпортовано {n} зразок кольору з {file}|Імпортовано {n} зразки кольорів з {file}|Імпортовано {n} зразків кольорів з {file}
@plural Played {n} step|Played {n} steps Відтворено {n} крок|Відтворено {n} кроки|Відтворено {n} кроків
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Оновлено {n} пов'язаний смартоб'єкт, змінений на диску|Оновлено {n} пов'язані смартоб'єкти, змінені на диску|Оновлено {n} пов'язаних смартоб'єктів, змінених на диску
@plural Saved for Web: {n} file|Saved for Web: {n} files Збережено для вебу: {n} файл|Збережено для вебу: {n} файли|Збережено для вебу: {n} файлів
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Видалено {n} шар, записаний у цій композиції. Оновіть композицію або скиньте попередження.|Видалено {n} шари, записані в цій композиції. Оновіть композицію або скиньте попередження.|Видалено {n} шарів, записаних у цій композиції. Оновіть композицію або скиньте попередження.
@plural {n} plane|{n} planes {n} площина|{n} площини|{n} площин
@plural {n} preset|{n} presets {n} набір|{n} набори|{n} наборів
@plural {n} sample point removed|{n} sample points removed Вилучено {n} точку вимірювання|Вилучено {n} точки вимірювання|Вилучено {n} точок вимірювання
@plural {n} slice|{n} slices {n} фрагмент|{n} фрагменти|{n} фрагментів
@plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попереджень щодо наборів пензлів)
@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} попередження|{what}: {n} попередження|{what}: {n} попереджень
# View › Guides › New Guide… › Color
Light Blue Світло-синій
Light Red Світло-червоний
Can't render this file because it contains an unexpected character in line 1384 and column 18.
+55
View File
@@ -26,6 +26,61 @@
Solidity: Độ đặc:
Ink Characteristics Đặc tính mực
# Vector clipboard, embedded SVG editor and shared Shape controls
Align Edges Căn cạnh
Align to Canvas Căn theo khung vẽ
Arrowheads Đầu mũi tên
Bring Shape Forward Đưa hình lên một bậc
Bring Shape to Front Đưa hình lên trên cùng
Color Picker Bộ chọn màu
Color opacity Độ mờ của màu
Concavity: Độ lõm:
Constrain Proportions Giữ tỷ lệ
Distribute Horizontally Phân bố theo chiều ngang
Distribute Vertically Phân bố theo chiều dọc
End Cuối
New Path Đường dẫn mới
No recent colors Không có màu gần đây
Path Alignment Căn chỉnh đường dẫn
Path Arrangement Sắp xếp đường dẫn
Path Operations Các phép toán đường dẫn
Path Options Tùy chọn đường dẫn
Proportional Theo tỷ lệ
Recent Colors Màu gần đây
Send Shape Backward Đưa hình xuống một bậc
Send Shape to Back Đưa hình xuống dưới cùng
Shape color selector Bộ chọn màu hình
Show Path Hiện đường dẫn
Smooth Indents Làm mịn các chỗ lõm
Start Đầu
Unconstrained Không ràng buộc
smartSvg Choose executable… Chọn tệp thực thi…
smartSvg Choose the application used to edit this SVG: Chọn ứng dụng dùng để chỉnh sửa SVG này:
smartSvg Clipboard SVG does not reliably identify its source application. SVG trong bảng nhớ tạm không xác định đáng tin cậy ứng dụng nguồn của nó.
smartSvg Edit SVG Smart Object Chỉnh sửa Đối tượng thông minh SVG
smartSvg Other application Ứng dụng khác
smartSvg PhotoCraft (internal editor) PhotoCraft (trình chỉnh sửa nội bộ)
smartSvg SVG editor opened. Save the SVG to update this Smart Object. Đã mở trình chỉnh sửa SVG. Lưu SVG để cập nhật Đối tượng thông minh này.
smartSvg Save to the opened SVG file. Each saved change becomes one undo step in PhotoCraft. Lưu vào tệp SVG đã mở. Mỗi lần lưu thay đổi tạo một bước hoàn tác trong PhotoCraft.
smartSvg Stop syncing this Smart Object Dừng đồng bộ Đối tượng thông minh này
smartSvg Updates wait while another document or an editing dialog is active. Các cập nhật sẽ chờ khi tài liệu khác hoặc hộp thoại chỉnh sửa đang hoạt động.
vectorPaste Import warnings: Cảnh báo nhập:
vectorPaste Layers Các lớp
vectorPaste Paste Dán
vectorPaste Paste as: Dán dưới dạng:
vectorPaste Path Đường dẫn
vectorPaste Pixels Pixel
vectorPaste SVG text is converted to outlines, not editable type. Văn bản SVG được chuyển thành đường viền, không phải văn bản có thể chỉnh sửa.
vectorPaste Shape Layer Lớp hình
vectorPaste Shape Layer requires one shape without group blending; use Layers to preserve this SVG Lớp hình yêu cầu một hình không có hòa trộn nhóm; dùng Các lớp để giữ nguyên SVG này
vectorPaste Smart Object Đối tượng thông minh
vectorPaste The SVG contains no editable paths SVG không chứa đường dẫn có thể chỉnh sửa
vectorPaste The SVG contains no editable shape SVG không chứa hình có thể chỉnh sửa
vectorPaste The SVG contains rasterised objects without editable paths SVG chứa các đối tượng đã raster hóa không có đường dẫn có thể chỉnh sửa
vectorPaste The SVG has incompatible fill rules; use Layers or Smart Object SVG có các quy tắc tô không tương thích; dùng Các lớp hoặc Đối tượng thông minh
vectorPaste The SVG is too large to rasterise (24 million pixel limit) SVG quá lớn để raster hóa (giới hạn 24 triệu pixel)
vectorPaste The pasted shape caches exceed the clipboard cache budget; use Pixels or Smart Object Bộ nhớ đệm hình được dán vượt quá ngân sách bộ nhớ đệm bảng nhớ tạm; dùng Pixel hoặc Đối tượng thông minh
# Phase 3.1 — top-level menus, File menu, Edit menu
# Custom paper dimensions in Print Settings.
Paper Width Chiều rộng giấy
Can't render this file because it contains an unexpected character in line 6 and column 47.
+47 -2
View File
@@ -2568,6 +2568,36 @@ fontWeight Italic 斜体
Quick Selection found nothing there 快速选择在该处未找到任何内容
Object Selection found nothing there — try a tighter rectangle 对象选择在该处未找到任何内容 — 请尝试更紧凑的矩形
No subject found — try a rectangle around the object 未找到主体 — 请尝试在对象周围绘制矩形
W 宽
H 高
Align to Canvas 对齐到画布
Arrowheads 箭头
Bring Shape Forward 形状前移
Bring Shape to Front 形状置于顶层
Constrain Proportions 约束比例
Distribute Horizontally 水平分布
Distribute Vertically 垂直分布
End 终点
Mask 蒙版
New Path 新建路径
Path Alignment 路径对齐
Path Arrangement 路径排列
Path Operations 路径操作
Path Options 路径选项
Send Shape Backward 形状后移
Send Shape to Back 形状置于底层
Show Path 显示路径
Start 起点
Align Edges 对齐边缘
Unconstrained 无约束
Proportional 比例
Smooth Indents 平滑缩进
Concavity: 凹度:
Solid Color 纯色
Color Picker 颜色选择器
Recent Colors 最近使用的颜色
No recent colors 尚无最近使用的颜色
Color opacity 颜色不透明度
Auto Erase 自动抹除
Click the first point or press {key} to close · {del} removes a point · Esc cancels 点按第一个点或按 {key} 闭合 · {del} 删除一个点 · Esc 取消
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close 点按,然后沿边缘移动 · 点按固定一个点 · {alt}-点按画直线段 · 双击闭合
@@ -2620,8 +2650,6 @@ cameraData Lens 镜头
Make Portrait 设为纵向
Make Landscape 设为横向
Add New Artboard 添加新画板
W 宽
H 高
Freeform Pen Tool 自由钢笔工具
Curvature Pen Tool 弯度钢笔工具
Curve Fit: 曲线拟合:
@@ -2682,6 +2710,23 @@ curves Smooth 平滑
Enter a name for the crop preset 输入裁剪预设的名称
There is no crop size or ratio to save 没有可保存的裁剪大小或比例
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已将 {n} 个色板导出到 {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已从 {group} 导入 {n} 个画笔
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已从 {file} 导入 {n} 个色板
@plural Played {n} step|Played {n} steps 已播放 {n} 个步骤
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 个在磁盘上发生更改的链接智能对象
@plural Saved for Web: {n} file|Saved for Web: {n} files 存储为 Web 所用格式:{n} 个文件
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此图层复合中记录的 {n} 个图层已被删除。请更新图层复合或清除警告。
@plural {n} plane|{n} planes {n} 个平面
@plural {n} preset|{n} presets {n} 个预设
@plural {n} sample point removed|{n} sample points removed 已移除 {n} 个取样点
@plural {n} slice|{n} slices {n} 个切片
@plural {n} step|{n} steps {n} 个步骤
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(另有 {n} 条画笔预设警告)
@plural {what} with {n} warning|{what} with {n} warnings {what}({n} 条警告)
Shape color selector 形状颜色选择器
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove 单击以放置 · Alt 单击或拖到画布外以移除
No document open 未打开任何文档
Can't render this file because it contains an unexpected character in line 2583 and column 53.
+47 -16
View File
@@ -2538,6 +2538,36 @@ fontWeight Italic 斜體
Quick Selection found nothing there 快速選取在該處找不到任何內容
Object Selection found nothing there — try a tighter rectangle 物件選取在該處找不到任何內容 — 請嘗試更緊密的矩形
No subject found — try a rectangle around the object 找不到主體 — 請嘗試在物件周圍繪製矩形
W 寬
H 高
Align to Canvas 對齊至畫布
Arrowheads 箭頭
Bring Shape Forward 形狀前移
Bring Shape to Front 形狀置於頂層
Constrain Proportions 維持比例
Distribute Horizontally 水平分佈
Distribute Vertically 垂直分佈
End 終點
Mask 遮色片
New Path 新增路徑
Path Alignment 路徑對齊
Path Arrangement 路徑排列
Path Operations 路徑操作
Path Options 路徑選項
Send Shape Backward 形狀後移
Send Shape to Back 形狀置於底層
Show Path 顯示路徑
Start 起點
Align Edges 對齊邊緣
Unconstrained 不受限制
Proportional 比例
Smooth Indents 平滑凹口
Concavity: 凹度:
Solid Color 純色
Color Picker 色彩選擇器
Recent Colors 最近使用的顏色
No recent colors 尚無最近使用的顏色
Color opacity 顏色不透明度
Auto Erase 自動擦除
Click the first point or press {key} to close · {del} removes a point · Esc cancels 按一下第一個點或按 {key} 封閉 · {del} 刪除一個點 · Esc 取消
Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close 按一下,然後沿著邊緣移動 · 按一下固定一個點 · {alt}-按一下畫直線區段 · 按兩下封閉
@@ -2590,8 +2620,6 @@ cameraData Lens 鏡頭
Make Portrait 設為直向
Make Landscape 設為橫向
Add New Artboard 新增工作區域
W 寬
H 高
Freeform Pen Tool 自由筆型工具
Curvature Pen Tool 曲線筆工具
Curve Fit: 曲線擬合:
@@ -2652,6 +2680,23 @@ curves Smooth 平滑
Enter a name for the crop preset 輸入裁切預設集的名稱
There is no crop size or ratio to save 沒有可儲存的裁切尺寸或比例
# Plural status messages and counts
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已將 {n} 個色票匯出至 {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已從 {group} 匯入 {n} 個筆刷
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已從 {file} 匯入 {n} 個色票
@plural Played {n} step|Played {n} steps 已播放 {n} 個步驟
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 個在磁碟上變更的連結智慧型物件
@plural Saved for Web: {n} file|Saved for Web: {n} files 儲存為網頁用:{n} 個檔案
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此圖層構圖中記錄的 {n} 個圖層已被刪除。請更新圖層構圖或清除警告。
@plural {n} plane|{n} planes {n} 個平面
@plural {n} preset|{n} presets {n} 個預設集
@plural {n} sample point removed|{n} sample points removed 已移除 {n} 個取樣點
@plural {n} slice|{n} slices {n} 個切片
@plural {n} step|{n} steps {n} 個步驟
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(另有 {n} 則筆刷預設集警告)
@plural {what} with {n} warning|{what} with {n} warnings {what}({n} 則警告)
Shape color selector 形狀顏色選擇器
# Color Sampler (added with the i18n coverage repair)
Click to place · Alt-click or drag off-canvas to remove 按一下以放置 · Alt 按一下或拖曳至畫布外以移除
No document open 未開啟任何文件
@@ -2669,20 +2714,6 @@ curves Smooth 平滑
The layer or its mask changed. Drag the mask onto the Trash again. 圖層或其遮色片已變更。請再次將遮色片拖曳到垃圾桶。
Delete the vector mask? 要刪除向量遮色片嗎?
# Counted status messages and labels (#3454; adapted from PR #3821)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已將 {n} 個色票匯出至 {file}
@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已從 {group} 匯入 {n} 個筆刷
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已從 {file} 匯入 {n} 個色票
@plural Played {n} step|Played {n} steps 已播放 {n} 個步驟
@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 個在磁碟上變更的連結智慧型物件
@plural Saved for Web: {n} file|Saved for Web: {n} files 儲存為網頁用:{n} 個檔案
@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此構圖記錄的 {n} 個圖層已刪除。請更新構圖,或清除警告。
@plural {n} plane|{n} planes {n} 個平面
@plural {n} preset|{n} presets {n} 個預設集
@plural {n} sample point removed|{n} sample points removed 已移除 {n} 個取樣點
@plural {n} slice|{n} slices {n} 個切片
@plural {n} step|{n} steps {n} 個步驟
@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (另有 {n} 個筆刷預設集警告)
@plural {what} with {n} warning|{what} with {n} warnings {what} ({n} 個警告)
# View › Guides › New Guide… › Color
Light Blue 淺藍色
Light Red 淺紅色
Can't render this file because it contains an unexpected character in line 6 and column 47.
+14 -2
View File
@@ -1,7 +1,7 @@
//! Layers panel row: the right-hand indicators and the name between them and the thumbnails
//! (#144).
//!
//! The indicators (lock, fx badge with its effects triangle, link, the blend-mode label) are
//! The indicators (lock, fx badge with its effects triangle, link, shape type, blend-mode label) are
//! laid out first, from the row's right edge inward; the name gets the width that is left and
//! is cut with an ellipsis, so a long name never runs under an indicator at any panel width or
//! UI scale. The blend-mode label is optional: it is dropped before the name gets too narrow.
@@ -9,7 +9,7 @@
use egui::{Align2, FontId, Galley, Painter, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2};
use photocraft_color::BlendMode;
use photocraft_doc::Layer;
use photocraft_doc::{Layer, LayerContent};
use serde_json::{Value, json};
use std::sync::Arc;
@@ -34,6 +34,7 @@ pub enum Indicator {
FxTriangle,
Fx,
Link,
Shape,
Blend,
}
@@ -80,6 +81,9 @@ pub fn reserved_width(l: &Layer) -> f32 {
if l.link_group.is_some() {
w += ICON_W + GAP;
}
if matches!(l.content, LayerContent::Shape(_)) {
w += ICON_W + GAP;
}
w
}
@@ -131,6 +135,9 @@ pub fn indicators(
if l.link_group.is_some() {
items.push((Indicator::Link, ICON_W));
}
if matches!(l.content, LayerContent::Shape(_)) {
items.push((Indicator::Shape, ICON_W));
}
if let Some(g) = &blend_galley {
items.push((Indicator::Blend, g.size().x));
}
@@ -141,6 +148,11 @@ pub fn indicators(
match kind {
Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint),
Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link-2", 12.0, t.text_faint),
Indicator::Shape => {
icons::paint(ui, r, "pentagon", 13.0, t.icon);
let resp = ui.interact(r, ui.id().with(("shape-layer", l.id.0)), Sense::hover()).on_hover_text(tl!("Shape Layer"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tl!("Shape Layer")));
}
Indicator::Fx => {
if let Some(g) = fx_galley.clone() {
painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_dim);
+81
View File
@@ -171,14 +171,23 @@ pub mod scrollbars;
mod scrub_wrap;
pub mod select_mask_view;
pub mod served_fonts;
mod shape_canvas;
pub mod shape_dialog;
pub(crate) mod shape_fill_ui;
mod shape_modes;
mod shape_options;
mod shape_paint_ui;
pub mod shape_stroke_ui;
pub mod shortcut_dispatch;
pub mod shortcuts;
mod single_line_drag;
mod sizing;
pub mod slice_ui;
#[cfg(not(target_arch = "wasm32"))]
mod smart_svg_ui;
#[cfg(target_arch = "wasm32")]
#[path = "smart_svg_ui/browser.rs"]
mod smart_svg_ui;
pub mod smart_ui;
pub mod snap_ui;
pub(crate) mod solid_fill_ui;
@@ -215,6 +224,7 @@ pub mod type_panels_ui;
pub mod type_tool;
mod type_transform;
mod variables_ui;
mod vector_paste_ui;
pub mod vector_ui;
pub mod view_cmds;
pub mod vp_ui;
@@ -338,6 +348,36 @@ pub type EncodePngFn = Box<dyn Fn(u32, u32, &[u8]) -> Result<Vec<u8>, String>>;
pub type ClipboardSetFn = Box<dyn FnMut(u32, u32, &[u8]) -> Result<(), String>>;
/// Read an RGBA8 image from the OS clipboard.
pub type ClipboardGetFn = Box<dyn FnMut() -> Option<(u32, u32, Vec<u8>)>>;
/// Upper bound checked before allocating native vector clipboard data.
pub use photocraft_engine::vector_paste_cmds::MAX_VECTOR_CLIPBOARD_BYTES;
/// Clipboard representation selected while one native clipboard lock is held.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VectorClipboardSource {
RegisteredSvg,
SvgText,
}
/// Immutable vector bytes captured only in response to an explicit paste.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VectorClipboard {
pub svg: Vec<u8>,
pub source: VectorClipboardSource,
pub sequence: Option<u32>,
}
pub type ClipboardGetVectorFn = Box<dyn FnMut() -> Result<Option<VectorClipboard>, String>>;
/// An editor selected by the user, never an executable supplied by document metadata.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ExternalSvgEditor {
VectorCraft,
Illustrator,
Program(String),
}
/// A native editing session supplies stable saved source snapshots without blocking the UI.
pub trait ExternalSvgSession {
fn poll(&mut self) -> Option<Result<Vec<u8>, String>>;
}
pub type OpenExternalSvgFn = Box<dyn FnMut(ExternalSvgEditor, std::sync::Arc<Vec<u8>>) -> Result<Box<dyn ExternalSvgSession>, String>>;
/// Shared queue of (file name, bytes) delivered asynchronously.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// Read the saved preferences text (`None` when there is none yet).
@@ -392,6 +432,8 @@ pub type CaptionStyleFn = Box<dyn Fn(&egui::Context) -> CaptionStyle>;
/// I/O dependencies.
#[derive(Default)]
pub struct Services {
/// Explicit interactive SVG smart-object editing; unavailable in browser and automation.
pub open_external_svg: Option<OpenExternalSvgFn>,
/// User-initiated desktop/browser pixel sampling.
pub screen_pick: Option<screen_picker::Service>,
/// Decode a file's bytes into a document (PSD, PNG, JPEG, …).
@@ -430,6 +472,8 @@ pub struct Services {
/// OS clipboard images: copies go out, screenshots and images from other apps come in.
pub clipboard_set_image: Option<ClipboardSetFn>,
pub clipboard_get_image: Option<ClipboardGetFn>,
/// SVG clipboard data. Never polled during rendering or command enablement.
pub clipboard_get_vector: Option<ClipboardGetVectorFn>,
/// Preferences persistence: the platform config directory on native, browser storage on
/// the web (see `prefs_ui`).
pub load_prefs: Option<LoadTextFn>,
@@ -647,6 +691,8 @@ pub struct PhotocraftApp {
pub(crate) transform_preview: Option<transform_tool::TransformPreview>,
/// Move-tool ⇧/⌥ drag state (move_mods).
pub(crate) move_mods: move_mods::MoveDrag,
/// Live rectangle corner-radius gesture (shape_canvas).
pub(crate) shape_canvas: shape_canvas::State,
/// Cached document for a modal text Style Options color preview.
pub(crate) text_style_preview: Option<type_panels_ui::color_picker::Preview>,
/// Live Layer Style dialog preview: (key over revision + style fields, preview or validation error).
@@ -682,6 +728,9 @@ pub struct PhotocraftApp {
/// The OS clipboard was already read for the paste in flight (Edit › Paste positions it first),
/// so `run` doesn't read it a second time.
pub(crate) clip_read_for_paste: bool,
/// Captured vector source awaiting a Paste As choice; never persisted.
pub(crate) vector_paste: Option<vector_paste_ui::PendingPaste>,
pub(crate) smart_svg: smart_svg_ui::State,
/// Pointer position over the canvas (document px), for the Info panel and status bar.
pub(crate) hover_doc: Option<[f64; 2]>,
/// Layer hovered in the Layers panel, outlined on the canvas.
@@ -837,8 +886,11 @@ impl PhotocraftApp {
os_clip_sig: None,
clip_external: false,
clip_read_for_paste: false,
vector_paste: None,
smart_svg: Default::default(),
transform_preview: None,
move_mods: Default::default(),
shape_canvas: Default::default(),
style_preview: None,
text_style_preview: None,
solid_fill_preview: None,
@@ -979,6 +1031,12 @@ impl PhotocraftApp {
if let Some(result) = panel_docking::command(self, id, &params) {
return result;
}
if !clip_read && let Some(result) = vector_paste_ui::intercept(self, id, &params) {
return result;
}
if let Some(result) = smart_svg_ui::intercept(self, id, &params) {
return result;
}
if let Some(r) = transform_tool::intercept(self, id) {
return r;
}
@@ -1075,6 +1133,13 @@ impl PhotocraftApp {
self.perf.command_ms = gpu_canvas::now_ms() - t0;
match &r {
Ok(_) => {
if id == "channel.target" {
// An explicit Channels selection replaces the Layers thumbnail target.
// Other commands keep thumbnail-only mask selection on Composite.
let target = self.session.active().map(|st| st.channel_view.target);
self.ui.mask_target = target == Some(photocraft_engine::channel_cmds::ChannelTarget::LayerMask);
self.ui.vector_mask_target = target == Some(photocraft_engine::channel_cmds::ChannelTarget::VectorMask);
}
self.sync_views();
let removed = self.session.take_samplers_removed();
if removed > 0 {
@@ -1287,6 +1352,12 @@ impl PhotocraftApp {
/// [`Self::save_as`], writing a Targa with `tga_bits` per pixel when given (no Targa Options
/// prompt): `file.save` and `file.saveAs` with `tgaBits`.
pub fn save_as_with(&mut self, path: Option<String>, tga_bits: Option<photocraft_io::TgaBits>) -> Result<Value, String> {
if self.smart_svg.modal_open() {
return Err("Answer Edit SVG Contents before starting a save".into());
}
if self.vector_paste.is_some() {
return Err("Answer Paste As before starting a save".into());
}
if self.save_options_open() {
return Err("Answer TIFF or Targa Options before starting another save".into());
}
@@ -1331,6 +1402,12 @@ impl PhotocraftApp {
/// [`Self::save_as_with`] once the path is known.
fn save_to(&mut self, path: String, tga_bits: Option<photocraft_io::TgaBits>) -> Result<Value, String> {
if self.smart_svg.modal_open() {
return Err("Answer Edit SVG Contents before starting a save".into());
}
if self.vector_paste.is_some() {
return Err("Answer Paste As before starting a save".into());
}
// An RGB Targa asks for its bits per pixel, unless the caller chose them; the save
// continues from the prompt (still a copy when it would be one without the prompt),
// which records the step once answered.
@@ -1778,6 +1855,7 @@ impl eframe::App for PhotocraftApp {
let dpi = self.session.active().map_or(72.0, |d| f64::from(d.doc.resolution_dpi.max(1.0)));
crate::widgets::set_field_resolution(&ctx, dpi, ppi);
// View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns).
shape_paint_ui::prune(self, &ctx);
screen_picker::show(&ctx);
if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) {
egui::Modal::new(egui::Id::new("screen-color-wait")).show(&ctx, |ui| {
@@ -1833,6 +1911,9 @@ impl eframe::App for PhotocraftApp {
discard_ui::show(self, &ctx);
tiff_options_ui::show(self, &ctx);
targa_options_ui::show(self, &ctx);
vector_paste_ui::show(self, &ctx);
smart_svg_ui::poll(self, &ctx);
smart_svg_ui::show(self, &ctx);
distort_ui::show(self, &ctx);
camera_raw_ui::show(self, &ctx);
wide_angle_ui::show(self, &ctx);
+3
View File
@@ -36,6 +36,9 @@ pub fn drop_action(layer: u64, kind: MaskKind) -> (String, Value) {
/// Opens the prompt for a [`drop_action`]; `None` for every other command.
pub fn intercept(app: &mut PhotocraftApp, command: &str, params: &Value) -> Option<Result<Value, String>> {
if app.automation_input {
return None;
}
let vector = match command {
LAYER_MASK_DELETE => false,
VECTOR_MASK_DELETE => true,
+14 -4
View File
@@ -7,10 +7,10 @@ use crate::state::DialogKind;
/// Shared gate for native menu clicks and control-channel `ui.menu.invoke`.
/// Dialogs and unsaved-changes prompts block edits, but allow the four view-navigation
/// commands also usable through shortcuts. Camera Raw blocks menu commands entirely, and a pending
/// crop blocks the commands Photoshop greys during one (`crop_ui::blocks`).
/// commands also usable through shortcuts. Camera Raw, Paste As and the SVG editor choice block
/// menu commands entirely. A pending crop blocks the commands Photoshop greys during one (`crop_ui::blocks`).
pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool {
if app.camera_raw.is_some() || crate::crop_ui::blocks(app, id) {
if app.camera_raw.is_some() || app.vector_paste.is_some() || app.smart_svg.modal_open() || crate::crop_ui::blocks(app, id) {
return false;
}
(app.ui.dialogs.is_empty() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id)
@@ -163,6 +163,12 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va
/// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it.
pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
if let Some(result) = crate::vector_paste_ui::intercept(app, id, &params) {
return result;
}
if let Some(result) = crate::smart_svg_ui::intercept(app, id, &params) {
return result;
}
if let Some(tool) = crate::tool_shortcuts::tool(id) {
app.ui.tool = tool;
return Ok(json!({"tool": tool}));
@@ -767,7 +773,11 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
// An image copied in another app can only be seen by reading the OS clipboard, which happens
// on an explicit paste: with a clipboard service these stay enabled. Paste and New from
// Clipboard need no document (with none open, Paste makes one); Paste in Place does.
"edit.paste" | "file.newFromClipboard" => app.session.is_enabled(id) || app.services.clipboard_get_image.is_some(),
"edit.paste" | "file.newFromClipboard" => {
app.session.is_enabled(id)
|| app.services.clipboard_get_image.is_some()
|| (id == "edit.paste" && app.services.clipboard_get_vector.is_some() && app.session.is_enabled("edit.pasteVector"))
}
"edit.pasteSpecial.pasteInPlace" => app.session.is_enabled(id) || (app.services.clipboard_get_image.is_some() && app.session.active().is_some()),
"select.selectAndMask" => app.session.is_enabled("select.refineEdge"),
"type.editText" => app
+19 -1
View File
@@ -174,7 +174,7 @@ fn with_descendants(doc: &Document, ids: &[LayerId]) -> Vec<LayerId> {
/// offset of the latest Move drag frame drawn ([`display_doc`]) when the layer moves with the drag,
/// so its Layer Edges outline moves in lockstep with its pixels (#2413). `None` when it shows
/// where it is.
pub(crate) fn shown_offset(app: &PhotocraftApp, doc: DocId, revision: u64, id: LayerId) -> Option<(i32, i32)> {
pub(crate) fn shown_layer_offset(app: &PhotocraftApp, doc: DocId, revision: u64, id: LayerId) -> Option<(i32, i32)> {
let p = app.move_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision && !p.floating && p.shown.is_some())?;
if !p.moving.contains(&id) {
return None;
@@ -251,6 +251,24 @@ pub(crate) fn showing(app: &PhotocraftApp) -> bool {
app.move_preview.as_ref().is_some_and(|p| p.shown.is_some())
}
/// Geometry of the Move image already drawn this frame, before newer pointer events.
/// Overlay drawing must use this snapshot; hit testing still uses the committed document.
fn shown_preview(app: &PhotocraftApp) -> Option<&MovePreview> {
if app.session.prefs().interface.show_bounding_box_when_dragging_layer || app.drag.as_ref()?.tool != Tool::Move {
return None;
}
let st = app.session.active()?;
app.move_preview.as_ref().filter(|p| p.doc == st.doc.id && p.revision == st.revision && !p.floating && p.shown.is_some())
}
pub(crate) fn shown_doc(app: &PhotocraftApp) -> Option<&Document> {
shown_preview(app)?.shown.as_deref()
}
pub(crate) fn shown_offset(app: &PhotocraftApp) -> Option<(i32, i32)> {
shown_preview(app)?.offsets.last().copied()
}
/// Whether `key` is a Move drag preview key.
pub(crate) fn is_preview_key(key: u64) -> bool {
key > BASE && key < BASE << 1
+76 -3
View File
@@ -1324,6 +1324,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Every theme gets the font, style and size controls (#1387), as with Auto-Select (#1275).
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => crate::type_tool::options_bar(app, ui),
Tool::Move if t.pro => {
let shape = crate::canvas::shape_transform_controls_target(app);
let o = &mut app.ui.tool_options;
widgets::checkbox(ui, &mut o.move_auto_select, tl!("Auto-Select:"));
widgets::dropdown(
@@ -1334,7 +1335,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
76.0,
);
widgets::vline(ui, 22.0);
widgets::checkbox(ui, &mut o.move_show_transform, tl!("Show Transform Controls"));
widgets::checkbox(ui, if shape { &mut o.shape.show_transform } else { &mut o.move_show_transform }, tl!("Show Transform Controls"));
widgets::vline(ui, 22.0);
ui.spacing_mut().item_spacing.x = 0.0;
for (icon, tip, cmd) in [
@@ -1384,8 +1385,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
),
),
Tool::Move => {
// The Studio themes keep Photoshop's Auto-Select toggle too (#1275).
widgets::checkbox(ui, &mut app.ui.tool_options.move_auto_select, tl!("Auto-Select:"));
// The Studio themes keep the Move tool's selection and transform toggles too.
let shape = crate::canvas::shape_transform_controls_target(app);
let o = &mut app.ui.tool_options;
widgets::checkbox(ui, &mut o.move_auto_select, tl!("Auto-Select:"));
widgets::checkbox(ui, if shape { &mut o.shape.show_transform } else { &mut o.move_show_transform }, tl!("Show Transform Controls"));
hint(ui, tl!("Drag to move the active layer"));
}
Tool::Eyedropper => crate::eyedropper_ui::options(app, ui),
@@ -5498,6 +5502,75 @@ mod clipping_indicator_tests;
#[path = "mask_props_ui_tests.rs"]
mod mask_properties_tests;
#[cfg(test)]
mod move_options_tests {
use super::*;
use egui_kittest::kittest::Queryable;
fn harness(app: PhotocraftApp, theme: crate::theme::ThemeKind) -> egui_kittest::Harness<'static, PhotocraftApp> {
let mut h = egui_kittest::Harness::builder().with_size(vec2(960.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
options_bar(app, ui);
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.run_steps(4);
h
}
#[test]
fn studio_move_transform_toggle_changes_only_the_existing_option() {
for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::StudioLight] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width":64,"height":64})).unwrap();
app.ui.tool = Tool::Move;
let mut h = harness(app, theme);
let before = h.state().session.active().unwrap().doc.clone();
let history = h.state().session.active().unwrap().history.past_len();
let auto_select = h.state().ui.tool_options.move_auto_select;
let shape_transform = h.state().ui.tool_options.shape.show_transform;
assert!(!h.state().ui.tool_options.move_show_transform);
for expected in [true, false] {
h.get_by_label("Show Transform Controls").click();
h.run_steps(2);
assert_eq!(h.state().ui.tool_options.move_show_transform, expected, "{theme:?}");
assert_eq!(h.state().ui.tool_options.shape.show_transform, shape_transform);
assert_eq!(h.state().ui.tool_options.move_auto_select, auto_select);
assert_eq!(h.state().session.active().unwrap().doc, before);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
}
}
}
#[test]
fn selected_shape_transform_toggle_defaults_on_and_keeps_raster_move_option() {
for theme in [crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::StudioLight] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width":64,"height":64})).unwrap();
app.run("shape.create", json!({"kind":"rect","rect":[8,8,32,24]})).unwrap();
app.ui.tool = Tool::Move;
let mut h = harness(app, theme);
let before = h.state().session.active().unwrap().doc.clone();
let history = h.state().session.active().unwrap().history.past_len();
let auto_select = h.state().ui.tool_options.move_auto_select;
assert!(h.state().ui.tool_options.shape.show_transform);
assert!(!h.state().ui.tool_options.move_show_transform);
for expected in [false, true] {
h.get_by_label("Show Transform Controls").click();
h.run_steps(2);
assert_eq!(h.state().ui.tool_options.shape.show_transform, expected, "{theme:?}");
assert!(!h.state().ui.tool_options.move_show_transform);
assert_eq!(h.state().ui.tool_options.move_auto_select, auto_select);
assert_eq!(h.state().session.active().unwrap().doc, before);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
}
}
}
}
#[cfg(test)]
mod compact_visual_regressions {
use super::*;
+110 -33
View File
@@ -219,26 +219,39 @@ pub fn paint_gradient(ui: &egui::Ui, rect: Rect, stops: &[(f32, [f32; 4])]) {
p.add(mesh);
}
fn pattern_texture(ctx: &egui::Context, pat: &photocraft_doc::Pattern) -> TextureHandle {
cached_texture(ctx, ("pattern", pat.id.clone()), || {
let (w, h) = (pat.width.max(1), pat.height.max(1));
let fmt = pat.surface.format();
let px = pat.surface.read_region(photocraft_geom::Rect::new(0, 0, w as i32, h as i32));
let n = fmt.channels();
// Tiles smaller than the swatch repeat; larger ones are shown shrunk.
const S: u32 = 64;
let step = (w.max(h) as f32 / S as f32).max(1.0);
let mut img = ColorImage::new([S as usize, S as usize], vec![Color32::TRANSPARENT; (S * S) as usize]);
for y in 0..S {
for x in 0..S {
let sx = ((x as f32 * step) as u32) % w;
let sy = ((y as f32 * step) as u32) % h;
let i = (sy * w + sx) as usize * n;
img.pixels[(y * S + x) as usize] = c32(photocraft_raster::to_rgba(&fmt, &px[i..i + n]));
fn pattern_thumbnail(pat: &photocraft_doc::Pattern) -> ColorImage {
const S: usize = 64;
let (w, h) = (pat.width, pat.height);
if w == 0 || h == 0 || i32::try_from(w).is_err() || i32::try_from(h).is_err() {
return ColorImage::new([S, S], vec![Color32::TRANSPARENT; S * S]);
}
let fmt = pat.surface.format();
let mut px = vec![0.0; fmt.channels()];
let mut pixels = Vec::with_capacity(S * S);
// Sample the sparse surface directly: even an oversized pattern only needs 4096 pixels.
// Smaller tiles repeat; larger ones shrink, without an input-sized intermediate buffer.
let span = u64::from(w.max(h)).max(S as u64);
for y in 0..S {
for x in 0..S {
let sx = (x as u64 * span / S as u64) % u64::from(w);
let sy = (y as u64 * span / S as u64) % u64::from(h);
if let (Ok(sx), Ok(sy)) = (i32::try_from(sx), i32::try_from(sy)) {
pat.surface.read_pixel(sx, sy, &mut px);
pixels.push(c32(photocraft_raster::to_rgba(&fmt, &px)));
} else {
pixels.push(Color32::TRANSPARENT);
}
}
img
})
}
ColorImage::new([S, S], pixels)
}
pub(crate) fn pattern_texture(ctx: &egui::Context, pat: &photocraft_doc::Pattern) -> TextureHandle {
let image = pattern_thumbnail(pat);
// Imported documents can reuse a pattern id for different pixels. Hash the bounded thumbnail,
// rather than the whole pattern, so its displayed content decides which texture is cached.
let key = egui::Id::new(("pattern", &pat.id, pat.width, pat.height, pat.surface.format(), &image.pixels));
cached_texture(ctx, key, || image)
}
/// Photoshop's pattern picker: a swatch of the library pattern `selected` (an id) that opens a grid
@@ -261,22 +274,34 @@ pub(crate) fn pattern_picker(app: &PhotocraftApp, ui: &mut egui::Ui, selected: &
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Patterns")));
let mut picked = None;
egui::Popup::menu(&resp).show(|ui| {
ui.set_max_width(6.0 * (SWATCH + 4.0));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for p in pats {
let (r, cell) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::click());
ui.painter().image(pattern_texture(&ctx, p).id(), r, uv, Color32::WHITE);
let (w, c) = if p.id == selected { (2.0, t.accent) } else { (1.0, t.field_border) };
ui.painter().rect_stroke(r, 0.0, Stroke::new(w, c), egui::StrokeKind::Outside);
let cell = cell.on_hover_text(p.display_name());
cell.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, p.display_name()));
if cell.clicked() {
picked = Some(p.id.clone());
ui.close();
}
picked = pattern_grid(app, ui, selected);
});
picked
}
/// Library grid shared by the standalone picker and paint popups. Drawing it inline avoids
/// replacing an already-open paint popup with a second memory-backed popup.
pub(crate) fn pattern_grid(app: &PhotocraftApp, ui: &mut egui::Ui, selected: &str) -> Option<String> {
const SWATCH: f32 = 40.0;
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
let uv = Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0));
let mut picked = None;
ui.set_max_width(6.0 * (SWATCH + 4.0));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
for p in &app.session.patterns.items {
let (r, cell) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::click());
ui.painter().image(pattern_texture(&ctx, p).id(), r, uv, Color32::WHITE);
let (w, c) = if p.id == selected { (2.0, t.accent) } else { (1.0, t.field_border) };
ui.painter().rect_stroke(r, 0.0, Stroke::new(w, c), egui::StrokeKind::Outside);
let cell = cell.on_hover_text(p.display_name());
cell.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), p.display_name()));
if cell.clicked() {
picked = Some(p.id.clone());
ui.close();
}
});
}
});
picked.filter(|id| id != selected)
}
@@ -1233,6 +1258,58 @@ mod tests {
(app, ctx)
}
#[test]
fn pattern_thumbnail_samples_huge_sparse_patterns_without_materializing_them() {
let mut surface = photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::RGBA32F, &[0.25, 0.5, 0.75, 0.2]);
surface.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.5]);
let pattern = photocraft_doc::Pattern { id: "huge".into(), name: "Huge".into(), width: 100_000, height: 100_000, surface };
let image = pattern_thumbnail(&pattern);
assert_eq!(image.size, [64, 64]);
assert_eq!(image.pixels.len(), 4096);
assert_eq!(image.pixels[0], Color32::from_rgba_unmultiplied(255, 0, 0, 128));
assert_eq!(image.pixels[1], Color32::from_rgba_unmultiplied(64, 128, 191, 51));
assert_eq!(image.pixels[4095], image.pixels[1]);
assert_eq!(pattern.surface.tile_count(), 1);
}
#[test]
fn pattern_thumbnail_repeats_small_tiles_and_rejects_unaddressable_dimensions() {
let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA16);
surface.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.5]);
surface.write_pixel(1, 0, &[0.0, 1.0, 0.0, 1.0]);
let mut pattern = photocraft_doc::Pattern { id: "repeat".into(), name: "Repeat".into(), width: 2, height: 1, surface };
let image = pattern_thumbnail(&pattern);
for row in image.pixels.as_chunks::<64>().0 {
for pair in row.as_chunks::<2>().0 {
assert_eq!(pair, &[Color32::from_rgba_unmultiplied(255, 0, 0, 128), Color32::GREEN]);
}
}
for (w, h) in [(0, 1), (1, 0), (u32::MAX, 1), (1, u32::MAX)] {
(pattern.width, pattern.height) = (w, h);
let image = pattern_thumbnail(&pattern);
assert_eq!(image.pixels.len(), 4096);
assert!(image.pixels.iter().all(|p| *p == Color32::TRANSPARENT));
}
}
#[test]
fn pattern_thumbnail_cache_distinguishes_same_id_with_different_pixels() {
let ctx = egui::Context::default();
let mut pattern = photocraft_doc::Pattern {
id: "shared-id".into(),
name: "Shared".into(),
width: 2,
height: 2,
surface: photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]),
};
let red = pattern_texture(&ctx, &pattern);
assert_eq!(pattern_texture(&ctx, &pattern).id(), red.id());
pattern.surface = photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::RGBA8, &[0.0, 1.0, 0.0, 1.0]);
let green = pattern_texture(&ctx, &pattern);
assert_ne!(green.id(), red.id());
assert_eq!(pattern_texture(&ctx, &pattern).id(), green.id());
}
#[test]
fn window_menu_toggles_panels_and_color_tabs() {
let (mut app, ctx) = app();
+232
View File
@@ -0,0 +1,232 @@
//! Live rectangle corner-radius controls. Document edits remain engine commands.
use egui::{Modifiers, Stroke};
use photocraft_doc::{DocId, LayerContent, LayerId, LiveShape};
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::{ToolEvent, ViewXform};
use crate::state::Tool;
use crate::theme::Tokens;
const HANDLE_INSET: f64 = 14.0;
const GRAB: f64 = 6.0;
const ARC_INSET: f64 = 1.0 - std::f64::consts::FRAC_1_SQRT_2;
#[derive(Default)]
pub(crate) struct State {
serial: u64,
drag: Option<Drag>,
}
pub(crate) fn dragging(app: &PhotocraftApp) -> bool {
app.shape_canvas.drag.is_some()
}
pub(crate) fn supports_tool(tool: Tool) -> bool {
matches!(tool, Tool::Move | Tool::DirectSelection | Tool::PathSelection) || crate::vector_ui::is_shape_tool(tool)
}
/// Navigation handles its own release, so it must also end an interrupted radius gesture.
pub(crate) fn end_if_navigation(app: &mut PhotocraftApp, tool: Tool) {
if matches!(tool, Tool::Hand | Tool::Zoom | Tool::RotateView) {
app.shape_canvas.drag = None;
}
}
struct Drag {
doc: DocId,
layer: LayerId,
tool: Tool,
revision: u64,
corner: usize,
start: [f64; 2],
rect: [f64; 4],
radii: [f64; 4],
edited: bool,
key: String,
}
struct Rectangle {
doc: DocId,
layer: LayerId,
revision: u64,
rect: [f64; 4],
radii: [f64; 4],
}
fn rectangle(app: &PhotocraftApp) -> Option<Rectangle> {
let tool = app.active_tool();
if app.ui.mask_target
|| app.ui.vector_mask_target
|| app.ui.transform.is_some()
|| !supports_tool(tool)
|| (crate::vector_ui::is_shape_tool(tool) && app.ui.tool_options.shape.mode != "shape")
{
return None;
}
let st = app.session.active()?;
let layer = st.active_layer?;
let locks = st.doc.effective_locks(layer);
if locks.all || locks.pixels || locks.position {
return None;
}
let LayerContent::Shape(sh) = &st.doc.layer(layer)?.content else { return None };
let Some(LiveShape::Rect { rect, radii }) = &sh.live else { return None };
if rect.iter().chain(radii.iter()).any(|n| !n.is_finite()) || rect[2] <= 0.0 || rect[3] <= 0.0 {
return None;
}
Some(Rectangle { doc: st.doc.id, layer, revision: st.revision, rect: *rect, radii: *radii })
}
fn direction(corner: usize) -> [f64; 2] {
match corner {
0 => [1.0, 1.0],
1 => [-1.0, 1.0],
2 => [-1.0, -1.0],
_ => [1.0, -1.0],
}
}
fn handles(rect: [f64; 4], radii: [f64; 4], zoom: f64) -> [[f64; 2]; 4] {
let [x, y, w, h] = rect;
let radii = photocraft_vector::shapes::clamp_radii(w, h, radii);
let inset = (HANDLE_INSET / zoom.max(0.01)).min(w * 0.25).min(h * 0.25);
let corners = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
std::array::from_fn(|i| {
let d = direction(i);
let distance = inset + radii[i] * ARC_INSET;
[corners[i][0] + d[0] * distance, corners[i][1] + d[1] * distance]
})
}
fn hit(rect: [f64; 4], radii: [f64; 4], p: [f64; 2], zoom: f64, aspect: f64) -> Option<usize> {
let zoom = zoom.max(0.01);
let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
handles(rect, radii, zoom)
.into_iter()
.enumerate()
.map(|(i, q)| (i, ((q[0] - p[0]) * zoom * aspect).hypot((q[1] - p[1]) * zoom)))
.filter(|(_, distance)| *distance <= GRAB)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// A changed document, target or tool ends the gesture before another context can receive it.
pub(crate) fn cancel_if_left(app: &mut PhotocraftApp) {
let keep = app
.shape_canvas
.drag
.as_ref()
.is_none_or(|d| rectangle(app).is_some_and(|r| r.doc == d.doc && r.layer == d.layer && r.revision == d.revision && app.active_tool() == d.tool));
if !keep {
app.shape_canvas.drag = None;
}
}
/// Escape restores only this gesture; unrelated edits can never be undone by a stale handle.
pub(crate) fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
cancel_if_left(app);
if app.shape_canvas.drag.is_none() || !ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::Escape)) {
return false;
}
cancel(app);
true
}
fn cancel(app: &mut PhotocraftApp) {
cancel_if_left(app);
if app.shape_canvas.drag.take().is_some_and(|d| d.edited)
&& let Err(e) = app.run("edit.undo", json!({}))
{
app.ui.status = e;
app.ui.status_error = true;
}
}
/// Runs before snapping and Move/Free Transform so a knob press grabs its actual position.
pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool {
cancel_if_left(app);
match ev {
ToolEvent::Down { x, y, .. } => {
let Some(r) = rectangle(app) else { return false };
let Some(corner) = hit(r.rect, r.radii, [x, y], f64::from(app.point_zoom()), f64::from(app.ui.view.display_aspect())) else { return false };
app.shape_canvas.serial = app.shape_canvas.serial.wrapping_add(1);
app.shape_canvas.drag = Some(Drag {
doc: r.doc,
layer: r.layer,
tool: app.active_tool(),
revision: r.revision,
corner,
start: [x, y],
rect: r.rect,
radii: r.radii,
edited: false,
key: format!("shape-radius-{}-{}-{}", r.doc.0, r.layer.0, app.shape_canvas.serial),
});
true
}
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
let Some(mut d) = app.shape_canvas.drag.take() else { return false };
if x.is_finite() && y.is_finite() && (d.edited || (x - d.start[0]).hypot(y - d.start[1]) * f64::from(app.point_zoom()) > 0.5) {
let sign = direction(d.corner);
let delta = ((x - d.start[0]) * sign[0] + (y - d.start[1]) * sign[1]) / (2.0 * ARC_INSET);
let old = photocraft_vector::shapes::clamp_radii(d.rect[2], d.rect[3], d.radii);
let radius = (old.get(d.corner).copied().unwrap_or(0.0) + delta).clamp(0.0, d.rect[2].min(d.rect[3]) / 2.0);
let mut radii = if mods.alt { d.radii } else { [radius; 4] };
if mods.alt
&& let Some(r) = radii.get_mut(d.corner)
{
*r = radius;
}
let changed = rectangle(app).is_some_and(|r| r.radii != radii);
if changed {
match app.run("shape.edit", json!({"layer":d.layer.0,"radii":radii,"coalesce":d.key})) {
Ok(_) => {
d.edited = true;
if let Some(st) = app.session.active() {
d.revision = st.revision;
}
}
Err(e) => {
app.ui.status = e;
app.ui.status_error = true;
return true;
}
}
}
}
if !matches!(ev, ToolEvent::Up { .. }) {
app.shape_canvas.drag = Some(d);
}
true
}
}
}
pub(crate) fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
let Some(mut r) = rectangle(app) else { return };
if let Some(doc) = crate::move_ui::shown_doc(app) {
let Some(layer) = doc.layer(r.layer) else { return };
let LayerContent::Shape(shape) = &layer.content else { return };
let Some(LiveShape::Rect { rect, radii }) = &shape.live else { return };
r.rect = *rect;
r.radii = *radii;
}
let t = Tokens::get(painter.ctx());
let grabbed = app.shape_canvas.drag.as_ref().map(|d| d.corner);
for (i, p) in handles(r.rect, r.radii, f64::from(xf.zoom)).into_iter().enumerate() {
let p = xf.to_screen(p[0] as f32, p[1] as f32);
painter.circle(p, 4.0, if grabbed == Some(i) { t.accent_soft } else { t.field }, Stroke::new(1.0, t.accent));
painter.circle_filled(p, 1.5, t.accent);
}
}
pub(crate) fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option<egui::CursorIcon> {
let r = rectangle(app)?;
(app.shape_canvas.drag.is_some() || hit(r.rect, r.radii, p, f64::from(app.point_zoom()), f64::from(app.ui.view.display_aspect())).is_some())
.then_some(egui::CursorIcon::PointingHand)
}
#[cfg(test)]
mod tests;
+620
View File
@@ -0,0 +1,620 @@
use super::*;
use crate::canvas::tool_event;
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":200,"height":160,"background":"transparent"})).unwrap();
app.sync_views();
app.run("shape.create", json!({"kind":"roundedRect","rect":[20,20,120,100],"radii":[5,10,15,20]})).unwrap();
app.ui.tool = Tool::Rectangle;
app
}
fn radius(app: &PhotocraftApp) -> [f64; 4] {
rectangle(app).unwrap().radii
}
fn knob(app: &PhotocraftApp, corner: usize) -> [f64; 2] {
let r = rectangle(app).unwrap();
handles(r.rect, r.radii, f64::from(app.point_zoom()))[corner]
}
fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) {
tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, mods);
assert!(app.shape_canvas.drag.is_some());
assert!(app.drag.is_none(), "a radius knob must not also start a shape/move drag");
}
fn move_to(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) {
tool_event(app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, mods);
}
fn release(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) {
tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, mods);
assert!(app.shape_canvas.drag.is_none());
}
#[test]
fn linked_radius_is_live_and_one_undo_per_gesture() {
let mut a = app();
let before = a.session.active().unwrap().doc.clone();
let n = a.session.active().unwrap().history.past_len();
let p = knob(&a, 0);
press(&mut a, p, Modifiers::NONE);
release(&mut a, p, Modifiers::NONE);
assert_eq!(a.session.active().unwrap().doc, before, "clicking a knob preserves unlinked radii");
press(&mut a, p, Modifiers::NONE);
for d in [2.0, 4.0, 8.0] {
move_to(&mut a, [p[0] + d, p[1] + d], Modifiers::NONE);
}
let live = radius(&a);
assert!(live[0] > 5.0 && live.iter().all(|r| *r == live[0]));
release(&mut a, [p[0] + 8.0, p[1] + 8.0], Modifiers::NONE);
assert_eq!(a.session.active().unwrap().history.past_len(), n + 1);
let after = a.session.active().unwrap().doc.clone();
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, before);
a.run("edit.redo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, after);
let p = knob(&a, 0);
press(&mut a, p, Modifiers::NONE);
release(&mut a, [p[0] - 2.0, p[1] - 2.0], Modifiers::NONE);
assert_eq!(a.session.active().unwrap().history.past_len(), n + 2);
}
#[test]
fn alt_edits_only_grabbed_corner_and_limits_radius() {
let mut a = app();
a.ui.tool = Tool::Move;
let count = a.session.active().unwrap().doc.layers.len();
let p = knob(&a, 1);
press(&mut a, p, Modifiers::ALT);
release(&mut a, [p[0] - 200.0, p[1] + 200.0], Modifiers::ALT);
assert_eq!(radius(&a), [5.0, 50.0, 15.0, 20.0]);
assert_eq!(a.session.active().unwrap().doc.layers.len(), count, "Alt on a radius knob must not duplicate a layer");
}
#[test]
fn radius_handles_follow_rotated_flipped_scaled_canvas() {
let mut a = app();
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
for rotation in [0.0, 43.0, -90.0, 180.0] {
for flip in [false, true] {
for zoom in [0.25, 1.0, 4.0] {
a.ui.views[0].zoom = zoom;
let xf = ViewXform {
aspect: 1.0,
rect: egui::Rect::from_min_size(egui::pos2(70.0, 30.0), egui::vec2(600.0, 450.0)),
zoom,
center: [80.0, 70.0],
flip,
rotation,
};
let expected = handles([20.0, 20.0, 120.0, 100.0], [5.0, 10.0, 15.0, 20.0], f64::from(zoom));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
draw_overlay(&a, &ui.ctx().layer_painter(egui::LayerId::background()), &xf);
});
output.textures_delta.clear();
let circles: Vec<_> = output
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Circle(c) if c.radius == 4.0 => Some(c.center),
_ => None,
})
.collect();
assert_eq!(circles.len(), 4);
for (i, p) in expected.into_iter().enumerate() {
let screen = xf.to_screen(p[0] as f32, p[1] as f32);
assert!(circles[i].distance(screen) < 0.001);
let doc = xf.to_doc(screen);
assert_eq!(hit([20.0, 20.0, 120.0, 100.0], [5.0, 10.0, 15.0, 20.0], doc, f64::from(zoom), f64::from(xf.aspect)), Some(i));
assert_eq!(cursor(&a, doc), Some(egui::CursorIcon::PointingHand));
}
}
}
}
}
#[test]
fn radius_hit_area_is_six_screen_points_with_pixel_aspect_correction() {
let rect = [20.0, 20.0, 120.0, 100.0];
let radii = [5.0, 10.0, 15.0, 20.0];
for aspect in [0.5, 1.0, 2.0] {
for rotation in [0.0, 43.0, -90.0] {
for flip in [false, true] {
let xf = ViewXform {
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 450.0)),
zoom: 2.0,
center: [80.0, 70.0],
flip,
rotation,
aspect,
};
let p = handles(rect, radii, f64::from(xf.zoom))[0];
let screen = xf.to_screen(p[0] as f32, p[1] as f32);
for direction in [egui::vec2(1.0, 0.0), egui::vec2(0.0, 1.0)] {
for (distance, expected) in [(5.0, Some(0)), (7.0, None)] {
let doc = xf.to_doc(screen + direction * distance);
assert_eq!(
hit(rect, radii, doc, f64::from(xf.zoom), f64::from(xf.aspect)),
expected,
"aspect {aspect}, rotation {rotation}, flip {flip}"
);
}
}
}
}
}
}
#[test]
fn native_anamorphic_radius_press_matches_its_hover_area() {
let (mut a, ctx) = canvas_app();
crate::menus::invoke(&mut a, &ctx, "view.pixelAspectRatio.anamorphic2To1", json!({})).unwrap();
for rotation in [0.0, 43.0, -90.0] {
a.ui.views[0].rotation = rotation;
for flip in [false, true] {
a.ui.view.flip_horizontal = flip;
canvas_frame(&mut a, &ctx, vec![]);
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
let p = knob(&a, 0);
let screen = xf.to_screen(p[0] as f32, p[1] as f32);
let inside = screen + egui::vec2(5.0, 0.0);
let outside = screen + egui::vec2(7.0, 0.0);
assert_eq!(cursor(&a, xf.to_doc(inside)), Some(egui::CursorIcon::PointingHand));
assert_eq!(cursor(&a, xf.to_doc(outside)), None);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(inside), button(inside, true, Modifiers::NONE)]);
assert!(dragging(&a), "a visible radius grip owns the press, rotation {rotation}, flip {flip}");
assert!(a.drag.is_none() && a.ui.transform.is_none());
canvas_frame(&mut a, &ctx, vec![button(inside, false, Modifiers::NONE)]);
assert!(!dragging(&a));
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
}
}
#[test]
fn locks_and_mask_targets_prevent_radius_gestures() {
for lock in ["all", "pixels", "position"] {
let mut a = app();
let p = knob(&a, 0);
let id = a.session.active().unwrap().active_layer.unwrap();
a.run("layer.setProps", json!({"layer":id.0,"locks":{lock:true}})).unwrap();
let before = a.session.active().unwrap().doc.clone();
assert!(!pointer(&mut a, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE));
assert_eq!(a.session.active().unwrap().doc, before);
}
for vector_mask in [false, true] {
let mut a = app();
let p = knob(&a, 0);
a.ui.mask_target = !vector_mask;
a.ui.vector_mask_target = vector_mask;
assert!(!pointer(&mut a, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE));
}
}
#[test]
fn escape_restores_radius_and_context_changes_never_edit_another_document() {
let mut a = app();
let before = a.session.active().unwrap().doc.clone();
let p = knob(&a, 0);
press(&mut a, p, Modifiers::NONE);
move_to(&mut a, [p[0] + 4.0, p[1] + 4.0], Modifiers::NONE);
let ctx = egui::Context::default();
let mut output = ctx.run_ui(
egui::RawInput {
events: vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
..Default::default()
},
|ui| {
assert!(keys(&mut a, ui.ctx()));
},
);
output.textures_delta.clear();
assert_eq!(a.session.active().unwrap().doc, before);
assert!(a.shape_canvas.drag.is_none());
press(&mut a, p, Modifiers::NONE);
move_to(&mut a, [p[0] + 4.0, p[1] + 4.0], Modifiers::NONE);
a.run("file.new", json!({"width":100,"height":100})).unwrap();
let other = a.session.active().unwrap().doc.clone();
cancel_if_left(&mut a);
assert!(a.shape_canvas.drag.is_none());
assert!(!pointer(&mut a, ToolEvent::Up { x: p[0] + 40.0, y: p[1] + 40.0 }, Modifiers::NONE));
assert_eq!(a.session.active().unwrap().doc, other);
}
#[test]
fn tool_switch_and_unrelated_edits_end_radius_gesture() {
let mut a = app();
let p = knob(&a, 0);
press(&mut a, p, Modifiers::NONE);
a.ui.tool = Tool::Brush;
cancel_if_left(&mut a);
assert!(a.shape_canvas.drag.is_none());
a.ui.tool = Tool::Rectangle;
press(&mut a, p, Modifiers::NONE);
move_to(&mut a, [p[0] + 4.0, p[1] + 4.0], Modifiers::NONE);
a.run("layer.setProps", json!({"name":"Unrelated edit"})).unwrap();
let edited = a.session.active().unwrap().doc.clone();
cancel(&mut a);
assert_eq!(a.session.active().unwrap().doc, edited, "a stale gesture cannot undo another action");
}
fn canvas_frame(app: &mut PhotocraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::epaint::ClippedShape> {
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 400.0));
app.last_canvas_rect = rect;
let view = app.ui.views[0].clone();
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events, ..Default::default() }, |ui| {
app.ui.views[0] = crate::canvas::canvas_view(app, ui, 0, rect, view.clone(), true);
});
out.textures_delta.clear();
out.shapes
}
fn canvas_app() -> (PhotocraftApp, egui::Context) {
let mut app = app();
app.ui.views[0].zoom = 1.0;
app.ui.views[0].center = [100.0, 80.0];
app.ui.views[0].fit_pending = false;
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
canvas_frame(&mut app, &ctx, vec![]);
(app, ctx)
}
fn button(pos: egui::Pos2, pressed: bool, modifiers: Modifiers) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }
}
fn space(pressed: bool) -> egui::Event {
egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
}
#[test]
fn native_space_navigation_never_grabs_radius_or_leaves_a_hover_edit() {
let (mut a, ctx) = canvas_app();
let before = a.session.active().unwrap().doc.clone();
let n = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
let p = knob(&a, 0);
let screen = xf.to_screen(p[0] as f32, p[1] as f32);
let moved = screen + egui::vec2(35.0, 24.0);
canvas_frame(&mut a, &ctx, vec![space(true), egui::Event::PointerMoved(screen), button(screen, true, Modifiers::NONE)]);
assert!(!dragging(&a), "Space+press belongs to navigation even over a radius knob");
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(moved)]);
canvas_frame(&mut a, &ctx, vec![button(moved, false, Modifiers::NONE)]);
canvas_frame(&mut a, &ctx, vec![space(false)]);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(moved + egui::vec2(40.0, 25.0))]);
assert!(!dragging(&a));
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), n);
}
#[test]
fn native_space_interrupts_radius_drag_before_navigation_takes_release() {
let (mut a, ctx) = canvas_app();
let xf = ViewXform::active(&a).unwrap();
let p = knob(&a, 0);
let screen = xf.to_screen(p[0] as f32, p[1] as f32);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(screen), button(screen, true, Modifiers::NONE)]);
assert!(dragging(&a));
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(screen + egui::vec2(2.0, 2.0))]);
let changed = a.session.active().unwrap().doc.clone();
let n = a.session.active().unwrap().history.past_len();
canvas_frame(&mut a, &ctx, vec![space(true)]);
assert!(!dragging(&a), "navigation ends a radius gesture without waiting for its consumed release");
let moved = screen + egui::vec2(30.0, 25.0);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(moved), button(moved, false, Modifiers::NONE)]);
canvas_frame(&mut a, &ctx, vec![space(false), egui::Event::PointerMoved(moved + egui::vec2(25.0, 25.0))]);
assert_eq!(a.session.active().unwrap().doc, changed);
assert_eq!(a.session.active().unwrap().history.past_len(), n);
}
#[test]
fn navigation_tool_override_cannot_start_or_keep_radius_gesture() {
for tool in [Tool::Hand, Tool::Zoom, Tool::RotateView] {
let mut a = app();
let p = knob(&a, 0);
press(&mut a, p, Modifiers::NONE);
a.tool_override = Some(tool);
cancel_if_left(&mut a);
assert!(!dragging(&a));
assert!(!pointer(&mut a, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE));
assert_eq!(cursor(&a, p), None);
}
}
#[test]
fn native_radius_press_precedes_move_transform_and_egui_drag_threshold() {
let (mut a, ctx) = canvas_app();
a.ui.tool = Tool::Move;
a.ui.tool_options.move_show_transform = true;
let n = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
let p = knob(&a, 0);
let screen = xf.to_screen(p[0] as f32, p[1] as f32);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(screen), button(screen, true, Modifiers::NONE)]);
assert!(dragging(&a), "radius gesture starts on the actual press");
assert!(a.ui.transform.is_none());
let moved = screen + egui::vec2(1.0, 1.0);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(moved)]);
assert!(radius(&a)[0] > 5.0, "live radius changes before egui's ordinary drag threshold");
canvas_frame(&mut a, &ctx, vec![button(moved, false, Modifiers::NONE)]);
assert!(!dragging(&a));
assert!(a.ui.transform.is_none() && a.drag.is_none());
assert_eq!(a.session.active().unwrap().history.past_len(), n + 1);
}
#[test]
fn native_shape_boolean_operation_uses_press_modifiers_released_before_drag() {
let (mut a, ctx) = canvas_app();
let id = a.session.active().unwrap().active_layer.unwrap();
let xf = ViewXform::active(&a).unwrap();
let start = xf.to_screen(150.0, 125.0);
let end = xf.to_screen(190.0, 145.0);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(start), button(start, true, Modifiers::SHIFT)]);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(end)]);
canvas_frame(&mut a, &ctx, vec![button(end, false, Modifiers::NONE)]);
assert_eq!(a.session.active().unwrap().active_layer, Some(id));
let LayerContent::Shape(sh) = &a.session.active().unwrap().doc.layer(id).unwrap().content else { panic!("shape") };
assert_eq!(sh.path.subpaths.len(), 2, "Shift at press adds to the existing layer even when released before the drag threshold");
assert!(sh.live.is_none());
}
fn radius_centers(shapes: &[egui::epaint::ClippedShape]) -> Vec<egui::Pos2> {
shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Circle(c) if c.radius == 4.0 => Some(c.center),
_ => None,
})
.collect()
}
fn assert_moved_handles(shapes: &[egui::epaint::ClippedShape], original: [[f64; 2]; 4], delta: [f64; 2], xf: &ViewXform) {
let actual = radius_centers(shapes);
assert_eq!(actual.len(), 4);
for (actual, p) in actual.into_iter().zip(original) {
let expected = xf.to_screen((p[0] + delta[0]) as f32, (p[1] + delta[1]) as f32);
assert!(actual.distance(expected) < 0.001, "radius handle at {actual:?}, expected {expected:?}");
}
}
#[test]
fn native_move_radius_handles_follow_held_pixels_release_and_undo() {
for (rotation, flip, zoom) in [(0.0, false, 1.0), (37.0, true, 1.6), (-90.0, false, 0.75)] {
let (mut a, ctx) = canvas_app();
a.ui.tool = Tool::Move;
a.ui.tool_options.move_auto_select = false;
a.ui.extras.snap = false;
a.ui.view.show.smart_guides = false;
a.ui.view.flip_horizontal = flip;
a.ui.views[0].rotation = rotation;
a.ui.views[0].zoom = zoom;
canvas_frame(&mut a, &ctx, vec![]);
let xf = ViewXform::active(&a).unwrap();
let original = handles([20.0, 20.0, 120.0, 100.0], [5.0, 10.0, 15.0, 20.0], f64::from(xf.zoom));
let before = a.session.active().unwrap().doc.clone();
let revision = a.session.active().unwrap().revision;
let history = a.session.active().unwrap().history.past_len();
let start = xf.to_screen(70.0, 60.0);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(start), button(start, true, Modifiers::NONE)]);
for (end, delta) in [([87.4, 69.6], [17.0, 10.0]), ([99.2, 52.9], [29.0, -7.0])] {
let screen = xf.to_screen(end[0], end[1]);
canvas_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(screen)]);
// The image is selected before this frame's pointer events; a held frame shows the new image.
let shapes = canvas_frame(&mut a, &ctx, vec![]);
assert!(a.drag.as_ref().is_some_and(|d| d.tool == Tool::Move));
assert!(a.shape_canvas.drag.is_none());
assert_moved_handles(&shapes, original, delta, &xf);
let st = a.session.active().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.revision, revision);
assert_eq!(st.history.past_len(), history);
}
let end = xf.to_screen(99.2, 52.9);
canvas_frame(&mut a, &ctx, vec![button(end, false, Modifiers::NONE)]);
let shapes = canvas_frame(&mut a, &ctx, vec![]);
assert_moved_handles(&shapes, original, [29.0, -7.0], &xf);
assert!(a.drag.is_none());
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
a.run("edit.undo", json!({})).unwrap();
let shapes = canvas_frame(&mut a, &ctx, vec![]);
assert_moved_handles(&shapes, original, [0.0, 0.0], &xf);
assert_eq!(a.session.active().unwrap().doc, before);
}
}
fn vector_and_radius_shapes(app: &PhotocraftApp, ctx: &egui::Context, xf: &ViewXform) -> Vec<egui::epaint::ClippedShape> {
let doc = app.session.active().unwrap().doc.clone();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(xf.rect);
crate::vector_ui::draw_overlay(app, &painter, xf, &doc);
draw_overlay(app, &painter, xf);
});
output.textures_delta.clear();
output.shapes
}
fn assert_moved_path(shapes: &[egui::epaint::ClippedShape], original: &photocraft_doc::Path, delta: [f64; 2], xf: &ViewXform) {
let expected = crate::vector_ui::path_lines(original, &|p| xf.to_screen((p[0] + delta[0]) as f32, (p[1] + delta[1]) as f32));
for (points, closed) in expected {
assert!(shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Path(p) if p.closed == closed && p.points.len() == points.len() && p.points.iter().zip(&points).all(|(a,b)| a.distance(*b) < 0.001))), "shape outline follows the image's offset {delta:?}");
}
}
#[test]
fn move_path_and_handles_follow_the_image_shown_before_later_pointer_input() {
let mut a = app();
a.run("shape.edit", json!({"fill":"#ff0000","stroke":{"color":"#0000ff","width":6}})).unwrap();
a.ui.tool_options.move_auto_select = false;
a.ui.extras.snap = false;
a.ui.view.show.smart_guides = false;
a.tool_override = Some(Tool::Move);
let st = a.session.active().unwrap();
let id = st.active_layer.unwrap();
let before = st.doc.clone();
let LayerContent::Shape(original) = &before.layer(id).unwrap().content else { panic!("shape") };
let history = st.history.past_len();
let revision = st.revision;
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
let xf = ViewXform {
aspect: 1.0,
rect: egui::Rect::from_min_size(egui::pos2(17.0, 29.0), egui::vec2(600.0, 450.0)),
zoom: 1.6,
center: [100.0, 80.0],
flip: true,
rotation: 37.0,
};
let knobs = handles([20.0, 20.0, 120.0, 100.0], [5.0, 10.0, 15.0, 20.0], f64::from(xf.zoom));
tool_event(&mut a, ToolEvent::Down { x: 70.0, y: 60.0, pressure: 1.0 }, Modifiers::NONE);
move_to(&mut a, [120.0, 85.0], Modifiers::NONE);
let shown = crate::move_ui::display_doc(&mut a, 0).unwrap().0;
let LayerContent::Shape(moved) = &shown.layer(id).unwrap().content else { panic!("shape") };
assert_eq!(moved.fill, original.fill);
assert_eq!(moved.stroke, original.stroke);
let pixel = |doc: &photocraft_doc::Document, x, y| photocraft_compose::render(doc, photocraft_geom::Rect::new(x, y, x + 1, y + 1)).px[0];
let old_stroke = pixel(&before, 20, 70);
assert!(old_stroke[2] > 0.99 && old_stroke[0] < 0.01 && old_stroke[3] > 0.99);
assert_eq!(pixel(&shown, 70, 95), old_stroke, "the rasterized blue stroke moves with the image");
assert_eq!(pixel(&shown, 20, 70)[3], 0.0, "the original stroke location is clear");
assert!(pixel(&shown, 100, 85)[0] > 0.99, "the separate red fill is preserved");
// canvas_view already chose `shown`; input arriving later must not move just the overlays ahead.
move_to(&mut a, [130.0, 95.0], Modifiers::NONE);
let shapes = vector_and_radius_shapes(&a, &ctx, &xf);
assert_moved_handles(&shapes, knobs, [50.0, 25.0], &xf);
assert_moved_path(&shapes, &original.path, [50.0, 25.0], &xf);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().revision, revision);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
crate::move_ui::display_doc(&mut a, 0).unwrap();
let shapes = vector_and_radius_shapes(&a, &ctx, &xf);
assert_moved_handles(&shapes, knobs, [60.0, 35.0], &xf);
assert_moved_path(&shapes, &original.path, [60.0, 35.0], &xf);
release(&mut a, [130.0, 95.0], Modifiers::NONE);
let shapes = vector_and_radius_shapes(&a, &ctx, &xf);
assert_moved_path(&shapes, &original.path, [60.0, 35.0], &xf);
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
a.run("edit.undo", json!({})).unwrap();
let shapes = vector_and_radius_shapes(&a, &ctx, &xf);
assert_moved_path(&shapes, &original.path, [0.0, 0.0], &xf);
assert_eq!(a.session.active().unwrap().doc, before);
}
#[test]
fn stale_move_images_do_not_supply_shape_overlay_geometry() {
for context in ["cancel", "revision", "preference", "document", "different-drag"] {
let mut a = app();
a.tool_override = Some(Tool::Move);
a.ui.tool_options.move_auto_select = false;
a.ui.extras.snap = false;
a.ui.view.show.smart_guides = false;
tool_event(&mut a, ToolEvent::Down { x: 70.0, y: 60.0, pressure: 1.0 }, Modifiers::NONE);
move_to(&mut a, [90.0, 70.0], Modifiers::NONE);
crate::move_ui::display_doc(&mut a, 0).unwrap();
match context {
"cancel" => a.drag = None,
"revision" => {
a.run("layer.setProps", json!({"name":"Updated"})).unwrap();
}
"preference" => {
a.session.edit_prefs(|p| p.interface.show_bounding_box_when_dragging_layer = true);
}
"document" => {
a.run("file.new", json!({"width":200,"height":160,"background":"transparent"})).unwrap();
a.sync_views();
a.run("shape.create", json!({"kind":"roundedRect","rect":[20,20,120,100],"radii":[5,10,15,20]})).unwrap();
}
"different-drag" => a.drag.as_mut().unwrap().tool = Tool::Brush,
_ => panic!("unknown test context: {context}"),
}
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
let xf = ViewXform {
aspect: 1.0,
rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 400.0)),
zoom: 1.0,
center: [100.0, 80.0],
flip: false,
rotation: 0.0,
};
let st = a.session.active().unwrap();
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("shape") };
let path = shape.path.clone();
let shapes = vector_and_radius_shapes(&a, &ctx, &xf);
assert_moved_handles(&shapes, handles([20.0, 20.0, 120.0, 100.0], [5.0, 10.0, 15.0, 20.0], 1.0), [0.0, 0.0], &xf);
assert_moved_path(&shapes, &path, [0.0, 0.0], &xf);
}
}
#[test]
fn move_bounds_use_four_canvas_transformed_corners_of_the_last_shown_image() {
for (rotation, flip, zoom) in [(0.0, false, 1.0), (37.0, true, 1.6), (-90.0, false, 0.75)] {
let mut a = app();
a.run("shape.edit", json!({"fill":"#ff0000","stroke":{"color":"#0000ff","width":6}})).unwrap();
a.ui.tool = Tool::Move;
a.ui.tool_options.move_auto_select = false;
a.ui.extras.snap = false;
a.ui.view.show.smart_guides = false;
let st = a.session.active().unwrap();
let before = st.doc.clone();
let revision = st.revision;
let history = st.history.past_len();
let layer = before.layer(st.active_layer.unwrap()).unwrap();
// The painted bounds include the stroke, rather than only the live rectangle's geometry.
let bounds = layer.surface().unwrap().content_bounds();
assert!(bounds.x0 < 20 && bounds.y0 < 20 && bounds.x1 > 140 && bounds.y1 > 120);
let corners = [[bounds.x0, bounds.y0], [bounds.x1, bounds.y0], [bounds.x1, bounds.y1], [bounds.x0, bounds.y1]];
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
let xf = ViewXform {
aspect: 1.0,
rect: egui::Rect::from_min_size(egui::pos2(17.0, 29.0), egui::vec2(600.0, 450.0)),
zoom,
center: [100.0, 80.0],
flip,
rotation,
};
tool_event(&mut a, ToolEvent::Down { x: 70.0, y: 60.0, pressure: 1.0 }, Modifiers::NONE);
move_to(&mut a, [120.4, 85.6], Modifiers::NONE);
crate::move_ui::display_doc(&mut a, 0).unwrap();
// This input lands after the canvas image was selected for the current frame.
move_to(&mut a, [130.0, 95.0], Modifiers::NONE);
for (refresh_image, delta) in [(false, [50, 26]), (true, [60, 35])] {
if refresh_image {
crate::move_ui::display_doc(&mut a, 0).unwrap();
}
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(xf.rect);
crate::snap_ui::draw(&a, &painter, &xf);
});
output.textures_delta.clear();
let paths: Vec<_> = output
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Path(p) => Some(p),
_ => None,
})
.collect();
assert_eq!(paths.len(), 1, "the Move bounds are one closed path");
assert!(paths[0].closed);
assert_eq!(paths[0].points.len(), 4);
for (actual, p) in paths[0].points.iter().zip(corners) {
let expected = xf.to_screen((p[0] + delta[0]) as f32, (p[1] + delta[1]) as f32);
assert!(actual.distance(expected) < 0.001, "rotation {rotation}, flip {flip}: bounds at {actual:?}, expected {expected:?}");
}
let st = a.session.active().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.revision, revision);
assert_eq!(st.history.past_len(), history);
}
}
}
+582
View File
@@ -0,0 +1,582 @@
use super::*;
use egui::{Event, Key, Modifiers, PointerButton};
use photocraft_doc::{LayerContent, LiveShape};
use serde_json::json;
fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, mut events: Vec<Event>, modifiers: Modifiers) -> egui::FullOutput {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 480.0));
app.last_canvas_rect = rect;
let view = app.ui.views[0].clone();
events.insert(0, Event::ModifiersChanged(modifiers));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events, ..Default::default() }, |ui| {
crate::shortcuts::handle(app, ctx);
app.ui.views[0] = canvas_view(app, ui, 0, rect, view.clone(), true);
});
out.textures_delta.clear();
out
}
fn app(tool: Tool) -> (PhotocraftApp, egui::Context) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":320,"height":240,"background":"transparent"})).unwrap();
app.run("shape.create", json!({"kind":"roundedRect","rect":[50,40,140,120],"radii":[5,10,15,20],"fill":"#ffe030"})).unwrap();
app.sync_views();
app.ui.tool = tool;
app.ui.extras.rulers = false;
app.ui.extras.snap = false;
app.ui.views[0].zoom = 1.0;
app.ui.views[0].center = [160.0, 120.0];
app.ui.views[0].fit_pending = false;
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
frame(&mut app, &ctx, vec![], Modifiers::NONE);
(app, ctx)
}
fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }
}
fn key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key) {
let events = [true, false].into_iter().map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }).collect();
frame(app, ctx, events, Modifiers::NONE);
}
fn drag(app: &mut PhotocraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers) {
frame(app, ctx, vec![Event::PointerMoved(from), button(from, true, modifiers)], modifiers);
for fraction in [0.4, 0.7, 1.0] {
frame(app, ctx, vec![Event::PointerMoved(from.lerp(to, fraction))], modifiers);
}
assert!(app.ui.transform.is_some(), "the native control drag must enter Free Transform");
frame(app, ctx, vec![button(to, false, modifiers)], modifiers);
}
fn collect(shape: &egui::Shape, squares: &mut Vec<Pos2>, circles: &mut Vec<Pos2>) {
match shape {
egui::Shape::Rect(r) if r.fill == Color32::WHITE && (r.rect.size() - vec2(7.0, 7.0)).length() < 0.01 => squares.push(r.rect.center()),
egui::Shape::Circle(c) if (c.radius - 4.0).abs() < 0.01 => circles.push(c.center),
egui::Shape::Vec(shapes) => {
for s in shapes {
collect(s, squares, circles);
}
}
_ => {}
}
}
fn painted_controls(out: &egui::FullOutput) -> (Vec<Pos2>, Vec<Pos2>) {
let (mut squares, mut circles) = (Vec::new(), Vec::new());
for s in &out.shapes {
collect(&s.shape, &mut squares, &mut circles);
}
(squares, circles)
}
fn shape(app: &PhotocraftApp) -> &photocraft_doc::ShapeLayer {
let st = app.session.active().unwrap();
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("editable shape must survive") };
shape
}
#[test]
fn selected_shape_paints_eight_controls_and_four_radius_knobs_for_move_and_shape_tools() {
for tool in [Tool::Move, Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape] {
let (mut a, ctx) = app(tool);
assert!(a.ui.tool_options.shape.show_transform);
assert!(!a.ui.tool_options.move_show_transform, "shapes have automatic controls, independent of the raster toggle");
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let out = frame(&mut a, &ctx, vec![], Modifiers::NONE);
let (squares, circles) = painted_controls(&out);
assert_eq!(squares.len(), 8, "{tool:?}");
assert_eq!(circles.len(), 4, "{tool:?}");
let xf = ViewXform::active(&a).unwrap();
for [x, y] in [[50.0, 40.0], [120.0, 40.0], [190.0, 40.0], [190.0, 100.0], [190.0, 160.0], [120.0, 160.0], [50.0, 160.0], [50.0, 100.0]] {
let expected = xf.to_screen(x, y);
assert!(squares.iter().any(|p| p.distance(expected) < 0.1), "{tool:?}: missing control at {expected:?}");
}
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
}
#[test]
fn raster_controls_keep_the_move_checkbox_contract() {
let (mut a, ctx) = app(Tool::Move);
a.run("layer.new.layer", json!({})).unwrap();
a.session
.edit("Raster box", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(50, 40, 190, 160), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
assert!(painted_controls(&frame(&mut a, &ctx, vec![], Modifiers::NONE)).0.is_empty());
a.ui.tool_options.move_show_transform = true;
let (squares, circles) = painted_controls(&frame(&mut a, &ctx, vec![], Modifiers::NONE));
assert_eq!(squares.len(), 8);
assert!(circles.is_empty());
a.ui.tool = Tool::Rectangle;
assert!(painted_controls(&frame(&mut a, &ctx, vec![], Modifiers::NONE)).0.is_empty());
}
#[test]
fn native_shape_control_scale_is_staged_then_commits_once_and_undoes() {
for tool in [Tool::Move, Tool::Rectangle] {
let (mut a, ctx) = app(tool);
let before = a.session.active().unwrap().doc.clone();
let old_path = shape(&a).path.clone();
let history = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
let from = xf.to_screen(190.0, 160.0);
let hovered = frame(&mut a, &ctx, vec![Event::PointerMoved(from)], Modifiers::NONE);
assert_eq!(hovered.platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe);
drag(&mut a, &ctx, from, xf.to_screen(230.0, 180.0), Modifiers::NONE);
let staged = a.ui.transform.as_ref().unwrap();
assert_eq!(staged.quad[0], [50.0, 40.0]);
assert!(staged.quad[2][0] > 220.0 && staged.quad[2][1] > 180.0);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
key(&mut a, &ctx, Key::Enter);
assert!(a.ui.transform.is_none());
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
assert_ne!(shape(&a).path, old_path);
let after = a.session.active().unwrap().doc.clone();
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, before);
a.run("edit.redo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, after);
}
}
#[test]
fn native_ctrl_corner_shape_distort_can_cancel_or_commit_without_rasterizing() {
for tool in [Tool::Move, Tool::Rectangle] {
let (mut a, ctx) = app(tool);
let before = a.session.active().unwrap().doc.clone();
let old_shape = shape(&a).clone();
let history = a.session.active().unwrap().history.past_len();
let mods = Modifiers { command: true, ctrl: true, ..Modifiers::NONE };
let xf = ViewXform::active(&a).unwrap();
for commit in [false, true] {
drag(&mut a, &ctx, xf.to_screen(190.0, 40.0), xf.to_screen(220.0, 55.0), mods);
let quad = a.ui.transform.as_ref().unwrap().quad;
assert_eq!(quad[0], [50.0, 40.0]);
assert!((quad[1][0] - 220.0).abs() < 0.1 && (quad[1][1] - 55.0).abs() < 0.1);
assert_eq!(quad[2], [190.0, 160.0]);
assert_eq!(quad[3], [50.0, 160.0]);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
key(&mut a, &ctx, if commit { Key::Enter } else { Key::Escape });
assert!(a.ui.transform.is_none());
if commit {
let new_shape = shape(&a);
assert_ne!(new_shape.path, old_shape.path);
assert_eq!(new_shape.fill, old_shape.fill);
assert_eq!(new_shape.stroke, old_shape.stroke);
assert!(new_shape.live.is_none(), "a distorted path must not regenerate as the original rectangle");
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
a.run("edit.undo", json!({})).unwrap();
}
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
}
}
#[test]
fn rotated_flipped_native_shape_control_drag_uses_the_drawn_corner() {
for flip in [false, true] {
let (mut a, ctx) = app(Tool::Rectangle);
a.ui.views[0].rotation = 33.0;
a.ui.views[0].zoom = 1.25;
a.ui.view.flip_horizontal = flip;
let out = frame(&mut a, &ctx, vec![], Modifiers::NONE);
let xf = ViewXform::active(&a).unwrap();
let (squares, _) = painted_controls(&out);
assert_eq!(squares.len(), 8);
for [x, y] in [[50.0, 40.0], [190.0, 40.0], [190.0, 160.0], [50.0, 160.0]] {
let expected = xf.to_screen(x, y);
assert!(squares.iter().any(|p| p.distance(expected) < 0.1), "flip={flip}: {expected:?}");
}
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let mods = Modifiers { command: true, ctrl: true, ..Modifiers::NONE };
drag(&mut a, &ctx, xf.to_screen(190.0, 40.0), xf.to_screen(210.0, 55.0), mods);
let quad = a.ui.transform.as_ref().unwrap().quad;
assert!((quad[1][0] - 210.0).abs() < 0.1 && (quad[1][1] - 55.0).abs() < 0.1, "flip={flip}: {quad:?}");
for (i, expected) in [(0, [50.0, 40.0]), (2, [190.0, 160.0]), (3, [50.0, 160.0])] {
assert_eq!(quad[i], expected);
}
key(&mut a, &ctx, Key::Escape);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
}
#[test]
fn native_radius_press_keeps_priority_over_automatic_shape_controls() {
let (mut a, ctx) = app(Tool::Move);
let history = a.session.active().unwrap().history.past_len();
let out = frame(&mut a, &ctx, vec![], Modifiers::NONE);
let (_, circles) = painted_controls(&out);
let knob = circles[0];
let hovered = frame(&mut a, &ctx, vec![Event::PointerMoved(knob)], Modifiers::NONE);
assert_eq!(hovered.platform_output.cursor_icon, egui::CursorIcon::PointingHand);
frame(&mut a, &ctx, vec![button(knob, true, Modifiers::NONE)], Modifiers::NONE);
assert!(crate::shape_canvas::dragging(&a));
assert!(a.ui.transform.is_none() && a.drag.is_none());
let end = knob + vec2(3.0, 3.0);
frame(&mut a, &ctx, vec![Event::PointerMoved(end)], Modifiers::NONE);
frame(&mut a, &ctx, vec![button(end, false, Modifiers::NONE)], Modifiers::NONE);
let Some(LiveShape::Rect { radii, .. }) = &shape(&a).live else { panic!("radius keeps live geometry") };
assert!(radii[0] > 5.0);
assert!(a.ui.transform.is_none() && a.drag.is_none());
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
}
#[test]
fn shape_controls_respect_targets_locks_modes_and_navigation() {
for context in ["disabled", "mask", "vectorMask", "path", "pixels", "positionLock", "allLock", "hand", "zoom", "rotate"] {
let (mut a, ctx) = app(Tool::Rectangle);
match context {
"disabled" => a.ui.tool_options.shape.show_transform = false,
"mask" => a.ui.mask_target = true,
"vectorMask" => a.ui.vector_mask_target = true,
"path" | "pixels" => a.ui.tool_options.shape.mode = context.into(),
"positionLock" | "allLock" => {
a.session
.edit("Lock", |doc, active| {
let locks = &mut doc.layer_mut(active.unwrap()).unwrap().locks;
locks.position = context == "positionLock";
locks.all = context == "allLock";
Ok(())
})
.unwrap();
}
"hand" => a.ui.tool = Tool::Hand,
"zoom" => a.ui.tool = Tool::Zoom,
"rotate" => a.ui.tool = Tool::RotateView,
_ => panic!("unknown test context: {context}"),
}
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let out = frame(&mut a, &ctx, vec![], Modifiers::NONE);
assert!(painted_controls(&out).0.is_empty(), "{context}");
assert!(a.ui.transform.is_none());
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
let (mut a, ctx) = app(Tool::Rectangle);
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
let from = xf.to_screen(190.0, 160.0);
let space = |pressed| Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
let out = frame(&mut a, &ctx, vec![space(true), Event::PointerMoved(from), button(from, true, Modifiers::NONE)], Modifiers::NONE);
assert!(painted_controls(&out).0.is_empty());
let to = from + vec2(35.0, 25.0);
frame(&mut a, &ctx, vec![Event::PointerMoved(to)], Modifiers::NONE);
frame(&mut a, &ctx, vec![button(to, false, Modifiers::NONE), space(false)], Modifiers::NONE);
assert!(a.ui.transform.is_none() && !crate::shape_canvas::dragging(&a));
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
#[test]
fn failed_native_shape_control_apply_keeps_the_session_for_correction_or_cancel() {
let (mut a, ctx) = app(Tool::Move);
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
drag(&mut a, &ctx, xf.to_screen(190.0, 160.0), xf.to_screen(220.0, 180.0), Modifiers::NONE);
a.ui.transform.as_mut().unwrap().quad[1] = [2_000_000_000.0, 40.0];
let rejected_quad = a.ui.transform.as_ref().unwrap().quad;
key(&mut a, &ctx, Key::Enter);
assert_eq!(a.ui.transform.as_ref().unwrap().quad, rejected_quad);
assert!(a.transform_preview.is_some());
assert!(!a.ui.status.is_empty());
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
key(&mut a, &ctx, Key::Escape);
assert!(a.ui.transform.is_none() && a.transform_preview.is_none());
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
#[test]
fn automation_pointer_starts_the_same_visible_shape_grip() {
let (mut a, ctx) = app(Tool::Rectangle);
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let params = json!({"tool":"rectangle","modifiers":{"command":true,"ctrl":true},"events":[
{"kind":"down","x":190,"y":40},{"kind":"move","x":210,"y":55},{"kind":"up","x":210,"y":55}
]});
let (request, _) = crate::control::ControlRequest::new("ui.pointer", params);
let crate::control::Outcome::Done(reply) = crate::control::handle(&mut a, &ctx, &request) else { panic!("pointer is synchronous") };
assert_eq!(reply["ok"], true, "{reply}");
let quad = a.ui.transform.as_ref().unwrap().quad;
assert_eq!(quad[1], [210.0, 55.0]);
assert_eq!(quad[0], [50.0, 40.0]);
assert_eq!(quad[2], [190.0, 160.0]);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
key(&mut a, &ctx, Key::Escape);
assert!(a.ui.transform.is_none());
assert_eq!(a.session.active().unwrap().doc, before);
}
#[test]
fn native_shape_distort_preview_caches_the_quad_keeps_text_above_and_matches_commit() {
let (mut a, ctx) = app(Tool::Move);
a.session.edit_prefs(|p| p.performance.low_resolution_previews = false);
let shape_id = a.session.active().unwrap().active_layer.unwrap();
let text_id =
photocraft_doc::LayerId(a.run("type.create", json!({"x":80,"y":110,"text":"TEST","size":32,"color":"#000000"})).unwrap()["layer"].as_u64().unwrap());
a.run("layer.select", json!({"layer":shape_id.0})).unwrap();
frame(&mut a, &ctx, vec![], Modifiers::NONE);
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let order: Vec<_> = before.layers.iter().map(|l| l.id).collect();
let text = before.layer(text_id).unwrap().surface().unwrap();
let bounds = text.content_bounds();
let point = (bounds.y0..bounds.y1)
.flat_map(|y| (bounds.x0..bounds.x1).map(move |x| (x, y)))
.filter(|&(x, y)| (65..180).contains(&x) && (60..140).contains(&y))
.max_by(|&(x, y), &(xx, yy)| text.pixel(x, y).last().unwrap().total_cmp(text.pixel(xx, yy).last().unwrap()))
.unwrap();
assert!(text.pixel(point.0, point.1).last().copied().unwrap() > 0.999, "the text oracle uses an opaque glyph interior");
let xf = ViewXform::active(&a).unwrap();
let mods = Modifiers { command: true, ctrl: true, ..Modifiers::NONE };
drag(&mut a, &ctx, xf.to_screen(190.0, 40.0), xf.to_screen(220.0, 55.0), mods);
crate::transform_tool::follow_edits(&mut a);
let idx = a.session.active_index().unwrap();
let (first, first_key) = crate::transform_tool::display_doc(&a, idx).unwrap();
let LayerContent::Shape(first_shape) = &first.layer(shape_id).unwrap().content else { panic!("vector preview") };
assert_ne!(first_shape.path, shape(&a).path);
assert_ne!(first.layer(shape_id).unwrap().surface().unwrap().content_bounds(), before.layer(shape_id).unwrap().surface().unwrap().content_bounds());
crate::transform_tool::follow_edits(&mut a);
let (steady, steady_key) = crate::transform_tool::display_doc(&a, idx).unwrap();
assert!(std::sync::Arc::ptr_eq(&first, &steady));
assert_eq!(first_key, steady_key);
// Both quads are nonaffine: the quad itself must invalidate the cache, rather than None == None.
drag(&mut a, &ctx, xf.to_screen(220.0, 55.0), xf.to_screen(230.0, 70.0), mods);
crate::transform_tool::follow_edits(&mut a);
let (shown, shown_key) = crate::transform_tool::display_doc(&a, idx).unwrap();
assert_ne!(shown_key, first_key);
assert!(!std::sync::Arc::ptr_eq(&first, &shown));
let LayerContent::Shape(shown_shape) = &shown.layer(shape_id).unwrap().content else { panic!("vector preview") };
assert_ne!(shown_shape.path, first_shape.path);
assert_eq!(shown.layers.iter().map(|l| l.id).collect::<Vec<_>>(), order);
assert_eq!(shown.layer(text_id).unwrap(), before.layer(text_id).unwrap());
assert!(shown.layer(shape_id).unwrap().visible);
let pixel_rect = photocraft_geom::Rect::new(point.0, point.1, point.0 + 1, point.1 + 1);
let pixel = photocraft_compose::render(&shown, pixel_rect).px[0];
assert!(pixel[0] < 0.06 && pixel[1] < 0.06 && pixel[2] < 0.06 && pixel[3] > 0.99, "higher text remains black: {pixel:?}");
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
let accepted_path = shown_shape.path.clone();
let accepted_paint = (shown_shape.fill.clone(), shown_shape.stroke.clone());
key(&mut a, &ctx, Key::Enter);
assert!(a.ui.transform.is_none());
assert_eq!(shape(&a).path, accepted_path);
assert_eq!((shape(&a).fill.clone(), shape(&a).stroke.clone()), accepted_paint);
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
assert_eq!(photocraft_compose::render(&a.session.active().unwrap().doc, pixel_rect).px[0], pixel);
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, before);
}
#[test]
fn rejected_shape_control_apply_releases_capture_when_tool_or_layer_changes() {
for switch_layer in [false, true] {
let (mut a, ctx) = app(Tool::Move);
let first = a.session.active().unwrap().active_layer.unwrap();
let second =
photocraft_doc::LayerId(a.run("shape.create", json!({"kind":"rect","rect":[215,140,70,60],"fill":"#0099ff"})).unwrap()["layer"].as_u64().unwrap());
a.run("layer.select", json!({"layer":first.0})).unwrap();
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
drag(&mut a, &ctx, xf.to_screen(190.0, 160.0), xf.to_screen(220.0, 180.0), Modifiers::NONE);
a.ui.transform.as_mut().unwrap().quad[1] = [2_000_000_000.0, 40.0];
let (expected_layer, from, to) = if switch_layer {
a.run("layer.select", json!({"layer":second.0})).unwrap();
(second, [285.0, 200.0], [300.0, 210.0])
} else {
a.ui.tool = Tool::Rectangle;
(first, [190.0, 160.0], [215.0, 180.0])
};
crate::transform_tool::end_if_left(&mut a);
frame(&mut a, &ctx, vec![], Modifiers::NONE);
assert!(a.ui.transform.is_none() && a.transform_preview.is_none(), "switch_layer={switch_layer}: old owner must release capture");
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
// The new context receives a fresh control drag, rather than the rejected owner's box.
drag(&mut a, &ctx, xf.to_screen(from[0], from[1]), xf.to_screen(to[0], to[1]), Modifiers::NONE);
assert_eq!(a.ui.transform.as_ref().unwrap().layer, expected_layer.0);
key(&mut a, &ctx, Key::Escape);
assert!(a.ui.transform.is_none() && a.transform_preview.is_none());
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
}
#[test]
fn native_shape_grips_are_hidden_for_alpha_quick_mask_and_mask_only_targets() {
for target in ["alpha", "quickMask", "maskView"] {
let (mut a, ctx) = app(Tool::Move);
match target {
"alpha" => {
a.run("channel.new", json!({"fill":"white"})).unwrap();
a.run("channel.target", json!({"channel":0})).unwrap();
}
"quickMask" => {
a.run("select.rect", json!({"x":0,"y":0,"width":320,"height":240})).unwrap();
a.run("select.editInQuickMaskMode", json!({})).unwrap();
}
"maskView" => {
a.run("layer.layerMask.revealAll", json!({})).unwrap();
a.run("view.layerMask", json!({"mode":"gray"})).unwrap();
}
_ => panic!("unknown test target: {target}"),
}
// Commands synchronize the thumbnail flag; emulate a UI flag that no longer reflects the engine target.
a.ui.mask_target = false;
assert!(!a.ui.mask_target, "the engine's target must be honored even without the UI thumbnail flag");
assert_ne!(paint_target(&a), json!("pixels"));
let path = shape(&a).path.clone();
let out = frame(&mut a, &ctx, vec![], Modifiers::NONE);
assert!(painted_controls(&out).0.is_empty(), "{target}: no shape resize controls on a channel/mask target");
let xf = ViewXform::active(&a).unwrap();
let corner = xf.to_screen(190.0, 40.0);
frame(&mut a, &ctx, vec![Event::PointerMoved(corner), button(corner, true, Modifiers::NONE)], Modifiers::NONE);
frame(&mut a, &ctx, vec![button(corner, false, Modifiers::NONE)], Modifiers::NONE);
assert!(a.ui.transform.is_none() && a.transform_preview.is_none(), "{target}: a hidden grip must not start the wrong transform owner");
assert_eq!(shape(&a).path, path);
}
}
#[test]
fn native_nonaffine_controls_follow_the_image_drawn_before_this_frames_pointer_move() {
let (mut a, ctx) = app(Tool::Move);
a.session.edit_prefs(|p| p.performance.low_resolution_previews = false);
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let id = a.session.active().unwrap().active_layer.unwrap();
let idx = a.session.active_index().unwrap();
let xf = ViewXform::active(&a).unwrap();
let mods = Modifiers { command: true, ctrl: true, ..Modifiers::NONE };
let from = xf.to_screen(190.0, 40.0);
let moved_a = xf.to_screen(210.0, 55.0);
frame(&mut a, &ctx, vec![Event::PointerMoved(from), button(from, true, mods)], mods);
frame(&mut a, &ctx, vec![Event::PointerMoved(moved_a)], mods);
let quad_a = a.ui.transform.as_ref().unwrap().quad;
assert!((quad_a[1][0] - 210.0).abs() < 0.1 && (quad_a[1][1] - 55.0).abs() < 0.1);
frame(&mut a, &ctx, vec![], mods);
let (image_a, key_a) = crate::transform_tool::display_doc(&a, idx).unwrap();
let source_rect = a.ui.transform.as_ref().unwrap().rect;
let projected_a = photocraft_engine::transform_cmds::preview_shape_quad_transform(&before, id, source_rect, quad_a, "bicubic").unwrap();
let path = |doc: &photocraft_doc::Document| {
let LayerContent::Shape(shape) = &doc.layer(id).unwrap().content else { panic!("vector shape") };
shape.path.clone()
};
assert_eq!(path(&image_a), path(&projected_a));
let moved_b = xf.to_screen(230.0, 65.0);
let move_frame = frame(&mut a, &ctx, vec![Event::PointerMoved(moved_b)], mods);
let quad_b = a.ui.transform.as_ref().unwrap().quad;
assert!((quad_b[1][0] - 230.0).abs() < 0.1 && (quad_b[1][1] - 65.0).abs() < 0.1);
assert_ne!(quad_a, quad_b);
let (still_a, still_key_a) = crate::transform_tool::display_doc(&a, idx).unwrap();
assert_eq!(still_key_a, key_a);
assert!(std::sync::Arc::ptr_eq(&image_a, &still_a));
let (squares, _) = painted_controls(&move_frame);
assert_eq!(squares.len(), 8);
for p in quad_a {
let shown_corner = xf.to_screen(p[0] as f32, p[1] as f32);
assert!(squares.iter().any(|p| p.distance(shown_corner) < 0.1), "controls must match the cached image, not the newer input");
}
assert!(!squares.iter().any(|p| p.distance(moved_b) < 0.1), "new pointer geometry is not the image this frame");
let catch_up = frame(&mut a, &ctx, vec![], mods);
let (image_b, key_b) = crate::transform_tool::display_doc(&a, idx).unwrap();
assert_ne!(key_a, key_b);
assert_ne!(path(&image_a), path(&image_b));
let projected_b = photocraft_engine::transform_cmds::preview_shape_quad_transform(&before, id, source_rect, quad_b, "bicubic").unwrap();
assert_eq!(path(&image_b), path(&projected_b));
let (caught_squares, _) = painted_controls(&catch_up);
for p in quad_b {
let expected = xf.to_screen(p[0] as f32, p[1] as f32);
assert!(caught_squares.iter().any(|p| p.distance(expected) < 0.1));
}
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
frame(&mut a, &ctx, vec![button(moved_b, false, mods)], mods);
key(&mut a, &ctx, Key::Escape);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
#[test]
fn native_shape_transform_suppresses_the_old_passive_outline_and_restores_the_current_path() {
fn passive_paths(app: &PhotocraftApp, xf: &ViewXform) -> Vec<Vec<Pos2>> {
fn collect(shape: &egui::Shape, paths: &mut Vec<Vec<Pos2>>) {
match shape {
egui::Shape::Path(path) if path.closed => paths.push(path.points.clone()),
egui::Shape::Vec(shapes) => {
for shape in shapes {
collect(shape, paths);
}
}
_ => {}
}
}
// A separate painter context must not release the native gesture held in the canvas context.
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::Pro);
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }, |ui| {
crate::vector_ui::draw_overlay(app, ui.painter(), xf, &app.session.active().unwrap().doc);
});
out.textures_delta.clear();
let mut paths = Vec::new();
for clipped in &out.shapes {
collect(&clipped.shape, &mut paths);
}
paths
}
for distort in [false, true] {
for commit in [false, true] {
let (mut a, ctx) = app(Tool::Rectangle);
a.ui.tool_options.shape.show_path = true;
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let xf = ViewXform::active(&a).unwrap();
let original_outline = passive_paths(&a, &xf);
assert_eq!(original_outline.len(), 1, "the selected shape has its passive path at rest");
let mods = if distort { Modifiers { command: true, ctrl: true, ..Modifiers::NONE } } else { Modifiers::NONE };
let (from, to) =
if distort { (xf.to_screen(190.0, 40.0), xf.to_screen(220.0, 55.0)) } else { (xf.to_screen(190.0, 160.0), xf.to_screen(230.0, 180.0)) };
frame(&mut a, &ctx, vec![Event::PointerMoved(from), button(from, true, mods)], mods);
for fraction in [0.5, 1.0] {
frame(&mut a, &ctx, vec![Event::PointerMoved(from.lerp(to, fraction))], mods);
}
assert!(a.ui.transform.is_some());
assert!(passive_paths(&a, &xf).is_empty(), "distort={distort}: the held transform must not show the original passive shape path");
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
frame(&mut a, &ctx, vec![button(to, false, mods)], mods);
assert!(passive_paths(&a, &xf).is_empty(), "releasing the pointer keeps the staged transform open");
key(&mut a, &ctx, if commit { Key::Enter } else { Key::Escape });
assert!(a.ui.transform.is_none());
let restored = passive_paths(&a, &xf);
assert_eq!(restored.len(), 1, "the passive current path returns after the session ends");
if commit {
assert_ne!(restored, original_outline, "the restored outline follows committed geometry");
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
a.run("edit.undo", json!({})).unwrap();
}
assert_eq!(passive_paths(&a, &xf), original_outline);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
}
}
+55 -10
View File
@@ -15,7 +15,7 @@ const MARK: &str = "__createShape";
/// Largest side the dialog accepts, in pixels (Photoshop's document limit).
const MAX_PX: f64 = 300_000.0;
/// Remembered values (document pixels; sides and radii come from the options bar).
const KEYS: [&str; 4] = ["width", "height", "fromCenter", "starRatio"];
const KEYS: [&str; 5] = ["width", "height", "fromCenter", "starRatio", "smoothIndents"];
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
@@ -53,29 +53,50 @@ pub fn is_click(start: [f64; 2], points: &[[f64; 3]], zoom: f32) -> bool {
/// Open the creation dialog of `tool` for a click at `at` (document px). `None` for a tool without
/// one (Line, Custom Shape).
pub fn open(app: &mut PhotocraftApp, tool: Tool, at: [f64; 2]) -> Option<u64> {
open_gesture(app, tool, at, egui::Modifiers::NONE)
}
pub(crate) fn open_gesture(app: &mut PhotocraftApp, tool: Tool, at: [f64; 2], mods: egui::Modifiers) -> Option<u64> {
let (kind, label) = kind_of(tool)?;
if !(at[0].is_finite() && at[1].is_finite()) {
return None;
}
let o = &app.ui.tool_options;
let r = f64::from(o.corner_radius.max(0.0));
let sides = o.polygon_sides.clamp(3, 100);
let live = crate::shape_options::drawing_live(app);
let r = match &live {
Some(photocraft_doc::LiveShape::Rect { radii, .. }) if tool == Tool::Rectangle => radii[0],
Some(photocraft_doc::LiveShape::Polygon { corner_radius, .. }) if matches!(tool, Tool::Triangle | Tool::Polygon) => *corner_radius,
_ => f64::from(o.corner_radius.max(0.0)),
};
let sides = match &live {
Some(photocraft_doc::LiveShape::Polygon { sides, .. }) if tool == Tool::Polygon => *sides,
_ => o.polygon_sides.clamp(3, 100),
};
let mut f = Map::new();
f.insert(MARK.into(), json!(kind));
f.insert("__command".into(), json!("shape.create"));
f.insert("__label".into(), json!(label));
f.insert("__at".into(), json!([at[0].round(), at[1].round()]));
f.insert("__at".into(), json!(if o.shape.align_edges { at.map(f64::round) } else { at }));
if let Some(operation) = crate::vector_ui::gesture_operation(app, mods) {
f.insert("__operation".into(), json!(operation));
}
f.insert("width".into(), json!(100.0));
f.insert("height".into(), json!(100.0));
f.insert("fromCenter".into(), json!(false));
f.insert("fromCenter".into(), json!(o.shape.from_center || mods.alt));
match kind {
"rectangle" => {
f.insert("radii".into(), json!([r, r, r, r]));
f.insert("linkRadii".into(), json!(true));
}
"polygon" => {
"triangle" | "polygon" => {
f.insert("cornerRadius".into(), json!(r));
f.insert("sides".into(), json!(sides));
f.insert("starRatio".into(), json!(100.0));
let (ratio, smooth) = match &live {
Some(photocraft_doc::LiveShape::Polygon { star_ratio, smooth_indents, .. }) if tool == Tool::Polygon => (*star_ratio, *smooth_indents),
_ => (o.shape.star_ratio, o.shape.smooth_indents),
};
f.insert("starRatio".into(), json!(ratio * 100.0));
f.insert("smoothIndents".into(), json!(smooth));
}
_ => {}
}
@@ -88,6 +109,13 @@ pub fn open(app: &mut PhotocraftApp, tool: Tool, at: [f64; 2]) -> Option<u64> {
}
}
}
if matches!(o.shape.size_mode.as_str(), "fixed" | "proportional") {
f.insert("width".into(), json!(o.shape.size_width));
f.insert("height".into(), json!(o.shape.size_height));
}
if mods.alt {
f.insert("fromCenter".into(), json!(true));
}
Some(app.ui.open_dialog(DialogKind::Command, f))
}
@@ -123,11 +151,11 @@ pub fn geometry(f: &Map<String, Value>) -> Result<Value, String> {
if radii.iter().any(|r| *r > 0.0) { json!({"kind": "roundedRect", "rect": rect, "radii": radii}) } else { json!({"kind": "rect", "rect": rect}) }
}
"ellipse" => json!({"kind": "ellipse", "rect": rect}),
"triangle" => json!({"kind": "polygon", "rect": rect, "sides": 3}),
"triangle" => json!({"kind": "polygon", "rect": rect, "sides": 3,"cornerRadius":num(f,"cornerRadius").unwrap_or(0.0)}),
"polygon" => {
let sides = num(f, "sides").map_or(5.0, |s| s.round().clamp(3.0, 100.0));
let ratio = num(f, "starRatio").map_or(100.0, |r| r.clamp(1.0, 100.0));
json!({"kind": "polygon", "rect": rect, "sides": sides, "starRatio": ratio / 100.0})
json!({"kind": "polygon", "rect": rect, "sides": sides, "starRatio": ratio / 100.0,"cornerRadius":num(f,"cornerRadius").unwrap_or(0.0),"smoothIndents":f.get("smoothIndents").and_then(Value::as_bool).unwrap_or(false)})
}
other => return Err(format!("unknown shape `{other}`")),
})
@@ -135,7 +163,8 @@ pub fn geometry(f: &Map<String, Value>) -> Result<Value, String> {
/// OK: create the shape and remember the values for the next click.
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
let result = geometry(f).and_then(|g| crate::vector_ui::create_shape(app, g));
let operation = f.get("__operation").and_then(Value::as_str);
let result = geometry(f).and_then(|g| crate::vector_ui::create_shape_with_operation(app, g, operation));
match &result {
Ok(_) => {
if let Some(kind) = f.get(MARK).and_then(Value::as_str) {
@@ -232,9 +261,25 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>)
f.insert("starRatio".into(), json!(f64::from(r.clamp(1.0, 100.0))));
}
});
ui.horizontal(|ui| {
label(ui, "");
let mut smooth = f.get("smoothIndents").and_then(Value::as_bool).unwrap_or(false);
if crate::widgets::checkbox(ui, &mut smooth, tl!("Smooth Indents")).changed() {
f.insert("smoothIndents".into(), json!(smooth));
}
});
}
_ => {}
}
if matches!(kind.as_str(), "triangle" | "polygon") {
ui.horizontal(|ui| {
label(ui, tl!("Radius:"));
let mut radius = num(f, "cornerRadius").unwrap_or(0.0) as f32;
if crate::widgets::value_field(ui, &mut radius, 0.0..=10000.0, "px", 72.0).changed() {
f.insert("cornerRadius".into(), json!(radius));
}
});
}
ui.horizontal(|ui| {
label(ui, "");
let mut center = f.get("fromCenter").and_then(Value::as_bool).unwrap_or(false);
+1 -1
View File
@@ -76,7 +76,7 @@ fn from_center_and_the_other_tools() {
let id = only_dialog(&app);
set(&mut app, id, json!({"width": 50, "height": 60, "sides": 7, "starRatio": 50}));
crate::dialogs::confirm(&mut app, id).unwrap();
assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5 });
assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5, corner_radius: 0.0, smooth_indents: false });
press(&mut app, Tool::Triangle, [5.0, 6.0], [5.0, 6.0]);
let id = only_dialog(&app);
+524
View File
@@ -0,0 +1,524 @@
//! Shape-tool modes, path actions and view-only path display options.
use crate::{PhotocraftApp, state::Tool};
use photocraft_doc::{LayerContent, LiveShape, Path, ShapeLayer};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct ShapeToolOptions {
pub mode: String,
pub operation: String,
pub blend: photocraft_color::BlendMode,
pub opacity: f32,
pub anti_alias: bool,
pub path_width: f32,
pub path_color: Option<[u8; 3]>,
pub show_path: bool,
/// Show resize/rotate controls automatically on the selected shape layer.
pub show_transform: bool,
pub from_center: bool,
pub constrain: bool,
pub size_mode: String,
pub size_width: f64,
pub size_height: f64,
pub align_edges: bool,
pub star_ratio: f64,
pub smooth_indents: bool,
pub align_canvas: bool,
pub arrowheads: photocraft_doc::vector::LineArrowheads,
#[serde(skip)]
pub path_geometry: Option<(u64, Value, Path)>,
}
impl Default for ShapeToolOptions {
fn default() -> Self {
Self {
mode: "shape".into(),
operation: "new".into(),
blend: photocraft_color::BlendMode::Normal,
opacity: 100.0,
anti_alias: true,
path_width: 1.0,
path_color: None,
show_path: true,
show_transform: true,
from_center: false,
constrain: false,
size_mode: "unconstrained".into(),
size_width: 100.0,
size_height: 100.0,
align_edges: true,
star_ratio: 1.0,
smooth_indents: false,
align_canvas: false,
arrowheads: photocraft_doc::vector::LineArrowheads { width: 10.0, length: 20.0, ..Default::default() },
path_geometry: None,
}
}
}
impl ShapeToolOptions {
pub(crate) fn validate(&self) -> Result<(), String> {
if !matches!(self.mode.as_str(), "shape" | "path" | "pixels") {
return Err("shapeOptions.mode must be shape, path or pixels".into());
}
if !matches!(self.operation.as_str(), "new" | "combine" | "subtract" | "intersect" | "exclude") {
return Err("Invalid shapeOptions.operation".into());
}
if !matches!(self.size_mode.as_str(), "unconstrained" | "square" | "fixed" | "proportional") {
return Err("Invalid shapeOptions.sizeMode".into());
}
if !(0.0..=100.0).contains(&self.opacity)
|| !(0.5..=10.0).contains(&self.path_width)
|| ![self.arrowheads.width, self.arrowheads.length].iter().all(|v| (0.0..=10000.0).contains(v))
|| !(-50.0..=50.0).contains(&self.arrowheads.concavity)
|| ![self.size_width, self.size_height].iter().all(|v| (1.0..=300000.0).contains(v))
|| !(0.01..=1.0).contains(&self.star_ratio)
{
return Err("Shape options contain invalid numeric values".into());
}
Ok(())
}
}
pub(crate) fn path_shape(app: &PhotocraftApp) -> Option<ShapeLayer> {
let st = app.session.active()?;
let name = app.ui.selected_path.as_deref().filter(|n| *n != "layer").unwrap_or("work");
let path = if name == "work" { st.doc.work_path.as_ref()? } else { &st.doc.paths.iter().find(|p| p.name == name)?.path };
let live = app.ui.tool_options.shape.path_geometry.as_ref().and_then(|(doc, geometry, original)| {
if *doc == st.doc.id.0 && name == "work" && original == path { photocraft_engine::vector_cmds::shape_live(geometry).ok() } else { None }
});
Some(ShapeLayer { path: path.clone(), live, ..Default::default() })
}
pub(crate) fn edit_path(app: &mut PhotocraftApp, patch: Value) -> Result<(), String> {
let shape = path_shape(app).ok_or("No path to edit")?;
let name = app.ui.selected_path.as_deref().filter(|n| *n != "layer").unwrap_or("work").to_string();
let mut geometry = app
.ui
.tool_options
.shape
.path_geometry
.as_ref()
.filter(|(doc, _, original)| shape.live.is_some() && original == &shape.path && app.session.active().is_some_and(|st| st.doc.id.0 == *doc))
.map(|(_, p, _)| p.clone());
let path = if patch.get("transform").is_some() {
let m: [f64; 6] = serde_json::from_value(patch["transform"].clone()).map_err(|e| e.to_string())?;
geometry = None;
shape.path.transform(&photocraft_geom::Affine { m })
} else {
let p = geometry.as_mut().ok_or("This path has no live shape parameters")?;
if let Some(patch) = patch.as_object() {
for (k, v) in patch {
p[k] = v.clone();
}
}
photocraft_engine::vector_cmds::shape_path(p).map_err(|e| e.to_string())?
};
photocraft_vector::flatten::validate_shape(&path, None).map_err(String::from)?;
let mut params = json!({"name": name, "path": photocraft_engine::vector_cmds::path_json(&path)});
if let Some(key) = patch.get("coalesce") {
params["coalesce"] = key.clone();
}
app.run("path.set", params)?;
app.ui.tool_options.shape.path_geometry = geometry.and_then(|p| app.session.active().map(|st| (st.doc.id.0, p, path)));
Ok(())
}
pub(crate) fn create(app: &mut PhotocraftApp, geometry: Value) -> Result<Value, String> {
create_with_operation(app, geometry, None)
}
pub(crate) fn create_with_operation(app: &mut PhotocraftApp, mut geometry: Value, operation: Option<&str>) -> Result<Value, String> {
app.ui.tool_options.shape.validate()?;
match app.ui.tool_options.shape.mode.as_str() {
"path" => {
let path = if geometry["kind"] == "path" {
photocraft_engine::vector_cmds::parse_path(&geometry["path"]).map_err(|e| e.to_string())?
} else {
photocraft_engine::vector_cmds::shape_path(&geometry).map_err(|e| e.to_string())?
};
photocraft_vector::flatten::validate_shape(&path, None).map_err(String::from)?;
let mut params = json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&path)});
let op = operation.unwrap_or(&app.ui.tool_options.shape.operation).to_string();
if op != "new" {
if let Some(old) = app.session.active().and_then(|st| st.doc.work_path.as_ref()) {
let mut combined = old.clone();
combined.subpaths.extend(path.subpaths.iter().cloned());
photocraft_vector::flatten::validate_shape(&combined, None).map_err(String::from)?;
}
params["op"] = json!(op);
}
let result = app.run("path.set", params)?;
let original = app.session.active().and_then(|st| st.doc.work_path.clone());
app.ui.tool_options.shape.path_geometry =
if op == "new" { original.and_then(|p| app.session.active().map(|st| (st.doc.id.0, geometry, p))) } else { None };
app.ui.selected_path = Some("work".into());
Ok(result)
}
"pixels" => {
geometry["mode"] = json!(app.ui.tool_options.shape.blend);
geometry["opacity"] = json!(app.ui.tool_options.shape.opacity);
geometry["antiAlias"] = json!(app.ui.tool_options.shape.anti_alias);
geometry["color"] = json!(app.session.tools.foreground);
geometry["target"] = crate::canvas::paint_target(app);
app.run("shape.drawPixels", geometry)
}
_ => {
let op = operation.unwrap_or(&app.ui.tool_options.shape.operation).to_string();
if op != "new" {
let id = app
.session
.active()
.and_then(|st| st.active_layer)
.filter(|id| app.session.active().and_then(|st| st.doc.layer(*id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_))))
.ok_or("Select a shape layer for this operation")?;
geometry["addTo"] = json!(id.0);
geometry["op"] = json!(op);
}
app.run("shape.create", geometry)
}
}
}
pub(crate) fn path_display(app: &PhotocraftApp, ctx: &egui::Context) -> (egui::Color32, f32) {
let o = &app.ui.tool_options.shape;
let color = o.path_color.map_or(crate::theme::Tokens::get(ctx).accent, |c| egui::Color32::from_rgb(c[0], c[1], c[2]));
(color, o.path_width.clamp(0.5, 10.0))
}
fn run(app: &mut PhotocraftApp, id: &str, p: Value) {
if let Err(e) = app.run(id, p) {
app.ui.status = e;
app.ui.status_error = true;
}
}
fn menu_button(ui: &mut egui::Ui, icon: &str, label: &str) -> egui::Response {
let r = crate::icons::button(ui, icon, 24.0, false, label);
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
r
}
pub(crate) fn actions(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) {
let mode = app.ui.tool_options.shape.mode.clone();
if mode == "pixels" {
ui.label(tl!("Mode:"));
let opts: Vec<_> = photocraft_color::BlendMode::LAYER_MODES.into_iter().map(|m| (m, m.label())).collect();
crate::widgets::dropdown(ui, "shape-pixel-blend", &mut app.ui.tool_options.shape.blend, &opts, 126.0);
ui.label(tl!("Opacity:"));
crate::widgets::value_field(ui, &mut app.ui.tool_options.shape.opacity, 0.0..=100.0, "%", 58.0);
ui.checkbox(&mut app.ui.tool_options.shape.anti_alias, tl!("Anti-alias"));
} else {
if mode == "path" {
let name = app.ui.selected_path.clone().filter(|n| n != "layer").unwrap_or("work".into());
let path = path_shape(app);
ui.add_enabled_ui(path.is_some(), |ui| {
if ui.button(tl!("Selection")).clicked() {
run(app, "path.toSelection", json!({"name":name}));
}
let mask =
app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).is_some_and(|l| {
!matches!(l.content, LayerContent::Shape(_)) && !app.session.active().is_some_and(|st| st.doc.effective_locks(l.id).all)
});
if ui.add_enabled(mask, egui::Button::new(tl!("Mask"))).clicked() {
run(app, "layer.vectorMask.fromPath", json!({"name":name}));
}
if ui.button(tl!("Shape")).clicked()
&& let Some(path) = &path
{
let geometry = json!({"kind":"path","path":photocraft_engine::vector_cmds::path_json(&path.path),"fill":app.session.tools.foreground});
match app.run("shape.create", geometry) {
Ok(_) => {
app.ui.selected_path = Some("layer".into());
app.ui.tool_options.shape.mode = "shape".into();
}
Err(e) => {
app.ui.status = e;
app.ui.status_error = true;
}
}
}
});
}
operation_menu(app, ui);
component_menus(app, ui);
}
options_menu(app, ui, tool);
ui.checkbox(&mut app.ui.tool_options.shape.align_edges, tl!("Align Edges"));
}
fn operation_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let shape_mode = app.ui.tool_options.shape.mode == "shape";
let active = (!app.ui.mask_target && !app.ui.vector_mask_target).then(|| crate::vector_ui::active_shape_path(app)).flatten();
let unlocked = app.session.active().is_some_and(|st| {
st.active_layer.is_some_and(|id| {
let l = st.doc.effective_locks(id);
!l.all && !l.pixels && !l.position
})
});
let response = menu_button(ui, "square-plus", tl!("Path Operations"));
egui::Popup::menu(&response).show(|ui| {
for (id, label) in [
("new", if shape_mode { tl!("New Layer") } else { tl!("New Path") }),
("combine", tl!("Combine Shapes")),
("subtract", tl!("Subtract Front Shape")),
("intersect", tl!("Intersect Shape Areas")),
("exclude", tl!("Exclude Overlapping Shapes")),
] {
let enabled = id == "new" || if shape_mode { active.is_some() && unlocked } else { path_shape(app).is_some_and(|s| !s.path.subpaths.is_empty()) };
if ui.add_enabled(enabled, egui::Button::selectable(app.ui.tool_options.shape.operation == id, label)).clicked() {
app.ui.tool_options.shape.operation = id.into();
ui.close();
}
}
let merge = if shape_mode && !app.ui.mask_target && !app.ui.vector_mask_target {
let selected = photocraft_engine::layer_multi_cmds::selected(&app.session);
if selected.len() > 1 {
app.session.active().is_some_and(|st| {
selected.iter().all(|id| {
let locks = st.doc.effective_locks(*id);
!locks.all
&& !locks.pixels
&& !locks.position
&& st.doc.layer(*id).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Shape(_)))
})
})
} else {
active.as_ref().is_some_and(|(_, p)| p.components().len() > 1) && unlocked
}
} else if !shape_mode {
path_shape(app).is_some_and(|s| s.path.components().len() > 1)
} else {
false
};
if ui.add_enabled(merge, egui::Button::new(tl!("Merge Shape Components"))).clicked() {
if shape_mode {
run(app, "layer.combineShapes.mergeShapeComponents", json!({}));
} else {
run(app, "path.merge", json!({"name":app.ui.selected_path.as_deref().filter(|n| *n != "layer").unwrap_or("work")}));
}
ui.close();
}
});
}
fn component_menus(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let path_mode = app.ui.tool_options.shape.mode == "path";
let (mut params, path) = if path_mode {
(json!({"name":app.ui.selected_path.as_deref().filter(|n| *n != "layer").unwrap_or("work")}), path_shape(app).map(|s| s.path))
} else if app.ui.mask_target || app.ui.vector_mask_target {
(json!({}), None)
} else {
match crate::vector_ui::active_shape_path(app) {
Some((id, p)) => (json!({"name":"layer","layer":id}), Some(p)),
None => (json!({}), None),
}
};
let count = path.as_ref().map_or(0, |p| p.components().len());
let selected = path
.as_ref()
.map(|p| {
let ds = &app.ui.direct_selection;
let target_matches = if path_mode {
ds.target == Some(crate::direct_select::PathRef::Work) && params["name"] == "work"
} else {
ds.target == params["layer"].as_u64().map(crate::direct_select::PathRef::Layer)
};
if !target_matches {
return Vec::new();
}
p.components().into_iter().enumerate().filter(|(_, r)| ds.anchors.iter().any(|a| r.contains(&a[0]))).map(|(i, _)| i).collect::<Vec<_>>()
})
.unwrap_or_default();
let locked = params["layer"].as_u64().is_some_and(|id| {
app.session.active().is_some_and(|st| {
let l = st.doc.effective_locks(photocraft_doc::LayerId(id));
l.all || l.pixels
})
});
let position_locked =
params["layer"].as_u64().is_some_and(|id| app.session.active().is_some_and(|st| st.doc.effective_locks(photocraft_doc::LayerId(id)).position));
ui.add_enabled_ui(count > 0 && !locked, |ui| {
let response = menu_button(ui, "align-left", tl!("Path Alignment"));
egui::Popup::menu(&response).show(|ui| {
ui.checkbox(&mut app.ui.tool_options.shape.align_canvas, tl!("Align to Canvas"));
let n = if selected.is_empty() { count } else { selected.len() };
for (action, label) in [
("left", tl!("Left")),
("centerX", tl!("Horizontal Centers")),
("right", tl!("Right")),
("top", tl!("Top")),
("centerY", tl!("Vertical Centers")),
("bottom", tl!("Bottom")),
("distributeX", tl!("Distribute Horizontally")),
("distributeY", tl!("Distribute Vertically")),
] {
let enabled = if action.starts_with("distribute") { n >= 3 } else { n >= 2 || app.ui.tool_options.shape.align_canvas };
if ui.add_enabled(enabled && !position_locked, egui::Button::new(label)).clicked() {
params["action"] = json!(action);
params["to"] = json!(if app.ui.tool_options.shape.align_canvas { "canvas" } else { "selection" });
if !selected.is_empty() {
params["components"] = json!(selected);
}
run(app, "path.align", params.clone());
ui.close();
}
}
});
ui.add_enabled_ui(count > 1, |ui| {
let response = menu_button(ui, "layers", tl!("Path Arrangement"));
egui::Popup::menu(&response).show(|ui| {
let selected = if selected.is_empty() { vec![count.saturating_sub(1)] } else { selected.clone() };
for (action, label) in [
("front", tl!("Bring Shape to Front")),
("forward", tl!("Bring Shape Forward")),
("backward", tl!("Send Shape Backward")),
("back", tl!("Send Shape to Back")),
] {
let toward_front = matches!(action, "front" | "forward");
let enabled = if toward_front {
selected.iter().any(|i| (*i + 1..count).any(|j| !selected.contains(&j)))
} else {
selected.iter().any(|i| (0..*i).any(|j| !selected.contains(&j)))
};
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
params["action"] = json!(action);
params["components"] = json!(selected);
run(app, "path.arrange", params.clone());
app.ui.direct_selection = Default::default();
ui.close();
}
}
});
});
});
}
fn options_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) {
let shape = if app.ui.tool_options.shape.mode == "path" {
path_shape(app)
} else if app.ui.tool_options.shape.mode == "shape" && !app.ui.mask_target && !app.ui.vector_mask_target {
crate::vector_ui::active_shape_path(app).and_then(|(id, _)| app.session.active()?.doc.layer(photocraft_doc::LayerId(id))).and_then(|l| {
match &l.content {
LayerContent::Shape(sh) => Some(sh.clone()),
_ => None,
}
})
} else {
None
};
let live_heads = shape.as_ref().and_then(|s| match &s.live {
Some(LiveShape::Line { arrowheads, .. }) => Some(arrowheads.clone().unwrap_or_else(|| photocraft_doc::vector::LineArrowheads {
start: false,
end: false,
..app.ui.tool_options.shape.arrowheads.clone()
})),
_ => None,
});
let mut heads = live_heads.clone().unwrap_or_else(|| app.ui.tool_options.shape.arrowheads.clone());
let before = heads.clone();
let locked = app.ui.tool_options.shape.mode == "shape"
&& app.session.active().is_some_and(|st| {
st.active_layer.is_some_and(|id| {
let l = st.doc.effective_locks(id);
l.all || l.pixels
})
});
let response = menu_button(ui, "settings", tl!("Path Options"));
egui::Popup::menu(&response).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let o = &mut app.ui.tool_options.shape;
ui.checkbox(&mut o.show_path, tl!("Show Path"));
ui.checkbox(&mut o.show_transform, tl!("Show Transform Controls"));
ui.horizontal(|ui| {
ui.label(tl!("Width:"));
crate::widgets::value_field(ui, &mut o.path_width, 0.5..=10.0, "px", 62.0);
});
let mut color = o.path_color.unwrap_or_else(|| {
let c = crate::theme::Tokens::get(ui.ctx()).accent;
[c.r(), c.g(), c.b()]
});
ui.horizontal(|ui| {
ui.label(tl!("Color:"));
if ui.color_edit_button_srgb(&mut color).changed() {
o.path_color = Some(color);
}
});
if ui.button(tl!("Reset")).clicked() {
o.path_color = None;
o.path_width = 1.0;
o.show_path = true;
o.show_transform = true;
}
ui.separator();
ui.checkbox(&mut o.from_center, tl!("From Center"));
ui.checkbox(&mut o.constrain, tl!("Constrain Proportions"));
if tool != Tool::Line {
for (mode, label) in
[("unconstrained", tl!("Unconstrained")), ("square", tl!("Square")), ("fixed", tl!("Fixed Size")), ("proportional", tl!("Proportional"))]
{
ui.radio_value(&mut o.size_mode, mode.into(), label);
}
if matches!(o.size_mode.as_str(), "fixed" | "proportional") {
for (label, value) in [(tl!("Width:"), &mut o.size_width), (tl!("Height:"), &mut o.size_height)] {
ui.horizontal(|ui| {
ui.label(label);
let mut number = *value as f32;
if crate::widgets::value_field(ui, &mut number, 1.0..=300000.0, "px", 70.0).changed() {
*value = f64::from(number);
}
});
}
}
}
if tool == Tool::Line {
ui.separator();
ui.add_enabled_ui(!locked && (shape.is_none() || live_heads.is_some() || o.mode == "pixels"), |ui| {
ui.label(tl!("Arrowheads"));
ui.horizontal(|ui| {
ui.checkbox(&mut heads.start, tl!("Start"));
ui.checkbox(&mut heads.end, tl!("End"));
});
for (label, value) in [(tl!("Width:"), &mut heads.width), (tl!("Length:"), &mut heads.length)] {
let mut number = *value as f32;
ui.horizontal(|ui| {
ui.label(label);
if crate::widgets::value_field(ui, &mut number, 0.0..=10000.0, "px", 70.0).changed() {
*value = f64::from(number);
}
});
}
ui.horizontal(|ui| {
ui.label(tl!("Concavity:"));
let mut number = heads.concavity as f32;
if crate::widgets::value_field(ui, &mut number, -50.0..=50.0, "%", 70.0).changed() {
heads.concavity = f64::from(number);
}
});
});
}
});
if heads != before {
let mut params = json!({"arrowheads":heads});
if let Some(key) = crate::shape_options::edit_key(ui.ctx()) {
params["coalesce"] = json!(format!("shape-arrowheads-{:?}-{key}", app.session.active().map(|st| (st.doc.id, st.active_layer))));
}
let result = if app.ui.tool_options.shape.mode == "path" && live_heads.is_some() {
edit_path(app, params)
} else if app.ui.tool_options.shape.mode == "shape" && live_heads.is_some() {
app.run("shape.edit", params).map(|_| ())
} else {
Ok(())
};
match result {
Ok(()) => app.ui.tool_options.shape.arrowheads = heads,
Err(e) => {
app.ui.status = e;
app.ui.status_error = true;
}
}
}
}
#[cfg(test)]
#[path = "shape_modes_tests.rs"]
mod tests;
+383
View File
@@ -0,0 +1,383 @@
use super::*;
use egui_kittest::{Harness, kittest::Queryable};
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":200,"height":160,"background":"transparent"})).unwrap();
app.ui.tool = Tool::Rectangle;
app
}
fn draw(app: &mut PhotocraftApp, tool: Tool) {
app.ui.tool = tool;
crate::vector_ui::finish_shape(app, tool, [20.0, 20.0], [100.0, 80.0], Default::default());
assert!(!app.ui.status_error, "{}", app.ui.status);
}
fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
harness_size(app, egui::vec2(1200.0, 600.0))
}
fn harness_size(app: PhotocraftApp, size: egui::Vec2) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(size).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
crate::i18n::with_language(crate::i18n::lang_from_tag("en").unwrap(), || {
ui.horizontal(|ui| crate::vector_ui::options_bar(app, ui, app.ui.tool));
});
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(4);
h
}
#[test]
fn narrow_toolbar_can_scroll_to_and_open_path_options() {
let mut a = app();
draw(&mut a, Tool::Polygon);
let before = a.session.active().unwrap().doc.clone();
let mut h = harness_size(a, egui::vec2(400.0, 100.0));
h.hover_at(egui::pos2(200.0, 20.0));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(-2000.0, 0.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(12);
let gear = h.get_by_label("Path Options");
assert!(gear.rect().left() >= 0.0 && gear.rect().right() <= 400.0);
gear.click();
h.run_steps(3);
h.get_by_label("Show Path").click();
h.run_steps(3);
assert!(!h.state().ui.tool_options.shape.show_path);
assert_eq!(h.state().session.active().unwrap().doc, before);
}
#[test]
fn every_shape_tool_draws_each_mode_with_one_undo_step() {
for mode in ["shape", "path", "pixels"] {
for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape] {
let mut a = app();
a.ui.tool_options.shape.mode = mode.into();
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
draw(&mut a, tool);
let drawn = a.session.active().unwrap().doc.clone();
assert_ne!(drawn, before, "{mode} {tool:?}");
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1, "{mode} {tool:?}");
match mode {
"shape" => assert_eq!(drawn.layers.len(), before.layers.len() + 1),
"path" => {
assert_eq!(drawn.layers.len(), before.layers.len());
assert!(drawn.work_path.is_some());
}
_ => {
assert_eq!(drawn.layers.len(), before.layers.len());
assert!(drawn.work_path.is_none());
}
}
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, before);
a.run("edit.redo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, drawn);
}
}
}
#[test]
fn drawing_uses_the_active_shapes_displayed_fill_and_complete_stroke() {
let mut a = app();
a.run("shape.create",json!({"rect":[4,4,20,20],"fill":{"gradient":{"stops":[[0,"#aa2233"],[1,"#2266cc"]],"angle":45}},"stroke":{"color":"#118833","width":7,"dashes":[2,1],"cap":"round","opacity":40}})).unwrap();
let st = a.session.active().unwrap();
let LayerContent::Shape(original) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
let original = original.clone();
let colors = (a.session.tools.foreground, a.session.tools.background);
draw(&mut a, Tool::EllipseShape);
let st = a.session.active().unwrap();
let LayerContent::Shape(next) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
assert_eq!(next.fill, original.fill);
assert_eq!(next.stroke, original.stroke);
assert_eq!((a.session.tools.foreground, a.session.tools.background), colors);
}
#[test]
fn drawing_inherits_displayed_live_geometry_in_shape_and_path_modes() {
for mode in ["shape", "path"] {
let mut a = app();
a.ui.tool_options.shape.mode = mode.into();
a.ui.tool_options.polygon_sides = 8;
a.ui.tool_options.corner_radius = 7.0;
draw(&mut a, Tool::Polygon);
a.ui.tool_options.polygon_sides = 5;
a.ui.tool_options.corner_radius = 0.0;
crate::shape_dialog::open(&mut a, Tool::Polygon, [10.0, 10.0]).unwrap();
let dialog = a.ui.dialogs.last().unwrap();
assert_eq!(dialog.fields["sides"], 8);
assert_eq!(dialog.fields["cornerRadius"], 7.0);
a.ui.dialogs.clear();
draw(&mut a, Tool::Polygon);
assert!(matches!(crate::shape_options::drawing_live(&a), Some(LiveShape::Polygon { sides: 8, corner_radius: 7.0, .. })));
a.ui.tool_options.line_weight = 9.0;
a.ui.tool_options.shape.arrowheads.end = true;
draw(&mut a, Tool::Line);
a.ui.tool_options.line_weight = 1.0;
a.ui.tool_options.shape.arrowheads.end = false;
draw(&mut a, Tool::Line);
assert!(matches!(crate::shape_options::drawing_live(&a), Some(LiveShape::Line { weight:9.0, arrowheads:Some(h), .. }) if h.end));
}
}
#[test]
fn path_conversion_buttons_make_selection_mask_and_shape() {
let mut a = app();
a.ui.tool_options.shape.mode = "path".into();
draw(&mut a, Tool::Rectangle);
let work = a.session.active().unwrap().doc.work_path.clone().unwrap();
let pixel = a.session.active().unwrap().active_layer.unwrap();
let mut h = harness(a);
h.get_by_label("Selection").click();
h.run_steps(3);
assert!(h.state().session.active().unwrap().doc.selection.is_some());
h.get_by_label("Mask").click();
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc.layer(pixel).unwrap().vector_mask.as_ref().unwrap().path, work);
h.get_by_label("Shape").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
assert_eq!(sh.path, work);
assert_eq!(h.state().ui.tool_options.shape.mode, "shape");
}
#[test]
fn shape_and_path_operations_append_without_changing_layer_count() {
for mode in ["shape", "path"] {
for operation in ["combine", "subtract", "intersect", "exclude"] {
let mut a = app();
a.ui.tool_options.shape.mode = mode.into();
draw(&mut a, Tool::Rectangle);
let count = a.session.active().unwrap().doc.layers.len();
let before = a.session.active().unwrap().doc.clone();
a.ui.tool_options.shape.operation = operation.into();
draw(&mut a, Tool::EllipseShape);
let path =
if mode == "shape" { crate::vector_ui::active_shape_path(&a).unwrap().1 } else { a.session.active().unwrap().doc.work_path.clone().unwrap() };
assert_eq!(path.subpaths.len(), 2);
assert_eq!(a.session.active().unwrap().doc.layers.len(), count);
assert_eq!(photocraft_engine::vector_cmds::path_json(&path)["subpaths"][1]["op"], operation);
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, before);
}
}
}
#[test]
fn appending_shape_tool_paths_checks_combined_geometry_budget_atomically() {
let mut a = app();
a.ui.tool_options.shape.mode = "path".into();
a.ui.tool_options.shape.operation = "combine".into();
let component = json!({"closed":true,"knots":[[1,1],[2,1],[2,2],[1,2]]});
a.run("path.set", json!({"path":{"subpaths":vec![component;1024]}})).unwrap();
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
crate::vector_ui::finish_shape(&mut a, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Default::default());
assert!(a.ui.status_error);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!(a.session.active().unwrap().history.past_len(), history);
}
#[test]
fn path_shape_options_edit_live_geometry_but_never_a_different_document() {
let mut a = app();
a.ui.tool_options.shape.mode = "path".into();
a.ui.tool_options.corner_radius = 4.0;
draw(&mut a, Tool::Triangle);
assert!(matches!(path_shape(&a).unwrap().live, Some(LiveShape::Polygon { corner_radius: 4.0, sides: 3, .. })));
edit_path(&mut a, json!({"cornerRadius":9,"rect":[20,20,120,70]})).unwrap();
assert!(matches!(path_shape(&a).unwrap().live, Some(LiveShape::Polygon { corner_radius: 9.0, .. })));
let path = a.session.active().unwrap().doc.work_path.clone().unwrap();
a.run("file.new", json!({"width":200,"height":160})).unwrap();
a.run("path.set", json!({"path":photocraft_engine::vector_cmds::path_json(&path)})).unwrap();
assert!(path_shape(&a).unwrap().live.is_none());
assert!(edit_path(&mut a, json!({"cornerRadius":2})).is_err());
}
#[test]
fn path_options_change_view_only_and_line_end_edits_change_active_shape() {
let mut a = app();
draw(&mut a, Tool::Line);
let before = a.session.active().unwrap().doc.clone();
let history = a.session.active().unwrap().history.past_len();
let mut h = harness(a);
h.get_by_label("Path Options").click();
h.run_steps(3);
h.get_by_label("Show Path").click();
h.run_steps(3);
assert!(!h.state().ui.tool_options.shape.show_path);
assert_eq!(h.state().session.active().unwrap().doc, before);
h.get_by_label("End").click();
h.run_steps(3);
let st = h.state().session.active().unwrap();
assert_eq!(st.history.past_len(), history + 1);
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
assert!(matches!(&sh.live,Some(LiveShape::Line{arrowheads:Some(a),..}) if a.end));
}
#[test]
fn control_shape_options_validate_before_applying_any_field() {
let mut a = app();
let ctx = egui::Context::default();
let call = |a: &mut PhotocraftApp, p| {
let (request, _) = crate::control::ControlRequest::new("ui.set", p);
crate::control::handle(a, &ctx, &request)
};
call(&mut a, json!({"shapeOptions":{"mode":"pixels","opacity":25,"pathWidth":3}}));
assert_eq!(a.ui.tool_options.shape.mode, "pixels");
assert_eq!(a.ui.tool_options.shape.opacity, 25.0);
call(&mut a, json!({"shapeOptions":{"arrowheads":{"end":true}}}));
assert!(a.ui.tool_options.shape.arrowheads.end);
assert_eq!(a.ui.tool_options.shape.arrowheads.width, 10.0);
let before = a.ui.clone();
for p in [
json!({"tool":"polygon","shapeOptions":{"mode":"bad"}}),
json!({"tool":"polygon","shapeOptions":{"opacity":-1}}),
json!({"tool":"polygon","shapeOptions":{"pathWidth":1e100}}),
json!({"tool":"polygon","shapeOptions":{"unknown":true}}),
json!({"tool":"polygon","shapeOptions":{"arrowheads":{"unknown":true}}}),
json!({"tool":"polygon","shapeOptions":{"sizeMode":"bad"}}),
json!({"tool":"polygon","shapeOptions":{"sizeWidth":0}}),
json!({"tool":"polygon","shapeOptions":{"sizeHeight":300001}}),
json!({"tool":"polygon","shapeOptions":{"starRatio":0}}),
json!({"tool":"polygon","shapeOptions":{"arrowheads":{"concavity":51}}}),
] {
call(&mut a, p);
assert_eq!(a.ui, before);
}
}
#[test]
fn size_modes_and_pixel_alignment_share_preview_and_commit_geometry() {
let mut a = app();
a.ui.tool_options.shape.align_edges = false;
let rect = |a: &PhotocraftApp, mods| crate::vector_ui::shape_geometry(a, Tool::Rectangle, [10.25, 20.25], [80.75, 45.75], mods).unwrap()["rect"].clone();
assert_eq!(rect(&a, egui::Modifiers::NONE), json!([10.25, 20.25, 70.5, 25.5]));
a.ui.tool_options.shape.align_edges = true;
assert_eq!(rect(&a, egui::Modifiers::NONE), json!([10.0, 20.0, 71.0, 26.0]));
a.ui.tool_options.shape.align_edges = false;
a.ui.tool_options.shape.size_mode = "square".into();
assert_eq!(rect(&a, egui::Modifiers::NONE), json!([10.25, 20.25, 70.5, 70.5]));
a.ui.tool_options.shape.size_mode = "fixed".into();
a.ui.tool_options.shape.size_width = 40.0;
a.ui.tool_options.shape.size_height = 20.0;
assert_eq!(rect(&a, egui::Modifiers::NONE), json!([10.25, 20.25, 40.0, 20.0]));
assert_eq!(rect(&a, egui::Modifiers::ALT), json!([-9.75, 10.25, 40.0, 20.0]));
a.ui.tool_options.shape.size_mode = "proportional".into();
assert_eq!(rect(&a, egui::Modifiers::NONE), json!([10.25, 20.25, 70.5, 35.25]));
let expected = rect(&a, egui::Modifiers::NONE);
crate::vector_ui::finish_shape(&mut a, Tool::Rectangle, [10.25, 20.25], [80.75, 45.75], egui::Modifiers::NONE);
let st = a.session.active().unwrap();
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
let Some(LiveShape::Rect { rect, .. }) = shape.live else { panic!() };
assert_eq!(json!(rect), expected);
// A horizontal drag with Shift still produces a square instead of a zero-height shape.
assert!(crate::vector_ui::shape_geometry(&a, Tool::Rectangle, [10.0, 10.0], [50.0, 10.0], egui::Modifiers::SHIFT).is_some());
}
#[test]
fn press_modifiers_choose_boolean_operation_without_changing_defaults() {
for (mods, op) in [
(egui::Modifiers::SHIFT, photocraft_doc::PathOp::Combine),
(egui::Modifiers::ALT, photocraft_doc::PathOp::Subtract),
(egui::Modifiers { shift: true, alt: true, ..Default::default() }, photocraft_doc::PathOp::Intersect),
] {
let mut a = app();
draw(&mut a, Tool::Rectangle);
let before = a.session.active().unwrap().doc.clone();
let id = a.session.active().unwrap().active_layer.unwrap();
let history = a.session.active().unwrap().history.past_len();
crate::vector_ui::finish_shape_gesture(&mut a, Tool::Rectangle, [50.0, 40.0], [140.0, 100.0], mods, egui::Modifiers::NONE);
assert!(!a.ui.status_error, "{}", a.ui.status);
let st = a.session.active().unwrap();
assert_eq!(st.doc.layers.len(), before.layers.len());
assert_eq!(st.active_layer, Some(id));
let LayerContent::Shape(shape) = &st.doc.layer(id).unwrap().content else { panic!() };
assert_eq!(shape.path.subpaths.last().unwrap().op, op);
assert_eq!(st.history.past_len(), history + 1);
assert_eq!(a.ui.tool_options.shape.operation, "new");
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.active().unwrap().doc, before);
}
}
#[test]
fn align_edges_is_view_state_and_old_preferences_load_defaults() {
let mut a = app();
draw(&mut a, Tool::Rectangle);
let before = a.session.active().unwrap().doc.clone();
let mut h = harness(a);
h.get_by_label("Align Edges").click();
h.run_steps(3);
assert!(!h.state().ui.tool_options.shape.align_edges);
assert_eq!(h.state().session.active().unwrap().doc, before);
let old: ShapeToolOptions = serde_json::from_value(json!({"mode":"path","fromCenter":true})).unwrap();
assert_eq!(old.size_mode, "unconstrained");
assert!(old.align_edges);
assert_eq!(old.star_ratio, 1.0);
assert!(!old.smooth_indents);
}
#[test]
fn full_paint_defaults_are_atomic_view_state_and_seed_new_shapes() {
let mut a = app();
let ctx = egui::Context::default();
let before = a.session.active().unwrap().doc.clone();
let colors = (a.session.tools.foreground, a.session.tools.background);
let call = |a: &mut PhotocraftApp, p| {
let (request, _) = crate::control::ControlRequest::new("ui.set", p);
let crate::control::Outcome::Done(response) = crate::control::handle(a, &ctx, &request) else {
panic!("ui.set must return synchronously");
};
response
};
let response = call(
&mut a,
json!({"shapeFillPaint":{"gradient":{"stops":[[0,"#ff0000"],[1,"#0000ff"]],"opacityStops":[[0,0.25],[1,0.5]],"midpoints":[0.3],"offset":[0.1,-0.2],"align":false,"dither":true}},"shapeStrokePaint":[0.2,0.3,0.4,0.25]}),
);
assert_eq!(response["ok"], true, "full paint defaults are accepted: {response}");
assert!(
matches!(&a.ui.tool_options.shape_fill_paint,Some(photocraft_doc::Fill::Gradient { opacity_stops, midpoints, align:false, dither:true, ..}) if opacity_stops.len()==2 && midpoints==&[0.3])
);
assert_eq!(a.session.active().unwrap().doc, before);
assert_eq!((a.session.tools.foreground, a.session.tools.background), colors);
let options = a.ui.tool_options.clone();
for invalid in
[json!({"tool":"polygon","shapeFillPaint":{"gradient":{"stops":[[0,"bad"]]}}}), json!({"tool":"polygon","shapeStrokePaint":{"type":"unknown"}})]
{
let response = call(&mut a, invalid);
assert_eq!(response["ok"], false, "invalid paints are rejected: {response}");
assert_eq!(a.ui.tool_options, options);
assert_eq!(a.ui.tool, Tool::Rectangle);
}
draw(&mut a, Tool::Rectangle);
let st = a.session.active().unwrap();
let LayerContent::Shape(shape) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!() };
assert_eq!(shape.fill, options.shape_fill_paint);
assert_eq!(shape.stroke.as_ref().unwrap().paint, options.shape_stroke_paint.unwrap());
}
#[test]
fn masks_do_not_receive_shape_boolean_shortcuts_and_thin_lines_keep_weight() {
let mut a = app();
a.run("shape.create", json!({"kind":"line","from":[10,20],"to":[80,20],"weight":0.5,"fill":"#ff0000"})).unwrap();
let geometry = crate::vector_ui::shape_geometry(&a, Tool::Line, [10.0, 40.0], [80.0, 40.0], egui::Modifiers::NONE).unwrap();
assert_eq!(geometry["weight"], 0.5);
for vector in [false, true] {
a.ui.mask_target = !vector;
a.ui.vector_mask_target = vector;
assert_eq!(crate::vector_ui::gesture_operation(&a, egui::Modifiers::SHIFT), None);
assert_eq!(crate::vector_ui::gesture_operation(&a, egui::Modifiers::ALT), None);
}
}
+718
View File
@@ -0,0 +1,718 @@
//! Shape toolbar edits share the engine commands used by the Properties panel.
use egui::Sense;
use photocraft_doc::{Fill, LayerContent, LayerId, LiveShape, ShapeLayer};
use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::shape_stroke_ui::StrokeOptions;
use crate::state::Tool;
use crate::theme::Tokens;
const MAX_SIZE: f32 = 300_000.0;
pub(crate) fn fill_color(app: &PhotocraftApp) -> [f32; 4] {
app.ui.tool_options.shape_fill_color.unwrap_or(app.session.tools.foreground)
}
pub(crate) fn stroke_color(app: &PhotocraftApp) -> [f32; 4] {
app.ui.tool_options.shape_stroke_color.unwrap_or(app.session.tools.background)
}
fn solid(color: [f32; 4]) -> Fill {
Fill::Solid(photocraft_doc::Color::rgba(color[0], color[1], color[2], color[3]))
}
pub(crate) fn paint(app: &PhotocraftApp) -> (Option<Fill>, Option<photocraft_doc::ShapeStroke>) {
if app.ui.tool_options.shape.mode == "shape"
&& let Some((_, sh)) = target(app)
{
return (sh.fill, sh.stroke);
}
let o = &app.ui.tool_options;
(o.shape_fill.then(|| default_fill(app)), (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, default_stroke(app))))
}
fn default_fill(app: &PhotocraftApp) -> Fill {
app.ui.tool_options.shape_fill_paint.clone().unwrap_or_else(|| solid(fill_color(app)))
}
fn default_stroke(app: &PhotocraftApp) -> Fill {
app.ui.tool_options.shape_stroke_paint.clone().unwrap_or_else(|| solid(stroke_color(app)))
}
fn target(app: &PhotocraftApp) -> Option<(LayerId, ShapeLayer)> {
if app.ui.mask_target || app.layer_mask_targeted() || app.ui.vector_mask_target {
return None;
}
let st = app.session.active()?;
let id = st.active_layer?;
match &st.doc.layer(id)?.content {
LayerContent::Shape(sh) => Some((id, sh.clone())),
_ => None,
}
}
/// Geometry shown in the toolbar also seeds the next shape of the same tool.
pub(crate) fn drawing_live(app: &PhotocraftApp) -> Option<LiveShape> {
match app.ui.tool_options.shape.mode.as_str() {
"shape" if !app.ui.mask_target && !app.layer_mask_targeted() && !app.ui.vector_mask_target => {
let st = app.session.active()?;
match &st.doc.layer(st.active_layer?)?.content {
LayerContent::Shape(sh) => sh.live.clone(),
_ => None,
}
}
"path" => crate::shape_modes::path_shape(app)?.live,
_ => None,
}
}
fn bounds(sh: &ShapeLayer) -> Option<[f64; 4]> {
match &sh.live {
Some(LiveShape::Rect { rect, .. } | LiveShape::Ellipse { rect } | LiveShape::Polygon { rect, .. }) => Some(*rect),
Some(LiveShape::Line { from, to, .. }) => Some([from[0].min(to[0]), from[1].min(to[1]), (to[0] - from[0]).abs(), (to[1] - from[1]).abs()]),
_ => sh.path.control_bounds().map(|(x0, y0, x1, y1)| [x0, y0, x1 - x0, y1 - y0]),
}
.filter(|r| r.iter().all(|v| v.is_finite()))
}
enum Change {
Fill(Option<Fill>),
Stroke(Option<Fill>),
Width(f32),
Style(StrokeOptions),
Size { height: bool, value: f32, linked: bool },
Radius(f32),
Sides(u32),
StarRatio(f64),
SmoothIndents(bool),
Weight(f32),
}
fn resize(sh: &ShapeLayer, height: bool, value: f32, linked: bool) -> Result<Value, String> {
if !value.is_finite() || !(1.0..=MAX_SIZE).contains(&value) {
return Err("Shape dimensions must be between 1 and 300000 pixels".into());
}
let [x, y, w, h] = bounds(sh).ok_or("The shape has no finite bounds")?;
let old = if height { h } else { w };
if old <= 0.0 || (linked && (w <= 0.0 || h <= 0.0)) {
return Err("Cannot resize a zero-length shape dimension".into());
}
let scale = f64::from(value) / old;
// Keep the entered dimension exact; derive only its linked companion.
let value = f64::from(value);
let (nw, nh) = if height { (if linked { w * value / h } else { w }, value) } else { (value, if linked { h * value / w } else { h }) };
if !scale.is_finite() || !nw.is_finite() || !nh.is_finite() {
return Err("The resized shape dimensions are not finite".into());
}
if linked && (!(1.0..=f64::from(MAX_SIZE)).contains(&nw) || !(1.0..=f64::from(MAX_SIZE)).contains(&nh)) {
return Err("Linked shape dimensions must be between 1 and 300000 pixels".into());
}
Ok(match sh.live {
Some(LiveShape::Rect { .. } | LiveShape::Ellipse { .. } | LiveShape::Polygon { .. }) => json!({"rect": [x, y, nw, nh]}),
Some(LiveShape::Line { from, to, .. }) => {
let point =
|p: [f64; 2]| [if !height || linked { x + (p[0] - x) * scale } else { p[0] }, if height || linked { y + (p[1] - y) * scale } else { p[1] }];
json!({"from": point(from), "to": point(to)})
}
// Custom paths keep their path geometry instead of becoming rectangles.
_ => {
let (sx, sy) = (if !height || linked { scale } else { 1.0 }, if height || linked { scale } else { 1.0 });
let transform = [sx, 0.0, 0.0, sy, x * (1.0 - sx), y * (1.0 - sy)];
if transform.iter().any(|v| !v.is_finite()) {
return Err("The resized shape transform is not finite".into());
}
json!({"transform": transform})
}
})
}
/// Change an explicit toolbar target, then update defaults only after a successful edit.
fn apply(app: &mut PhotocraftApp, id: Option<LayerId>, change: Change, coalesce: Option<String>) -> Result<(), String> {
if let Some(id) = id
&& matches!(change, Change::Size { .. })
&& app.session.active().is_some_and(|st| st.doc.effective_locks(id).position)
{
return Err("Shape position is locked".into());
}
let path_shape = (app.ui.tool_options.shape.mode == "path").then(|| crate::shape_modes::path_shape(app)).flatten();
let shape = id
.and_then(|id| app.session.active()?.doc.layer(id))
.and_then(|l| match &l.content {
LayerContent::Shape(sh) => Some(sh),
_ => None,
})
.or(path_shape.as_ref());
let default_width = app.ui.tool_options.stroke_width;
let stroke_width = shape.and_then(|sh| sh.stroke.as_ref()).map_or(if default_width > 0.0 { default_width } else { 3.0 }, |s| s.width);
if id.is_some() || path_shape.is_some() {
let sh = shape.ok_or("The toolbar target is no longer a shape layer")?;
let mut patch = match &change {
Change::Fill(fill) => json!({"fill": photocraft_engine::vector_cmds::shape_paint_params(fill.as_ref(), None)["fill"].clone()}),
Change::Stroke(None) => json!({"stroke": null}),
Change::Stroke(Some(fill)) => {
let mut stroke = if sh.stroke.is_none() { app.ui.tool_options.shape_stroke.params() } else { json!({}) };
stroke["fill"] = photocraft_engine::vector_cmds::shape_paint_params(Some(fill), None)["fill"].clone();
stroke["width"] = json!(stroke_width);
json!({"stroke": stroke})
}
Change::Width(w) if *w <= 0.0 => json!({"stroke": null}),
Change::Width(w) => {
let mut stroke = json!({"width": w});
if sh.stroke.is_none() {
stroke["fill"] = photocraft_engine::vector_cmds::shape_paint_params(Some(&default_stroke(app)), None)["fill"].clone();
// A new stroke also takes the current tool's cap, join and dash settings.
if let Some(style) = app.ui.tool_options.shape_stroke.params().as_object() {
for (key, value) in style {
stroke[key] = value.clone();
}
}
}
json!({"stroke": stroke})
}
Change::Style(style) => {
let mut stroke = style.params();
if sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0) {
stroke["width"] = json!(if stroke_width > 0.0 { stroke_width } else { 3.0 });
stroke["fill"] = photocraft_engine::vector_cmds::shape_paint_params(Some(&default_stroke(app)), None)["fill"].clone();
}
json!({"stroke": stroke})
}
Change::Size { height, value, linked } => resize(sh, *height, *value, *linked)?,
Change::Radius(r) => {
if matches!(sh.live, Some(LiveShape::Polygon { .. })) {
json!({"cornerRadius": r})
} else {
json!({"radii": r})
}
}
Change::Sides(n) => json!({"sides": n}),
Change::StarRatio(r) => json!({"starRatio": r}),
Change::SmoothIndents(smooth) => json!({"smoothIndents": smooth}),
Change::Weight(w) => json!({"weight": w}),
};
if let Some(key) = coalesce {
patch["coalesce"] = json!(format!("shape-toolbar-{:?}-{id:?}-{key}", app.session.active().map(|st| st.doc.id)));
}
if let Some(id) = id {
patch["layer"] = json!(id.0);
app.run("shape.edit", patch)?;
} else {
crate::shape_modes::edit_path(app, patch)?;
}
}
let o = &mut app.ui.tool_options;
match change {
Change::Fill(fill) => {
o.shape_fill = fill.is_some();
if let Some(Fill::Solid(color)) = &fill {
let [r, g, b] = color.to_rgb();
o.shape_fill_color = Some([r, g, b, color.alpha]);
}
if fill.is_some() {
o.shape_fill_paint = fill;
}
}
Change::Stroke(fill) => {
o.stroke_width = if fill.is_some() { stroke_width } else { 0.0 };
if let Some(Fill::Solid(color)) = &fill {
let [r, g, b] = color.to_rgb();
o.shape_stroke_color = Some([r, g, b, color.alpha]);
}
if fill.is_some() {
o.shape_stroke_paint = fill;
}
}
Change::Width(w) => o.stroke_width = w,
Change::Style(style) => {
o.shape_stroke = style;
if o.stroke_width <= 0.0 {
o.stroke_width = if stroke_width > 0.0 { stroke_width } else { 3.0 };
}
}
Change::Radius(r) => o.corner_radius = r,
Change::Sides(n) => o.polygon_sides = n,
Change::StarRatio(r) => o.shape.star_ratio = r,
Change::SmoothIndents(smooth) => o.shape.smooth_indents = smooth,
Change::Weight(w) => o.line_weight = w,
Change::Size { .. } => {}
}
Ok(())
}
/// Every pointer drag and focus session gets its own history group.
pub(crate) fn edit_key(ctx: &egui::Context) -> Option<String> {
let focused = ctx.memory(|m| m.focused());
let focus_key = egui::Id::new("shape-toolbar-focus");
let (previous, started) = ctx.data(|d| d.get_temp::<(Option<egui::Id>, u64)>(focus_key)).unwrap_or_default();
let started = if previous == focused { started } else { ctx.cumulative_pass_nr() };
ctx.data_mut(|d| d.insert_temp(focus_key, (focused, started)));
// A popup can apply on both the initial press and the release frame, including a
// batched click. Retain its press identity after egui clears press_start_time.
let (pressed, pointer_edit) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.any_down() || i.pointer.any_released()));
let pointer_key = egui::Id::new("shape-paint-pointer-gesture");
let pass = ctx.cumulative_pass_nr();
let (mut gesture, mut last_press) = ctx.data(|d| d.get_temp::<(u64, u64)>(pointer_key)).unwrap_or((0, u64::MAX));
if pressed && last_press != pass {
gesture = gesture.wrapping_add(1);
last_press = pass;
ctx.data_mut(|d| d.insert_temp(pointer_key, (gesture, last_press)));
}
if pointer_edit {
return Some(format!("pointer-{gesture}"));
}
focused.map(|id| format!("focus-{}-{started}", id.value()))
}
fn dimension(ui: &mut egui::Ui, height: bool, value: &mut f32) -> bool {
let t = Tokens::get(ui.ctx());
let label = if height { tl!("H") } else { tl!("W") };
let response = ui
.add(egui::Label::new(egui::RichText::new(format!("{label}:")).color(t.text_dim).size(12.0)).sense(Sense::drag()))
.on_hover_cursor(egui::CursorIcon::ResizeHorizontal)
.on_hover_text(if height { tl!("Height") } else { tl!("Width") });
let start_key = response.id.with("scrub-start");
if response.drag_started() {
ui.data_mut(|d| d.insert_temp(start_key, *value));
}
let mut changed = false;
if response.dragged()
&& let (Some(start), Some(origin), Some(pos)) =
(ui.data(|d| d.get_temp::<f32>(start_key)), ui.input(|i| i.pointer.press_origin()), response.interact_pointer_pos())
{
let next = (start + (pos.x - origin.x) * 0.5).clamp(1.0, MAX_SIZE);
changed = next != *value;
*value = next;
}
// Disabled zero-length axes and imported subpixel sizes must not clamp on every draw.
let field = crate::widgets::value_field(ui, value, 0.0..=MAX_SIZE.max(*value), "px", 62.0);
ui.is_enabled() && (field.changed() || changed)
}
pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) {
let gesture_key = edit_key(ui.ctx());
// Selection tools edit the selected layer independently of the next drawing mode.
let selecting = matches!(tool, Tool::PathSelection | Tool::DirectSelection);
let mode = if selecting { "shape".into() } else { app.ui.tool_options.shape.mode.clone() };
let shown = if mode == "shape" { target(app) } else { None };
let path_shape = if mode == "path" { crate::shape_modes::path_shape(app) } else { None };
let id = shown.as_ref().map(|(id, _)| *id);
let sh = shown.as_ref().map(|(_, sh)| sh).or(path_shape.as_ref());
let locked = id.is_some_and(|id| {
app.session.active().is_some_and(|st| {
let locks = st.doc.effective_locks(id);
locks.all || locks.pixels
})
});
let position_locked = id.is_some_and(|id| app.session.active().is_some_and(|st| st.doc.effective_locks(id).position));
let fill = sh.map_or_else(|| app.ui.tool_options.shape_fill.then(|| default_fill(app)), |sh| sh.fill.clone());
let stroke = sh.and_then(|sh| sh.stroke.as_ref());
let paint = if sh.is_some() { stroke.map(|s| s.paint.clone()) } else { (app.ui.tool_options.stroke_width > 0.0).then(|| default_stroke(app)) };
let mut width = if sh.is_some() { stroke.map_or(0.0, |s| s.width) } else { app.ui.tool_options.stroke_width };
let style = stroke.map_or_else(|| app.ui.tool_options.shape_stroke.clone(), StrokeOptions::from);
let t = Tokens::get(ui.ctx());
let label = |ui: &mut egui::Ui, text: &str| {
ui.label(egui::RichText::new(text).color(t.text_dim).size(12.0));
};
let mut change = None;
let mut discrete_paint = false;
let mut stroke_key = None;
let doc_id = app.session.active().map(|st| st.doc.id);
let path_name = (mode == "path").then(|| app.ui.selected_path.as_deref().filter(|n| *n != "layer").unwrap_or("work").to_string());
ui.push_id(("shape-toolbar", doc_id, id, path_name), |ui| {
ui.add_enabled_ui(!locked, |ui| {
if mode == "shape" {
label(ui, tl!("Fill:"));
if let Some(c) = crate::shape_paint_ui::swatch(app, ui, fill.as_ref(), fill_color(app), tl!("Fill color")) {
discrete_paint = c.discrete;
change = Some(Change::Fill(c.paint));
}
label(ui, tl!("Stroke:"));
if let Some(c) = crate::shape_paint_ui::swatch(app, ui, paint.as_ref(), stroke_color(app), tl!("Stroke color")) {
discrete_paint = c.discrete;
change = Some(Change::Stroke(c.paint));
}
let max_width = 288.0_f32.max(width);
if crate::widgets::value_field(ui, &mut width, 0.0..=max_width, "px", 58.0).changed() && ui.is_enabled() {
change = Some(Change::Width(width));
}
ui.add_enabled_ui(true, |ui| {
let closed = sh.is_none_or(|sh| sh.path.subpaths.iter().all(|s| s.closed));
let corners = sh.map_or(tool != Tool::EllipseShape, |sh| !matches!(sh.live, Some(LiveShape::Ellipse { .. })));
if let Some(next) = crate::shape_stroke_ui::button(
ui,
&format!("shape-toolbar-{doc_id:?}-{id:?}-stroke"),
&style,
&mut app.ui.stroke_editor,
closed,
corners,
doc_id.zip(id),
width > 0.0,
) {
stroke_key = Some(next.coalesce);
change = Some(Change::Style(next.options));
}
});
crate::widgets::vline(ui, 22.0);
}
if mode != "pixels" {
let rect = sh.and_then(bounds);
let mut linked = app.ui.tool_options.shape_link_dimensions;
ui.add_enabled_ui(rect.is_some_and(|r| r[2] > 0.0 && r[3] > 0.0), |ui| {
if crate::icons::button(ui, "link", 24.0, linked, tl!("Link width and height")).clicked() {
linked = !linked;
}
});
app.ui.tool_options.shape_link_dimensions = linked;
for height in [false, true] {
let mut value = rect.map_or(0.0, |r| if height { r[3] } else { r[2] }) as f32;
ui.add_enabled_ui(!position_locked && value.is_finite() && value > 0.0, |ui| {
if dimension(ui, height, &mut value) {
change = Some(Change::Size { height, value, linked });
}
});
}
}
match tool {
Tool::Rectangle | Tool::Triangle | Tool::Polygon | Tool::PathSelection | Tool::DirectSelection => {
let live_radius = sh.and_then(|sh| match sh.live {
Some(LiveShape::Rect { radii, .. }) if tool == Tool::Rectangle || selecting => Some(radii[0] as f32),
Some(LiveShape::Polygon { corner_radius, .. }) if !selecting && tool != Tool::Rectangle => Some(corner_radius as f32),
_ => None,
});
ui.add_enabled_ui(sh.is_none() || live_radius.is_some(), |ui| {
label(ui, tl!("Radius:"));
let mut radius = live_radius.unwrap_or(app.ui.tool_options.corner_radius);
let max_radius = 10000.0_f32.max(radius);
if crate::widgets::value_field(ui, &mut radius, 0.0..=max_radius, "px", 62.0).changed() && ui.is_enabled() {
change = Some(Change::Radius(radius));
}
});
}
_ => {}
}
match tool {
Tool::Polygon => {
let live_sides = sh.and_then(|sh| match sh.live {
Some(LiveShape::Polygon { sides, .. }) => Some(sides),
_ => None,
});
ui.add_enabled_ui(sh.is_none() || live_sides.is_some(), |ui| {
label(ui, tl!("Sides:"));
let mut sides = live_sides.unwrap_or(app.ui.tool_options.polygon_sides) as f32;
if crate::widgets::value_field(ui, &mut sides, 3.0..=100.0, "", 48.0).changed() && ui.is_enabled() {
change = Some(Change::Sides(sides.round().clamp(3.0, 100.0) as u32));
}
let (ratio, smooth) = sh
.and_then(|sh| match sh.live {
Some(LiveShape::Polygon { star_ratio, smooth_indents, .. }) => Some((star_ratio, smooth_indents)),
_ => None,
})
.unwrap_or((app.ui.tool_options.shape.star_ratio, app.ui.tool_options.shape.smooth_indents));
let mut percent = (ratio * 100.0) as f32;
label(ui, tl!("Star Ratio:"));
if crate::widgets::value_field(ui, &mut percent, 1.0..=100.0, "%", 58.0).changed() && ui.is_enabled() {
change = Some(Change::StarRatio(f64::from(percent) / 100.0));
}
let mut smooth = smooth;
if ui.checkbox(&mut smooth, tl!("Smooth Indents")).changed() {
change = Some(Change::SmoothIndents(smooth));
}
});
}
Tool::Line => {
let live_weight = sh.and_then(|sh| match sh.live {
Some(LiveShape::Line { weight, .. }) => Some(weight as f32),
_ => None,
});
ui.add_enabled_ui(sh.is_none() || live_weight.is_some(), |ui| {
label(ui, tl!("Weight:"));
let mut weight = live_weight.unwrap_or(app.ui.tool_options.line_weight);
let (min_weight, max_weight) = (1.0_f32.min(weight), 1000.0_f32.max(weight));
if crate::widgets::value_field(ui, &mut weight, min_weight..=max_weight, "px", 58.0).changed() && ui.is_enabled() {
change = Some(Change::Weight(weight));
}
});
}
Tool::CustomShape => crate::preset_panels::shape_picker(app, ui),
_ => {}
}
});
});
let key = stroke_key.or(if discrete_paint { None } else { gesture_key });
if let Some(change) = change
&& let Err(error) = apply(app, id, change, key)
{
app.ui.status = error;
app.ui.status_error = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::{Harness, kittest::Queryable};
fn app(depth: u32) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200, "depth": depth})).unwrap();
app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap();
app.ui.tool = Tool::Rectangle;
app
}
fn rectangle(app: &mut PhotocraftApp) -> LayerId {
LayerId(app.run("shape.create", json!({"kind": "rect", "rect": [10, 20, 100, 50], "fill": "#aa2233"})).unwrap()["layer"].as_u64().unwrap())
}
fn shape(app: &PhotocraftApp, id: LayerId) -> &ShapeLayer {
match &app.session.active().unwrap().doc.layer(id).unwrap().content {
LayerContent::Shape(sh) => sh,
_ => panic!("expected a shape layer"),
}
}
#[test]
fn colors_edit_the_active_shape_and_new_defaults_without_changing_tool_colors() {
for depth in [8, 16, 32] {
let mut app = app(depth);
let id = rectangle(&mut app);
let original = shape(&app, id).fill.clone();
let colors = (app.session.tools.foreground, app.session.tools.background);
apply(&mut app, Some(id), Change::Fill(Some(solid([34.0 / 255.0, 102.0 / 255.0, 170.0 / 255.0, 1.0]))), None).unwrap();
let fill = shape(&app, id).fill.clone();
assert_ne!(fill, original);
assert!(shape(&app, id).stroke.is_none());
app.run("edit.undo", json!({})).unwrap();
assert_eq!(shape(&app, id).fill, original);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(shape(&app, id).fill, fill);
apply(&mut app, Some(id), Change::Stroke(Some(solid([68.0 / 255.0, 170.0 / 255.0, 102.0 / 255.0, 1.0]))), None).unwrap();
let stroke = shape(&app, id).stroke.clone();
assert_eq!(shape(&app, id).fill, fill);
assert_eq!(stroke.as_ref().unwrap().width, 3.0);
let created = crate::vector_ui::create_shape(&mut app, json!({"kind": "ellipse", "rect": [120, 20, 70, 50]})).unwrap();
let next = shape(&app, LayerId(created["layer"].as_u64().unwrap()));
assert_eq!(next.fill, fill);
assert_eq!(next.stroke, stroke);
assert_eq!((app.session.tools.foreground, app.session.tools.background), colors);
}
}
#[test]
fn dimensions_keep_live_geometry_and_each_drag_has_one_undo_step() {
let mut app = app(16);
let id = rectangle(&mut app);
app.run("shape.edit", json!({"layer": id.0, "radii": [1, 2, 3, 4]})).unwrap();
for value in [110.0, 120.0] {
apply(&mut app, Some(id), Change::Size { height: false, value, linked: true }, Some("drag-a".into())).unwrap();
}
assert_eq!(bounds(shape(&app, id)), Some([10.0, 20.0, 120.0, 60.0]));
let Some(LiveShape::Rect { radii, .. }) = shape(&app, id).live else { panic!("live rectangle") };
assert_eq!(radii, [1.0, 2.0, 3.0, 4.0]);
apply(&mut app, Some(id), Change::Size { height: true, value: 80.0, linked: true }, Some("drag-b".into())).unwrap();
assert_eq!(bounds(shape(&app, id)), Some([10.0, 20.0, 160.0, 80.0]));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(bounds(shape(&app, id)), Some([10.0, 20.0, 120.0, 60.0]));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(bounds(shape(&app, id)), Some([10.0, 20.0, 100.0, 50.0]));
app.run("edit.redo", json!({})).unwrap();
assert_eq!(bounds(shape(&app, id)), Some([10.0, 20.0, 120.0, 60.0]));
}
#[test]
fn custom_paths_and_lines_resize_without_replacing_their_geometry() {
let mut app = app(8);
let id = rectangle(&mut app);
let path = photocraft_engine::vector_cmds::path_json(&shape(&app, id).path);
app.run("shape.edit", json!({"layer": id.0, "path": path})).unwrap();
assert!(shape(&app, id).live.is_none());
apply(&mut app, Some(id), Change::Size { height: false, value: 200.0, linked: false }, None).unwrap();
assert_eq!(bounds(shape(&app, id)), Some([10.0, 20.0, 200.0, 50.0]));
assert!(shape(&app, id).live.is_none());
let id = LayerId(app.run("shape.create", json!({"kind": "line", "from": [10, 20], "to": [110, 20], "weight": 4})).unwrap()["layer"].as_u64().unwrap());
apply(&mut app, Some(id), Change::Size { height: false, value: 150.0, linked: false }, None).unwrap();
assert_eq!(shape(&app, id).live, Some(LiveShape::Line { from: [10.0, 20.0], to: [160.0, 20.0], weight: 4.0, arrowheads: None }));
assert!(apply(&mut app, Some(id), Change::Size { height: true, value: 50.0, linked: false }, None).is_err());
}
#[test]
fn edits_respect_locks_mask_targets_and_invalid_dimensions() {
let mut app = app(32);
let id = rectangle(&mut app);
let before = shape(&app, id).clone();
let defaults = app.ui.tool_options.shape_fill_color;
app.run("layer.setProps", json!({"layer": id.0, "locks": {"pixels": true}})).unwrap();
assert!(apply(&mut app, Some(id), Change::Fill(Some(solid([1.0; 4]))), None).is_err());
assert_eq!(shape(&app, id), &before);
assert_eq!(app.ui.tool_options.shape_fill_color, defaults);
app.run("layer.setProps", json!({"layer": id.0, "locks": {"pixels": false, "position": true}})).unwrap();
assert!(apply(&mut app, Some(id), Change::Size { height: false, value: 120.0, linked: false }, None).is_err());
app.run("layer.setProps", json!({"layer": id.0, "locks": {"position": false}})).unwrap();
for value in [0.0, -1.0, f32::NAN, f32::INFINITY, 300001.0] {
assert!(apply(&mut app, Some(id), Change::Size { height: false, value, linked: true }, None).is_err());
assert_eq!(shape(&app, id), &before);
}
app.ui.mask_target = true;
assert!(target(&app).is_none());
app.ui.mask_target = false;
app.ui.vector_mask_target = true;
assert!(target(&app).is_none());
}
#[test]
fn implicit_mask_target_does_not_edit_or_seed_the_selected_shape() {
let mut app = app(8);
rectangle(&mut app);
app.session.execute("layer.layerMask.revealAll", json!({})).unwrap();
app.ui.mask_target = false;
app.session.active_mut().unwrap().channel_view.color_hidden = vec![true; 3];
assert!(app.layer_mask_targeted());
assert!(target(&app).is_none());
assert!(drawing_live(&app).is_none());
app.session.active_mut().unwrap().channel_view.color_hidden.clear();
assert!(target(&app).is_some());
assert!(drawing_live(&app).is_some());
}
#[test]
fn stroke_width_and_style_preserve_paint_and_defaults_enable_a_new_stroke() {
let mut app = app(8);
let id = rectangle(&mut app);
app.ui.tool_options.shape_stroke.dashes = vec![3.0, 2.0];
apply(&mut app, Some(id), Change::Width(5.0), None).unwrap();
let stroke = shape(&app, id).stroke.clone().unwrap();
assert_eq!(stroke.paint, solid(app.session.tools.background));
assert_eq!(stroke.dashes, [3.0, 2.0]);
let mut style = StrokeOptions::from(&stroke);
style.dashes = vec![1.0, 4.0];
apply(&mut app, Some(id), Change::Style(style), None).unwrap();
assert_eq!(shape(&app, id).stroke.as_ref().unwrap().paint, stroke.paint);
assert_eq!(shape(&app, id).stroke.as_ref().unwrap().width, stroke.width);
}
fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 600.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
crate::i18n::with_language(crate::i18n::lang_from_tag("en").unwrap(), || {
ui.horizontal(|ui| crate::vector_ui::options_bar(app, ui, app.ui.tool));
});
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(4);
h
}
#[test]
fn toolbar_fill_and_stroke_buttons_edit_the_selected_shape() {
let mut app = app(8);
let id = rectangle(&mut app);
app.run("shape.edit", json!({"layer": id.0, "stroke": {"color": "#114488", "width": 4}})).unwrap();
let original = shape(&app, id).clone();
let mut h = harness(app);
h.get_by_label("Fill color").click();
h.run_steps(3);
h.get_by_label("No Color").click();
h.run_steps(3);
assert!(shape(h.state(), id).fill.is_none());
assert_eq!(shape(h.state(), id).stroke, original.stroke);
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(3);
h.get_by_label("Stroke color").click();
h.run_steps(3);
h.get_by_label("No Color").click();
h.run_steps(3);
assert!(shape(h.state(), id).stroke.is_none());
assert_eq!(shape(h.state(), id).fill, original.fill);
}
#[test]
fn dragging_width_label_updates_the_shape_and_undoes_in_one_step() {
let mut app = app(8);
let id = rectangle(&mut app);
app.ui.tool_options.shape_link_dimensions = true;
let mut h = harness(app);
let label = h.query_all(egui_kittest::kittest::by().label("W:").include_labels()).next().unwrap().rect().center();
h.hover_at(label);
h.step();
h.event(egui::Event::PointerButton { pos: label, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.step();
for x in [10.0, 20.0, 40.0] {
h.event(egui::Event::PointerMoved(label + egui::vec2(x, 0.0)));
h.step();
}
h.event(egui::Event::PointerButton {
pos: label + egui::vec2(40.0, 0.0),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(3);
assert_eq!(bounds(shape(h.state(), id)), Some([10.0, 20.0, 120.0, 60.0]));
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(bounds(shape(h.state(), id)), Some([10.0, 20.0, 100.0, 50.0]));
}
#[test]
fn drawing_the_toolbar_does_not_clamp_large_strokes_or_zero_length_line_axes() {
for params in [
json!({"kind": "rect", "rect": [10, 20, 20, 10], "stroke": {"width": 500, "color": "#114488"}}),
json!({"kind": "line", "from": [10, 20], "to": [110, 20], "weight": 4}),
] {
let mut app = app(8);
let is_line = params["kind"] == "line";
let id = LayerId(app.run("shape.create", params).unwrap()["layer"].as_u64().unwrap());
if is_line {
app.ui.tool = Tool::Line;
}
let before = shape(&app, id).clone();
let revision = app.session.active().unwrap().revision;
let mut h = harness(app);
h.run_steps(6);
assert_eq!(shape(h.state(), id), &before);
assert_eq!(h.state().session.active().unwrap().revision, revision);
assert!(!h.state().ui.status_error);
}
}
#[test]
fn old_tool_settings_keep_their_color_behavior() {
let mut saved = serde_json::to_value(crate::state::ToolOptions::default()).unwrap();
for key in ["shape_fill_color", "shape_stroke_color", "shape_link_dimensions"] {
saved.as_object_mut().unwrap().remove(key);
}
let old: crate::state::ToolOptions = serde_json::from_value(saved).unwrap();
assert!(old.shape_fill_color.is_none() && old.shape_stroke_color.is_none());
assert!(!old.shape_link_dimensions);
}
#[test]
fn star_toolbar_edits_live_geometry_without_changing_paint_and_undoes() {
let mut a = app(16);
a.ui.tool = Tool::Polygon;
let id = LayerId(
a.run("shape.create", json!({"kind":"polygon","rect":[20,20,100,100],"sides":5,"starRatio":0.5,"fill":"#aa2233"})).unwrap()["layer"]
.as_u64()
.unwrap(),
);
let original = shape(&a, id).clone();
apply(&mut a, Some(id), Change::StarRatio(0.4), None).unwrap();
assert!(matches!(shape(&a, id).live, Some(LiveShape::Polygon { star_ratio: 0.4, .. })));
let sharp = shape(&a, id).path.clone();
let mut h = harness(a);
h.get_by_label("Smooth Indents").click();
h.run_steps(3);
assert!(matches!(shape(h.state(), id).live, Some(LiveShape::Polygon { smooth_indents: true, .. })));
assert_ne!(shape(h.state(), id).path, sharp);
assert_eq!(shape(h.state(), id).fill, original.fill);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(shape(h.state(), id).path, sharp);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(shape(h.state(), id), &original);
}
}

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