mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-06 18:56:53 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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", ¶ms, "--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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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", ¶ms).unwrap_err();
|
||||
assert!(error.to_string().contains("ambient filesystem paths"), "{error}");
|
||||
assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).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", ¶ms).is_ok());
|
||||
assert!(authorize_desktop_engine_command("edit.pasteVector", ¶ms).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, ¶ms).is_ok(), "{id}");
|
||||
assert!(authorize_desktop_engine_command(id, ¶ms).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, ¶ms).is_ok());
|
||||
assert!(authorize_desktop_engine_command(spec.id, ¶ms).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", ¶ms).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);
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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]> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(_))));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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!({}));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms, |_, _| {
|
||||
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
@@ -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
|
||||
),
|
||||
|
||||
@@ -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, ¶ms).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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ©_xf, doc.bounds(), rotated, checkerboard, tex);
|
||||
if shape_proxy {
|
||||
paint_shape_proxy(app, idx, &ctx, &painter, ©_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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
@@ -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 %");
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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);
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
if !clip_read && let Some(result) = vector_paste_ui::intercept(self, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
if let Some(result) = smart_svg_ui::intercept(self, id, ¶ms) {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
if let Some(result) = crate::smart_svg_ui::intercept(app, id, ¶ms) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user