diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0bcea57790..a1122e5a7c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -33,8 +33,8 @@ 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::{ - EditedNineSlice, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, - charge_symbol_art, + EditedNineSlice, MAX_GUIDE_FRACTION, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, + SymbolNineSlice, charge_symbol_art, }; pub use symbol_overrides::SymbolOverride; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 6949aa86fb..53f84e869c 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -10,9 +10,19 @@ //! The cells are sized along the symbol's own axes, and the instance's rotation, shear, reflection //! and position then apply to them. //! -//! 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. Stored -//! under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. +//! A guide may lie outside the art, and the instance still scales, by the slices the art falls in +//! ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), +//! Use 9-slice scaling section). VectorCraft's own rules for that: a guide past an edge of the art +//! cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an +//! axis past opposite edges the art scales as a whole along it; with both past the same edge the +//! art lies in the outer cell at the other edge and keeps its size, from that edge of the instance, +//! or shrinks to the instance's width or height when the instance is smaller than the art. +//! +//! The guides are stored as fractions 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. A fraction below 0 +//! puts a guide past its own edge, and one above 1 past the opposite edge; each lies within +//! ±[`MAX_GUIDE_FRACTION`], and the two guides of an axis keep their order. Stored under +//! [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. //! //! [`NineSliceMap`] places such an instance's art for the canvas, bounds, Break Link, Expand and //! the exporters ([`Document::place_symbol_art`]). @@ -49,11 +59,16 @@ impl Default for SymbolNineSlice { } } +/// The farthest a guide may lie from its edge, as a fraction of the symbol's width or height, in +/// either direction (VectorCraft's own rule). +pub const MAX_GUIDE_FRACTION: f64 = 16.0; + 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. + /// Guides from `[left, top, right, bottom]`; `None` unless each is finite and within + /// ±[`MAX_GUIDE_FRACTION`], and the two on each axis leave a gap between them + /// (`left + right < 1`, `top + bottom < 1`). 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); + let ok = |v: f64| v.is_finite() && (-MAX_GUIDE_FRACTION..=MAX_GUIDE_FRACTION).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 }) } @@ -102,7 +117,8 @@ pub struct EditedNineSlice { } impl EditedNineSlice { - /// Guide `g` as a line across the art's box, from one edge to the other, in the document. + /// Guide `g` as a line across the art's box, from one edge to the other, in the document. A + /// guide outside the art spans the art's height or width beside it. pub fn line(&self, g: NineSliceGuide) -> (Point, Point) { let (b, s) = (self.art, self.guides); let (a, c) = match g { @@ -126,14 +142,17 @@ impl EditedNineSlice { }) } - /// The guides with guide `g` moved across its axis to document point `p`. The guide stays - /// within the art's box, and [`MIN_CENTER_BAND`] short of the opposite guide, or where it is - /// when the guides are already closer than that. A point that is not finite leaves the guides - /// as they are. + /// The guides with guide `g` moved across its axis to document point `p`. The guide may pass + /// either edge of the art, up to [`MAX_GUIDE_FRACTION`] from its own edge, and stops + /// [`MIN_CENTER_BAND`] short of the opposite guide, or where it is when the guides are already + /// closer than that. A point that is not finite, or that gives a fraction that is not, leaves + /// the guides as they are. pub fn moved(&self, g: NineSliceGuide, p: Point) -> SymbolNineSlice { let (b, mut out) = (self.art, self.guides); let q = self.to_doc.inverse() * p; - let fit = |v: f64, other: f64, now: f64| if v.is_finite() { v.clamp(0.0, (1.0 - other - MIN_CENTER_BAND).max(now)) } else { now }; + let fit = |v: f64, other: f64, now: f64| { + if v.is_finite() { v.clamp(-MAX_GUIDE_FRACTION, (1.0 - other - MIN_CENTER_BAND).min(MAX_GUIDE_FRACTION).max(now)) } else { now } + }; match g { NineSliceGuide::Left => out.left = fit((q.x - b.x0) / b.width(), out.right, out.left), NineSliceGuide::Right => out.right = fit((b.x1 - q.x) / b.width(), out.left, out.right), @@ -181,20 +200,27 @@ const MAX_SLICE_DEPTH: u32 = 64; /// One axis of a 9-slice map: the three cells between the cuts and where each one goes. #[derive(Clone, Copy, Debug, PartialEq)] struct AxisMap { - /// The cuts in the art at its natural size: its start, the two guides, its end. + /// The cuts in the art at its natural size: its start, the two guides, its end. A guide + /// outside the art cuts it at the nearer end, so a cell can be empty. src: [f64; 4], /// Where the cuts go. The outer cells keep their length unless the scaled length is shorter - /// than both together; then both shrink in proportion and the middle cell is empty. + /// than both together; then both shrink in proportion and the middle cell is empty. An empty + /// cell's start is where the cell before it ends, so the map stays continuous. dst: [f64; 4], } impl AxisMap { - /// The axis from `lo` to `hi` with guides `near` and `far` (fractions of its length in from - /// each end), scaled by `k` (above 0). + /// The axis from `lo` to `hi` (`lo < hi`) with guides `near` and `far` (fractions of its + /// length in from each end, `near + far < 1`; either may lie past an end), scaled by `k` + /// (above 0). With both guides past the far end the art keeps its length from the scaled + /// start, and with both past the near end it keeps its length up to the scaled end; when the + /// scaled length is shorter than the art, the art shrinks to the scaled length. fn new(lo: f64, hi: f64, near: f64, far: f64, k: f64) -> Self { let len = hi - lo; - let (a, b) = (near * len, far * len); - let src = [lo, lo + a, hi - b, hi]; + let c1 = (lo + near * len).clamp(lo, hi); + let c2 = (hi - far * len).clamp(c1, hi); + let src = [lo, c1, c2, hi]; + let (a, b) = (c1 - lo, hi - c2); let (t0, t3) = (k * lo, k * hi); let room = t3 - t0; let (a, b) = if a + b <= room { @@ -203,9 +229,15 @@ impl AxisMap { let f = (room / (a + b)).max(0.0); (a * f, b * f) }; - let t1 = t0 + a; - let t2 = (t3 - b).max(t1); - Self { src, dst: [t0, t1, t2, t3] } + let dst = if c2 > c1 { + let t1 = t0 + a; + [t0, t1, (t3 - b).max(t1), t3] + } else if a > 0.0 { + [t0, t0 + a, t0 + a, t3] + } else { + [t3 - b, t3 - b, t3 - b, t3] + }; + Self { src, dst } } /// The cell `v` lies in; values outside the art belong to the outer cells. @@ -279,10 +311,11 @@ pub struct NineSliceMap { impl NineSliceMap { /// The map for an instance placed by `xf` that shows art with natural-size transform - /// `natural` and geometric bounds `art` (in symbol space). `None` when the art or the - /// transform is degenerate, or when the instance is at its natural size on both axes (then - /// `xf` places the art as it is). + /// `natural` and geometric bounds `art` (in symbol space). `None` when the guides are not ones + /// [`SymbolNineSlice::new`] accepts, when the art or the transform is degenerate, or when the + /// instance is at its natural size on both axes (then `xf` places the art as it is). pub fn new(guides: SymbolNineSlice, art: Rect, natural: Affine, xf: Affine) -> Option { + let guides = SymbolNineSlice::new(guides.left, guides.top, guides.right, guides.bottom)?; let b = natural.transform_rect_bbox(art); if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= 1e-9 || b.height() <= 1e-9 { return None; @@ -894,6 +927,113 @@ mod tests { assert!(got.iter().all(|b| finite(*b)), "{got:?}"); } + /// Guides may lie outside the art, up to 16 times its size from their edge, as long as the + /// two on each axis keep their order; values that are not finite are refused. + #[test] + fn guides_outside_the_art_are_accepted_in_order_and_within_the_limit() { + let ok = |l, t, r, b| SymbolNineSlice::new(l, t, r, b).is_some(); + assert!(ok(-0.25, 0.25, 0.25, 0.25)); + assert!(ok(1.25, 0.25, -0.5, 0.25), "both past the right edge"); + assert!(ok(0.25, -16.0, 0.25, 16.0) && ok(-16.0, 0.25, -16.0, 0.25)); + assert!(!ok(-16.5, 0.25, 0.25, 0.25) && !ok(0.25, 0.25, 0.25, 17.0)); + assert!(!ok(f64::NAN, 0.25, 0.25, 0.25) && !ok(0.25, f64::INFINITY, 0.25, 0.25) && !ok(0.25, 0.25, f64::NEG_INFINITY, 0.25)); + assert!(!ok(1.2, 0.25, -0.2, 0.25), "the left guide on the right one"); + assert!(!ok(0.25, 0.6, 0.25, 0.6), "crossed"); + let g = SymbolNineSlice::from_value(&json!([-0.25, 1.5, 0.5, -1.0])).unwrap(); + assert_eq!(g, SymbolNineSlice { left: -0.25, top: 1.5, right: 0.5, bottom: -1.0 }); + assert_eq!(SymbolNineSlice::from_value(&g.to_value()), Some(g)); + assert!(SymbolNineSlice::from_value(&json!([-0.25, 0.1, 1.5])).is_none()); + assert!(SymbolNineSlice::from_value(&json!([-20.0, 0.1, 0.5, 0.1])).is_none()); + // Guides built without the check are not used to place art. + let bad = SymbolNineSlice { left: f64::NAN, ..SymbolNineSlice::default() }; + assert!(NineSliceMap::new(bad, Rect::new(0.0, 0.0, 100.0, 100.0), Affine::IDENTITY, Affine::scale(2.0)).is_none()); + // Art without width or height has no map, whatever the guides. + let out = SymbolNineSlice::new(-0.25, 1.25, 0.25, -0.5).unwrap(); + for art in [Rect::new(0.0, 0.0, 0.0, 100.0), Rect::new(0.0, 0.0, 100.0, 0.0)] { + assert!(NineSliceMap::new(out, art, Affine::IDENTITY, Affine::scale(2.0)).is_none(), "{art:?}"); + } + } + + /// At 200 × 150, a left guide 25 % outside the art leaves the art in the center and right + /// columns: the left column holds no art, the center one (0..75) stretches to 0..175 and the + /// right one keeps its 25. Both guides outside on opposite sides scale the art as a whole on + /// that axis; both past the same side keep the art at its size in the outer cell at the other + /// side, from that side of the instance. + #[test] + fn art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it() { + let xf = Affine::scale_non_uniform(2.0, 1.5); + let d = markers(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + let got = boxes(&d, xf); + let k = 175.0 / 75.0; + assert!(near(got[0], Rect::new(0.0, 0.0, 200.0, 150.0)), "{:?}", got[0]); + assert!(near(got[1], Rect::new(0.0, 0.0, 10.0 * k, 10.0)), "the top-left square stretches: {:?}", got[1]); + assert!(near(got[2], Rect::new(190.0, 0.0, 200.0, 10.0)), "the top-right corner keeps its size: {:?}", got[2]); + assert!(near(got[5], Rect::new(45.0 * k, 65.0, 55.0 * k, 85.0)), "{:?}", got[5]); + // Both vertical guides outside, on opposite sides: the art scales as a whole across. + let d = markers(SymbolNineSlice::new(-0.25, 0.25, -1.0, 0.25)); + let got = boxes(&d, xf); + assert!(near(got[1], Rect::new(0.0, 0.0, 20.0, 10.0)) && near(got[2], Rect::new(180.0, 0.0, 200.0, 10.0)), "{got:?}"); + assert!(near(got[5], Rect::new(90.0, 65.0, 110.0, 85.0)), "{:?}", got[5]); + // Both past the right edge: the art keeps its width from the instance's left edge. + let d = markers(SymbolNineSlice::new(1.2, 0.25, -0.5, 0.25)); + let got = boxes(&d, xf); + assert!(near(got[0], Rect::new(0.0, 0.0, 100.0, 150.0)), "{:?}", got[0]); + assert!(near(got[2], Rect::new(90.0, 0.0, 100.0, 10.0)) && near(got[5], Rect::new(45.0, 65.0, 55.0, 85.0)), "{got:?}"); + // Both past the left edge: the art keeps its width up to the instance's right edge. + let d = markers(SymbolNineSlice::new(-0.5, 0.25, 1.2, 0.25)); + let got = boxes(&d, xf); + assert!(near(got[0], Rect::new(100.0, 0.0, 200.0, 150.0)), "{:?}", got[0]); + assert!(near(got[1], Rect::new(100.0, 0.0, 110.0, 10.0)), "{:?}", got[1]); + // The same rules apply to rows: both horizontal guides above the art keep its height up to + // the instance's bottom edge. + let d = markers(SymbolNineSlice::new(0.25, -0.5, 0.25, 1.2)); + let got = boxes(&d, xf); + assert!(near(got[0], Rect::new(0.0, 50.0, 200.0, 150.0)), "{:?}", got[0]); + assert!(near(got[3], Rect::new(0.0, 140.0, 10.0, 150.0)), "{:?}", got[3]); + // Smaller than the art, an art-covered outer cell shrinks in proportion. + let d = markers(SymbolNineSlice::new(1.2, 0.25, -0.5, 0.25)); + let got = boxes(&d, Affine::scale_non_uniform(0.5, 1.5)); + assert!(near(got[0], Rect::new(0.0, 0.0, 50.0, 150.0)) && near(got[1], Rect::new(0.0, 0.0, 5.0, 10.0)), "{got:?}"); + } + + /// With guides outside the art, the map stays finite, never runs backward, and does not jump + /// at the art's edges or at the guides; objects keep to the cells that hold art. + #[test] + fn the_map_with_guides_outside_the_art_is_continuous_and_monotone() { + let cases = [(-0.25, 0.25), (-0.25, -1.0), (1.2, -0.5), (-0.5, 1.2), (0.0, -16.0), (-16.0, 16.0), (0.25, 0.25)]; + for (l, r) in cases { + let d = markers(SymbolNineSlice::new(l, 0.25, r, 0.25)); + for sx in [0.3, 2.0, 1e6] { + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(sx, 1.5)).unwrap(); + let at = |x: f64| m.map_point(Point::new(x, 40.0)).x; + let mut last = f64::NEG_INFINITY; + for i in 0..=1200 { + let x = -10.0 + i as f64 * 0.1; + let v = at(x); + assert!(v.is_finite() && v >= last - 1e-12 * last.abs().max(1.0), "{l} {r} {sx}: {x} → {v} after {last}"); + last = v; + } + for edge in [0.0, 75.0, 100.0] { + let jump = (at(edge + 1e-9) - at(edge - 1e-9)).abs(); + assert!(jump < 1e-6 * sx.max(1.0), "{l} {r} {sx}: a jump of {jump} at {edge}"); + } + } + } + // A gradient bar across the art with the left guide outside is drawn in the two columns + // that hold art, not three. + let mut d = markers(SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + assert_eq!(m.place(&bar, &d).children().map(Vec::len), Some(2)); + assert_eq!(m.most_placed(&bar, &d), 7); + // With both guides outside on opposite sides, the bar lies in one cell and is moved whole. + let d = markers(SymbolNineSlice::new(-0.25, 0.25, -0.25, 0.25)); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + assert!(matches!(m.place(&bar, &d).kind, NodeKind::Path { .. })); + assert_eq!(m.most_placed(&bar, &d), 1); + } + /// A negative scale reflects the sized cells; a rotation turns them, the corners keeping /// their size along the symbol's own axes. #[test] @@ -1456,27 +1596,37 @@ mod tests { assert!(d.edited_nine_slice().is_none()); } - /// Moving a guide changes that guide alone, follows the pointer across its axis only, stays - /// within the art's box and stops short of the opposite guide. + /// Moving a guide changes that guide alone, follows the pointer across its axis only, may pass + /// either edge of the art up to [`MAX_GUIDE_FRACTION`], and stops short of the opposite guide. #[test] - fn a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide() { + fn a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide() { let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); let q = SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 }; assert_eq!(e.moved(NineSliceGuide::Left, Point::new(30.0, 999.0)), SymbolNineSlice { left: 0.1, ..q }); assert_eq!(e.moved(NineSliceGuide::Bottom, Point::new(-50.0, 180.0)), SymbolNineSlice { bottom: 0.2, ..q }); - assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: 0.0, ..q }); - assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: 0.0, ..q }); + // Past the art's near edges: the art spans 10..210 across and 20..220 down. + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-40.0, 50.0)), SymbolNineSlice { left: -0.25, ..q }); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: -2.6, ..q }); + assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: -MAX_GUIDE_FRACTION, ..q }); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-1e300, 0.0)), SymbolNineSlice { left: -MAX_GUIDE_FRACTION, ..q }); let crossed = e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)); assert!((crossed.left - (0.75 - MIN_CENTER_BAND)).abs() < 1e-12, "{crossed:?}"); assert!(SymbolNineSlice::new(crossed.left, crossed.top, crossed.right, crossed.bottom).is_some()); assert_eq!(e.moved(NineSliceGuide::Top, Point::new(f64::NAN, f64::NAN)), q); + assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, f64::INFINITY)), q); + // With the right guide past the art's right edge, the left one may pass that edge too. + let wide = SymbolNineSlice { right: -0.5, ..q }; + let e = editing(Some(wide), xf).edited_nine_slice().unwrap(); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(260.0, 50.0)), SymbolNineSlice { left: 1.25, ..wide }); + assert!(near_point(e.line(NineSliceGuide::Right).0, Point::new(310.0, 20.0))); + assert!(near_point(e.line(NineSliceGuide::Right).1, Point::new(310.0, 220.0))); // Saved guides closer than the minimum center band keep their place when a drag does not // move them closer. let narrow = SymbolNineSlice::new(0.00005, 0.25, 0.9999, 0.25).unwrap(); let e = editing(Some(narrow), xf).edited_nine_slice().unwrap(); assert_eq!(e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)), narrow); - assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: 0.0, ..narrow }); + assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: -0.05, ..narrow }); // A mirrored placement measures along the symbol's own axes. let e = editing(Some(SymbolNineSlice::default()), Affine::scale_non_uniform(-1.0, 1.0)).edited_nine_slice().unwrap(); assert!(near_point(e.line(NineSliceGuide::Left).0, Point::new(-25.0, 0.0))); diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 347968b2c5..50c63b59cd 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -199,7 +199,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, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; 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 quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions of the natural size in from each edge, each -16..16 (below 0 or above 1 puts a guide outside the art), with left + right < 1 and top + bottom < 1 (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}", has_selection, symbol_new ), @@ -208,7 +208,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions in from each edge, each -16..16, with left + right < 1 and top + bottom < 1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", has_doc, symbol_options ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index edd2335f6a..2fb31c5f6b 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -47,21 +47,20 @@ 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 a quarter in from each edge, when none are -/// given). +/// size, below 0 or above 1 for a guide outside the art): `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 a quarter in from each edge, 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") - })?), - }; + 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 -16..16, with left + right < 1 and top + bottom < 1") + })?), + }; Ok(match (on, guides) { (Some(false), _) => Some(None), (None, None) => None, diff --git a/crates/engine/src/tests/symbol_nine_slice.rs b/crates/engine/src/tests/symbol_nine_slice.rs index fba17f11ee..b0689d1587 100644 --- a/crates/engine/src/tests/symbol_nine_slice.rs +++ b/crates/engine/src/tests/symbol_nine_slice.rs @@ -250,3 +250,103 @@ fn a_guide_dragged_on_the_canvas_changes_how_a_scaled_instance_draws() { let left = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left; assert!((left - 0.05).abs() < 1e-9, "{left}"); } + +/// The 200 × 150 [`panel`] instance with its left guide 25 % outside the art: the left column +/// holds no art, so the corner square is in the center column, which runs 0..75 in the art and +/// 0..175 in the instance, and the center square moves with it. The rows are as before. +fn assert_left_outside(boxes: &[Rect]) { + let k = 175.0 / 75.0; + let want = [ + Rect::new(50.0, 50.0, 250.0, 200.0), + Rect::new(50.0, 50.0, 50.0 + 10.0 * k, 60.0), + Rect::new(50.0 + 45.0 * k, 115.0, 50.0 + 55.0 * k, 135.0), + ]; + assert_eq!(boxes.len(), want.len(), "{boxes:?}"); + for (b, w) in boxes.iter().zip(want) { + assert!(close(*b, w), "{b:?} vs {w:?} in {boxes:?}"); + } +} + +/// `symbol.options` takes guides outside the art, within -16..16 and in order on each axis, as +/// one undo step; a scaled instance then draws by the cells its art falls in, and Break Link to +/// Symbol gives that art. Values out of range or out of order are refused without an edit. +#[test] +fn guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art() { + let (mut s, id) = panel(); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [-0.25, 0.25, 0.25, 0.25]})).unwrap(); + assert_left_outside(&placed_boxes(&s, id)); + let list = s.execute("symbol.list", &json!({})).unwrap(); + assert_eq!(list["symbols"][0]["nineSlice"], json!([-0.25, 0.25, 0.25, 0.25])); + for bad in [json!([-16.5, 0.25, 0.25, 0.25]), json!([0.25, 0.25, 0.25, 17]), json!([1.2, 0.25, -0.2, 0.25]), json!([0.25, 0.6, 0.25, 0.6])] { + let e = s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": bad})).unwrap_err().to_string(); + assert!(e.contains("each -16..16"), "{e}"); + } + assert_eq!(s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left, -0.25, "no edit"); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [1.25, 0.25, -0.5, 0.25]})).unwrap(); + // Both vertical guides past the art's right edge: the art keeps its width from the left. + let boxes = placed_boxes(&s, id); + assert!(close(boxes[0], Rect::new(50.0, 50.0, 150.0, 200.0)) && close(boxes[1], Rect::new(50.0, 50.0, 60.0, 60.0)), "{boxes:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_left_outside(&placed_boxes(&s, id)); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + let art = NodeId(r["ids"][0].as_u64().unwrap()); + assert_left_outside(&path_boxes(s.doc().unwrap().doc.node(art).unwrap())); +} + +/// Dragging the left guide past the art's left edge on the canvas puts it outside the art, as one +/// undo step, and the scaled instance draws by the cells that hold art. +#[test] +fn a_guide_dragged_past_the_arts_edge_lies_outside_it() { + let (mut s, id) = panel(); + s.execute("symbol.edit", &json!({"name": "Panel"})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let e = s.doc().unwrap().doc.edited_nine_slice().unwrap(); + let ((a, b), (l, r)) = (e.line(NineSliceGuide::Left), (e.line(NineSliceGuide::Left).0.x, e.line(NineSliceGuide::Right).0.x)); + let y = (a.y + b.y) / 2.0; + // The guides lie a quarter in from each edge, so the art's left edge lies half the guides' + // distance to the left of the left guide, and 25 % outside it lies as far again. + let to = l - (r - l); + s.pointer(&PointerEvent::new(PointerKind::Down, l + 1.0, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to, y), v).unwrap(); + let g = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap(); + assert!((g.left + 0.25).abs() < 1e-9, "{g:?}"); + let e = s.doc().unwrap().doc.edited_nine_slice().unwrap(); + assert!((e.line(NineSliceGuide::Left).0.x - to).abs() < 1e-9, "the guide is drawn where it was dropped"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left, 0.25, "one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("symbol.exitEditing", &json!({})).unwrap(); + assert_left_outside(&placed_boxes(&s, id)); +} + +/// A native file keeps guides outside the art exactly, and an instance opened from it draws as +/// before saving; a file whose saved guides are out of range or out of order opens with 9-slice +/// off for that symbol. +#[test] +fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() { + let (mut s, id) = panel(); + let guides = [-0.123456789, 1.5, 0.3, -2.75]; + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": guides})).unwrap(); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": [-0.25, 0.25, 0.25, 0.25]})).unwrap(); + s.execute("symbol.options", &json!({"name": "Panel", "nineSliceGuides": guides})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + let g = back.symbol_nine_slice("Panel").unwrap(); + assert_eq!([g.left, g.top, g.right, g.bottom], guides); + let placed = |d: &vectorcraft_doc::Document| { + let NodeKind::SymbolInstance { symbol, xf } = &d.node(id).unwrap().kind else { panic!("an instance") }; + let art = d.symbols.iter().find(|s| &s.name == symbol).unwrap().art.clone(); + path_boxes(&d.place_symbol_art(symbol, &art, *xf).unwrap()) + }; + assert_eq!(placed(&back), placed(&doc)); + for bad in [json!([-20.0, 0.25, 0.25, 0.25]), json!([0.8, 0.25, 0.5, 0.25]), json!([0.25, 0.25, 0.25]), json!("x")] { + let mut d = (*doc).clone(); + d.unknown.insert(vectorcraft_doc::SYMBOL_NINE_SLICE.into(), json!({ "Panel": bad })); + let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); + assert!(back.symbol_nine_slice("Panel").is_none(), "{bad}"); + let NodeKind::SymbolInstance { symbol, xf } = &back.node(id).unwrap().kind else { panic!("an instance") }; + assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole"); + } +} diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index ed98d620bf..8c4e95f4e6 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -980,6 +980,34 @@ fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); } +/// A scaled instance of a symbol whose left 9-slice guide lies outside its art is written as the +/// canvas draws it: the corner square is in the center column, which runs 0..75 in the art and +/// 0..175 in the instance, so it is about 23 wide. The editing copy read alone has no guides. +#[test] +fn a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn() { + let mut doc = Document::new(400.0, 400.0); + let layer = doc.layers[0].id; + let square = |doc: &mut Document, r: Rect| { + Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::basic(solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0)) + }; + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0))); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0))); + let art = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + doc.set_symbol_nine_slice("S", vectorcraft_doc::SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + let xf = Affine::scale_non_uniform(2.0, 1.5); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + let back = read_back(&doc); + let mut all = vec![]; + paths(back.layers[0].children().unwrap(), &mut all); + let sizes: Vec<(i64, i64)> = + all.iter().filter_map(|p| p.geometric_bounds()).map(|b| (b.width().round() as i64, b.height().round() as i64)).collect(); + assert!(sizes.contains(&(23, 10)), "the corner stretches with the center column: {sizes:?}"); + assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); + assert!(back.symbol_nine_slice("S").is_none()); +} + /// Nested symbols whose instances multiply, written as their art (on a large canvas), stop at /// [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art in all, with a warning. #[test] diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index 3e58b02286..8e83b6262e 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -115,6 +115,41 @@ fn a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it() { assert_similar(&canvas, &resvg_render(&svg, 300, 200), 24.0, 0.002); } +/// A scaled instance of a symbol whose left 9-slice guide lies outside its art is written as the +/// canvas draws it: the corner square is in the stretched center column. Reading the file back +/// gives that art and no 9-slice guides, which SVG has no place for. +#[test] +fn a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn() { + let mut d = Document::new(300.0, 200.0); + let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let rects = [(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb8(220, 220, 220)), (Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK)]; + let art: Vec> = rects.iter().map(|(r, c)| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(*r), fill(*c)))).collect(); + let group = Node::group(d.alloc_id(), art); + d.symbols.push(Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(group) }); + d.set_symbol_nine_slice("Panel", vectorcraft_doc::SymbolNineSlice::new(-0.25, 0.25, 0.25, 0.25)); + instance(&mut d, "Panel", Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5)); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.matches(" 10, "the guide drawn outside the art"); app.run("symbol.exitEditing", json!({})).unwrap(); assert_eq!(dots(&mut app, Point::new(110.0, 100.0), Point::new(110.0, 200.0)), 0, "none outside the mode"); } diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index 79e26764d0..9e6fa98f82 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -4,7 +4,8 @@ //! (`symbol.options`). //! //! Fields: `name`, `nineSlice` (bool) with `sliceLeft` / `sliceTop` / `sliceRight` / `sliceBottom` -//! (percent of the symbol's size), `exportType` (`movieClip` | `graphic`), `type` (`dynamic` | +//! (percent of the symbol's size, -1600..1600: below 0 or above 100 for a guide outside the art), +//! `exportType` (`movieClip` | `graphic`), `type` (`dynamic` | //! `static`; a new symbol is dynamic), `registration` (0..8, the 9-point grid; //! 4 = centre), `__edit` (true when editing an existing symbol). Help: //! . @@ -26,20 +27,39 @@ const SLICE_KEYS: [&str; 4] = ["sliceLeft", "sliceTop", "sliceRight", "sliceBott /// A guide field's value when the dialog has none: a quarter of the symbol's size, in percent. const DEFAULT_SLICE_PERCENT: f64 = 25.0; -/// The 9-slice dialog fields for `guides` (percent of the symbol's size; a quarter each when off). +/// The field that keeps the guides the dialog opened with, as fractions, in `[left, top, right, +/// bottom]` order. +const SLICE_OPENED: &str = "__sliceOpened"; + +/// The range of a guide field, in percent: a guide may lie outside the art, up to +/// [`vectorcraft_doc::MAX_GUIDE_FRACTION`] times its size from its edge. +const SLICE_PERCENT_RANGE: std::ops::RangeInclusive = -vectorcraft_doc::MAX_GUIDE_FRACTION * 100.0..=vectorcraft_doc::MAX_GUIDE_FRACTION * 100.0; + +/// The 9-slice dialog fields for `guides` (percent of the symbol's size, unrounded; a quarter 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())); + let opened = [g.left, g.top, g.right, g.bottom]; + for (key, v) in SLICE_KEYS.iter().zip(opened) { + m.insert((*key).to_string(), json!(v * 100.0)); } + m.insert(SLICE_OPENED.into(), json!(opened)); m } -/// The guides the dialog's fields describe, as the `nineSlice` / `nineSliceGuides` params. +/// The guides the dialog's fields describe, as the `nineSlice` / `nineSliceGuides` params. A field +/// still showing the guide it opened with gives that guide exactly. fn slice_params(d: &Dialog) -> Value { - let [l, t, r, b] = SLICE_KEYS.map(|key| d.f64(key, DEFAULT_SLICE_PERCENT) / 100.0); + let opened = d.fields.get(SLICE_OPENED).and_then(Value::as_array); + let [l, t, r, b] = std::array::from_fn(|i| { + let percent = d.f64(SLICE_KEYS[i], DEFAULT_SLICE_PERCENT); + match opened.and_then(|a| a.get(i)).and_then(Value::as_f64) { + Some(g) if g * 100.0 == percent => g, + _ => percent / 100.0, + } + }); if d.bool("nineSlice") { json!({ "nineSlice": true, "nineSliceGuides": [l, t, r, b] }) } else { json!({ "nineSlice": false }) } } @@ -134,7 +154,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { 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, DEFAULT_SLICE_PERCENT); - if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, 0.0..=100.0, "%", 0, 64.0) { + if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, SLICE_PERCENT_RANGE, "%", 3, 64.0) { d.fields.insert(key.into(), json!(n)); } ui.end_row(); diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 1afed73418..9aa4bd7b99 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7139,7 +7139,7 @@ antiAlias Sharp शार्प @msg needs points जरूरत अंक @msg New Window isn't available yet: a document opens in one window नई विंडो अभी तक उपलब्ध नहीं है: एक डॉक्यूमेंट एक विंडो में खुलता है @msg nineSlice must be true or false nineSlice को true या false होना चाहिए -@msg nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1 nineSliceGuides को [left, top, right, bottom] होना चाहिए, हर मान 0..1, और left + right < 1 तथा top + bottom < 1 +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides को [left, top, right, bottom] होना चाहिए, हर मान -16..16, और left + right < 1 तथा top + bottom < 1 @msg no action set is current कोई कार्रवाई सेट चालू नहीं है @msg no action set to save बचाने के लिए कोई कार्रवाई सेट नहीं @msg no active data set कोई सक्रिय डेटा सेट नहीं diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 86b9ba6580..cc871ad8a3 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8097,7 +8097,7 @@ digits Hindi Hindi @msg need at least two points minst två punkter krävs @msg needs points punkter krävs @msg nineSlice must be true or false nineSlice måste vara true eller false -@msg nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1 nineSliceGuides måste vara [left, top, right, bottom], vart och ett 0..1, med left + right < 1 och top + bottom < 1 +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides måste vara [left, top, right, bottom], vart och ett -16..16, med left + right < 1 och top + bottom < 1 @msg no action set is current ingen åtgärdsuppsättning är aktuell @msg no active data set ingen aktiv datauppsättning @msg no appearance item {index} inget utseendeobjekt {index} diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index c9dd29efbb..04f99c3c1f 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -837,6 +837,23 @@ mod tests { 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); + app.ui.dialog = None; + // Guides outside the art, and ones that are not whole percents, open as they are and come + // back unchanged from OK; a field changed to a value past 100 % puts that guide outside. + let guides = [-0.123456789, 1.5, 0.013, -2.75]; + app.run("symbol.options", json!({"name": "Mark", "nineSliceGuides": guides})).unwrap(); + app.run("ui.symbolOptions", json!({"edit": true, "name": "Mark"})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + for (key, want) in [("sliceLeft", -12.3456789), ("sliceTop", 150.0), ("sliceRight", 1.3), ("sliceBottom", -275.0)] { + assert!((dialog.f64(key, 0.0) - want).abs() < 1e-9, "{key}: {}", dialog.f64(key, 0.0)); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.run("symbol.list", json!({})).unwrap()["symbols"][1]["nineSlice"], json!(guides)); + app.run("ui.symbolOptions", json!({"edit": true, "name": "Mark"})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("sliceRight".into(), json!(-125.5)); + crate::dialogs::confirm(&mut app).unwrap(); + let nine = app.run("symbol.list", json!({})).unwrap()["symbols"][1]["nineSlice"].clone(); + assert_eq!(nine, json!([-0.123456789, 1.5, -1.255, -2.75])); } /// F8 is Symbol Options… (`ui.symbolOptions`) by default, on every platform. Shift+F8 and Cmd+F8 diff --git a/docs/gaps.md b/docs/gaps.md index 567e136699..d34956d4e0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -663,7 +663,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi `graphicStyle.addFromLibrary`). - **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; - Global Edit of groups holding type or images, and rotated copies as matches; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). + Global Edit of groups holding type or images, and rotated copies as matches. - **Estimate:** 5–15 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index 62a2c2d0ad..c6a28f4a48 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2779,8 +2779,9 @@ already walks Image Trace. Short agent entry points: `static`|`dynamic`), `symbol.new {name?, registration?, exportType?, nineSlice?, nineSliceGuides?, type?, ids?}`, `symbol.options {name?, newName?, registration?, exportType?, nineSlice?, nineSliceGuides?, type?}` (`nineSlice` bool turns - 9-slice guides on or off; `nineSliceGuides` is `[left, top, right, bottom]`, fractions 0..1 of the symbol size, a - quarter each when none are given; rows of `symbol.list` carry `nineSlice`: the guides or `null`; a scaled instance of + 9-slice guides on or off; `nineSliceGuides` is `[left, top, right, bottom]`, fractions of the symbol size in from + each edge, each -16..16 with `left + right < 1` and `top + bottom < 1`, a quarter each when none are given; below 0 + or above 1 puts a guide outside the art, and the art then scales by the cells it falls in; rows of `symbol.list` carry `nineSlice`: the guides or `null`; a scaled instance of a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; dragging a guide on the canvas in symbol editing mode sends `nineSliceGuides` in one undo step; `type` is Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0addf1e199..c2d77c7a7b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -883,7 +883,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Dynamic symbol appearance follows what an object draws** (VectorCraft's own rule): `symbol.instance.setAppearance` refuses a fill or stroke for an object of the art that does not draw it (a guide, blend, envelope, Repeat, Intertwine group, gradient mesh or placed document; a nested symbol instance's stroke, and its fill unless a solid color or none, which stains its art; an image's fill or stroke without a Convert to Shape or Outline Object effect) with an error that names the key and the object, and the call changes nothing. Opacity, blend and `null`, which takes a stored change back, are taken for every object. Tests: `crates/engine/src/tests/dynamic_symbols.rs`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. - **Dynamic symbol instances on the canvas** ([Symbols, archived copy](https://web.archive.org/web/20240206075757/https://helpx.adobe.com/illustrator/using/symbols.html)): each instance of a dynamic symbol can have an appearance of its own while it shares the symbol's shape. The rest of this entry is VectorCraft's own rules. A Direct Selection click on an instance of a dynamic symbol selects the path, compound path or type object of the symbol's art under the pointer and outlines it (`symbol.instance.selectPart`); the Swatches and Color panels, the Control bar, the Eyedropper and the other fill and stroke controls then change that object's fill or stroke (solid, none, gradient or pattern) for this instance only, each as one undo step, and `transparency.set` changes its opacity and blend the same way; the symbol's art and its other instances stay as they were. Alt or Shift picks the instance whole. An Eyedropper source that is not a path, compound path or type, a click on any other object of the art (a group, image, blend or nested instance) and the commands that change the selected objects (moving, Delete, Group, Hide, Lock, Arrange, Expand, the gradient annotator, isolate/knockout on Transparency, the Appearance panel's rows and others) are refused with a status message. Copy, Export Selection, Fit to Selection, Liquify, Isolate, Add to Library and Save Selection use the whole instance (`docs/mcp.md`), and another selection tool selects the instance whole again. Tests: `crates/engine/src/tests/dynamic_symbols.rs` (`gradients_and_patterns_on_a_picked_object_are_taken`, `opacity_and_blend_on_a_picked_object_go_through_transparency_set`), `direct::tests::a_click_on_a_dynamic_instance_picks_the_object_under_the_pointer`. - - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. + - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it may pass the art's edges (see the next entry) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_passes_the_arts_edges_and_stops_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. + - **9-slice guides outside the art** ([the help page](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), Use 9-slice scaling section): a guide may lie outside the symbol's art, set by dragging it past the art's edge in symbol editing mode, by `symbol.options`'s `nineSliceGuides` or by a Symbol Options field below 0% or above 100%, and a scaled instance still scales, by the cells its art falls in. VectorCraft's own rules: each guide lies within 16 times the art's width or height of its edge (`-16..16` as a fraction, `-1600..1600` in percent) and the two guides of an axis keep their order; a guide past an edge of the art cuts the art at that edge, so the outer cell on its side holds no art; with both guides of an axis past opposite edges the art scales as a whole along it; with both past the same edge the art lies in the outer cell at the other edge and keeps its size, from that edge of the instance, or shrinks to the instance's width or height when the instance is smaller than the art; Symbol Options shows and keeps the guides unrounded. Native files keep the guides exactly; SVG and `.ai` write such an instance as its placed art and keep no guides. Tests: `symbol_nine_slice::tests::guides_outside_the_art_are_accepted_in_order_and_within_the_limit`, `art_scales_by_the_cells_it_falls_in_when_guides_lie_outside_it`, `the_map_with_guides_outside_the_art_is_continuous_and_monotone` in `crates/doc`, `guides_outside_the_art_are_set_by_command_and_scale_by_the_cells_that_hold_art`, `a_guide_dragged_past_the_arts_edge_lies_outside_it`, `guides_outside_the_art_survive_saving_and_reopening_a_native_file` in `crates/engine`, `nineslice::tests::a_guide_outside_the_art_is_taken_and_dragged_past_the_arts_edge`, `a_nine_slice_instance_with_a_guide_outside_its_art_is_written_as_drawn` (SVG and `.ai`). - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. - **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`.