Transparency: isolation mode and opacity mask editing can't be entered from each other

Enter Isolation Mode worked while an opacity mask was edited, and Edit
Opacity Mask and View Opacity Mask worked in isolation mode, so each
mode replaced the other's isolated art. In the reference app, isolation
mode can't be entered while a mask is edited, and mask editing can't be
entered in isolation mode.

object.isolate and perspective.editText, which shows type in isolation
mode, are now off while an opacity mask is edited, and a call returns
an error that ends "stop editing the opacity mask first". In isolation
mode transparency.editOpacityMask and transparency.viewOpacityMask are
off, and transparency.makeOpacityMask refuses one target, an object or
a layer, which it would take into mask editing; a call returns an error
that ends "exit isolation mode first". Make Opacity Mask with two
objects still masks one with the other. Pattern editing mode does not
count as isolation mode for these commands.

Object > Perspective > Edit Text, the canvas context menu and the
Transparency panel's menu and Make Mask button follow the commands'
enablement. A click or Alt-click on the mask thumbnail in isolation
mode shows the error and does not edit the mask. While a mask is
edited, the Contextual Task Bar leaves out Isolate, and the Properties
panel's Quick Actions leave out Isolate Group.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-29 11:20:08 +07:00
committed by bflatastic
parent e251bbf159
commit dfe201dec3
38 changed files with 296 additions and 17 deletions
+2 -2
View File
@@ -198,8 +198,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}",
has_doc,
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again; refused while an opacity mask is edited → {id}",
super::maskedit::outside_mask_editing,
edit_text
),
]
+27 -5
View File
@@ -5,7 +5,9 @@
//! object updates live; the renderer doesn't paint the editing layer (the mask is seen through its
//! effect, as in the reference app). Leaving the mode removes the layer, and saving or exporting
//! never writes it ([`Document::without_edit_modes`]). Meanwhile the Transparency panel's commands
//! act on the masked object (see `opacitymask::transparency_targets`).
//! act on the masked object (see `opacitymask::transparency_targets`). Mask editing is not entered
//! in isolation mode ([`outside_isolation_mode`]), and isolation mode is not entered while a mask is
//! edited ([`outside_mask_editing`]).
use std::sync::Arc;
@@ -27,8 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Opacity Mask",
["Window", "Transparency"],
None,
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does → {layer}",
has_doc,
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode → {layer}",
outside_isolation_mode,
enter
),
cmd!(
@@ -45,13 +47,33 @@ pub fn specs() -> Vec<CommandSpec> {
"View Opacity Mask",
["Window", "Transparency"],
None,
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing) → {on, id}",
has_doc,
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode → {on, id}",
outside_isolation_mode,
view_mask
),
]
}
/// Is the document in isolation mode? Pattern editing mode and opacity mask editing isolate their
/// art too, and do not count as isolation mode here.
pub(crate) fn in_isolation_mode(st: &crate::DocState) -> bool {
st.isolation.is_some() && st.doc.pattern_edit.is_none() && st.doc.mask_edit.is_none()
}
/// A document is open and it is not in isolation mode ([`in_isolation_mode`]): opacity mask
/// editing is not entered in isolation mode.
pub(crate) fn outside_isolation_mode(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if in_isolation_mode(st) { Err("exit isolation mode first".into()) } else { Ok(()) }
}
/// A document is open and no opacity mask is being edited: isolation mode is not entered while
/// an opacity mask is edited.
pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.doc.mask_edit.is_some() { Err("stop editing the opacity mask first".into()) } else { Ok(()) }
}
/// Mask art made of `children`: a plain group, so the mask keeps one art node. Editing spreads it
/// back out onto the editing layer.
pub(crate) fn mask_group(children: Vec<Arc<Node>>) -> Node {
+2 -2
View File
@@ -240,8 +240,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Enter Isolation Mode",
[],
None,
"{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused",
has_doc,
"{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused, and so is every object while an opacity mask is edited",
crate::cmd::maskedit::outside_mask_editing,
isolate
),
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
+20 -2
View File
@@ -24,8 +24,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Opacity Mask",
["Window", "Transparency"],
None,
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask → {id, editing}",
has_doc,
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode → {id, editing}",
can_make,
make
),
cmd!(
@@ -162,6 +162,20 @@ fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
Ok(ids)
}
/// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty
/// mask and takes into mask editing, is not in isolation mode
/// ([`super::maskedit::outside_isolation_mode`]). The selection is one object when it has one
/// subject root, which `make` masks without `ids`, and at most one outermost object, which it
/// masks with `ids` (a call's `ids` stand in for the selection here). `make` itself refuses any
/// single target in isolation mode, a layer included.
fn can_make(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let Some(st) = s.active() else { return Ok(()) };
let roots = super::appearance::subject_roots(s).map_or(0, |r| r.len());
let outermost = super::edit::outermost(&st.doc, st.selection.in_paint_order(&st.doc)).len();
if roots == 1 && outermost <= 1 { super::maskedit::outside_isolation_mode(s) } else { Ok(()) }
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "transparency.makeOpacityMask";
let d = &s.doc()?.doc;
@@ -178,6 +192,10 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
None => super::appearance::subject_roots(s)?,
};
let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) };
// One target gets an empty mask and enters mask editing, which isolation mode does not allow.
if below.is_empty() && super::maskedit::in_isolation_mode(s.doc()?) {
return Err(bad(C, "exit isolation mode first"));
}
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
// Several objects: the top one is the mask. One: an empty mask, drawn in mask editing.
+81 -1
View File
@@ -1,6 +1,7 @@
//! 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.
//! report mixed values, and saves and exports never carry the editing layer. Mask editing is not
//! entered in isolation mode, and isolation mode is not entered while a mask is edited.
use serde_json::{Value, json};
use vectorcraft_geom::Affine;
@@ -228,6 +229,85 @@ fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
assert_eq!((s.doc().unwrap().isolation, s.doc().unwrap().active_layer), (Some(layer), Some(layer)));
}
/// The command `cmd` with `p` is off, for `why`.
fn off(s: &mut Session, cmd: &str, p: Value, why: &str) {
let err = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}");
}
/// Isolation mode is not entered while an opacity mask is edited: Enter Isolation Mode is off for
/// a group in the mask art and for one outside it, and mask editing goes on. After Stop Editing
/// Opacity Mask it isolates again.
#[test]
fn isolation_mode_is_not_entered_while_an_opacity_mask_is_edited() {
let mut s = session();
let group = |s: &mut Session, x: f64| {
let (a, b) = (rect(s, x, 20.0), rect(s, x + 30.0, 20.0));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap())
};
let outside = group(&mut s, 220.0);
editing_mask(&mut s);
let layer = s.doc().unwrap().isolation;
let inside = group(&mut s, 0.0);
for id in [inside, outside] {
off(&mut s, "object.isolate", json!({"id": id.0}), "stop editing the opacity mask first");
assert_eq!(s.doc().unwrap().isolation, layer);
assert!(doc(&s).mask_edit.is_some(), "still editing");
}
s.execute("select.set", &json!({"ids": [inside.0]})).unwrap();
off(&mut s, "object.isolate", json!({}), "stop editing the opacity mask first");
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
s.execute("object.isolate", &json!({"id": outside.0})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(outside));
}
/// Opacity mask editing is not entered in isolation mode: Edit Opacity Mask and View Opacity Mask
/// are off, and so is Make Opacity Mask for one object, which would edit its new empty mask. Make
/// Opacity Mask with two objects still masks one with the other. After Exit Isolation Mode the mask
/// is edited again.
#[test]
fn opacity_mask_editing_is_not_entered_in_isolation_mode() {
let mut s = session();
let (obj, m) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0));
s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap();
let other = rect(&mut s, 220.0, 30.0);
s.execute("select.set", &json!({"ids": [obj.0, other.0]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
s.execute("select.set", &json!({"ids": [obj.0]})).unwrap();
let why = "exit isolation mode first";
off(&mut s, "transparency.editOpacityMask", json!({}), why);
off(&mut s, "transparency.editOpacityMask", json!({"id": obj.0}), why);
off(&mut s, "transparency.viewOpacityMask", json!({}), why);
off(&mut s, "transparency.viewOpacityMask", json!({"on": true, "id": obj.0}), why);
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
off(&mut s, "transparency.makeOpacityMask", json!({}), why);
off(&mut s, "transparency.makeOpacityMask", json!({"ids": [other.0]}), why);
// A layer given as the one target is refused too.
let layer = doc(&s).layers[0].id;
let err = s.execute("transparency.makeOpacityMask", &json!({"ids": [layer.0]})).unwrap_err();
assert!(err.to_string().ends_with(why), "{err}");
let st = s.doc().unwrap();
assert!(st.doc.mask_edit.is_none() && st.mask_view.is_none() && mask(&s, other).is_none());
assert_eq!(s.doc().unwrap().isolation, Some(g));
// Two objects: the top one masks the other, without mask editing.
let top = rect(&mut s, 220.0, 10.0);
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [other.0, top.0]})).unwrap();
assert_eq!(r, json!({"id": other.0, "editing": false}));
assert_eq!(s.doc().unwrap().isolation, Some(g));
// Two members of a compound path given in `ids` are two objects: one masks the other.
let (c1, c2) = (rect(&mut s, 250.0, 10.0), rect(&mut s, 265.0, 10.0));
s.execute("select.set", &json!({"ids": [c1.0, c2.0]})).unwrap();
s.execute("object.compoundPath.make", &json!({})).unwrap();
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [c1.0, c2.0]})).unwrap();
assert_eq!(r["editing"], json!(false));
assert!(doc(&s).mask_edit.is_none());
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap();
assert_eq!(doc(&s).mask_edit.map(|e| e.object), Some(obj));
}
#[test]
fn mask_and_transparency_commands_take_ids_without_a_selection() {
let mut s = session();
+17
View File
@@ -141,6 +141,23 @@ fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() {
assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err());
}
/// Edit Text, which shows the type in isolation mode, is off while an opacity mask is edited.
#[test]
fn edit_text_is_off_while_an_opacity_mask_is_edited() {
let (mut s, rect) = persp_session();
let id = persp_text(&mut s);
s.execute("transparency.makeOpacityMask", &json!({"ids": [rect.0]})).unwrap();
let layer = s.doc().unwrap().isolation;
assert!(layer.is_some(), "editing the new empty mask");
let err = s.execute("perspective.editText", &json!({"id": id.0})).unwrap_err();
assert!(matches!(&err, EngineError::Disabled(_, why) if why == "stop editing the opacity mask first"), "{err}");
assert_eq!(s.doc().unwrap().isolation, layer);
assert!(node(&s, id).projection().is_some(), "still projected");
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
s.execute("perspective.editText", &json!({"id": id.0})).unwrap();
assert_eq!(s.doc().unwrap().isolation, Some(id));
}
/// Edit Text ending while a background save writes the file leaves the document saved once the
/// file is written: the flat view is never saved. Saved while editing and edited after: undoing
/// the edit once Edit Text has ended is back at what the file holds.
+4 -1
View File
@@ -117,7 +117,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none()));
} else if is_group {
items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none()));
items.push((tl!("Isolate"), "square-dashed", "object.isolate", none()));
// Isolate is left out while Enter Isolation Mode is off, as it is while an opacity mask is edited.
if crate::menus::enabled(app, "object.isolate") {
items.push((tl!("Isolate"), "square-dashed", "object.isolate", none()));
}
} else if is_compound {
items.push(edit_path());
items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none()));
+26
View File
@@ -6352,6 +6352,32 @@ fn the_task_bar_offers_the_artboard_tools_actions() {
assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]);
}
/// A selected group's Contextual Task Bar offers Ungroup and Isolate. While an opacity mask is
/// edited, when Enter Isolation Mode is off, a group in the mask art gets Ungroup without Isolate.
#[test]
fn the_task_bar_leaves_isolate_out_while_an_opacity_mask_is_edited() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.select_tool("selection");
let ctx = egui::Context::default();
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
let rect = |app: &mut VectorcraftApp, x: f64| {
app.session.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 40, "height": 40})).unwrap()["id"].clone()
};
let group = |app: &mut VectorcraftApp, x: f64| {
let (a, b) = (rect(app, x), rect(app, x + 50.0));
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
app.session.execute("object.group", &json!({})).unwrap();
};
group(&mut app, 20.0);
assert_eq!(names(&mut app), ["Ungroup", "Isolate", "Duplicate"]);
let (obj, m) = (rect(&mut app, 200.0), rect(&mut app, 200.0));
app.session.execute("transparency.makeOpacityMask", &json!({"ids": [obj, m]})).unwrap();
app.session.execute("transparency.editOpacityMask", &json!({"id": obj})).unwrap();
group(&mut app, 200.0);
assert_eq!(names(&mut app), ["Ungroup", "Duplicate"]);
}
/// The Artboard tool's Contextual Task Bar buttons act on its selected artboards: Resize opens
/// Artboard Options for the active one, Rearrange All opens Rearrange All Artboards, Export opens
/// Export for Screens with the selected artboards checked, and Duplicate copies each selected
+1
View File
@@ -4213,6 +4213,7 @@ axis Both И двете
@msg the object already has an opacity mask обектите вече имат маска за непрозрачност
@msg the object has no geometry обектът няма геометрия
@msg the object has no opacity mask обектът няма маска за непрозрачност
@msg stop editing the opacity mask first първо спрете редактирането на маската за непрозрачност
@msg the object has no stroke обектът няма щрих
@msg the object is beyond the plane's horizon обектът е извън хоризонта на равнината
@msg the object isn't in perspective обектът не е в перспектива
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4619,6 +4619,7 @@ imageTrace Outline Obrys
@msg the object already has an opacity mask objekt už má masku krytí
@msg the object has no geometry objekt nemá žádnou geometrii
@msg the object has no opacity mask objekt nemá masku krytí
@msg stop editing the opacity mask first nejprve ukončete úpravy masky krytí
@msg the object has no stroke objekt nemá tah
@msg the object is beyond the plane's horizon objekt je za horizontem roviny
@msg the object isn't in perspective objekt není v perspektivě
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4314,6 +4314,7 @@ imageTrace Outline Kontur
@msg the object already has an opacity mask objektet har allerede en opacitetsmaske
@msg the object has no geometry objektet har ingen geometri
@msg the object has no opacity mask objektet har ingen opacitetsmaske
@msg stop editing the opacity mask first stop først redigeringen af opacitetsmasken
@msg the object has no stroke objektet har ingen streg
@msg the object is beyond the plane's horizon objektet er uden for planens horisont
@msg the object isn't in perspective objektet er ikke i perspektiv
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4242,6 +4242,7 @@ theme Appearance Erscheinungsbild
@msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske
@msg the object has no geometry das Objekt hat keine Geometrie
@msg the object has no opacity mask das Objekt hat keine Deckkraftmaske
@msg stop editing the opacity mask first beenden Sie zuerst die Bearbeitung der Deckkraftmaske
@msg the object has no stroke das Objekt hat keine Kontur
@msg the object is beyond the plane's horizon das Objekt liegt jenseits des Horizonts der Perspektivebene
@msg the object isn't in perspective das Objekt ist nicht in der Perspektive
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4275,6 +4275,7 @@ theme Appearance Εμφάνιση
@msg the object already has an opacity mask το αντικείμενο έχει ήδη μάσκα αδιαφάνειας
@msg the object has no geometry το αντικείμενο δεν έχει γεωμετρία
@msg the object has no opacity mask το αντικείμενο δεν έχει μάσκα αδιαφάνειας
@msg stop editing the opacity mask first τερματίστε πρώτα την επεξεργασία της μάσκας αδιαφάνειας
@msg the object has no stroke το αντικείμενο δεν έχει περίγραμμα
@msg the object is beyond the plane's horizon το αντικείμενο βρίσκεται πέρα από τον ορίζοντα του επιπέδου
@msg the object isn't in perspective το αντικείμενο δεν βρίσκεται σε προοπτική
Can't render this file because it contains an unexpected character in line 2070 and column 19.
+1
View File
@@ -4317,6 +4317,7 @@ axis Both Ambos
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
@msg the object has no geometry el objeto no tiene geometría
@msg the object has no opacity mask el objeto no tiene máscara de opacidad
@msg stop editing the opacity mask first detén primero la edición de la máscara de opacidad
@msg the object has no stroke el objeto no tiene trazo
@msg the object is beyond the plane's horizon el objeto está más allá del horizonte del plano
@msg the object isn't in perspective el objeto no está en perspectiva
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4526,6 +4526,7 @@ imageTrace Outline Ääriviiva
@msg the object already has an opacity mask objektilla on jo peittävyysmaski
@msg the object has no geometry objektilla ei ole geometriaa
@msg the object has no opacity mask objektilla ei ole peittävyysmaskia
@msg stop editing the opacity mask first lopeta ensin peittävyysmaskin muokkaus
@msg the object has no stroke objektilla ei ole viivaa
@msg the object is beyond the plane's horizon objekti on tason horisontin takana
@msg the object isn't in perspective objekti ei ole perspektiivissä
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4314,6 +4314,7 @@ axis Both Les deux
@msg the object already has an opacity mask l’objet a déjà un masque d’opacité
@msg the object has no geometry l’objet n’a pas de géométrie
@msg the object has no opacity mask l’objet n’a pas de masque d’opacité
@msg stop editing the opacity mask first arrêtez d’abord la modification du masque d’opacité
@msg the object has no stroke l’objet n’a pas de contour
@msg the object is beyond the plane's horizon l’objet est au-delà de l’horizon du plan
@msg the object isn't in perspective l’objet n’est pas en perspective
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5044,6 +5044,7 @@ imageTrace Outline Körvonal
@msg the object already has an opacity mask az objektumnak már van átlátszósági maszkja
@msg the object has no geometry az objektumnak nincs geometriája
@msg the object has no opacity mask az objektumnak nincs átlátszósági maszkja
@msg stop editing the opacity mask first előbb fejezd be az átlátszósági maszk szerkesztését
@msg the object has no stroke az objektumnak nincs körvonala
@msg the object is beyond the plane's horizon az objektum a sík horizontján túl van
@msg the object isn't in perspective az objektum nincs perspektívában
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4314,6 +4314,7 @@ imageTrace Outline Kontur
@msg the object already has an opacity mask objek sudah memiliki masker opasitas
@msg the object has no geometry objek tidak memiliki geometri
@msg the object has no opacity mask objek tidak memiliki masker opasitas
@msg stop editing the opacity mask first hentikan pengeditan masker opasitas terlebih dahulu
@msg the object has no stroke objek tidak memiliki goresan
@msg the object is beyond the plane's horizon objek berada di luar horizon bidang
@msg the object isn't in perspective objek tidak dalam perspektif
Can't render this file because it contains an unexpected character in line 3857 and column 19.
+1
View File
@@ -4357,6 +4357,7 @@ axis Both Entrambi
@msg the object already has an opacity mask l'oggetto ha già una maschera di opacità
@msg the object has no geometry l'oggetto non ha geometria
@msg the object has no opacity mask l'oggetto non ha una maschera di opacità
@msg stop editing the opacity mask first interrompi prima la modifica della maschera di opacità
@msg the object has no stroke l'oggetto non ha traccia
@msg the object is beyond the plane's horizon l'oggetto è oltre l'orizzonte del piano
@msg the object isn't in perspective l'oggetto non è in prospettiva
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4086,6 +4086,7 @@ swatch Bright Violet ビビッドバイオレット
@msg select a blend or give `id` ブレンドを選択するか、`id` を指定してください
@msg select a gradient mesh グラデーションメッシュを選択してください
@msg the object has no opacity mask オブジェクトに不透明マスクがありません
@msg stop editing the opacity mask first 先に不透明マスクの編集を終了してください
@msg Convert to Artboards: selection has no area アートボードに変換: 選択範囲に面積がありません
@msg Convert to Shape: no rectangles or ellipses selected シェイプに変換: 長方形または楕円形が選択されていません
@msg image would be too large; lower the resolution 画像が大きくなりすぎるため、解像度を下げてください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4078,6 +4078,7 @@ font-technology Variable ცვლადი
@msg the object already has an opacity mask ობიექტს უკვე აქვს გაუმჭვირვალობის ნიღაბი
@msg the object has no geometry ობიექტს გეომეტრია არ აქვს
@msg the object has no opacity mask ობიექტს გაუმჭვირვალობის ნიღაბი არ აქვს
@msg stop editing the opacity mask first ჯერ დაასრულეთ გაუმჭვირვალობის ნიღბის რედაქტირება
@msg the object has no stroke ობიექტს კონტური არ აქვს
@msg the object is beyond the plane's horizon ობიექტი სიბრტყის ჰორიზონტს მიღმაა
@msg the object isn't in perspective ობიექტი პერსპექტივაში არ არის
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4018,6 +4018,7 @@ theme Appearance 외관
@msg select a blend or give `id` 블렌드를 선택하거나 `id`를 지정하세요
@msg select a gradient mesh 그레이디언트 메시를 선택하세요
@msg the object has no opacity mask 개체에 불투명 마스크가 없습니다
@msg stop editing the opacity mask first 먼저 불투명 마스크 편집을 종료하세요
@msg Convert to Artboards: selection has no area 아트보드로 변환: 선택 항목에 면적이 없습니다
@msg Convert to Shape: no rectangles or ellipses selected 모양으로 변환: 선택한 사각형이나 타원이 없습니다
@msg image would be too large; lower the resolution 이미지가 너무 커집니다. 해상도를 낮추세요
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4326,6 +4326,7 @@ imageTrace Outline Contour
@msg the object already has an opacity mask het object heeft al een dekkingmasker
@msg the object has no geometry het object heeft geen geometrie
@msg the object has no opacity mask het object heeft geen dekkingmasker
@msg stop editing the opacity mask first stop eerst met het bewerken van het dekkingmasker
@msg the object has no stroke het object heeft geen lijn
@msg the object is beyond the plane's horizon het object ligt voorbij de horizon van het vlak
@msg the object isn't in perspective het object is niet in perspectief
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4446,6 +4446,7 @@ imageTrace Outline Kontur
@msg the object already has an opacity mask obiekt ma już maskę krycia
@msg the object has no geometry obiekt nie ma geometrii
@msg the object has no opacity mask obiekt nie ma maski krycia
@msg stop editing the opacity mask first najpierw zakończ edycję maski krycia
@msg the object has no stroke obiekt nie ma obrysu
@msg the object is beyond the plane's horizon obiekt jest za horyzontem płaszczyzny
@msg the object isn't in perspective obiekt nie jest w perspektywie
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3889,6 +3889,7 @@ axis Both Ambos
@msg the object already has an opacity mask o objeto já tem uma máscara de opacidade
@msg the object has no geometry o objeto não tem geometria
@msg the object has no opacity mask o objeto não tem máscara de opacidade
@msg stop editing the opacity mask first pare de editar a máscara de opacidade primeiro
@msg the object has no stroke o objeto não tem traço
@msg the object is beyond the plane's horizon o objeto está além do horizonte do plano
@msg the object isn't in perspective o objeto não está em perspectiva
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4353,6 +4353,7 @@ axis Both Оба
@msg the object already has an opacity mask у объекта уже есть непрозрачная маска
@msg the object has no geometry у объекта нет геометрии
@msg the object has no opacity mask у объекта нет непрозрачной маски
@msg stop editing the opacity mask first сначала прекратите редактирование непрозрачной маски
@msg the object has no stroke у объекта нет обводки
@msg the object is beyond the plane's horizon объект находится за горизонтом плоскости
@msg the object isn't in perspective объект не в перспективе
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4226,6 +4226,7 @@ axis Both Båda
@msg the object already has an opacity mask objektet har redan en opacitetsmask
@msg the object has no geometry objektet saknar geometri
@msg the object has no opacity mask objektet har ingen opacitetsmask
@msg stop editing the opacity mask first sluta redigera opacitetsmasken först
@msg the object has no stroke objektet har ingen kontur
@msg the object is beyond the plane's horizon objektet ligger bortom planets horisont
@msg the object isn't in perspective objektet är inte i perspektiv
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4080,6 +4080,7 @@ axis Both Her İkisi
@msg the object already has an opacity mask nesnenin zaten bir opaklık maskesi var
@msg the object has no geometry nesnenin geometrisi yoktur
@msg the object has no opacity mask nesnenin opaklık maskesi yok
@msg stop editing the opacity mask first önce opaklık maskesini düzenlemeyi durdur
@msg the object has no stroke nesnenin konturu yok
@msg the object is beyond the plane's horizon nesne uçağın ufkunun ötesinde
@msg the object isn't in perspective nesne perspektifte değil
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4341,6 +4341,7 @@ axis Both Обидві
@msg the object already has an opacity mask об’єкт вже має маску непрозорості.
@msg the object has no geometry об’єкт не має геометрії
@msg the object has no opacity mask об’єкт не має маски непрозорості
@msg stop editing the opacity mask first спочатку припиніть редагування маски непрозорості
@msg the object has no stroke об’єкт не має обведення
@msg the object is beyond the plane's horizon об’єкт знаходиться за горизонтом площини
@msg the object isn't in perspective об’єкт не в перспективі
Can't render this file because it contains an unexpected character in line 749 and column 40.
+1
View File
@@ -4701,6 +4701,7 @@ theme Appearance Giao diện
@msg the object already has an opacity mask đối tượng đã có mặt nạ độ mờ
@msg the object has no geometry đối tượng không có hình học
@msg the object has no opacity mask đối tượng không có mặt nạ độ mờ
@msg stop editing the opacity mask first hãy dừng chỉnh sửa mặt nạ độ mờ trước
@msg the object has no stroke đối tượng không có nét
@msg the object is beyond the plane's horizon đối tượng vượt qua đường chân trời của mặt phẳng
@msg the object isn't in perspective đối tượng không ở trong phối cảnh
Can't render this file because it contains an unexpected character in line 4073 and column 10.
+1
View File
@@ -3823,6 +3823,7 @@ imageTrace Outline 轮廓
@msg select a blend 请选择一个混合对象
@msg select a gradient mesh 请选择一个渐变网格
@msg the object has no opacity mask 该对象没有不透明度蒙版
@msg stop editing the opacity mask first 请先停止编辑不透明度蒙版
@msg Convert to Artboards: selection has no area 转换为画板:所选对象没有面积
@msg Convert to Shape: no rectangles or ellipses selected 转换为形状:未选择矩形或椭圆
@msg image would be too large; lower the resolution 图像将过大;请降低分辨率
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1784,6 +1784,7 @@ file-filter Templates 範本
@msg select a blend 請選取漸變物件
@msg select a gradient mesh 請選取漸層網格
@msg the object has no opacity mask 物件沒有不透明度遮色片
@msg stop editing the opacity mask first 請先停止編輯不透明度遮色片
@msg Convert to Artboards: selection has no area 轉換為工作區域:選取範圍沒有面積
@msg Convert to Shape: no rectangles or ellipses selected 轉換為形狀:未選取矩形或橢圓形
@msg image would be too large; lower the resolution 影像會過大;請降低解析度
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4 -1
View File
@@ -130,7 +130,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
if is_group {
actions.push((tl!("Ungroup"), "object.ungroup"));
actions.push((tl!("Isolate Group"), "object.isolate"));
// Isolate Group is left out while Enter Isolation Mode is off, as it is while an opacity mask is edited.
if crate::menus::enabled(app, "object.isolate") {
actions.push((tl!("Isolate Group"), "object.isolate"));
}
}
if is_live {
actions.push((tl!("Expand Shape"), "object.expandShape"));
@@ -1342,6 +1342,28 @@ fn merge_live_paint_is_a_quick_action_for_a_live_paint_group_and_more() {
assert_eq!(app.session.active().unwrap().doc.parent_of(c_id), parent, "Undo takes it out again");
}
/// A selected group has Ungroup and Isolate Group in Quick Actions. While an opacity mask is
/// edited, when Enter Isolation Mode is off, a group in the mask art has Ungroup only.
#[test]
fn isolate_group_is_not_a_quick_action_while_an_opacity_mask_is_edited() {
let mut app = new_app();
let group = |app: &mut VectorcraftApp, x: f64| {
let (a, b) = (rect(app, x, 20.0, 40.0, 40.0), rect(app, x + 50.0, 20.0, 40.0, 40.0));
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("object.group", json!({})).unwrap();
};
let ctx = egui::Context::default();
group(&mut app, 20.0);
let texts = tall(&mut app, &ctx, vec![]);
assert!(shows(&texts, "Ungroup") && shows(&texts, "Isolate Group"), "{texts:?}");
let (obj, m) = (rect(&mut app, 200.0, 100.0, 80.0, 80.0), rect(&mut app, 200.0, 100.0, 40.0, 80.0));
app.run("transparency.makeOpacityMask", json!({"ids": [obj, m]})).unwrap();
app.run("transparency.editOpacityMask", json!({"id": obj})).unwrap();
group(&mut app, 200.0);
let texts = tall(&mut app, &ctx, vec![]);
assert!(shows(&texts, "Ungroup") && !shows(&texts, "Isolate Group"), "{texts:?}");
}
/// A stroke with a variable width shows its Width Profile in the Appearance section, as every
/// stroke does with the Width tool; choosing another profile is one undo step. A uniform stroke
/// shows none with other tools.
+56 -1
View File
@@ -1,5 +1,6 @@
//! Headless frames of the Transparency panel's mask thumbnail (M3.86): Alt-click views only the
//! mask (and edits it), Alt-click again shows the artwork.
//! mask (and edits it), Alt-click again shows the artwork. In isolation mode neither a click nor an
//! Alt-click edits the mask.
use serde_json::json;
use vectorcraft_engine::Session;
@@ -45,3 +46,57 @@ fn alt_clicking_the_mask_thumbnail_views_only_the_mask() {
click_mask(&ctx, &mut app, egui::Modifiers::ALT);
assert_eq!(viewed(&app), Some(a));
}
/// Where the panel painted `label`, in a frame of it.
fn label_at(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| transparency::show(app, ui));
out.textures_delta.clear();
let at = out.shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text() == label => Some(t.visual_bounding_rect().center()),
_ => None,
});
at.unwrap_or_else(|| panic!("the panel draws {label:?}"))
}
/// In isolation mode a click or an Alt-click on the mask thumbnail does not edit the mask, and the
/// status bar says to exit isolation mode first. Make Mask is off for one object, which it would
/// take into mask editing. After Exit Isolation Mode a click edits the mask.
#[test]
fn the_mask_thumbnail_does_not_edit_the_mask_in_isolation_mode() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
let b = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, b]}));
run(&mut app, "transparency.makeOpacityMask", json!({}));
let c = run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, c]}));
let g = run(&mut app, "object.group", json!({}))["id"].as_u64().unwrap();
run(&mut app, "object.isolate", json!({"id": g}));
run(&mut app, "select.set", json!({"ids": [a]}));
let ctx = egui::Context::default();
for modifiers in [egui::Modifiers::NONE, egui::Modifiers::ALT] {
app.ui.status.clear();
click_mask(&ctx, &mut app, modifiers);
let st = app.session.active().unwrap();
assert!(st.doc.mask_edit.is_none() && st.shown_mask().is_none(), "{modifiers:?}");
assert_eq!(st.isolation.map(|i| i.0), Some(g));
assert!(app.ui.status.ends_with("exit isolation mode first"), "{modifiers:?}: {}", app.ui.status);
}
// One object without a mask: Make Mask is off, and a click on it does nothing.
run(&mut app, "select.set", json!({"ids": [c]}));
app.ui.status.clear();
frame(&ctx, &mut app, vec![], egui::Modifiers::NONE);
let at = label_at(&ctx, &mut app, "Make Mask");
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(at), button(true), button(false)], egui::Modifiers::NONE);
let st = app.session.active().unwrap();
let id = vectorcraft_doc::NodeId(c.as_u64().unwrap());
assert!(st.doc.node(id).unwrap().extras().mask.is_none() && st.doc.mask_edit.is_none());
assert_eq!(app.ui.status, "", "the button is off");
run(&mut app, "object.exitIsolation", json!({}));
run(&mut app, "select.set", json!({"ids": [a]}));
click_mask(&ctx, &mut app, egui::Modifiers::NONE);
assert_eq!(app.session.active().unwrap().doc.mask_edit.map(|e| e.object.0), Some(a));
}
+4 -2
View File
@@ -143,7 +143,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.vertical(|ui| {
let label = if mask.is_some() { tl!("Release") } else { tl!("Make Mask") };
let enabled = mask.is_some() || (has && editing.is_none());
// Make Mask is off in isolation mode for one object, which it would take into mask editing.
let enabled = mask.is_some() || (has && editing.is_none() && crate::menus::enabled(app, "transparency.makeOpacityMask"));
let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner;
if r.on_disabled_hover_text(tl!("Select the art (and, on top of it, the mask object)")).clicked() {
let id = if mask.is_some() { "transparency.releaseOpacityMask" } else { "transparency.makeOpacityMask" };
@@ -196,7 +197,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let editing = app.session.active().is_some_and(|d| d.doc.mask_edit.is_some());
let (_, n) = state(app, ui.ctx());
let mask = n.as_ref().and_then(|n| n.extras().mask.as_deref());
for (label, id, enabled) in mask_items(mask, n.is_some() && !editing) {
let can_make = n.is_some() && !editing && crate::menus::enabled(app, "transparency.makeOpacityMask");
for (label, id, enabled) in mask_items(mask, can_make) {
if menu_item(ui, tl!(label), enabled, false) {
app.run(id, json!({})).ok();
}
+6
View File
@@ -1262,6 +1262,12 @@ then composite against the art below it). SVG has no non-isolated groups, so the
mask is edited; called again it shows the artwork, still editing. It is view state of the open document: the canvas
(and `ui.screenshot`) shows it, exports never do, and it ends when mask editing ends.
Isolation mode and mask editing exclude each other. In isolation mode `transparency.editOpacityMask` and
`transparency.viewOpacityMask` are off, and `transparency.makeOpacityMask` refuses one target, an object or a layer,
which it would take into mask editing; a call returns an error that ends "exit isolation mode first". While a mask is edited,
`object.isolate` and `perspective.editText` are off, and a call returns an error that ends "stop editing the opacity
mask first". Pattern editing mode does not count as isolation mode here.
`view.transparencyGrid {on?}` (View → Show Transparency Grid; default: toggle) is view state of each open document,
like the mask view: the grid shows behind the artboards of the documents that turn it on, on the canvas and in
`ui.screenshot`, never in exports. It returns `{on}`, isn't undoable and doesn't mark the document changed.
+1
View File
@@ -823,6 +823,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again.
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group.
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
- **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged.
- **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.