diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d87082784..c6a137e93 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -22,10 +22,12 @@ mod inline; pub mod intertwine; pub mod shared; mod symbol_export_type; +mod symbol_nine_slice; mod symbol_registration; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; +pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice}; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; pub mod live; @@ -1688,6 +1690,37 @@ impl Document { self.clear_symbol_export_type(from); self.set_symbol_export_type(to, kind); } + + /// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default). + pub fn symbol_nine_slice(&self, name: &str) -> Option { + self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value) + } + + /// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`. + pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option) { + let Some(g) = guides else { + let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return }; + o.remove(name); + if o.is_empty() { + self.unknown.remove(SYMBOL_NINE_SLICE); + } + return; + }; + let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({})); + if let Some(o) = m.as_object_mut() { + o.insert(name.into(), g.to_value()); + } + } + + /// Move the stored 9-slice guides when a symbol is renamed. + pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) { + if from == to { + return; + } + let g = self.symbol_nine_slice(from); + self.set_symbol_nine_slice(from, None); + self.set_symbol_nine_slice(to, g); + } } fn default_graphic_styles() -> Vec { diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs new file mode 100644 index 000000000..7de70be38 --- /dev/null +++ b/crates/doc/src/symbol_nine_slice.rs @@ -0,0 +1,57 @@ +//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3 +//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the +//! centre stretch +//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). +//! +//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left +//! and top edges and from the right and bottom edges, so they survive resizing the art. Like +//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas. +//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. + +use serde_json::{Value, json}; + +/// Document unknown key: `{ symbolName: [left, top, right, bottom] }`. +pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice"; + +/// The four 9-slice guides as fractions of the symbol's natural size. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SymbolNineSlice { + /// Left guide, from the left edge. + pub left: f64, + /// Top guide, from the top edge. + pub top: f64, + /// Right guide, from the right edge. + pub right: f64, + /// Bottom guide, from the bottom edge. + pub bottom: f64, +} + +impl Default for SymbolNineSlice { + /// The centre third of each axis is the stretchy cell. + fn default() -> Self { + let t = 1.0 / 3.0; + Self { left: t, top: t, right: t, bottom: t } + } +} + +impl SymbolNineSlice { + /// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the + /// two on each axis leave a gap between them. + pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option { + let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v); + (ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom }) + } + + pub fn to_value(self) -> Value { + json!([self.left, self.top, self.right, self.bottom]) + } + + pub fn from_value(v: &Value) -> Option { + let a = v.as_array()?; + let n = |i: usize| a.get(i).and_then(Value::as_f64); + if a.len() != 4 { + return None; + } + Self::new(n(0)?, n(1)?, n(2)?, n(3)?) + } +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 946d3fb6f..821383ec8 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_brush::{self as brush, Brush}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration}; use vectorcraft_geom::{Affine, Point, Vec2}; use super::edit::{outermost, selected_roots}; @@ -110,7 +110,7 @@ pub fn specs() -> Vec { "New Symbol…", [], None, - "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}", has_selection, symbol_new ), @@ -119,7 +119,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}", has_doc, symbol_options ), @@ -558,6 +558,33 @@ fn export_type_param(p: &Value, cmd: &str) -> Result> { } } +/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural +/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it +/// on with guides `g` (the symbol's current ones, else the centre third, when none are given). +fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { + let on = match p.get("nineSlice") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?), + }; + let guides = + match p.get("nineSliceGuides") { + None | Some(Value::Null) => None, + Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| { + bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1") + })?), + }; + Ok(match (on, guides) { + (Some(false), _) => Some(None), + (None, None) => None, + (_, Some(g)) => Some(Some(g)), + (Some(true), None) => Some(Some(current.unwrap_or_default())), + }) +} + +fn nine_slice_json(d: &Document, name: &str) -> Value { + d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value) +} + fn rename_natural_size(d: &mut Document, from: &str, to: &str) { if from == to { return; @@ -614,6 +641,7 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result { "size": [w, h], "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id(), + "nineSlice": nine_slice_json(d, &sym.name), }) }) .collect(); @@ -644,6 +672,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result { let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol")); let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); let export = export_type_param(p, C)?.unwrap_or_default(); + let nine = nine_slice_param(p, C, None)?.flatten(); // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); let id = s.edit("New Symbol", |d, sel| { @@ -652,12 +681,15 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result { set_natural_size(d, &name, Some(size)); d.set_symbol_registration(&name, reg); d.set_symbol_export_type(&name, export); + d.set_symbol_nine_slice(&name, nine); d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); let id = replace_with_instance(d, &ids, &name, place)?; sel.set([id]); Ok(id) })?; - Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() })) + Ok( + json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), + ) } fn symbol_options(s: &mut Session, p: &Value) -> Result { @@ -665,9 +697,10 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result { let name = resolve_symbol(&s.doc()?.doc, p, C)?; let export = export_type_param(p, C)?; let reg = registration_param(p, C)?; + let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if new_name.is_none() && export.is_none() && reg.is_none() { - return Err(bad(C, "pass newName, registration and/or exportType")); + if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { + return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); } if let Some(ref n) = new_name && n != &name @@ -691,6 +724,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result { rename_natural_size(d, &name, n); d.rename_symbol_registration(&name, n); d.rename_symbol_export_type(&name, n); + d.rename_symbol_nine_slice(&name, n); if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { d.unknown.insert(CURRENT_SYMBOL.into(), json!(n)); } @@ -701,6 +735,9 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result { if let Some(kind) = export { d.set_symbol_export_type(&final_name, kind); } + if let Some(g) = nine { + d.set_symbol_nine_slice(&final_name, g); + } Ok(()) })?; let d = &s.doc()?.doc; @@ -708,6 +745,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result { "name": final_name, "registration": d.symbol_registration(&final_name).0, "exportType": d.symbol_export_type(&final_name).id(), + "nineSlice": nine_slice_json(d, &final_name), })) } @@ -866,6 +904,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result { set_natural_size(d, &name, None); d.set_symbol_registration(&name, SymbolRegistration::CENTER); d.clear_symbol_export_type(&name); + d.set_symbol_nine_slice(&name, None); if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { d.unknown.remove(CURRENT_SYMBOL); } @@ -883,15 +922,17 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { let size = natural_size(d, &name); let reg = d.symbol_registration(&name); let export = d.symbol_export_type(&name); + let nine = d.symbol_nine_slice(&name); s.edit("Duplicate Symbol", |d, _| { let art = d.reid(&sym.art); d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); set_natural_size(d, &new, Some(size)); d.set_symbol_registration(&new, reg); d.set_symbol_export_type(&new, export); + d.set_symbol_nine_slice(&new, nine); Ok(()) })?; - Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() })) + Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) })) } fn symbol_replace(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests_brushsym.rs index 5ba848239..ffed493ad 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests_brushsym.rs @@ -702,3 +702,26 @@ fn symbol_registration_places_the_chosen_point_on_xy() { assert!(close(bounds(&s, p), Rect::new(300.0, 200.0, 380.0, 240.0), 1e-9), "{:?}", bounds(&s, p)); assert!(s.execute("symbol.new", &json!({"registration": 9})).is_err()); } + +/// Symbol Options › 9-slice guides: off by default, stored per symbol, follow rename/duplicate, validated. +#[test] +fn symbol_nine_slice_guides_round_trip() { + let mut s = session(); + let (_, name, _) = make_symbol(&mut s); + assert!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["nineSlice"].is_null()); + assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_NINE_SLICE).is_none()); + assert!(s.execute("symbol.options", &json!({"name": name, "nineSliceGuides": [0.6, 0.2, 0.6, 0.2]})).is_err()); + assert!(s.execute("symbol.options", &json!({"name": name, "nineSlice": "yes"})).is_err()); + let r = s.execute("symbol.options", &json!({"name": name, "nineSlice": true})).unwrap(); + assert_eq!(r["nineSlice"].as_array().unwrap().len(), 4); + s.execute("symbol.options", &json!({"name": name, "nineSliceGuides": [0.25, 0.1, 0.25, 0.1]})).unwrap(); + let copy = s.execute("symbol.duplicate", &json!({"name": name})).unwrap(); + assert_eq!(copy["nineSlice"], json!([0.25, 0.1, 0.25, 0.1])); + s.execute("symbol.options", &json!({"name": name, "newName": "Panel"})).unwrap(); + let g = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap(); + assert_eq!((g.left, g.top), (0.25, 0.1)); + assert!(s.doc().unwrap().doc.symbol_nine_slice(&name).is_none()); + s.execute("symbol.options", &json!({"name": "Panel", "nineSlice": false})).unwrap(); + s.execute("symbol.delete", &json!({"name": "Box copy"})).unwrap(); + assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_NINE_SLICE).is_none()); +} diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index c33612277..b4167c129 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -2,11 +2,12 @@ //! from the selection (`symbol.new`), or for the current symbol in the Symbols panel //! (`symbol.options`). //! -//! Fields: `name`, `exportType` (`movieClip` | `graphic`), `registration` (0..8, the 9-point grid; +//! Fields: `name`, `nineSlice` (bool) with `sliceLeft` / `sliceTop` / `sliceRight` / `sliceBottom` +//! (percent of the symbol's size), `exportType` (`movieClip` | `graphic`), `registration` (0..8, the 9-point grid; //! 4 = centre), `__edit` (true when editing an existing symbol). Help: //! . -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use super::swatch_options::{grid, label}; use super::{DialogSpec, form}; @@ -17,6 +18,26 @@ use crate::widgets; /// The dialog kind of Symbol Options. pub const KIND: &str = "symbolOptions"; +/// The 9-slice guide fields, in `[left, top, right, bottom]` order. +const SLICE_KEYS: [&str; 4] = ["sliceLeft", "sliceTop", "sliceRight", "sliceBottom"]; + +/// The 9-slice dialog fields for `guides` (percent of the symbol's size; a third each when off). +fn slice_fields(guides: Option) -> Map { + let g = guides.unwrap_or_default(); + let mut m = Map::new(); + m.insert("nineSlice".into(), json!(guides.is_some())); + for (key, v) in SLICE_KEYS.iter().zip([g.left, g.top, g.right, g.bottom]) { + m.insert((*key).to_string(), json!((v * 100.0).round())); + } + m +} + +/// The guides the dialog's fields describe, as the `nineSlice` / `nineSliceGuides` params. +fn slice_params(d: &Dialog) -> Value { + let [l, t, r, b] = SLICE_KEYS.map(|key| d.f64(key, 100.0 / 3.0) / 100.0); + if d.bool("nineSlice") { json!({ "nineSlice": true, "nineSliceGuides": [l, t, r, b] }) } else { json!({ "nineSlice": false }) } +} + /// Export Type choices: (value, label). Illustrator's default is Movie Clip. const EXPORT_TYPES: [(&str, &str); 2] = [("movieClip", "Movie Clip"), ("graphic", "Graphic")]; @@ -29,7 +50,11 @@ pub fn open(app: &mut VectorcraftApp) -> Result { return Err("nothing selected".into()); } let name = st.doc.unique_symbol_name("New Symbol"); - app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name, "exportType": "movieClip", "registration": 4, "__edit": false }))); + let mut fields = json!({ "name": name, "exportType": "movieClip", "registration": 4, "__edit": false }); + if let Some(o) = fields.as_object_mut() { + o.extend(slice_fields(None)); + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) } @@ -51,16 +76,17 @@ pub fn open_edit(app: &mut VectorcraftApp, name: Option<&str>) -> Result bool { d.fields.insert("registration".into(), json!(i)); } ui.end_row(); + label(ui, ""); + form::check(ui, d, "nineSlice", tl!("Enable Guides for 9-Slice Scaling")); + ui.end_row(); + if d.bool("nineSlice") { + for (key, text) in SLICE_KEYS.into_iter().zip([tl!("Left:"), tl!("Top:"), tl!("Right:"), tl!("Bottom:")]) { + label(ui, text); + let v = d.f64(key, 100.0 / 3.0); + if let Some(n) = widgets::plain_field(ui, ("dlg-field", key), v, "%", 0, 64.0) { + d.fields.insert(key.into(), json!(n.clamp(0.0, 100.0))); + } + ui.end_row(); + } + } }); false } @@ -100,15 +139,25 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let r = if edit { let from = d.fields.get("__from").and_then(Value::as_str).unwrap_or(name); let mut params = json!({ "name": from, "exportType": export, "registration": registration }); + merge(&mut params, slice_params(d)); if from != name { params["newName"] = json!(name); } app.run("symbol.options", params) } else { - app.run("symbol.new", json!({ "name": name, "exportType": export, "registration": registration })) + let mut params = json!({ "name": name, "exportType": export, "registration": registration }); + merge(&mut params, slice_params(d)); + app.run("symbol.new", params) }; if r.is_ok() { app.ui.dialog = None; } r } + +/// Copy the keys of the object `extra` into the object `into`. +fn merge(into: &mut Value, extra: Value) { + if let (Some(a), Value::Object(b)) = (into.as_object_mut(), extra) { + a.extend(b); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2ee2037ef..5923f939e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5050,3 +5050,4 @@ theme Appearance Външен вид Movie Clip Филмов клип @msg name is empty името е празно @msg no symbol named `{n}` няма символ с име `{n}` + Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6d09c4a5d..f3392aaca 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5068,6 +5068,7 @@ mark-style Japanese Japonské Movie Clip Filmový klip @msg name is empty název je prázdný @msg no symbol named `{n}` žádný symbol s názvem `{n}` + Enable Guides for 9-Slice Scaling Povolit vodítka pro 9-slice škálování @msg a mask has more than one art block maska má více než jeden blok kresby @msg mask art ends outside a mask dictionary kresba masky končí mimo slovník masky @msg cancelled; nothing was changed zrušeno; nic nebylo změněno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cdc2c5c8b..53e9761d4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4629,6 +4629,7 @@ effect Regular Regelmäßig Movie Clip Movieclip @msg name is empty Name ist leer @msg no symbol named `{n}` kein Symbol namens `{n}` + Enable Guides for 9-Slice Scaling Hilfslinien für 9-Slice-Skalierung aktivieren @msg a mask has more than one art block eine Maske enthält mehr als einen Grafikblock @msg mask art ends outside a mask dictionary die Maskengrafik endet außerhalb eines Maskenwörterbuchs @msg cancelled; nothing was changed abgebrochen; es wurde nichts geändert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 25f4884f0..19ae037cd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5020,6 +5020,7 @@ effect Points Σημεία Movie Clip Κλιπ ταινίας @msg name is empty το όνομα είναι κενό @msg no symbol named `{n}` κανένα σύμβολο με όνομα `{n}` + Enable Guides for 9-Slice Scaling Ενεργοποίηση οδηγών για κλιμάκωση 9-slice @msg a mask has more than one art block η μάσκα έχει περισσότερα από ένα μπλοκ γραφικών @msg mask art ends outside a mask dictionary τα γραφικά της μάσκας τελειώνουν έξω από το λεξικό της μάσκας @msg cancelled; nothing was changed ακυρώθηκε· δεν άλλαξε τίποτα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 710c81f83..e350de389 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5010,6 +5010,7 @@ mark-style Japanese Japonesas Movie Clip Clip de película @msg name is empty el nombre está vacío @msg no symbol named `{n}` no hay ningún símbolo llamado `{n}` + Enable Guides for 9-Slice Scaling Activar guías para escalado de 9 sectores @msg a mask has more than one art block una máscara tiene más de un bloque de ilustración @msg mask art ends outside a mask dictionary la ilustración de la máscara termina fuera de un diccionario de máscara @msg cancelled; nothing was changed cancelado; no se ha cambiado nada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 5b6c102d6..3838ec01f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5032,6 +5032,7 @@ mark-style Japanese Japanilainen Movie Clip Elokuvaleike @msg name is empty nimi on tyhjä @msg no symbol named `{n}` symbolia nimeltä `{n}` ei ole + Enable Guides for 9-Slice Scaling Ota käyttöön 9-slice-skaalauksen apuviivat @msg a mask has more than one art block maskissa on useampi kuin yksi grafiikkalohko @msg mask art ends outside a mask dictionary maskin grafiikka päättyy maskin sanakirjan ulkopuolelle @msg cancelled; nothing was changed peruutettu; mitään ei muutettu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 03a3539cd..099e2c273 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5067,6 +5067,7 @@ theme Appearance Apparence Movie Clip Clip @msg name is empty le nom est vide @msg no symbol named `{n}` aucun symbole nommé `{n}` + Enable Guides for 9-Slice Scaling Activer les repères pour la mise à l’échelle 9-slice @msg a mask has more than one art block un masque contient plusieurs blocs graphiques @msg mask art ends outside a mask dictionary les éléments graphiques du masque se terminent en dehors d’un dictionnaire de masque @msg cancelled; nothing was changed annulé ; rien n'a été modifié diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7469119a3..08ae8a4dd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5054,6 +5054,7 @@ imageTrace Outline Körvonal Movie Clip Mozgóklip @msg name is empty a név üres @msg no symbol named `{n}` nincs `{n}` nevű szimbólum + Enable Guides for 9-Slice Scaling Segédvonalak engedélyezése a 9-slice méretezéshez @msg a mask has more than one art block egy maszk egynél több grafikai blokkot tartalmaz @msg mask art ends outside a mask dictionary a maszk grafikája a maszk szótárán kívül ér véget @msg cancelled; nothing was changed megszakítva; semmi sem változott diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d15dc33c1..ee593b23b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4667,6 +4667,7 @@ lighting Light Luce Movie Clip Clip filmato @msg name is empty il nome è vuoto @msg no symbol named `{n}` nessun simbolo denominato `{n}` + Enable Guides for 9-Slice Scaling Abilita guide per il ridimensionamento 9-slice @msg a mask has more than one art block una maschera contiene più di un blocco grafico @msg mask art ends outside a mask dictionary la grafica della maschera termina fuori da un dizionario di maschera @msg cancelled; nothing was changed annullato; non è stato modificato nulla diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index bee25dbd3..238fc633d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4708,6 +4708,7 @@ lighting Light ライト Movie Clip ムービークリップ @msg name is empty 名前が空です @msg no symbol named `{n}` `{n}` という名前のシンボルはありません + Enable Guides for 9-Slice Scaling 9スライス拡大・縮小用ガイドを有効にする @msg a mask has more than one art block マスクに複数のアートブロックがあります @msg mask art ends outside a mask dictionary マスクのアートがマスク辞書の外で終わっています @msg cancelled; nothing was changed キャンセルしました。何も変更されていません diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3acd460c2..e185f412f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4631,6 +4631,7 @@ lighting Light 광원 Movie Clip 무비 클립 @msg name is empty 이름이 비어 있습니다 @msg no symbol named `{n}` 이름이 `{n}`인 심볼이 없습니다 + Enable Guides for 9-Slice Scaling 9-슬라이스 크기 조절용 안내선 사용 @msg a mask has more than one art block 마스크에 아트 블록이 두 개 이상 있습니다 @msg mask art ends outside a mask dictionary 마스크 아트가 마스크 사전 밖에서 끝납니다 @msg cancelled; nothing was changed 취소됨; 변경된 내용이 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9aeee8b4a..3e8c86fb8 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4914,6 +4914,7 @@ mark-style Japanese Japońskie Movie Clip Klip filmowy @msg name is empty nazwa jest pusta @msg no symbol named `{n}` brak symbolu o nazwie `{n}` + Enable Guides for 9-Slice Scaling Włącz prowadnice skalowania 9-slice @msg a mask has more than one art block maska ma więcej niż jeden blok grafiki @msg mask art ends outside a mask dictionary grafika maski kończy się poza słownikiem maski @msg cancelled; nothing was changed anulowano; nic nie zostało zmienione diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3a39b0c7c..c6f79cb2b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4985,6 +4985,7 @@ mark-style Japanese Japonesas # Symbol Options › Export Type @msg name is empty o nome está vazio @msg no symbol named `{n}` nenhum símbolo chamado `{n}` + Enable Guides for 9-Slice Scaling Ativar guias para dimensionamento 9-slice @msg a mask has more than one art block uma máscara contém mais de um bloco de arte @msg mask art ends outside a mask dictionary a arte da máscara termina fora de um dicionário de máscara @msg cancelled; nothing was changed cancelado; nada foi alterado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 711727207..16da8def5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4663,6 +4663,7 @@ lighting Light Свет Movie Clip Видеоклип @msg name is empty имя пустое @msg no symbol named `{n}` нет символа с именем `{n}` + Enable Guides for 9-Slice Scaling Включить направляющие для 9-slice масштабирования @msg a mask has more than one art block маска содержит более одного блока графики @msg mask art ends outside a mask dictionary графика маски заканчивается за пределами словаря маски @msg cancelled; nothing was changed отменено; ничего не изменено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 95a77aec4..ffb4b06b5 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5024,6 +5024,7 @@ theme Appearance Utseende Movie Clip Filmklipp @msg name is empty namnet är tomt @msg no symbol named `{n}` ingen symbol med namnet `{n}` + Enable Guides for 9-Slice Scaling Aktivera hjälplinjer för 9-slice-skalning @msg a mask has more than one art block en mask innehåller mer än ett grafikblock @msg mask art ends outside a mask dictionary maskens grafik slutar utanför en maskordlista @msg cancelled; nothing was changed avbrutet; inget ändrades diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index adcb231a0..6e862b841 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4647,6 +4647,7 @@ lighting Light Світло Movie Clip Кліп @msg name is empty ім’я порожнє @msg no symbol named `{n}` немає символу з назвою `{n}` + Enable Guides for 9-Slice Scaling Увімкнути напрямні для 9-slice масштабування @msg a mask has more than one art block маска містить більше одного блоку графіки @msg mask art ends outside a mask dictionary графіка маски закінчується за межами словника маски @msg cancelled; nothing was changed скасовано; нічого не змінено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 696b6dfe8..3a9ceb717 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4647,6 +4647,7 @@ theme Appearance Giao diện Movie Clip Clip phim @msg name is empty tên trống @msg no symbol named `{n}` không có biểu tượng nào tên `{n}` + Enable Guides for 9-Slice Scaling Bật đường dẫn cho co giãn 9-slice @msg a mask has more than one art block mặt nạ có nhiều hơn một khối đồ họa @msg mask art ends outside a mask dictionary đồ họa mặt nạ kết thúc bên ngoài từ điển mặt nạ @msg cancelled; nothing was changed đã hủy; không có gì thay đổi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d5bbc0ba0..db7e698f8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4894,6 +4894,7 @@ lighting Light 光源 Movie Clip 影片剪辑 @msg name is empty 名称为空 @msg no symbol named `{n}` 没有名为 `{n}` 的符号 + Enable Guides for 9-Slice Scaling 启用 9-slice 缩放参考线 @msg cancelled; nothing was changed 已取消;未做任何更改 @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it 保存会写入 {_1},而不是 {_2}:要写入该格式请使用 file.saveAsTemplate @msg a mask has more than one art block 蒙版包含多个图稿块 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b78d7dafd..de1e1121e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3970,3 +3970,4 @@ lighting Light 光源 Movie Clip 影片片段 @msg name is empty 名稱為空 @msg no symbol named `{n}` 沒有名為 `{n}` 的符號 + Enable Guides for 9-Slice Scaling 啟用 9-slice 縮放參考線 diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index bfd11e74d..9bda11cf1 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -293,6 +293,18 @@ mod tests { crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(symbols(&mut app), ["New Symbol", "Mark"]); assert_eq!(app.run("symbol.list", json!({})).unwrap()["symbols"][1]["exportType"], "graphic"); + // Enable Guides for 9-Slice Scaling stores the guides (percent in the dialog) and reopens as set. + assert!(app.run("symbol.list", json!({})).unwrap()["symbols"][1]["nineSlice"].is_null()); + app.run("ui.symbolOptions", json!({"edit": true, "name": "Mark"})).unwrap(); + let dialog = app.ui.dialog.as_mut().unwrap(); + dialog.fields.insert("nineSlice".into(), json!(true)); + dialog.fields.insert("sliceLeft".into(), json!(20.0)); + crate::dialogs::confirm(&mut app).unwrap(); + let nine = app.run("symbol.list", json!({})).unwrap()["symbols"][1]["nineSlice"].clone(); + assert_eq!(nine[0], json!(0.2)); + app.run("ui.symbolOptions", json!({"edit": true, "name": "Mark"})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("nineSlice")); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("sliceLeft", 0.0), 20.0); } #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 71f74a058..6276ba097 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -248,10 +248,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Symbol Options › Registration (9-point; stored and used by `symbol.place`); Symbol Options › Export Type (Graphic or Movie Clip; default Movie Clip; `symbol.new` / `symbol.options` / Symbols panel; - canvas placement unchanged; Animate / Flash handoff metadata). + canvas placement unchanged; Animate / Flash handoff metadata); Symbol Options › Enable Guides for 9-Slice + Scaling (four guides as fractions of the symbol size, `nineSlice` / `nineSliceGuides` on `symbol.new` / + `symbol.options`, stored per symbol; handoff metadata, not drawn on the canvas, #2342). - **Missing:** the Art brush's Overlap, auto-generated Pattern brush corners, new tile art in Pattern Brush Options, the Key Color eyedropper; original brush, symbol and graphic-style libraries generated in code (never - Adobe's); dynamic symbols; Start Global Edit; Symbol Options 9-slice guides. + Adobe's); dynamic symbols; Start Global Edit; drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them. - **Estimate:** 15–26 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index 37d81bfb0..a089c96ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1105,7 +1105,10 @@ These families are in the live catalog (`command_list` / `vectorcraft://commands already walks Image Trace. Short agent entry points: - Symbols: `symbol.list` (each row includes `registration` 0..8 and `exportType`: `movieClip`|`graphic`), - `symbol.new {name?, registration?, exportType?, ids?}`, `symbol.options {name?, newName?, registration?, exportType?}`, + `symbol.new {name?, registration?, exportType?, nineSlice?, nineSliceGuides?, ids?}`, + `symbol.options {name?, newName?, registration?, exportType?, nineSlice?, nineSliceGuides?}` (`nineSlice` bool turns + 9-slice guides on or off; `nineSliceGuides` is `[left, top, right, bottom]`, fractions 0..1 of the symbol size; rows + of `symbol.list` carry `nineSlice`: the guides or `null`), `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}`, `symbol.replace`, `symbol.edit`, `symbol.duplicate`, `symbol.selectInstances {name}`. - Brushes: `brush.list`, `brush.get {name}`, `brush.apply {name, ids?}`, `brush.new {type, name?, params?, ids?}`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7a36748f5..5d954e895 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -79,8 +79,9 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (other types use flat shading; one light, painter visibility); materials, several lights, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange, Snap to Glyph | 9–14 | estimated | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4db4294c3..5e3e173b8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -211,7 +211,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | Pathfinder shape results keep their source names and SVG IDs | done | #1293; Minus Front keeps the back name, Minus Back the front name; Unite/Intersect/Exclude use the backmost named input; compound output carries it once; geometry and paint unchanged | | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | -| Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); export type, static or dynamic and 9-slice settings still missing | +| Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (guides stored, not drawn on the canvas); Dynamic/Static symbol type still missing | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains |