mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 01:50:22 -06:00
Merge pull request #5280 from dotCooCoo/feat/symbol-nine-slice-guides-on-canvas
Symbols: 9-slice guides are shown and dragged on the canvas while editing a symbol
This commit is contained in:
@@ -29,7 +29,10 @@ 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::{MAX_SLICE_GROWTH, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, charge_symbol_art};
|
||||
pub use symbol_nine_slice::{
|
||||
EditedNineSlice, 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};
|
||||
pub mod links;
|
||||
|
||||
@@ -71,6 +71,79 @@ impl SymbolNineSlice {
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the four 9-slice guides.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NineSliceGuide {
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl NineSliceGuide {
|
||||
pub const ALL: [Self; 4] = [Self::Left, Self::Top, Self::Right, Self::Bottom];
|
||||
}
|
||||
|
||||
/// The narrowest center band a guide dragged toward the opposite guide leaves, as a fraction of
|
||||
/// the art's width or height (VectorCraft's own rule).
|
||||
pub const MIN_CENTER_BAND: f64 = 0.001;
|
||||
|
||||
/// The 9-slice guides of the symbol edited in symbol editing mode, over its art on the editing
|
||||
/// layer ([`Document::edited_nine_slice`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditedNineSlice {
|
||||
/// The symbol's name.
|
||||
pub symbol: String,
|
||||
pub guides: SymbolNineSlice,
|
||||
/// The art's geometric box at its natural size.
|
||||
art: Rect,
|
||||
/// The art at its natural size → the document.
|
||||
to_doc: Affine,
|
||||
}
|
||||
|
||||
impl EditedNineSlice {
|
||||
/// Guide `g` as a line across the art's box, from one edge to the other, in the document.
|
||||
pub fn line(&self, g: NineSliceGuide) -> (Point, Point) {
|
||||
let (b, s) = (self.art, self.guides);
|
||||
let (a, c) = match g {
|
||||
NineSliceGuide::Left | NineSliceGuide::Right => {
|
||||
let x = if g == NineSliceGuide::Left { b.x0 + s.left * b.width() } else { b.x1 - s.right * b.width() };
|
||||
(Point::new(x, b.y0), Point::new(x, b.y1))
|
||||
}
|
||||
NineSliceGuide::Top | NineSliceGuide::Bottom => {
|
||||
let y = if g == NineSliceGuide::Top { b.y0 + s.top * b.height() } else { b.y1 - s.bottom * b.height() };
|
||||
(Point::new(b.x0, y), Point::new(b.x1, y))
|
||||
}
|
||||
};
|
||||
(self.to_doc * a, self.to_doc * c)
|
||||
}
|
||||
|
||||
/// The four guides' lines: left, top, right, bottom.
|
||||
pub fn lines(&self) -> [(NineSliceGuide, Point, Point); 4] {
|
||||
NineSliceGuide::ALL.map(|g| {
|
||||
let (a, b) = self.line(g);
|
||||
(g, a, b)
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 };
|
||||
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),
|
||||
NineSliceGuide::Top => out.top = fit((q.y - b.y0) / b.height(), out.bottom, out.top),
|
||||
NineSliceGuide::Bottom => out.bottom = fit((b.y1 - q.y) / b.height(), out.top, out.bottom),
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The most objects [`NineSliceMap::place`] makes for each object of the art it places: an
|
||||
/// object drawn in nine cells becomes a group of nine clip groups, each a clip path and a copy.
|
||||
pub const MAX_SLICE_GROWTH: u64 = 28;
|
||||
@@ -643,6 +716,25 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 9-slice guides of the symbol being edited in symbol editing mode, over its art on the
|
||||
/// editing layer: `None` outside the mode, when the symbol has no guides, or when its art has
|
||||
/// no width or height.
|
||||
pub fn edited_nine_slice(&self) -> Option<EditedNineSlice> {
|
||||
let se = self.symbol_edit.as_ref()?;
|
||||
let guides = self.symbol_nine_slice(&se.symbol)?;
|
||||
let sym = self.symbols.iter().find(|s| s.name == se.symbol)?;
|
||||
let art = self.symbol_boxes().quick().art_bounds(&se.symbol).or_else(|| sym.art.geometric_bounds())?;
|
||||
let natural = self.symbol_natural_xf(&se.symbol);
|
||||
let b = natural.transform_rect_bbox(art);
|
||||
if !finite(b) || b.width() <= 1e-9 || b.height() <= 1e-9 || natural.determinant().abs() < 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let to_doc = se.xf * natural.inverse();
|
||||
let det = to_doc.determinant();
|
||||
let usable = to_doc.as_coeffs().into_iter().all(f64::is_finite) && det.is_finite() && det.abs() > 1e-18;
|
||||
usable.then(|| EditedNineSlice { symbol: se.symbol.clone(), guides, art: b, to_doc })
|
||||
}
|
||||
|
||||
/// [`Self::place_symbol_art`] for writers that do not apply clipping
|
||||
/// ([`NineSliceMap::place_unclipped`]).
|
||||
pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option<Node> {
|
||||
@@ -1284,4 +1376,76 @@ mod tests {
|
||||
let v = inst.visual_bounds_with(&d.symbol_boxes());
|
||||
assert!(v.is_some_and(|b| near(b, whole)), "{v:?}");
|
||||
}
|
||||
|
||||
/// Symbol "S" of [`markers`] edited in symbol editing mode, its art placed by `xf`.
|
||||
fn editing(guides: Option<SymbolNineSlice>, xf: Affine) -> Document {
|
||||
let mut d = markers(guides);
|
||||
let layer = d.layers[0].id;
|
||||
d.symbol_edit = Some(crate::SymbolEdit {
|
||||
symbol: "S".into(),
|
||||
layer,
|
||||
instance: None,
|
||||
xf,
|
||||
group: true,
|
||||
changed: false,
|
||||
back_isolation: None,
|
||||
back_layer: None,
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
fn near_point(a: Point, b: Point) -> bool {
|
||||
(a - b).hypot() < 1e-9
|
||||
}
|
||||
|
||||
/// While a symbol with guides is edited, its four guides cross its art's box from edge to edge
|
||||
/// where the art shows on the editing layer: here at 25 % in, the art doubled and moved.
|
||||
#[test]
|
||||
fn the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it() {
|
||||
let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0);
|
||||
let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap();
|
||||
assert_eq!(e.symbol, "S");
|
||||
let want = [
|
||||
(NineSliceGuide::Left, Point::new(60.0, 20.0), Point::new(60.0, 220.0)),
|
||||
(NineSliceGuide::Top, Point::new(10.0, 70.0), Point::new(210.0, 70.0)),
|
||||
(NineSliceGuide::Right, Point::new(160.0, 20.0), Point::new(160.0, 220.0)),
|
||||
(NineSliceGuide::Bottom, Point::new(10.0, 170.0), Point::new(210.0, 170.0)),
|
||||
];
|
||||
for ((g, a, b), (wg, wa, wb)) in e.lines().into_iter().zip(want) {
|
||||
assert!(g == wg && near_point(a, wa) && near_point(b, wb), "{g:?} {a:?} {b:?}");
|
||||
}
|
||||
// Outside the mode, without guides, or for a symbol that is gone: no guides.
|
||||
assert!(markers(Some(SymbolNineSlice::default())).edited_nine_slice().is_none());
|
||||
assert!(editing(None, xf).edited_nine_slice().is_none());
|
||||
let mut d = editing(Some(SymbolNineSlice::default()), xf);
|
||||
d.symbols.clear();
|
||||
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.
|
||||
#[test]
|
||||
fn a_moved_guide_stays_in_the_art_and_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 });
|
||||
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);
|
||||
// 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 });
|
||||
// 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)));
|
||||
assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-10.0, 0.0)), SymbolNineSlice { left: 0.1, ..q });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1658,6 +1658,30 @@ fn real_time_drawing_off_still_shows_a_dragged_guide() {
|
||||
assert_eq!((s.doc().unwrap().doc.guides[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), (150.0, Some("Move Guide")));
|
||||
}
|
||||
|
||||
/// With Real-time Drawing off, a 9-slice guide of the edited symbol moved with the Selection tool
|
||||
/// still moves while it is dragged, and the release leaves one Symbol Options undo step.
|
||||
#[test]
|
||||
fn real_time_drawing_off_still_shows_a_dragged_nine_slice_guide() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Box", "nineSlice": true})).unwrap();
|
||||
s.execute("symbol.edit", &json!({})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
set_pref(&mut s, "realTimeDrawing", json!(false));
|
||||
let left = |s: &Session| s.doc().unwrap().doc.symbol_nine_slice("Box").unwrap().left;
|
||||
assert_eq!(left(&s), 0.25);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 150.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 110.0, 150.0), v).unwrap();
|
||||
assert!((left(&s) - 0.1).abs() < 1e-9, "the guide moves while dragged: {}", left(&s));
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 110.0, 150.0), v).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!((left(&s) - 0.1).abs() < 1e-9);
|
||||
assert_eq!((d.history.undo.len(), d.history.undo.last().map(|e| e.label.as_str())), (steps + 1, Some("Symbol Options")));
|
||||
}
|
||||
|
||||
/// Real-time Drawing turned on mid-drag still applies the deferred preview on commit.
|
||||
#[test]
|
||||
fn real_time_drawing_turned_on_mid_drag_still_commits_deferred_preview() {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! 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 vectorcraft_doc::{EditedNineSlice, NineSliceGuide, Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -209,3 +210,43 @@ fn a_dynamic_instance_keeps_its_changes_through_the_guides() {
|
||||
assert_eq!(fills.last().cloned().flatten().as_deref(), Some("#ff0000"), "{fills:?}");
|
||||
assert_ne!(fills[0].as_deref(), Some("#ff0000"), "only the changed object");
|
||||
}
|
||||
|
||||
/// The boxes of the paths a symbol instance `id` draws, placed through its symbol's guides.
|
||||
fn placed_boxes(s: &Session, id: NodeId) -> Vec<Rect> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &d.node(id).unwrap().kind else { panic!("still 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())
|
||||
}
|
||||
|
||||
/// Dragging the left guide on the canvas while editing the symbol changes how a scaled instance
|
||||
/// draws: with the guide moved from 25% to 5%, the corner square of the 200 × 150 instance reaches
|
||||
/// past the left cell into the stretched center band, so it draws about 17.14 pt wide instead of 10.
|
||||
#[test]
|
||||
fn a_guide_dragged_on_the_canvas_changes_how_a_scaled_instance_draws() {
|
||||
let (mut s, id) = panel();
|
||||
assert_sliced(&placed_boxes(&s, id));
|
||||
s.execute("symbol.edit", &json!({"name": "Panel"})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let mid = |e: &EditedNineSlice, g| {
|
||||
let (a, b) = e.line(g);
|
||||
Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0)
|
||||
};
|
||||
let e = s.doc().unwrap().doc.edited_nine_slice().unwrap();
|
||||
// The left guide sits at 25% of the art's width and the right one at 75%: 5% lies 0.4 of
|
||||
// that span to the left of the left guide.
|
||||
let (from, right) = (mid(&e, NineSliceGuide::Left), mid(&e, NineSliceGuide::Right));
|
||||
let to = from + (from - right) * 0.4;
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x + 1.0, from.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();
|
||||
s.execute("symbol.exitEditing", &json!({})).unwrap();
|
||||
let boxes = placed_boxes(&s, id);
|
||||
assert_eq!(boxes.len(), 3, "{boxes:?}");
|
||||
// 5 pt of the square in the left cell, then 5 pt of the 70 pt center band stretched to 170 pt.
|
||||
let want = 5.0 + 5.0 * 170.0 / 70.0;
|
||||
assert!(close(boxes[1], Rect::new(50.0, 50.0, 50.0 + want, 60.0)), "{boxes:?}");
|
||||
let left = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap().left;
|
||||
assert!((left - 0.05).abs() < 1e-9, "{left}");
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ fn rgb(hex: &str, fallback: [u8; 3]) -> [u8; 3] {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a tool preview of `cmd` with params `p` sets only a symbol's 9-slice guides: a
|
||||
/// `symbol.options` whose params hold `nineSliceGuides` and nothing besides `name`.
|
||||
fn nine_slice_guide_preview(cmd: &str, p: &Value) -> bool {
|
||||
cmd == "symbol.options"
|
||||
&& p.as_object().is_some_and(|m| m.contains_key("nineSliceGuides") && m.keys().all(|k| k == "name" || k == "nineSliceGuides"))
|
||||
}
|
||||
|
||||
/// Requests from tools that only the frontend can fulfil.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum UiRequest {
|
||||
@@ -785,8 +792,9 @@ impl Session {
|
||||
Action::Preview(cmd, p) => {
|
||||
// Performance › Real-time Drawing and Editing (#394): off, remember the latest
|
||||
// params and apply them once on Commit instead of every move. A ruler guide
|
||||
// being dragged still shows: the guide is all there is to see (#4646).
|
||||
if !self.prefs.real_time_drawing && !matches!(cmd.as_str(), "guide.add" | "guide.move") {
|
||||
// being dragged still shows: the guide is all there is to see (#4646). So does a
|
||||
// symbol's 9-slice guide being dragged.
|
||||
if !self.prefs.real_time_drawing && !matches!(cmd.as_str(), "guide.add" | "guide.move") && !nine_slice_guide_preview(&cmd, &p) {
|
||||
self.clear_deferred_overlay_cache();
|
||||
let st = self.doc_mut()?;
|
||||
if let Some(it) = &mut st.interaction {
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod intertwine;
|
||||
pub mod meshblend;
|
||||
pub mod meshedit;
|
||||
pub mod mirror;
|
||||
pub mod nineslice;
|
||||
pub mod objects_on_path;
|
||||
pub mod params;
|
||||
pub mod pathtype;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! The 9-slice guides of a symbol edited in symbol editing mode: two horizontal and two vertical
|
||||
//! guides over its art
|
||||
//! ([helpx: maintain proportions while scaling symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)).
|
||||
//! The canvas draws them, dotted, with any tool. With the Selection tool, the pointer near a guide
|
||||
//! takes the Move cursor, and dragging the guide moves it (`symbol.options`'s
|
||||
//! `nineSliceGuides`), in one undo step.
|
||||
//!
|
||||
//! VectorCraft's own rules: a guide stays within the art's box and stops short of the opposite
|
||||
//! guide; the drag does not snap and its modifiers change nothing; a guide within reach takes the
|
||||
//! press before the art and the bounding box, and of two guides within reach the nearer one, else
|
||||
//! the first of left, top, right and bottom; Escape during the drag puts the guide back.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{EditedNineSlice, NineSliceGuide};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{Action, PointerEvent, ToolContext};
|
||||
|
||||
/// How near (screen px) the pointer must come to a guide to take it.
|
||||
const HIT_PX: f64 = 4.0;
|
||||
|
||||
/// The distance from `p` to the segment from `a` to `b`.
|
||||
fn to_segment(p: Point, a: Point, b: Point) -> f64 {
|
||||
let d = b - a;
|
||||
let len2 = d.hypot2();
|
||||
let t = if len2 > 0.0 { ((p - a).dot(d) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
p.distance(a + d * t)
|
||||
}
|
||||
|
||||
/// The edited symbol's guides and the one within reach of `p`, the nearest first.
|
||||
pub fn guide_at(cx: &ToolContext, p: Point) -> Option<(EditedNineSlice, NineSliceGuide)> {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let e = cx.doc.edited_nine_slice()?;
|
||||
let reach = cx.tol(HIT_PX);
|
||||
let near = e
|
||||
.lines()
|
||||
.into_iter()
|
||||
.map(|(g, a, b)| (g, to_segment(p, a, b)))
|
||||
.filter(|(_, d)| *d <= reach)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(g, _)| g)?;
|
||||
Some((e, near))
|
||||
}
|
||||
|
||||
/// Is `p` over a guide of the edited symbol?
|
||||
pub fn over_guide(cx: &ToolContext, p: Point) -> bool {
|
||||
guide_at(cx, p).is_some()
|
||||
}
|
||||
|
||||
/// Dragging a 9-slice guide.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuideDrag {
|
||||
frame: EditedNineSlice,
|
||||
guide: NineSliceGuide,
|
||||
start: Point,
|
||||
began: bool,
|
||||
}
|
||||
|
||||
impl GuideDrag {
|
||||
/// Start a drag when the press `ev` is on a guide of the edited symbol.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let (frame, guide) = guide_at(cx, ev.pos)?;
|
||||
Some(Self { frame, guide, start: ev.pos, began: false })
|
||||
}
|
||||
|
||||
/// The drag has left the click: the guide is moving.
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) || (!self.began && p.distance(self.start) < cx.tol(3.0)) {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Symbol Options".into()));
|
||||
}
|
||||
let guides = self.frame.moved(self.guide, p);
|
||||
out.push(Action::Preview("symbol.options".into(), json!({ "name": self.frame.symbol, "nineSliceGuides": guides.to_value() })));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
pub fn cancel(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::select::SelectionTool;
|
||||
use crate::testutil::*;
|
||||
use crate::{Cursor, Mods, PointerKind, Tool, ToolKey};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, Selection, Symbol, SymbolEdit, SymbolNineSlice};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
/// A 100 × 100 symbol "S" with 9-slice guides `guides`, edited in symbol editing mode with its
|
||||
/// art, a filled square, at (100, 100) on the editing layer: its guides lie at x and y = 125
|
||||
/// and 175.
|
||||
fn editing(guides: Option<SymbolNineSlice>) -> Document {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let square = |d: &mut Document, r: Rect| Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default_art());
|
||||
let art = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) });
|
||||
d.set_symbol_nine_slice("S", guides);
|
||||
let layer = d.add_layer(Some("S"));
|
||||
let copy = square(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
d.insert(Some(layer), 0, copy).unwrap();
|
||||
d.symbol_edit = Some(SymbolEdit {
|
||||
symbol: "S".into(),
|
||||
layer,
|
||||
instance: None,
|
||||
xf: Affine::translate((100.0, 100.0)),
|
||||
group: false,
|
||||
changed: false,
|
||||
back_isolation: None,
|
||||
back_layer: None,
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
fn preview(a: &Action) -> (String, serde_json::Value) {
|
||||
match a {
|
||||
Action::Preview(cmd, v) => (cmd.clone(), v.clone()),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer within 4 screen px of a guide takes it at any zoom, the nearer of two where
|
||||
/// they cross, and nothing is taken outside symbol editing mode or without guides.
|
||||
#[test]
|
||||
fn a_guide_is_taken_within_a_few_screen_pixels_at_any_zoom() {
|
||||
let d = editing(Some(SymbolNineSlice::default()));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
for zoom in [0.25, 1.0, 8.0] {
|
||||
let c = ToolContext { zoom, ..cx(&d, &s, &p) };
|
||||
let at = |x: f64, y: f64| guide_at(&c, Point::new(x, y)).map(|(_, g)| g);
|
||||
assert_eq!(at(125.0 + 3.5 / zoom, 150.0), Some(NineSliceGuide::Left), "{zoom}");
|
||||
assert_eq!(at(125.0 + 5.0 / zoom, 150.0), None, "{zoom}");
|
||||
assert_eq!(at(150.0, 175.0 - 2.0 / zoom), Some(NineSliceGuide::Bottom), "{zoom}");
|
||||
// Past the art's box the guides end.
|
||||
assert_eq!(at(125.0, 100.0 - 6.0 / zoom), None, "{zoom}");
|
||||
}
|
||||
let c = cx(&d, &s, &p);
|
||||
assert_eq!(guide_at(&c, Point::new(176.0, 173.0)).map(|(_, g)| g), Some(NineSliceGuide::Right));
|
||||
assert_eq!(guide_at(&c, Point::new(173.0, 176.0)).map(|(_, g)| g), Some(NineSliceGuide::Bottom));
|
||||
assert_eq!(guide_at(&c, Point::new(125.0, 125.0)).map(|(_, g)| g), Some(NineSliceGuide::Left), "left before top");
|
||||
assert!(guide_at(&c, Point::new(f64::NAN, 150.0)).is_none());
|
||||
let off = editing(None);
|
||||
assert!(!over_guide(&cx(&off, &s, &p), Point::new(125.0, 150.0)));
|
||||
let mut out = editing(Some(SymbolNineSlice::default()));
|
||||
out.symbol_edit = None;
|
||||
assert!(!over_guide(&cx(&out, &s, &p), Point::new(125.0, 150.0)));
|
||||
}
|
||||
|
||||
/// With the Selection tool, a press on a guide over the art takes the guide, not the art: the
|
||||
/// pointer shows the Move cursor, the drag previews that guide alone, and the release commits
|
||||
/// it as one step. A click without a drag sends nothing.
|
||||
#[test]
|
||||
fn dragging_a_guide_moves_that_guide_in_one_step() {
|
||||
let d = editing(Some(SymbolNineSlice::default()));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.cursor(&c, Point::new(126.0, 150.0), Mods::default()), Cursor::Move);
|
||||
assert_ne!(t.cursor(&c, Point::new(150.0, 150.0), Mods::default()), Cursor::Move, "the art between the guides");
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 126.0, 150.0)).is_empty(), "the art is not selected");
|
||||
assert!(t.busy());
|
||||
assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), Mods::default()), Cursor::Move, "kept during the drag");
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 110.0, 400.0));
|
||||
assert_eq!(a[0], Action::Begin("Symbol Options".into()));
|
||||
let (cmd, v) = preview(&a[1]);
|
||||
assert_eq!(cmd, "symbol.options");
|
||||
assert_eq!(v, json!({ "name": "S", "nineSliceGuides": [0.1, 0.25, 0.25, 0.25] }));
|
||||
// Past the opposite guide it stops short of it; modifiers change nothing.
|
||||
let shifted = PointerEvent { mods: Mods { shift: true, alt: true, ..Mods::default() }, ..ev(PointerKind::Drag, 190.0, 150.0) };
|
||||
let (_, v) = preview(&t.pointer(&c, &shifted)[0]);
|
||||
let left = v["nineSliceGuides"][0].as_f64().unwrap();
|
||||
assert!((left - (0.75 - vectorcraft_doc::MIN_CENTER_BAND)).abs() < 1e-12, "{v}");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 190.0, 150.0)), vec![Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
// A click on a guide without moving.
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 175.0, 150.0)).is_empty());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Drag, 176.0, 150.0)).is_empty());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 176.0, 150.0)).is_empty());
|
||||
// The top guide moves up and down only.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 150.0, 125.0));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 10.0, 140.0));
|
||||
assert_eq!(preview(&a[1]).1["nineSliceGuides"], json!([0.25, 0.4, 0.25, 0.25]));
|
||||
}
|
||||
|
||||
/// Escape during a guide drag cancels it and is the tool's; the release after it commits
|
||||
/// nothing. A double-click on a guide neither edits the art nor leaves the mode.
|
||||
#[test]
|
||||
fn escape_cancels_a_guide_drag_and_a_double_click_stays_on_the_guide() {
|
||||
let d = editing(Some(SymbolNineSlice::default()));
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = ToolContext { isolation: d.symbol_edit.as_ref().map(|e| e.layer), ..cx(&d, &s, &p) };
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 175.0, 150.0));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, 160.0, 150.0))[0], Action::Begin("Symbol Options".into()));
|
||||
assert!(t.claims_key(&c, ToolKey::Escape));
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(!t.busy() && !t.claims_key(&c, ToolKey::Escape));
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 160.0, 150.0)).is_empty());
|
||||
// Escape after a click that did not move cancels nothing.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 175.0, 150.0));
|
||||
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty());
|
||||
for k in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
|
||||
assert!(t.pointer(&c, &ev(k, 175.0, 150.0)).is_empty(), "{k:?}");
|
||||
}
|
||||
// Away from the guides a double-click outside the art still leaves isolation.
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::DoubleClick, 400.0, 400.0)), vec![Action::Exec("object.exitIsolation".into(), json!({}))]);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
//! a guide path to release it ([`release_guide_at`]), drag the brackets of type on a path
|
||||
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
|
||||
//! ([`crate::typewidget`]), click a Shaper Group's mode widget to switch between Face Selection and
|
||||
//! Construction mode ([`crate::shaperwidget`]).
|
||||
//! Construction mode ([`crate::shaperwidget`]), drag the 9-slice guides of the symbol being edited
|
||||
//! in symbol editing mode ([`crate::nineslice`]).
|
||||
//! The bounding box stands at the selection's own angle after a rotation, so its handles scale
|
||||
//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of
|
||||
//! scaling its type: the text reflows at its size.
|
||||
@@ -112,6 +113,8 @@ enum State {
|
||||
GridRepeat(crate::repeat_grid::GridDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Dragging a 9-slice guide of the symbol being edited.
|
||||
NineSlice(crate::nineslice::GuideDrag),
|
||||
/// Loaded with the text of area type or type on a path `from` by a click on its out port, or
|
||||
/// on its in port (`before`: the frame it goes into comes before `from` in its thread);
|
||||
/// `press`: where the next press went down and where the pointer is now.
|
||||
@@ -646,6 +649,12 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
return d.cancel();
|
||||
}
|
||||
if key == ToolKey::Escape
|
||||
&& matches!(self.state, State::NineSlice(_))
|
||||
&& let State::NineSlice(d) = std::mem::take(&mut self.state)
|
||||
{
|
||||
return d.cancel();
|
||||
}
|
||||
if key == ToolKey::Escape && matches!(self.state, State::Loaded { .. }) {
|
||||
self.state = State::Idle;
|
||||
return vec![];
|
||||
@@ -664,7 +673,7 @@ impl Tool for SelectionTool {
|
||||
match self.state {
|
||||
State::Marquee { .. } => key == ToolKey::Letter('e'),
|
||||
State::Corner(_) => matches!(key, ToolKey::Up | ToolKey::Down),
|
||||
State::Loaded { .. } | State::Mirror(_) | State::GridRepeat(_) => key == ToolKey::Escape,
|
||||
State::Loaded { .. } | State::Mirror(_) | State::GridRepeat(_) | State::NineSlice(_) => key == ToolKey::Escape,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -702,6 +711,10 @@ impl Tool for SelectionTool {
|
||||
{
|
||||
return out;
|
||||
}
|
||||
// The edited symbol's 9-slice guides take precedence over every other control and the art.
|
||||
if let Some(out) = self.nine_slice_guides(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
// Mirror and grid repeat controls take precedence over art and ruler guides; the mirror
|
||||
// axis line and the grid's controls yield to the bounding-box handles they overlap.
|
||||
if let Some(out) = self.repeat_controls(cx, ev) {
|
||||
@@ -926,6 +939,9 @@ impl Tool for SelectionTool {
|
||||
if let State::GridRepeat(d) = &self.state {
|
||||
return d.cursor(cx);
|
||||
}
|
||||
if matches!(self.state, State::NineSlice(_)) || (matches!(self.state, State::Idle) && crate::nineslice::over_guide(cx, p)) {
|
||||
return Cursor::Move;
|
||||
}
|
||||
if matches!(self.state, State::Idle)
|
||||
&& !m.cmd
|
||||
&& !m.ctrl
|
||||
@@ -1157,6 +1173,40 @@ impl SelectionTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A press on a 9-slice guide of the edited symbol starts dragging it; the drag and the
|
||||
/// release go to it.
|
||||
fn nine_slice_guides(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
if let State::NineSlice(mut d) = self.state.clone() {
|
||||
return Some(match ev.kind {
|
||||
PointerKind::Drag => {
|
||||
let out = d.drag(cx, ev.pos);
|
||||
self.state = State::NineSlice(d);
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
self.state = State::Idle;
|
||||
d.finish()
|
||||
}
|
||||
_ => vec![],
|
||||
});
|
||||
}
|
||||
if !matches!(self.state, State::Idle) {
|
||||
return None;
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let d = crate::nineslice::GuideDrag::hit(cx, ev)?;
|
||||
self.state = State::NineSlice(d);
|
||||
self.hover = None;
|
||||
Some(vec![])
|
||||
}
|
||||
// The clicks of a double-click on a guide belong to it: it neither edits the art nor
|
||||
// leaves symbol editing mode.
|
||||
PointerKind::DoubleClick if crate::nineslice::over_guide(cx, ev.pos) => Some(vec![]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn repeat_controls(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
if let State::Mirror(mut d) = self.state.clone() {
|
||||
match ev.kind {
|
||||
|
||||
@@ -70,8 +70,8 @@ pub use tool_overlays::HiddenChars;
|
||||
#[cfg(test)]
|
||||
use tool_overlays::{DIAL_RADIUS, HiddenMarks};
|
||||
use tool_overlays::{
|
||||
grid_repeat_overlay, hidden_chars_overlay, live_paint_gap_overlays, mirror_axis_overlay, original_path_overlay, print_tiling_overlay,
|
||||
rotate_view_dial, shaper_widget_overlay, slice_overlay, thread_overlays,
|
||||
grid_repeat_overlay, hidden_chars_overlay, live_paint_gap_overlays, mirror_axis_overlay, nine_slice_overlay, original_path_overlay,
|
||||
print_tiling_overlay, rotate_view_dial, shaper_widget_overlay, slice_overlay, thread_overlays,
|
||||
};
|
||||
pub(crate) use view::animated_zoom;
|
||||
pub use view::fit;
|
||||
@@ -325,6 +325,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.ui.view.guides {
|
||||
guide_lines(app, &painter, &xf, rect, &t);
|
||||
}
|
||||
nine_slice_overlay(app, &painter, &xf);
|
||||
|
||||
if !app.session.slices_hidden() {
|
||||
slice_overlay(app, &painter, &xf);
|
||||
|
||||
@@ -3159,6 +3159,65 @@ fn grid_repeat_handles_set_rows_and_spacing_in_one_undo_step_each() {
|
||||
assert_eq!((grid(&app).0, grid(&app).2), (5, 10.0), "the spacing's own undo step");
|
||||
}
|
||||
|
||||
/// While a symbol with 9-slice guides is edited, the canvas draws its four guides dotted across
|
||||
/// its art, with ruler guides hidden too, and none once the mode ends. Dragging the left guide in
|
||||
/// a zoomed view moves that guide alone, as one undo step.
|
||||
#[test]
|
||||
fn the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
app.run("symbol.new", json!({"name": "Box", "nineSlice": true})).unwrap();
|
||||
app.select_tool("selection");
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.view_mut().unwrap().zoom = 1.5;
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
// The dots drawn along the guide from document point `a` to `b`.
|
||||
let dots = |app: &mut VectorcraftApp, a: Point, b: Point| {
|
||||
let (a, b) = (xf.to_screen(a), xf.to_screen(b));
|
||||
let on = |q: Pos2| {
|
||||
let (d, len) = (b - a, (b - a).length());
|
||||
let t = (q - a).dot(d) / (len * len);
|
||||
(-0.01..=1.01).contains(&t) && (a + d * t).distance(q) < 0.01
|
||||
};
|
||||
shapes(app, &ctx).into_iter().filter(|s| matches!(s, Shape::LineSegment { points: [p, q], .. } if on(*p) && on(*q))).count()
|
||||
};
|
||||
let guides = [
|
||||
(Point::new(125.0, 100.0), Point::new(125.0, 200.0)),
|
||||
(Point::new(100.0, 125.0), Point::new(200.0, 125.0)),
|
||||
(Point::new(175.0, 100.0), Point::new(175.0, 200.0)),
|
||||
(Point::new(100.0, 175.0), Point::new(200.0, 175.0)),
|
||||
];
|
||||
assert_eq!(dots(&mut app, guides[0].0, guides[0].1), 0, "not while the instance is only selected");
|
||||
app.run("symbol.edit", json!({})).unwrap();
|
||||
app.ui.view.guides = false;
|
||||
for (a, b) in guides {
|
||||
assert!(dots(&mut app, a, b) > 10, "a dotted guide from {a:?} to {b:?}");
|
||||
}
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
let from = xf.to_screen(Point::new(125.0, 150.0));
|
||||
let to = xf.to_screen(Point::new(110.0, 160.0));
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||||
for k in 1..=4 {
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) * k as f32 / 4.0)]);
|
||||
}
|
||||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||||
let guides_now = |app: &VectorcraftApp| app.session.active().unwrap().doc.symbol_nine_slice("Box").unwrap();
|
||||
let g = guides_now(&app);
|
||||
assert!((g.left - 0.1).abs() < 1e-3 && (g.top, g.right, g.bottom) == (0.25, 0.25, 0.25), "{g:?}");
|
||||
assert!(app.session.active().unwrap().doc.symbol_edit.is_some(), "still editing the symbol");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(guides_now(&app).left, 0.25, "one undo step");
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert!((guides_now(&app).left - 0.1).abs() < 1e-3);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_bounding_box_handle_scales_a_default_mirror_repeat() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -166,6 +166,23 @@ pub(super) fn original_path_overlay(app: &VectorcraftApp, p: &egui::Painter, xf:
|
||||
}
|
||||
}
|
||||
|
||||
/// The 9-slice guides of the symbol edited in symbol editing mode: four thin dotted lines across
|
||||
/// its art's box, with any tool, whether ruler guides are shown or hidden. The Selection tool drags
|
||||
/// them ([`vectorcraft_tools::nineslice`]) at the places drawn here.
|
||||
pub(super) fn nine_slice_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(e) = app.session.active().and_then(|st| st.doc.edited_nine_slice()) else { return };
|
||||
let color = Tokens::get(p.ctx()).selection;
|
||||
for (_, a, b) in e.lines() {
|
||||
let (a, b) = (xf.to_screen(a), xf.to_screen(b));
|
||||
if !a.is_finite() || !b.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if let Some((a, b)) = clip_segment(a, b, xf.rect) {
|
||||
p.extend(Shape::dashed_line(&[a, b], Stroke::new(1.0, color), 2.0, 2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices
|
||||
/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show
|
||||
/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision.
|
||||
|
||||
+3
-2
@@ -529,7 +529,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
Export Type (Graphic or Movie Clip; default Movie Clip; `symbol.new` / `symbol.options` / Symbols panel;
|
||||
canvas placement unchanged; Animate / Flash handoff metadata); Symbol Options › Enable Guides for 9-Slice
|
||||
Scaling (four guides as fractions of the symbol size, `nineSlice` / `nineSliceGuides` on `symbol.new` /
|
||||
`symbol.options`, stored per symbol; handoff metadata, not drawn on the canvas, #2342); Brush Options › Key
|
||||
`symbol.options`, stored per symbol; handoff metadata, #2342; drawn dotted over the art in symbol editing
|
||||
mode, where the Selection tool drags them); Brush Options › Key
|
||||
Color eyedropper for scatter, art and pattern brushes (Hue Shift colorization; samples canvas art); Select ›
|
||||
Start Global Edit (#3521) on paths, compound paths and symbol instances: the objects of the same shape at
|
||||
any place and size, narrowed by Appearance, Size and the artboards to search, take an edit of any one of
|
||||
@@ -565,7 +566,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.
|
||||
9-slice guides outside the symbol's art (a dragged guide stops at the art's edge).
|
||||
- **Estimate:** 14–24 h.
|
||||
|
||||
## G3 3D and Materials
|
||||
|
||||
+2
-1
@@ -1592,7 +1592,8 @@ already walks Image Trace. Short agent entry points:
|
||||
`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
|
||||
a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; `type` is
|
||||
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),
|
||||
`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
|
||||
|
||||
@@ -620,9 +620,10 @@ 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).
|
||||
- **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. 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`.
|
||||
- **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`.
|
||||
- **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`.
|
||||
- **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes 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. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`.
|
||||
|
||||
+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 (scaled instances keep their corners; guides not drawn in symbol edit mode); 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; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); 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