mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 09:59:47 -06:00
Symbols: a scaled instance of a symbol with 9-slice guides keeps its corners and edges unstretched
Squashed from #5079 onto main.
This commit is contained in:
@@ -544,9 +544,8 @@ impl<'a> Scene<'a> {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(art) = self.doc.place_symbol_art_unclipped(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
|
||||
{
|
||||
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
art.transform(*xf, false);
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
self.nest -= 1;
|
||||
|
||||
@@ -29,7 +29,7 @@ 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_nine_slice::{MAX_SLICE_GROWTH, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, charge_symbol_art};
|
||||
pub use symbol_overrides::SymbolOverride;
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
|
||||
+1235
-10
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF};
|
||||
use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF};
|
||||
|
||||
/// The box a symbol instance is measured by when its symbol's art has no box
|
||||
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
|
||||
@@ -303,17 +303,28 @@ impl<'a> SymbolBoxes<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it,
|
||||
/// its strokes at their own weight, the instances in it measured by their art boxes. `None`
|
||||
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a
|
||||
/// click's box is measured.
|
||||
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it
|
||||
/// (through its 9-slice guides when it has them, [`Document::place_symbol_art`]), its strokes
|
||||
/// at their own weight, the instances in it measured by their art boxes. `None`
|
||||
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], while symbol art or a
|
||||
/// click's box is measured, and when placing the art through its guides could make more than
|
||||
/// [`MAX_SYMBOL_NODES`] objects ([`crate::NineSliceMap::most_placed`]).
|
||||
pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
|
||||
self.placed_visual_within(name, xf, MAX_SYMBOL_NODES)
|
||||
}
|
||||
|
||||
/// [`Self::placed_visual`], `None` when placing the art through its guides could make more
|
||||
/// than `most` objects.
|
||||
pub(crate) fn placed_visual_within(&self, name: &str, xf: Affine, most: u64) -> Option<Rect> {
|
||||
if self.quick.get() {
|
||||
return None;
|
||||
}
|
||||
self.measure_art(name, |art| {
|
||||
let mut art = (**art).clone();
|
||||
art.transform(xf, false);
|
||||
let art = match self.doc.symbol_slice_map(name, xf) {
|
||||
Some(m) if m.most_placed(art, self.doc) > most => return None,
|
||||
Some(m) => m.place(art, self.doc),
|
||||
None => self.doc.place_symbol_art(name, art, xf)?,
|
||||
};
|
||||
self.in_art(|| art.visual(Some(self)))
|
||||
})
|
||||
.flatten()
|
||||
|
||||
@@ -19,13 +19,16 @@ pub fn text_outliner() -> Outliner<'static> {
|
||||
Some(&outline_text)
|
||||
}
|
||||
|
||||
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance.
|
||||
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
|
||||
/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
|
||||
/// past its object limit).
|
||||
fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
art.transform(*xf, false);
|
||||
let doc = doc?;
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let art = doc.place_symbol_art(symbol, &art, *xf)?;
|
||||
Some(Node::group(n.id, vec![Arc::new(art)]))
|
||||
}
|
||||
_ => outline_text(n),
|
||||
|
||||
@@ -244,14 +244,22 @@ fn with_id(art: &Node, id: NodeId) -> Node {
|
||||
/// `n` as plain art that geometry effects can reshape, without its transparency and object
|
||||
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
|
||||
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
|
||||
/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts
|
||||
/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
|
||||
/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them).
|
||||
/// `None` for other kinds (images, paths, containers), and for an instance whose art placed
|
||||
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
|
||||
/// `doc`'s symbols that its instances show toward its instance budget.
|
||||
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Text(_) => outline_text(n)?,
|
||||
NodeKind::SymbolInstance { xf, .. } => {
|
||||
let mut a = symbol_art?.clone();
|
||||
a.transform(*xf, false);
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let a = match doc {
|
||||
Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?,
|
||||
None => {
|
||||
let mut a = symbol_art?.clone();
|
||||
a.transform(*xf, false);
|
||||
a
|
||||
}
|
||||
};
|
||||
Node::group(n.id, vec![Arc::new(a)])
|
||||
}
|
||||
// The object as the one piece the copies of its Transform effect are made from.
|
||||
|
||||
@@ -133,6 +133,15 @@ fn geometry_effects_are_deterministic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effects that symbol art across 9-slice guides draws once around all its cells are raster
|
||||
/// effects that do not reshape it.
|
||||
#[test]
|
||||
fn nine_slice_painted_effects_are_raster_effects() {
|
||||
for id in vectorcraft_doc::PAINTED_EFFECTS {
|
||||
assert!(is_raster(id) && !is_geometry(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_paths_stay_open() {
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 20.0), Point::new(100.0, 0.0)], false));
|
||||
|
||||
@@ -174,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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 third each) → {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 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}",
|
||||
has_selection,
|
||||
symbol_new
|
||||
),
|
||||
@@ -201,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Break Link to Symbol",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol) → {ids}",
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}",
|
||||
has_selection,
|
||||
symbol_break_link
|
||||
),
|
||||
|
||||
@@ -47,7 +47,8 @@ fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
|
||||
|
||||
/// `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).
|
||||
/// 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<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
|
||||
let on = match p.get("nineSlice") {
|
||||
None | Some(Value::Null) => None,
|
||||
@@ -339,7 +340,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
|
||||
/// appearance changes to a dynamic symbol's art when `own`.
|
||||
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
|
||||
/// guides when it has them.
|
||||
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
|
||||
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
|
||||
@@ -349,7 +351,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
{
|
||||
brush::tint_node(&mut art, &c, f.opacity);
|
||||
}
|
||||
art.transform(*xf, false);
|
||||
let art = d.place_symbol_art(symbol, &art, *xf)?;
|
||||
let mut art = d.reid(&art);
|
||||
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
|
||||
if !matches!(art.kind, NodeKind::Group { .. }) {
|
||||
@@ -362,9 +364,31 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its
|
||||
/// symbol's object count with the art of its opacity masks, or
|
||||
/// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus
|
||||
/// the group that opacity or a blending mode adds. 0 for any other object.
|
||||
fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 };
|
||||
let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 };
|
||||
let dynamic = own && !inst.extras().symbol_overrides.is_empty();
|
||||
let most = match d.symbol_slice_map(symbol, *xf) {
|
||||
Some(m) if dynamic => m.most_placed(&vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst), d),
|
||||
Some(m) => m.most_placed(&sym.art, d),
|
||||
None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64,
|
||||
None => sym.art.count_with_masks() as u64,
|
||||
};
|
||||
most.saturating_add(1)
|
||||
}
|
||||
|
||||
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the
|
||||
/// art's ids.
|
||||
/// art's ids. Fails before any edit when the instances together would make more than
|
||||
/// [`MAX_EXPAND_NODES`] objects.
|
||||
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId>> {
|
||||
let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own)));
|
||||
if total > MAX_EXPAND_NODES as u64 {
|
||||
return Err(EngineError::Other("Too many pieces to make at once".into()));
|
||||
}
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let Some(inst) = d.node(*id).cloned() else { continue };
|
||||
@@ -378,6 +402,9 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How many objects [`break_links`] may make at once.
|
||||
const MAX_EXPAND_NODES: usize = 1_000_000;
|
||||
|
||||
pub(super) fn selected_instances(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let mut out = vec![];
|
||||
|
||||
@@ -158,6 +158,7 @@ mod styles;
|
||||
mod swatchcmds;
|
||||
mod swatches;
|
||||
mod swatchlib;
|
||||
mod symbol_nine_slice;
|
||||
mod symbolbounds;
|
||||
mod symboledit;
|
||||
mod symbollib;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Symbol Options › 9-slice guides: a scaled instance stays an instance and draws its corners at
|
||||
//! their size; Break Link to Symbol gives the art as it draws, in one step.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made
|
||||
/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size.
|
||||
fn panel_symbol() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)];
|
||||
s.select(|_, sel| sel.set(ids)).unwrap();
|
||||
let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap();
|
||||
assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25]));
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
/// [`panel_symbol`] with its instance resized to 200 × 150 at (50, 50).
|
||||
fn panel() -> (Session, NodeId) {
|
||||
let (mut s, id) = panel_symbol();
|
||||
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// The stroke weights of the paths under `n`, in paint order.
|
||||
fn stroke_widths(n: &Node) -> Vec<f64> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. }) {
|
||||
out.push(c.appearance.stroke_width());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The geometric boxes of the paths under `n`, in paint order.
|
||||
fn path_boxes(n: &Node) -> Vec<Rect> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Path { path, .. } = &c.kind {
|
||||
out.extend(path.bounds());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The art as a 200 × 150 instance draws it: the frame, the corner square at 10 × 10 and the
|
||||
/// center square at 30 × 20.
|
||||
fn assert_sliced(boxes: &[Rect]) {
|
||||
let want = [Rect::new(50.0, 50.0, 250.0, 200.0), Rect::new(50.0, 50.0, 60.0, 60.0), Rect::new(135.0, 115.0, 165.0, 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_instance_stays_linked_and_break_link_gives_the_sliced_art() {
|
||||
let (mut s, id) = panel();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let inst = d.node(id).unwrap().clone();
|
||||
assert!(matches!(inst.kind, NodeKind::SymbolInstance { .. }), "still an instance");
|
||||
assert!(close(inst.geometric_bounds_with(&d.symbol_boxes()).unwrap(), Rect::new(50.0, 50.0, 250.0, 200.0)));
|
||||
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let art = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_sliced(&path_boxes(d.node(art).unwrap()));
|
||||
// The pieces are editable paths: the frame, cut at the guides, is a plain path, and the
|
||||
// squares, each inside one cell, stay live rectangles.
|
||||
let mut live = vec![];
|
||||
d.node(art).unwrap().walk(&mut |c| match &c.kind {
|
||||
NodeKind::SymbolInstance { .. } => panic!("no instance is left"),
|
||||
NodeKind::Path { live: l, .. } => live.push(l.is_some()),
|
||||
_ => {}
|
||||
});
|
||||
assert_eq!(live, [false, true, true]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }), "one undo step");
|
||||
}
|
||||
|
||||
/// Scaling the instance with Scale Strokes & Effects on or off leaves the art's strokes at their
|
||||
/// weight in the symbol: the corners and the cut paths draw them unchanged.
|
||||
#[test]
|
||||
fn the_art_keeps_its_stroke_weights_with_scale_strokes_on_or_off() {
|
||||
for strokes in [true, false] {
|
||||
let (mut s, id) = panel_symbol();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let want = stroke_widths(&d.symbols.iter().find(|s| s.name == "Panel").unwrap().art);
|
||||
assert_eq!(want.len(), 3);
|
||||
s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": strokes})).unwrap();
|
||||
s.execute("object.scale", &json!({"sx": 200, "sy": 150})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &d.node(id).unwrap().kind else { panic!("still an instance") };
|
||||
assert!(d.symbol_slice_map(symbol, *xf).is_some(), "scaled through its guides");
|
||||
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let art = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(stroke_widths(art), want, "scaleStrokes {strokes}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Break Link to Symbol on an instance whose art, cut at the guides, would make more than a
|
||||
/// million objects reports an error without an edit.
|
||||
#[test]
|
||||
fn an_instance_that_slices_into_too_many_objects_stays_an_instance() {
|
||||
let (mut s, id) = panel();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let art = doc.symbols.iter().find(|s| s.name == "Panel").unwrap().art.clone();
|
||||
let frame = art.geometric_bounds().unwrap();
|
||||
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
let mut kids = art.children().unwrap().to_vec();
|
||||
for _ in 0..36_000 {
|
||||
let look = vectorcraft_doc::Appearance::basic(gradient.clone(), vectorcraft_color::Paint::None, 0.0);
|
||||
kids.push(std::sync::Arc::new(Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(frame), look)));
|
||||
}
|
||||
let big = Node::group(doc.alloc_id(), kids);
|
||||
doc.symbols.iter_mut().find(|s| s.name == "Panel").unwrap().art = std::sync::Arc::new(big);
|
||||
let is_instance = |s: &Session| matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::SymbolInstance { .. });
|
||||
assert!(s.execute("symbol.breakLink", &json!({})).is_err());
|
||||
assert!(is_instance(&s), "Break Link leaves it an instance");
|
||||
}
|
||||
|
||||
/// Break Link to Symbol on two instances, each of which would make fewer than a million objects
|
||||
/// but which together would make more, reports an error without an edit.
|
||||
#[test]
|
||||
fn break_link_counts_the_pieces_of_every_selected_instance_together() {
|
||||
let (mut s, id) = panel();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let art = doc.symbols.iter().find(|s| s.name == "Panel").unwrap().art.clone();
|
||||
let frame = art.geometric_bounds().unwrap();
|
||||
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
let mut kids = art.children().unwrap().to_vec();
|
||||
for _ in 0..20_000 {
|
||||
let look = vectorcraft_doc::Appearance::basic(gradient.clone(), vectorcraft_color::Paint::None, 0.0);
|
||||
kids.push(std::sync::Arc::new(Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(frame), look)));
|
||||
}
|
||||
let big = std::sync::Arc::new(Node::group(doc.alloc_id(), kids));
|
||||
doc.symbols.iter_mut().find(|s| s.name == "Panel").unwrap().art = big.clone();
|
||||
let mut second = doc.node(id).unwrap().clone();
|
||||
second.id = doc.alloc_id();
|
||||
let id2 = second.id;
|
||||
let (par, idx, _) = doc.position(id).unwrap();
|
||||
doc.insert(par, idx + 1, second).unwrap();
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &doc.node(id).unwrap().kind else { panic!("an instance") };
|
||||
let each = doc.symbol_slice_map(symbol, *xf).unwrap().most_placed(&big, doc);
|
||||
assert!(each < 1_000_000 && 2 * each > 1_000_000, "{each}");
|
||||
s.select(|_, sel| sel.set([id, id2])).unwrap();
|
||||
assert!(s.execute("symbol.breakLink", &json!({})).is_err());
|
||||
for i in [id, id2] {
|
||||
assert!(matches!(s.doc().unwrap().doc.node(i).unwrap().kind, NodeKind::SymbolInstance { .. }), "no edit");
|
||||
}
|
||||
}
|
||||
|
||||
/// Break Link to Symbol on an instance of a symbol without guides whose opacity masks hold more
|
||||
/// than a million objects reports an error without an edit, though the art outside the masks
|
||||
/// holds about a thousand.
|
||||
#[test]
|
||||
fn an_instance_whose_masks_hold_too_many_objects_stays_an_instance() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let r = rect(&mut s, 50.0, 50.0, 20.0, 20.0);
|
||||
s.select(|_, sel| sel.set([r])).unwrap();
|
||||
let id = NodeId(s.execute("symbol.new", &json!({"name": "Masked"})).unwrap()["id"].as_u64().unwrap());
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
assert!(doc.symbol_nine_slice("Masked").is_none(), "no guides");
|
||||
let square = doc.symbols.iter().find(|s| s.name == "Masked").unwrap().art.clone();
|
||||
let mask_art = Node::group(doc.alloc_id(), vec![square.clone(); 1_000]);
|
||||
let mut masked = (*square).clone();
|
||||
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true)));
|
||||
let masked = std::sync::Arc::new(masked);
|
||||
let big = Node::group(doc.alloc_id(), vec![masked; 1_000]);
|
||||
assert!(big.count() < 1_000_000 && big.count_with_masks() > 1_000_000);
|
||||
doc.symbols.iter_mut().find(|s| s.name == "Masked").unwrap().art = std::sync::Arc::new(big);
|
||||
let is_instance = |s: &Session| matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::SymbolInstance { .. });
|
||||
assert!(s.execute("symbol.breakLink", &json!({})).is_err());
|
||||
assert!(is_instance(&s), "Break Link leaves it an instance");
|
||||
}
|
||||
|
||||
/// A scaled instance of a dynamic symbol keeps its own appearance changes when Break Link to
|
||||
/// Symbol places its art through the guides.
|
||||
#[test]
|
||||
fn a_dynamic_instance_keeps_its_changes_through_the_guides() {
|
||||
let (mut s, id) = panel_symbol();
|
||||
s.execute("symbol.options", &json!({"name": "Panel", "type": "dynamic"})).unwrap();
|
||||
s.execute("symbol.instance.setAppearance", &json!({"id": id.0, "path": [2], "fill": "#ff0000"})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
|
||||
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let art = d.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
|
||||
assert_sliced(&path_boxes(art));
|
||||
let mut fills = vec![];
|
||||
art.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { .. }) {
|
||||
fills.push(c.appearance.fill_paint().color().map(|c| c.to_hex()));
|
||||
}
|
||||
});
|
||||
assert_eq!(fills.last().cloned().flatten().as_deref(), Some("#ff0000"), "{fills:?}");
|
||||
assert_ne!(fills[0].as_deref(), Some("#ff0000"), "only the changed object");
|
||||
}
|
||||
@@ -278,9 +278,8 @@ impl<'a> Writer<'a> {
|
||||
self.warn(LIVE);
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
|
||||
{
|
||||
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
art.transform(*xf, false);
|
||||
art.visible = true;
|
||||
self.nest += 1;
|
||||
self.node(&art, locked);
|
||||
|
||||
@@ -370,9 +370,8 @@ impl<'a> Scene<'a> {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
|
||||
{
|
||||
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
art.transform(*xf, false);
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
self.nest -= 1;
|
||||
|
||||
@@ -216,9 +216,10 @@ impl<'a> Scene<'a> {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
&& let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n)
|
||||
&& let Some(art) =
|
||||
if self.clips { self.doc.place_symbol_art(symbol, &art, *xf) } else { self.doc.place_symbol_art_unclipped(symbol, &art, *xf) }
|
||||
{
|
||||
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
art.transform(*xf, false);
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
self.nest -= 1;
|
||||
|
||||
@@ -214,13 +214,37 @@ impl Exporter<'_> {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
// Within the objects a frame may draw of symbol instances, as on screen: nested
|
||||
// instances multiply.
|
||||
let sym = self.doc.symbols.iter().find(|x| &x.name == symbol);
|
||||
let total = sym.map_or(0, |sym| self.symbol_nodes.saturating_add(sym.art.count() as u64));
|
||||
if let Some(sym) = sym.filter(|_| total <= vectorcraft_doc::MAX_SYMBOL_NODES) {
|
||||
self.symbol_nodes = total;
|
||||
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
art.transform(*xf, false);
|
||||
self.node(s, &art, page, true);
|
||||
// Placing through 9-slice guides needs room for the most objects it can make
|
||||
// (`NineSliceMap::most_placed`), and the objects it makes count.
|
||||
// A dynamic symbol's instance shows its own appearance changes.
|
||||
let doc = self.doc;
|
||||
if let Some(sym) = doc.symbols.iter().find(|x| &x.name == symbol) {
|
||||
let changed;
|
||||
let art: &Node = if n.extras().symbol_overrides.is_empty() {
|
||||
&sym.art
|
||||
} else {
|
||||
changed = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
&changed
|
||||
};
|
||||
let count = art.count() as u64;
|
||||
let map = doc.symbol_slice_map(symbol, *xf);
|
||||
let most = map.map_or(count, |m| m.most_placed(art, doc));
|
||||
let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || {
|
||||
let art = match map {
|
||||
Some(m) => m.place(art, doc),
|
||||
None => {
|
||||
let mut a = art.clone();
|
||||
a.transform(*xf, false);
|
||||
a
|
||||
}
|
||||
};
|
||||
// As `most_placed`, placed art counts its opacity-mask art.
|
||||
let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64;
|
||||
(art, count)
|
||||
});
|
||||
if let Some(art) = placed {
|
||||
self.node(s, &art, page, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A gradient mesh is a smooth patch shading where it can be written as one.
|
||||
|
||||
@@ -236,6 +236,60 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() {
|
||||
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds());
|
||||
}
|
||||
|
||||
/// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas
|
||||
/// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20.
|
||||
#[test]
|
||||
fn a_scaled_nine_slice_instance_keeps_its_corners() {
|
||||
let mut d = doc(300.0, 200.0);
|
||||
let art = Node::group(
|
||||
NodeId(0),
|
||||
vec![
|
||||
Arc::new(rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb8(200, 200, 200))),
|
||||
Arc::new(rect_node(Rect::new(0.0, 0.0, 10.0, 10.0), Color::rgb(1.0, 0.0, 0.0))),
|
||||
Arc::new(rect_node(Rect::new(45.0, 45.0, 55.0, 55.0), Color::rgb(0.0, 0.0, 1.0))),
|
||||
],
|
||||
);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) });
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let xf = Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5);
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Panel".into(), xf }));
|
||||
let out = roundtrip(&d);
|
||||
let boxes: Vec<Rect> = leaves(&out).iter().filter_map(|n| n.geometric_bounds()).collect();
|
||||
assert_eq!(boxes.len(), 3, "{boxes:?}");
|
||||
assert!(close(boxes[0], Rect::new(20.0, 20.0, 220.0, 170.0), 0.01), "{boxes:?}");
|
||||
assert!(close(boxes[1], Rect::new(20.0, 20.0, 30.0, 30.0), 0.01), "{boxes:?}");
|
||||
assert!(close(boxes[2], Rect::new(105.0, 85.0, 135.0, 105.0), 0.01), "{boxes:?}");
|
||||
}
|
||||
|
||||
/// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES`
|
||||
/// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through
|
||||
/// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy
|
||||
/// with a disabled opacity mask of 1,001,001 objects), the first is written in its nine cells and
|
||||
/// the second is left out. With a small mask both are written.
|
||||
#[test]
|
||||
fn nine_slice_instances_share_one_object_limit_in_an_export() {
|
||||
let doc = |mask_objects: usize| {
|
||||
let mut d = doc(300.0, 200.0);
|
||||
let leaf = Arc::new(rect_node(Rect::new(0.0, 0.0, 1.0, 1.0), Color::BLACK));
|
||||
let inner = Arc::new(Node::group(NodeId(0), vec![leaf; mask_objects]));
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(0), vec![inner; mask_objects]), true);
|
||||
(mask.linked, mask.disabled) = (false, true);
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
square.extras_mut().mask = Some(Box::new(mask));
|
||||
let art = Node::group(NodeId(0), vec![Arc::new(square)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) });
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
for x in [20.0, 40.0] {
|
||||
let xf = Affine::translate((x, 20.0)) * Affine::scale_non_uniform(2.0, 1.5);
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Panel".into(), xf }));
|
||||
}
|
||||
d
|
||||
};
|
||||
assert_eq!(leaves(&roundtrip(&doc(2))).len(), 18);
|
||||
assert_eq!(leaves(&roundtrip(&doc(1_000))).len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_colours_preserved() {
|
||||
let mut d = doc(300.0, 100.0);
|
||||
|
||||
@@ -69,11 +69,12 @@ pub(crate) type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>
|
||||
/// ([`live::Found`]).
|
||||
pub(crate) type FxArtEntry = (Arc<Node>, Arc<Node>, Option<live::Found>);
|
||||
|
||||
/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits
|
||||
/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]).
|
||||
/// What symbol art placed by [`Renderer::placed_art`] or [`Renderer::sliced_art`] is kept under:
|
||||
/// the art's address, the bits of the transform's coefficients, whether strokes scale with it, and
|
||||
/// the stain ([`stain_bits`]).
|
||||
pub(crate) type PlacedKey = (usize, [u64; 6], bool, [u32; 6]);
|
||||
|
||||
/// Symbol art placed by [`Renderer::placed_art`].
|
||||
/// Symbol art placed by [`Renderer::placed_art`] or [`Renderer::sliced_art`].
|
||||
pub(crate) struct PlacedEntry {
|
||||
/// The symbol's art, held weakly: the reference keeps the art's allocation, so no other art can
|
||||
/// get its address while the entry exists, and does not keep the art's content.
|
||||
@@ -83,6 +84,10 @@ pub(crate) struct PlacedEntry {
|
||||
/// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when
|
||||
/// it holds type: the art is placed again when the fonts change.
|
||||
pub(crate) fonts: Option<u64>,
|
||||
/// The 9-slice map the art was placed through ([`Renderer::sliced_art`]), or `None` for art
|
||||
/// placed by the transform alone ([`Renderer::placed_art`]). An entry is used only by the
|
||||
/// function that made it, with the same map.
|
||||
pub(crate) slice: Option<vectorcraft_doc::NineSliceMap>,
|
||||
}
|
||||
|
||||
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
|
||||
|
||||
@@ -796,6 +796,32 @@ fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting()
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient bar with a drop shadow in a symbol with 9-slice guides, across the guides of an
|
||||
/// instance scaled twice across: the bar is drawn cell by cell, and its shadow still shows past
|
||||
/// the bar's right end, where the right cell ends.
|
||||
#[test]
|
||||
fn a_shadow_on_art_across_nine_slice_guides_reaches_past_the_outer_cells() {
|
||||
let _fonts = fonts_steady();
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_doc::color::GradientPaint::new(Default::default())));
|
||||
let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
let mut 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 shadow = json!({"x": 8, "y": 0, "blur": 0, "opacity": 100});
|
||||
bar.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true });
|
||||
let art = Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(bar)]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(art) });
|
||||
d.set_symbol_nine_slice("Bar", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let xf = Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.0);
|
||||
let instance = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Bar".into(), xf });
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, instance).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let out = Renderer::new().render(&d, 300, 200, Affine::IDENTITY, &opts);
|
||||
// The bar runs from x 20 to 220 and y 60 to 80; its shadow, 8 pt to the right, to x 228.
|
||||
assert!(lum(out.pixel(224, 70)) < WHITE, "{:?}", out.pixel(224, 70));
|
||||
assert_eq!(lum(out.pixel(232, 70)), WHITE, "{:?}", out.pixel(232, 70));
|
||||
}
|
||||
|
||||
/// While interacting, a raster moves with its object only when the content's pixels (for a
|
||||
/// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under
|
||||
/// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object,
|
||||
|
||||
@@ -485,11 +485,21 @@ impl Renderer {
|
||||
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
|
||||
NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(n, f.ink), n.extras().pixelated),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.charge_symbol(&sym.art)
|
||||
{
|
||||
let (art, kept) = self.placed_art(&sym.art, *xf, false, Some(n));
|
||||
self.draw_placed_art(ctx, f, &art, kept);
|
||||
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) {
|
||||
// The instance draws when the frame has room for its art's objects; a symbol
|
||||
// with 9-slice guides is placed cell by cell (`Self::sliced_art`).
|
||||
let mut used = self.symbol_nodes;
|
||||
let placed = match f.doc.symbol_slice_map(symbol, *xf) {
|
||||
Some(m) => self.sliced_art(f.doc, &sym.art, &m, *xf, n, &mut used),
|
||||
None => {
|
||||
let count = sym.art.count() as u64;
|
||||
vectorcraft_doc::charge_symbol_art(&mut used, count, || (self.placed_art(&sym.art, *xf, false, Some(n)), count))
|
||||
}
|
||||
};
|
||||
self.symbol_nodes = used;
|
||||
if let Some((art, kept)) = placed {
|
||||
self.draw_placed_art(ctx, f, &art, kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Blend { .. }
|
||||
@@ -504,13 +514,6 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Count symbol art `art`'s objects against the frame's [`vectorcraft_doc::MAX_SYMBOL_NODES`]: `false` (the
|
||||
/// instance isn't drawn) once they would pass it.
|
||||
fn charge_symbol(&mut self, art: &Node) -> bool {
|
||||
let total = self.symbol_nodes.saturating_add(art.count() as u64);
|
||||
(total <= vectorcraft_doc::MAX_SYMBOL_NODES).then(|| self.symbol_nodes = total).is_some()
|
||||
}
|
||||
|
||||
/// Draw symbol art `art` as [`Self::placed_art`] placed it for an instance or an inline graphic
|
||||
/// (`kept`: kept between frames, see [`Self::draw_kept_node`]).
|
||||
pub(super) fn draw_placed_art(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc<Node>, kept: bool) {
|
||||
|
||||
@@ -66,6 +66,7 @@ impl Renderer {
|
||||
if let Some(e) = self.placed_arts.get(&key, self.stamp)
|
||||
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
|
||||
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
|
||||
&& e.slice.is_none()
|
||||
{
|
||||
return match &e.placed {
|
||||
Some(placed) => (placed.clone(), true),
|
||||
@@ -73,14 +74,70 @@ impl Renderer {
|
||||
};
|
||||
}
|
||||
let placed = place(art, xf, scale_strokes, inst);
|
||||
let inserted = self.keep_placed(key, art, &placed, None);
|
||||
(placed, inserted)
|
||||
}
|
||||
|
||||
/// Symbol art `art` placed for instance `inst` (placed by `xf`) through 9-slice map `map`
|
||||
/// ([`vectorcraft_doc::NineSliceMap::place`], stained by the instance), and whether it is kept,
|
||||
/// when the frame's symbol budget (`used`, see [`vectorcraft_doc::charge_symbol_art`]) has room
|
||||
/// for it. The art is kept between frames as [`Self::placed_art`] keeps it, under the same key
|
||||
/// and only with the same map. Kept art is charged by its own object count, the art of its
|
||||
/// opacity masks included ([`Node::count_with_masks`]), and is not placed
|
||||
/// again; other art is charged by [`vectorcraft_doc::NineSliceMap::most_placed`] before it is
|
||||
/// placed.
|
||||
pub(super) fn sliced_art(
|
||||
&mut self,
|
||||
doc: &vectorcraft_doc::Document,
|
||||
art: &Arc<Node>,
|
||||
map: &vectorcraft_doc::NineSliceMap,
|
||||
xf: Affine,
|
||||
inst: &Node,
|
||||
used: &mut u64,
|
||||
) -> Option<(Arc<Node>, bool)> {
|
||||
let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art);
|
||||
let stain = stain_bits(vectorcraft_brush::stain(inst));
|
||||
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), false, stain);
|
||||
// As in `placed_art`, an instance with appearance changes of its own isn't kept.
|
||||
let overridden = !inst.extras().symbol_overrides.is_empty();
|
||||
let mut keeps = kept_kind && !overridden && !fx::has_object_fx(art);
|
||||
if keeps
|
||||
&& let Some(e) = self.placed_arts.get(&key, self.stamp)
|
||||
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
|
||||
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
|
||||
&& e.slice.as_ref() == Some(map)
|
||||
{
|
||||
match e.placed.clone() {
|
||||
Some(placed) => {
|
||||
let count = placed.count_with_masks() as u64;
|
||||
return vectorcraft_doc::charge_symbol_art(used, count, || ((placed, true), count));
|
||||
}
|
||||
None => keeps = false,
|
||||
}
|
||||
}
|
||||
let stained = crate::brush_fx::instance_art(art, inst);
|
||||
let most = map.most_placed(&stained, doc);
|
||||
let placed = vectorcraft_doc::charge_symbol_art(used, most, || {
|
||||
let placed = Arc::new(map.place(&stained, doc));
|
||||
let count = placed.count_with_masks() as u64;
|
||||
(placed, count)
|
||||
})?;
|
||||
let kept = keeps && self.keep_placed(key, art, &placed, Some(*map));
|
||||
Some((placed, kept))
|
||||
}
|
||||
|
||||
/// Keep `placed`, symbol art `art` as placed under `key` (through 9-slice map `slice`, when it
|
||||
/// has one), unless `art` depends on the document ([`depends_on_document`]): then the entry
|
||||
/// records that it is placed again in every frame. Whether `placed` is kept.
|
||||
fn keep_placed(&mut self, key: PlacedKey, art: &Arc<Node>, placed: &Arc<Node>, slice: Option<vectorcraft_doc::NineSliceMap>) -> bool {
|
||||
let kept = (!depends_on_document(art)).then(|| placed.clone());
|
||||
let keeps = kept.is_some();
|
||||
let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
|
||||
// The bytes count the key and the entry, the art's allocation that the weak reference
|
||||
// keeps, and the kept art.
|
||||
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
|
||||
let inserted = self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp);
|
||||
(placed, keeps && inserted)
|
||||
let inserted = self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts, slice }, bytes, self.stamp);
|
||||
keeps && inserted
|
||||
}
|
||||
|
||||
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
|
||||
|
||||
@@ -474,6 +474,70 @@ fn symbol_instances_keep_their_placed_art_between_frames() {
|
||||
assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art");
|
||||
}
|
||||
|
||||
/// The art placed through 9-slice guides that `r` keeps and that its last frame used.
|
||||
fn sliced_copies(r: &Renderer) -> Vec<Arc<Node>> {
|
||||
r.placed_arts.used_in(r.stamp).filter(|e| e.slice.is_some()).filter_map(|e| e.placed.clone()).collect()
|
||||
}
|
||||
|
||||
/// A second frame of an unchanged document draws the art placed through a symbol's 9-slice guides
|
||||
/// for its scaled instance in the first, and the geometry kept for that art's paths is used again.
|
||||
/// Moving a guide places the art again.
|
||||
#[test]
|
||||
fn nine_slice_instances_keep_their_placed_art_between_frames() {
|
||||
let _fonts = fonts_steady();
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
d.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let l = d.layers[0].id;
|
||||
let xf = Affine::translate((100.0, 50.0)) * Affine::scale_non_uniform(4.0, 2.0);
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "S".into(), xf })).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let kept = sliced_copies(&r);
|
||||
assert_eq!(kept.len(), 1, "the instance's sliced art is kept");
|
||||
let geometry = placed_copies(&r, &[&d]);
|
||||
assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first);
|
||||
let again = sliced_copies(&r);
|
||||
assert!(again.len() == 1 && Arc::ptr_eq(&again[0], &kept[0]), "the second frame draws the art the first placed");
|
||||
assert_eq!(placed_copies(&r, &[&d]), geometry, "and uses the geometry kept for it");
|
||||
assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art");
|
||||
let mut moved = d.clone();
|
||||
moved.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice { left: 0.1, top: 0.1, right: 0.1, bottom: 0.1 }));
|
||||
let want = Renderer::new().render(&moved, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
assert!(want != first, "the moved guides draw the art differently");
|
||||
assert!(r.render(&moved, 200, 100, Affine::IDENTITY, &opts).pixels == want, "the moved guides place the art again");
|
||||
let placed = sliced_copies(&r);
|
||||
assert!(placed.len() == 1 && !Arc::ptr_eq(&placed[0], &kept[0]), "under the moved guides");
|
||||
}
|
||||
|
||||
/// A scaled 9-slice instance of a dynamic symbol with appearance changes of its own draws them
|
||||
/// after a frame without them: its sliced art is not taken from the art kept for the same symbol
|
||||
/// and transform.
|
||||
#[test]
|
||||
fn nine_slice_instances_with_their_own_changes_are_placed_again() {
|
||||
let _fonts = fonts_steady();
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let art = symbol_art(&mut d);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "S".into(), art: Arc::new(art) });
|
||||
d.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let l = d.layers[0].id;
|
||||
let xf = Affine::translate((100.0, 50.0)) * Affine::scale_non_uniform(4.0, 2.0);
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "S".into(), xf })).unwrap();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
let mut changed = d.clone();
|
||||
let red = vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(Paint::solid(Color::rgb(1.0, 0.0, 0.0))), ..Default::default() };
|
||||
changed.node_mut(id).unwrap().extras_mut().symbol_overrides = vec![red];
|
||||
let want = Renderer::new().render(&changed, 200, 100, Affine::IDENTITY, &opts).pixels;
|
||||
assert!(want != first, "the change draws the art differently");
|
||||
assert!(r.render(&changed, 200, 100, Affine::IDENTITY, &opts).pixels == want, "the change is drawn");
|
||||
}
|
||||
|
||||
/// The stroke outlines and the gradient slices of strokes that `r` keeps and that its last frame made
|
||||
/// or used.
|
||||
fn stroke_outlines(r: &Renderer) -> (Vec<Arc<BezPath>>, Vec<Slices>) {
|
||||
|
||||
@@ -105,6 +105,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
instance_xf: Affine::IDENTITY,
|
||||
symbols: HashMap::new(),
|
||||
symbol_nest: 0,
|
||||
symbol_nodes: 0,
|
||||
inline_graphic: false,
|
||||
fonts: Vec::new(),
|
||||
shared_images: HashMap::new(),
|
||||
colorized_counts: HashMap::new(),
|
||||
@@ -271,6 +273,12 @@ struct Writer<'a> {
|
||||
/// Symbols being written inside one another (instances nested deeper are left out: a symbol
|
||||
/// can't contain itself).
|
||||
symbol_nest: u32,
|
||||
/// The objects in the placed art of the 9-slice instances written so far (see
|
||||
/// [`vectorcraft_doc::charge_symbol_art`]).
|
||||
symbol_nodes: u64,
|
||||
/// An inline graphic in type is being written: its symbol's 9-slice guides do not apply, as
|
||||
/// on the canvas.
|
||||
inline_graphic: bool,
|
||||
/// The characters type uses from each face ([`ExportOptions::embed_fonts`]).
|
||||
fonts: Vec<FontUse>,
|
||||
/// Images written once in the defs and drawn with `<use>`, by key and size: the pieces an
|
||||
|
||||
@@ -11,6 +11,9 @@ pub(super) struct SymbolDef {
|
||||
/// The def's id, once written.
|
||||
id: Option<String>,
|
||||
reuse: Reuse,
|
||||
/// The objects in the placed art of the 9-slice instances in the def (see
|
||||
/// [`Writer::symbol_nodes`]), charged again for each `<use>` of it.
|
||||
nodes: u64,
|
||||
}
|
||||
|
||||
/// Which instance transforms paint a symbol's art exactly as the def under that transform (the
|
||||
@@ -71,6 +74,11 @@ impl Reuse {
|
||||
| NodeKind::PlacedDocument(_)
|
||||
| NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false),
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
// A scale reaching an instance of a symbol with 9-slice guides changes how its
|
||||
// cells are sized.
|
||||
if doc.symbol_nine_slice(symbol).is_some() {
|
||||
self.scales = false;
|
||||
}
|
||||
if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) {
|
||||
self.scan(doc, &s.art, depth + 1);
|
||||
}
|
||||
@@ -415,16 +423,29 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let Some((first, rest)) = elements.split_first() else { return };
|
||||
for c in rest.iter().rev() {
|
||||
// The elements are written first, in paint order, so that each mask copies an element
|
||||
// with the symbol art count the element itself was written with
|
||||
// (`Self::symbol_nodes`): an instance left out of an element is left out of its mask.
|
||||
let (depth, nest) = (self.depth, rest.len());
|
||||
let first = self.detached(depth + nest, |w| w.node(first));
|
||||
let mut written = Vec::with_capacity(nest);
|
||||
for (i, c) in rest.iter().enumerate() {
|
||||
let before = self.symbol_nodes;
|
||||
written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c))));
|
||||
}
|
||||
let after = self.symbol_nodes;
|
||||
for (c, (before, _)) in rest.iter().zip(&written).rev() {
|
||||
self.symbol_nodes = *before;
|
||||
let mid = self.knockout_mask(c);
|
||||
self.line(&format!("<g mask=\"url(#{mid})\">"));
|
||||
self.depth += 1;
|
||||
}
|
||||
self.node(first);
|
||||
for c in rest {
|
||||
self.symbol_nodes = after;
|
||||
self.body.push_str(&first);
|
||||
for (_, body) in written {
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
self.node(c);
|
||||
self.body.push_str(&body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -600,12 +621,43 @@ impl Writer<'_> {
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let doc = self.doc;
|
||||
// The instances nested in an inline graphic's art are ordinary instances again.
|
||||
let inline = std::mem::take(&mut self.inline_graphic);
|
||||
let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return };
|
||||
if self.symbol_nest > MAX_SYMBOL_NEST {
|
||||
return;
|
||||
}
|
||||
let id = self.id_attr(n);
|
||||
let a = self.attrs(&css::transparency(n));
|
||||
// A scaled instance of a symbol with 9-slice guides is written as its placed art,
|
||||
// as the canvas draws it, instead of one `<use>` transform. The scale counts the
|
||||
// transforms of the instances it sits in. The placed art of all such instances in
|
||||
// the export holds at most `MAX_SYMBOL_NODES` objects: an instance that would take
|
||||
// it past that is left out, as the canvas leaves it out.
|
||||
if let Some(map) = doc.symbol_slice_map(symbol, self.xf * *xf).filter(|_| !inline) {
|
||||
let art = vectorcraft_brush::instance_art(&sym.art, n);
|
||||
let most = map.most_placed(&art, doc);
|
||||
let Some(art) = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || {
|
||||
let a = map.place(&art, doc);
|
||||
let c = a.count_with_masks() as u64;
|
||||
(a, c)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let saved = (self.xf, self.instance_xf);
|
||||
(self.xf, self.instance_xf) = (Affine::IDENTITY, Affine::IDENTITY);
|
||||
self.line(&format!("<g{id}{a}>"));
|
||||
self.depth += 1;
|
||||
let knockout = std::mem::take(&mut self.knockout);
|
||||
self.symbol_nest += 1;
|
||||
self.node(&art);
|
||||
self.symbol_nest -= 1;
|
||||
self.knockout = knockout;
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
(self.xf, self.instance_xf) = saved;
|
||||
return;
|
||||
}
|
||||
if let Some(def) = self.symbol_use(sym, n, *xf) {
|
||||
let m = self.xf * *xf;
|
||||
let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) };
|
||||
@@ -658,14 +710,20 @@ impl Writer<'_> {
|
||||
}
|
||||
if !self.symbols.contains_key(&sym.name) {
|
||||
let reuse = Reuse::of(self.doc, &sym.art);
|
||||
self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse });
|
||||
self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse, nodes: 0 });
|
||||
}
|
||||
let def = self.symbols.get(&sym.name)?;
|
||||
if !def.reuse.allows(xf) {
|
||||
return None;
|
||||
}
|
||||
if let Some(id) = &def.id {
|
||||
return Some(id.clone());
|
||||
// A `<use>` that would take the placed 9-slice art past `MAX_SYMBOL_NODES` gives way to
|
||||
// the instance's own art, which leaves out the instances that don't fit.
|
||||
if let Some(id) = def.id.clone() {
|
||||
let nodes = def.nodes;
|
||||
if nodes > 0 && vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, nodes, || ((), nodes)).is_none() {
|
||||
return None;
|
||||
}
|
||||
return Some(id);
|
||||
}
|
||||
// The art in symbol space, as its own picture: no knockout around it, and no object ids
|
||||
// (every instance shows it).
|
||||
@@ -673,7 +731,9 @@ impl Writer<'_> {
|
||||
let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous);
|
||||
(self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false);
|
||||
self.symbol_nest += 1;
|
||||
let before = self.symbol_nodes;
|
||||
let art = self.detached(3, |w| w.node(&sym.art));
|
||||
let nodes = self.symbol_nodes.saturating_sub(before);
|
||||
self.symbol_nest -= 1;
|
||||
(self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved;
|
||||
// The symbol's name, when the id had to differ from it, comes back on import.
|
||||
@@ -689,7 +749,7 @@ impl Writer<'_> {
|
||||
self.defs.push_str(&art);
|
||||
self.def(1, "</symbol>");
|
||||
if let Some(def) = self.symbols.get_mut(&sym.name) {
|
||||
def.id = Some(id.clone());
|
||||
(def.id, def.nodes) = (Some(id.clone()), nodes);
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ impl Writer<'_> {
|
||||
NodeId(u64::MAX),
|
||||
NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) },
|
||||
);
|
||||
let inline = std::mem::replace(&mut self.inline_graphic, true);
|
||||
self.node(&inst);
|
||||
self.inline_graphic = inline;
|
||||
}
|
||||
glyphs(self, above);
|
||||
self.anonymous = anonymous;
|
||||
|
||||
@@ -68,6 +68,148 @@ fn three_instances_give_one_symbol_and_three_uses() {
|
||||
assert!(boxes.iter().any(|b| (b.x0 - 90.0).abs() < 1e-3 && (b.y0 - 30.0).abs() < 1e-3), "{boxes:?}");
|
||||
}
|
||||
|
||||
/// A scaled instance of a symbol with 9-slice guides is written as the canvas draws it, with no
|
||||
/// `<use>`: at 200 × 150 its 10 × 10 corner square stays 10 × 10 and its center square becomes
|
||||
/// 30 × 20. An instance at the natural size still uses the def.
|
||||
#[test]
|
||||
fn a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it() {
|
||||
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),
|
||||
(Rect::new(45.0, 45.0, 55.0, 55.0), Color::BLACK),
|
||||
];
|
||||
let art: Vec<Arc<Node>> = 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", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
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("<use ").count(), 0, "{svg}");
|
||||
let canvas = render_artboard(&d);
|
||||
let dark = |im: &Image, x: u32, y: u32| im.rgba[((y * im.width + x) * 4) as usize] < 60;
|
||||
// The corner square covers 20..30; a plain scale would have stretched it to 20..40 by 20..35.
|
||||
assert!(dark(&canvas, 25, 25) && !dark(&canvas, 35, 32));
|
||||
// The center square covers 105..135 by 85..105; a plain scale would end it at x = 130.
|
||||
assert!(dark(&canvas, 132, 95) && dark(&canvas, 107, 87) && !dark(&canvas, 102, 95));
|
||||
assert_similar(&canvas, &resvg_render(&svg, 300, 200), 24.0, 0.002);
|
||||
// At its natural size the instance is a `<use>` of the symbol.
|
||||
let mut d = d.clone();
|
||||
let l = d.layers[0].id;
|
||||
d.remove(d.children(Some(l)).unwrap()[0].id).unwrap();
|
||||
instance(&mut d, "Panel", Affine::translate((20.0, 20.0)));
|
||||
assert_eq!(export(&d, &ExportOptions::default()).matches("<use ").count(), 1);
|
||||
// A scaled instance of a symbol whose art holds a natural-size "Panel" instance: the scale
|
||||
// reaches the inner instance, so neither is a `<use>` and the corners stay as the canvas
|
||||
// draws them.
|
||||
d.remove(d.children(Some(l)).unwrap()[0].id).unwrap();
|
||||
let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf: Affine::IDENTITY });
|
||||
let outer = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) });
|
||||
instance(&mut d, "Outer", Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5));
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<use ").count(), 0, "{svg}");
|
||||
let canvas = render_artboard(&d);
|
||||
assert!(dark(&canvas, 25, 25) && !dark(&canvas, 35, 32));
|
||||
assert_similar(&canvas, &resvg_render(&svg, 300, 200), 24.0, 0.002);
|
||||
}
|
||||
|
||||
/// A scaled instance of a symbol with 9-slice guides whose art placed through the guides could
|
||||
/// hold more than `MAX_SYMBOL_NODES` objects is left out, as the canvas leaves it out: here a
|
||||
/// gradient square in all nine cells, each copy with a disabled opacity mask of 2,251,501 objects.
|
||||
/// With a small mask the same instance is written in its nine clip groups.
|
||||
#[test]
|
||||
fn a_nine_slice_instance_past_the_object_limit_is_left_out() {
|
||||
let doc = |mask_objects: usize| {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default()));
|
||||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; mask_objects]));
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; mask_objects]), true);
|
||||
(mask.linked, mask.disabled) = (false, true);
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
let mut square =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
square.extras_mut().mask = Some(Box::new(mask));
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(square)]);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) });
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
instance(&mut d, "Panel", Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5));
|
||||
d
|
||||
};
|
||||
let svg = export(&doc(2), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<clipPath").count(), 9, "{svg}");
|
||||
let svg = export(&doc(1_500), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<clipPath").count(), 0, "{svg}");
|
||||
assert_eq!(svg.matches("<use ").count(), 0, "{svg}");
|
||||
}
|
||||
|
||||
/// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES`
|
||||
/// objects, as on the canvas: of two instances whose art placed through the guides could hold
|
||||
/// about 9,009,000 objects each (a gradient square in all nine cells, each copy with a disabled
|
||||
/// opacity mask of 1,001,001 objects), the first is written in its nine clip groups and the
|
||||
/// second is left out. With a small mask both are written. One such instance over a square in a
|
||||
/// knockout group is written twice, as itself and in the group's knockout mask, which copies it
|
||||
/// with the count it was written with: after another such instance, it is left out of both. Of
|
||||
/// two instances of a symbol "Outer" whose art holds one such instance, the first is a `<use>` of
|
||||
/// a def holding the instance's nine clip groups, and the second, which the def's art would take
|
||||
/// past the limit, is written as its own art without the instance.
|
||||
#[test]
|
||||
fn nine_slice_instances_share_one_object_limit_in_an_export() {
|
||||
let doc = |mask_objects: usize, plain: &[f64], layout: &str| {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default()));
|
||||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; mask_objects]));
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; mask_objects]), true);
|
||||
// A linked mask moves with its art, which lets "Outer" share a def.
|
||||
(mask.linked, mask.disabled) = (layout == "outer", true);
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
|
||||
let mut square =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
square.extras_mut().mask = Some(Box::new(mask));
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(square)]);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) });
|
||||
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let xf = |x: f64| Affine::translate((x, 20.0)) * Affine::scale_non_uniform(2.0, 1.5);
|
||||
for x in plain {
|
||||
instance(&mut d, "Panel", xf(*x));
|
||||
}
|
||||
let panel = |d: &mut Document, x: f64| Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf: xf(x) });
|
||||
let l = d.layers[0].id;
|
||||
if layout == "knockout" {
|
||||
let below = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 200.0)), Appearance::default());
|
||||
let top = panel(&mut d, 60.0);
|
||||
let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]);
|
||||
group.knockout = vectorcraft_doc::Knockout::On;
|
||||
d.insert(Some(l), usize::MAX, group).unwrap();
|
||||
} else if layout == "outer" {
|
||||
let art = Node::group(d.alloc_id(), vec![panel(&mut d, 20.0).into()]);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(art) });
|
||||
for y in [0.0, 10.0] {
|
||||
instance(&mut d, "Outer", Affine::translate((0.0, y)));
|
||||
}
|
||||
}
|
||||
d
|
||||
};
|
||||
let svg = export(&doc(2, &[20.0, 40.0], ""), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<clipPath").count(), 18, "{svg}");
|
||||
let svg = export(&doc(1_000, &[20.0, 40.0], ""), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<clipPath").count(), 9);
|
||||
assert_eq!(svg.matches("<use ").count(), 0);
|
||||
let svg = export(&doc(1_000, &[], "knockout"), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<mask").count(), 1);
|
||||
assert_eq!(svg.matches("<clipPath").count(), 18);
|
||||
let svg = export(&doc(1_000, &[20.0], "knockout"), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<mask").count(), 1);
|
||||
assert_eq!(svg.matches("<clipPath").count(), 9);
|
||||
let svg = export(&doc(2, &[], "outer"), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<use ").count(), 2, "{svg}");
|
||||
assert_eq!(svg.matches("<clipPath").count(), 9);
|
||||
let svg = export(&doc(1_000, &[], "outer"), &ExportOptions::default());
|
||||
assert_eq!(svg.matches("<use ").count(), 1);
|
||||
assert_eq!(svg.matches("<clipPath").count(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_the_def_cant_stand_for_get_their_own_art() {
|
||||
// Rotated: shared (a stroke turns with its path). Scaled: stroke weights don't scale on the
|
||||
|
||||
@@ -225,7 +225,8 @@ fn node_color_at(s: &mut Sampler, levels: &mut [Option<Document>], n: &Node, on_
|
||||
}
|
||||
|
||||
/// The color the art of symbol `name` shows at `p` as instance `inst` draws it: stained by the
|
||||
/// instance's fill and placed in the document by `xf`, its strokes and patterns unscaled.
|
||||
/// instance's fill and placed in the document by `xf` (through the symbol's 9-slice guides when it
|
||||
/// has them), its strokes and patterns unscaled. The objects the guides add count as work too.
|
||||
fn symbol_color_at(s: &mut Sampler, levels: &mut [Option<Document>], inst: &Node, name: &str, xf: Affine, p: Point) -> Option<Color> {
|
||||
if xf.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
@@ -233,8 +234,24 @@ fn symbol_color_at(s: &mut Sampler, levels: &mut [Option<Document>], inst: &Node
|
||||
let doc = s.doc;
|
||||
let sym = doc.symbols.iter().find(|s| s.name == name)?;
|
||||
s.spend(sym.art.count())?;
|
||||
let mut art = vectorcraft_brush::instance_art(&sym.art, inst);
|
||||
art.transform(xf, false);
|
||||
let art = vectorcraft_brush::instance_art(&sym.art, inst);
|
||||
let art = match doc.symbol_slice_map(name, xf) {
|
||||
Some(m) => {
|
||||
let most = usize::try_from(m.most_placed(&art, doc)).unwrap_or(usize::MAX).saturating_sub(sym.art.count());
|
||||
if s.work < most {
|
||||
// Too little work is left for the most objects the guides can make.
|
||||
s.spend(most)?;
|
||||
}
|
||||
let placed = m.place(&art, doc);
|
||||
s.spend(placed.count().saturating_sub(sym.art.count()))?;
|
||||
placed
|
||||
}
|
||||
None => {
|
||||
let mut art = art;
|
||||
art.transform(xf, false);
|
||||
art
|
||||
}
|
||||
};
|
||||
art_color_at(s, levels, [art], p)
|
||||
}
|
||||
|
||||
@@ -869,6 +886,52 @@ mod tests {
|
||||
assert_eq!(at(&d, 150.0, 155.0), "#00ff00");
|
||||
}
|
||||
|
||||
/// An instance of a symbol with 9-slice guides is sampled as it is drawn: scaled twice, its
|
||||
/// 10 pt red corner square stays 10 pt, so 15 pt in from the corner shows the green art behind it.
|
||||
#[test]
|
||||
fn a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn() {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::{NodeId, SymbolNineSlice};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let green = filled_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
|
||||
let red = filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(Node::group(NodeId(0), vec![green, red])) });
|
||||
let (l, inst) = (d.layers[0].id, d.alloc_id());
|
||||
let xf = Affine::translate((250.0, 250.0)) * Affine::scale(2.0);
|
||||
d.insert(Some(l), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let at = |d: &vectorcraft_doc::Document, x: f64, y: f64| {
|
||||
sampled_hex(&EyedropperTool::default().pointer(&cx(d, &s, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(shift)))
|
||||
};
|
||||
assert_eq!(at(&d, 265.0, 265.0), "#ff0000", "without guides the corner scales");
|
||||
d.set_symbol_nine_slice("Panel", Some(SymbolNineSlice::default()));
|
||||
assert_eq!((at(&d, 255.0, 255.0), at(&d, 265.0, 265.0)), ("#ff0000".to_string(), "#00ff00".to_string()));
|
||||
}
|
||||
|
||||
/// A scaled 9-slice instance of a symbol with 40,000 objects in its corner is sampled within
|
||||
/// the work limit, as it is without guides: objects that stay in one cell are not reserved for
|
||||
/// as if each were drawn in nine.
|
||||
#[test]
|
||||
fn a_large_nine_slice_instance_with_its_art_in_one_cell_is_sampled() {
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::{NodeId, SymbolNineSlice};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let mut art = vec![filled_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 0.0)))];
|
||||
art.extend((0..40_000).map(|_| filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)))));
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(Node::group(NodeId(0), art)) });
|
||||
d.set_symbol_nine_slice("Panel", Some(SymbolNineSlice::default()));
|
||||
let (l, inst) = (d.layers[0].id, d.alloc_id());
|
||||
let xf = Affine::translate((250.0, 250.0)) * Affine::scale(2.0);
|
||||
d.insert(Some(l), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let ev = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 255.0, 255.0).with_mods(shift));
|
||||
assert_eq!(sampled_hex(&ev), "#ff0000");
|
||||
}
|
||||
|
||||
/// A pattern nested more than three patterns deep, and a pattern the document lacks, sample as
|
||||
/// the gray the canvas draws them with.
|
||||
#[test]
|
||||
|
||||
@@ -23,7 +23,10 @@ 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).
|
||||
/// 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).
|
||||
fn slice_fields(guides: Option<vectorcraft_doc::SymbolNineSlice>) -> Map<String, Value> {
|
||||
let g = guides.unwrap_or_default();
|
||||
let mut m = Map::new();
|
||||
@@ -36,7 +39,7 @@ fn slice_fields(guides: Option<vectorcraft_doc::SymbolNineSlice>) -> Map<String,
|
||||
|
||||
/// 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);
|
||||
let [l, t, r, b] = SLICE_KEYS.map(|key| d.f64(key, DEFAULT_SLICE_PERCENT) / 100.0);
|
||||
if d.bool("nineSlice") { json!({ "nineSlice": true, "nineSliceGuides": [l, t, r, b] }) } else { json!({ "nineSlice": false }) }
|
||||
}
|
||||
|
||||
@@ -130,7 +133,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
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);
|
||||
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) {
|
||||
d.fields.insert(key.into(), json!(n));
|
||||
}
|
||||
|
||||
+1
-1
@@ -565,7 +565,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
dynamic instance, with the Fill/Stroke controls and Appearance panel then setting its change) and their `.ai`
|
||||
read and write; Global Edit of groups holding type or
|
||||
images, rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start;
|
||||
drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them.
|
||||
drawing the 9-slice guides on the canvas in symbol edit mode.
|
||||
- **Estimate:** 14–24 h.
|
||||
|
||||
## G3 3D and Materials
|
||||
|
||||
+4
-3
@@ -1568,9 +1568,10 @@ 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; rows
|
||||
of `symbol.list` carry `nineSlice`: the guides or `null`; `type` is Symbol Type, `static` by default for the command
|
||||
and `dynamic` by default in the Symbol Options dialog),
|
||||
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
|
||||
a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; `type` is
|
||||
Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog),
|
||||
`symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}`, `symbol.replace`,
|
||||
`symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary
|
||||
isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard
|
||||
|
||||
@@ -618,6 +618,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
- Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them.
|
||||
- **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in library, Basic Shapes, is original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`.
|
||||
- **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html): "When you scale the symbol, the parts of the symbol that don't lie within the guides are not scaled."): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), "Use 9-slice scaling": "guides are placed at 25% (or 1/4)"); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a `<use>`, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Guides are not drawn in symbol edit mode yet. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper).
|
||||
- **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`.
|
||||
- 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`.
|
||||
- **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`.
|
||||
|
||||
+1
-1
@@ -313,7 +313,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
| 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 |
|
||||
| Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) |
|
||||
| Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) |
|
||||
| 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); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) |
|
||||
| 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 (scaled instances keep their corners; guides not drawn in symbol edit mode); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) |
|
||||
| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; on-canvas Direct Selection of a part inside an instance (with the Fill/Stroke controls then setting its change) remains |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user