Files
dotCooCoo 88bd4812e0 Part of #1429: path, Pathfinder and live-object commands add and remove many objects in one pass
Offset Path, Outline Stroke, the Pathfinder panel, Clean Up, Make
Blend, Rasterize and the other commands that #1429's table marks 3,
except Split Into Grid, added, removed or replaced their objects one at
a time, and so did Remove Anchor Points, Redefine Symbol and the
removal of Shaper groups whose source art is gone. Each insertion or
removal moved the later objects of the layer or group. The lookups by
id that followed walked the tree, and every fourth walk rebuilt the
lookup cache. On 25,000 rectangles a release build of vectorcraft-cli
took 29.1 s for Offset Path.

These commands now make their edits with Document::wrap,
Document::insert_beside and Document::remove_many, in one pass over the
children of each layer or group, and Outline Stroke and Expand write
each new object into the place of the object it replaces. A list that
holds an object twice, or an object and another inside it, and that was
edited without an error before, is still edited one object at a time.
The documents, selections and undo steps are the same. On the same
rectangles Offset Path now takes 1.3 s.

Docs: docs/target-app-parity.md.
2026-10-13 05:21:31 +07:00

361 lines
19 KiB
Rust

//! Opacity-mask editing and the Transparency panel's command reach (M3.25): commands act on the
//! masked object while its mask is edited, accept ids without a selection, take opacity in percent,
//! report mixed values, and saves and exports never carry the editing layer.
use serde_json::{Value, json};
use super::*;
use crate::cmd::maskedit::MASK_EDIT_LAYER;
use crate::tests::{history, select_with_a_step_to_redo};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, w: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": w, "height": 200})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
/// An object (0..200) masked by a white rectangle (0..100), with the mask being edited.
fn editing_mask(s: &mut Session) -> NodeId {
let obj = rect(s, 0.0, 200.0);
let m = rect(s, 0.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m.0]})).unwrap();
s.execute("select.set", &json!({"ids": [obj.0, m.0]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
obj
}
fn doc(s: &Session) -> &Document {
&s.doc().unwrap().doc
}
fn mask(s: &Session, id: NodeId) -> Option<&vectorcraft_doc::OpacityMask> {
doc(s).node(id).and_then(|n| n.extras().mask.as_deref())
}
fn has_edit_layer(d: &Document) -> bool {
d.layers.iter().any(|l| l.name.as_deref() == Some(MASK_EDIT_LAYER))
}
fn data(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
#[test]
fn mask_options_act_on_the_masked_object_while_editing() {
let mut s = session();
let obj = editing_mask(&mut s);
// The selection is the mask art; the panel's commands still reach the object's mask.
assert!(!s.doc().unwrap().selection.contains(obj));
s.execute("transparency.setOpacityMask", &json!({"clip": false, "invert": true})).unwrap();
s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap();
s.execute("transparency.disableOpacityMask", &json!({})).unwrap();
let m = mask(&s, obj).unwrap();
assert!(!m.clip && m.invert && !m.linked && m.disabled);
assert!(doc(&s).mask_edit.is_some(), "still editing");
let info = s.execute("transparency.opacityMaskInfo", &json!({})).unwrap();
assert_eq!(info[0]["id"], obj.0);
// Opacity and blend go to the masked object too, not to the mask art.
s.execute("transparency.set", &json!({"opacity": 40, "blend": "Multiply"})).unwrap();
let n = doc(&s).node(obj).unwrap();
assert!((n.opacity - 0.4).abs() < 1e-6 && n.blend == vectorcraft_color::BlendMode::Multiply);
let info = s.execute("transparency.info", &json!({})).unwrap();
assert_eq!((info["ids"].clone(), info["opacity"].clone(), info["editingMask"].clone()), (json!([obj.0]), json!(40.0), json!(obj.0)));
}
/// Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options with the flags the masks
/// already have record no undo step, on the selection, with ids and while the mask is being edited:
/// the step to redo stays, the document stays unmodified and the journal still has the command. A
/// call that changes a flag on one of the masks is one undo step. A target without a mask and a
/// call that gives no flag are still errors.
#[test]
fn mask_options_already_set_record_no_undo_step() {
let mut s = session();
let masked = |s: &mut Session, x: f64| {
let (obj, m) = (rect(s, x, 100.0), rect(s, x, 50.0));
s.execute("select.set", &json!({"ids": [obj.0, m.0]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
obj
};
// `a`'s mask is as made (clipping, not inverted, enabled, linked); `b`'s has each flag the other
// way.
let (a, b) = (masked(&mut s, 0.0), masked(&mut s, 150.0));
s.execute("transparency.setOpacityMask", &json!({"id": b.0, "clip": false, "invert": true, "disabled": true, "linked": false})).unwrap();
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
let calls = [
("transparency.enableOpacityMask", json!({})),
("transparency.linkOpacityMask", json!({})),
("transparency.setOpacityMask", json!({"clip": true, "invert": false, "disabled": false, "linked": true})),
("transparency.disableOpacityMask", json!({"id": b.0})),
("transparency.unlinkOpacityMask", json!({"ids": [b.0]})),
("transparency.setOpacityMask", json!({"ids": [b.0], "clip": false, "invert": true})),
];
for (id, p) in calls {
assert_eq!(s.execute(id, &p).unwrap(), Value::Null, "{id} {p}");
assert_eq!(history(&s), before, "{id} {p}");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
// While `a`'s mask is edited, the selection is its mask art and the commands act on `a`.
s.execute("transparency.editOpacityMask", &json!({"id": a.0})).unwrap();
let art = s.doc().unwrap().selection.objects.clone();
select_with_a_step_to_redo(&mut s, &art);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved while editing");
let calls = [
("transparency.enableOpacityMask", json!({})),
("transparency.linkOpacityMask", json!({})),
("transparency.setOpacityMask", json!({"clip": true, "linked": true})),
];
for (id, p) in calls {
assert_eq!(s.execute(id, &p).unwrap(), Value::Null, "{id} {p} while editing");
assert_eq!(history(&s), before, "{id} {p} while editing");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
assert!(doc(&s).mask_edit.is_some(), "still editing");
// `b`'s mask is disabled and `a`'s is not: disabling both is one undo step, and it clears Redo.
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
select_with_a_step_to_redo(&mut s, &[a, b]);
let undo = history(&s).0;
s.execute("transparency.disableOpacityMask", &json!({})).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true));
assert!(mask(&s, a).unwrap().disabled && mask(&s, b).unwrap().disabled);
let plain = rect(&mut s, 0.0, 20.0);
let err = s.execute("transparency.enableOpacityMask", &json!({"id": plain.0})).unwrap_err();
assert!(err.to_string().contains("no target object has an opacity mask"), "{err}");
assert!(s.execute("transparency.setOpacityMask", &json!({"id": a.0})).is_err(), "no flag given");
}
#[test]
fn release_while_editing_leaves_editing_first() {
let mut s = session();
let obj = editing_mask(&mut s);
// Widen the mask while editing; releasing keeps that edit.
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
s.execute("transparency.releaseOpacityMask", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none() && !has_edit_layer(&st.doc));
assert!(mask(&s, obj).is_none());
let layer = &st.doc.layers[0];
let kids = layer.children().unwrap();
assert_eq!(kids.len(), 2, "the object and its released mask art");
assert_eq!(kids[1].geometric_bounds().unwrap().x0, 50.0);
assert_eq!(st.active_layer, Some(layer.id));
// One undo step restores the mask and editing mode.
s.execute("edit.undo", &json!({})).unwrap();
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
}
/// Releasing the masks of a group and of an object inside it puts each mask's art directly above
/// its object, as releasing one mask at a time does.
#[test]
fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids() {
let mut s = session();
let (a, m1) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 0.0, 50.0));
s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m1.0]})).unwrap();
let b = rect(&mut s, 150.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let m2 = rect(&mut s, 0.0, 300.0);
s.execute("transparency.makeOpacityMask", &json!({"ids": [group.0, m2.0]})).unwrap();
let before = doc(&s).clone();
let n = before.peek_next_id();
s.execute("transparency.releaseOpacityMask", &json!({"ids": [group.0, a.0]})).unwrap();
let ids = |n: &vectorcraft_doc::Node| n.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(&doc(&s).layers[0]), [group, NodeId(n)]);
assert_eq!(ids(doc(&s).node(group).unwrap()), [a, NodeId(n + 1), b]);
assert!(mask(&s, group).is_none() && mask(&s, a).is_none());
assert_eq!(s.doc().unwrap().selection.objects, [group, NodeId(n), a, NodeId(n + 1)]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(*doc(&s) == before, "Undo gives back the document");
}
#[test]
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
let mut s = session();
let obj = rect(&mut s, 0.0, 200.0);
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert_eq!(r, json!({"id": obj.0, "editing": true}));
let me = doc(&s).mask_edit.unwrap();
assert_eq!(me.object, obj);
assert_eq!(s.doc().unwrap().isolation, Some(me.layer));
assert!(s.doc().unwrap().selection.is_empty());
// An empty clipping mask hides the object; drawing white in editing mode reveals it there.
let render = |s: &Session| vectorcraft_render::Renderer::new().render(doc(s), 300, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default());
assert_eq!(render(&s).pixel(50, 100)[3], 0);
let m = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m]})).unwrap();
let img = render(&s);
assert!(img.pixel(50, 100)[3] > 200);
assert_eq!(img.pixel(150, 100)[3], 0);
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0);
// Making a mask while editing one is refused.
s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap();
assert!(s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0]})).is_err());
// One undo step undoes making the empty mask and entering editing; isolation follows.
let mut s = session();
let obj = rect(&mut s, 0.0, 200.0);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
let layer = doc(&s).mask_edit.unwrap().layer;
s.execute("edit.undo", &json!({})).unwrap();
assert!(mask(&s, obj).is_none() && doc(&s).mask_edit.is_none());
assert_eq!(s.doc().unwrap().isolation, None);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((s.doc().unwrap().isolation, s.doc().unwrap().active_layer), (Some(layer), Some(layer)));
}
#[test]
fn mask_and_transparency_commands_take_ids_without_a_selection() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0));
s.execute("select.none", &json!({})).unwrap();
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [b.0, a.0], "invert": true})).unwrap();
assert_eq!(r["id"], a.0, "the top object (b) masks the one below, whatever the order given");
s.execute("select.none", &json!({})).unwrap();
s.execute("transparency.setOpacityMask", &json!({"id": a.0, "clip": false})).unwrap();
s.execute("transparency.disableOpacityMask", &json!({"ids": [a.0]})).unwrap();
let m = mask(&s, a).unwrap();
assert!(!m.clip && m.invert && m.disabled);
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 25})).unwrap();
assert!((doc(&s).node(a).unwrap().opacity - 0.25).abs() < 1e-6);
assert!(s.execute("transparency.set", &json!({"opacity": 25})).is_err(), "no selection, no ids");
let layer = s.execute("transparency.editOpacityMask", &json!({"id": a.0})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(doc(&s).mask_edit.unwrap().layer, NodeId(layer));
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap();
assert!(mask(&s, a).is_none());
let err = s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap_err();
assert!(err.to_string().contains("no target object has an opacity mask"), "{err}");
}
#[test]
fn opacity_is_a_percentage_everywhere() {
let mut s = session();
let a = rect(&mut s, 0.0, 100.0);
s.execute("appearance.setItem", &json!({"index": 0, "opacity": 1})).unwrap();
let fill_opacity = |s: &Session| match &doc(s).node(a).unwrap().appearance.items[0] {
vectorcraft_doc::AppearanceItem::Fill(f) => f.opacity,
vectorcraft_doc::AppearanceItem::Stroke(st) => st.opacity,
};
assert!((fill_opacity(&s) - 0.01).abs() < 1e-6);
s.execute("appearance.setItem", &json!({"index": 0, "opacity": 100})).unwrap();
assert_eq!(fill_opacity(&s), 1.0);
s.execute("object.setProps", &json!({"opacity": 1})).unwrap();
assert!((doc(&s).node(a).unwrap().opacity - 0.01).abs() < 1e-6);
s.execute("object.setProps", &json!({"opacity": 150})).unwrap();
assert_eq!(doc(&s).node(a).unwrap().opacity, 1.0);
s.execute("transparency.set", &json!({"opacity": 0.5})).unwrap();
assert!((doc(&s).node(a).unwrap().opacity - 0.005).abs() < 1e-6);
// Appearance items take ids without a selection too.
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute("appearance.setItem", &json!({"index": 0, "opacity": 30})).is_err(), "no selection, no ids");
s.execute("appearance.setItem", &json!({"index": 0, "ids": [a.0], "opacity": 30})).unwrap();
assert!((fill_opacity(&s) - 0.3).abs() < 1e-6);
}
#[test]
fn info_reports_mixed_values_as_null() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 120.0, 100.0));
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 50, "isolate": true})).unwrap();
s.execute("transparency.set", &json!({"ids": [a.0, b.0], "blend": "Screen"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let info = s.execute("transparency.info", &json!({})).unwrap();
assert_eq!(
info,
json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null, "pageIsolatedBlending": false, "pageKnockoutGroup": false})
);
let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap();
assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true)));
let i = s.transparency_info();
assert_eq!((i.opacity, i.blend, i.knockout), (None, Some(vectorcraft_color::BlendMode::Screen), Some(vectorcraft_doc::Knockout::Neutral)));
s.execute("select.none", &json!({})).unwrap();
assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["opacity"], Value::Null);
// Many targets are gathered in one walk of the tree.
let ids: Vec<u64> = (0..12).map(|i| rect(&mut s, i as f64 * 20.0, 10.0).0).collect();
s.execute("transparency.set", &json!({"ids": ids, "opacity": 30})).unwrap();
let many = s.execute("transparency.info", &json!({"ids": ids})).unwrap();
assert_eq!(many["opacity"], json!(30.0));
}
#[test]
fn saving_while_editing_writes_no_editing_layer() {
let mut s = session();
let obj = editing_mask(&mut s);
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
let bytes = data(&s.execute("document.save", &json!({})).unwrap());
let back = vectorcraft_format::load(&bytes).unwrap();
assert!(back.mask_edit.is_none() && !has_edit_layer(&back));
assert_eq!(back.layers.len(), 1);
// The edit made while editing is in the saved mask.
assert_eq!(back.node(obj).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0);
// Still editing in the session.
assert!(doc(&s).mask_edit.is_some() && has_edit_layer(doc(&s)));
// A file saved mid-edit by an older version (editing record and layer written) opens clean.
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(doc(&s), false)).unwrap();
let full = serde_json::to_value(doc(&s)).unwrap();
v["document"]["layers"] = full["layers"].clone();
v["document"]["mask_edit"] = json!({"object": obj.0, "layer": doc(&s).mask_edit.unwrap().layer.0});
let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
assert!(old.mask_edit.is_none() && !has_edit_layer(&old) && old.layers.len() == 1);
}
#[test]
fn exports_while_editing_leave_the_editing_layer_out() {
let mut s = session();
editing_mask(&mut s);
let svg = |s: &mut Session| s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
let pdf = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "pdf"})).unwrap());
let png = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "png"})).unwrap());
let (svg_editing, pdf_editing, png_editing) = (svg(&mut s), pdf(&mut s), png(&mut s));
assert!(!svg_editing.contains("Opacity Mask Editing") && !svg_editing.contains("Opacity_Mask_Editing"));
// Before the fix the editing layer's art was written too: the PDF would differ from the one
// the document gives without it (same length: only the creation date may differ).
let raw = crate::export_pdf(doc(&s), &Default::default()).unwrap();
assert_ne!(raw.len(), pdf_editing.len(), "the editing layer would add content");
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
assert_eq!(svg(&mut s), svg_editing);
assert_eq!(pdf(&mut s).len(), pdf_editing.len());
assert_eq!(png(&mut s), png_editing);
}
#[test]
fn undo_and_redo_walk_in_and_out_of_mask_editing() {
let mut s = session();
let obj = rect(&mut s, 0.0, 200.0);
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
let layer = doc(&s).mask_edit.unwrap().layer;
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap();
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.doc.mask_edit.map(|m| m.layer), st.isolation, st.active_layer == Some(layer), has_edit_layer(&st.doc))
};
let editing = (Some(layer), Some(layer), true, true);
assert_eq!(state(&s), (None, None, false, false));
// Back into editing with the drawn art, then before it, then before the mask.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(state(&s), editing);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(state(&s), editing);
assert!(doc(&s).node(layer).unwrap().children().unwrap().is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(state(&s), (None, None, false, false));
assert!(mask(&s, obj).is_none());
// And forward again.
s.execute("edit.redo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(state(&s), editing);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(state(&s), (None, None, false, false));
assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0);
}