mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 06:17:22 -06:00
shape.create and shape.edit accept the full paint model for fill and stroke: solid colours with alpha or a model colour, gradients with opacity stops, midpoints, offset, dither and align, and patterns with id, angle, link and phase. Malformed paint values are rejected before the document changes. shape.info reports the same complete paint JSON. Pattern paints resolve a document or library pattern and embed its tile in the same undo step, and shape caches render with the document's patterns. Copy/Paste Fill and Stroke keep a snapshot of the referenced pattern so pasting still works after the source document is closed, and refuse to overwrite a different pattern with the same id. Refs #2496
223 lines
12 KiB
Rust
223 lines
12 KiB
Rust
//! 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);
|
|
}
|