From 78889ce8af7d351a6f16a35ef1804d11c805f5bb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:37:31 -0800 Subject: [PATCH 01/40] Transform: Scale and Reflect act along a rotated bounding box The Scale and Reflect tools, object.scale, object.reflect and the Transform panel flips act along a rotated bounding box instead of the page axes, until Object > Transform > Reset Bounding Box, as Free Transform does. The Scale and Reflect dialogs and the Properties panel flips run the same commands and follow the box too. - object.scale and object.reflect with a named axis take basis?: "box"|"page" ("box" by default; "page" keeps the page axes; any other value is an error before anything changes). The journal records the basis, so recorded actions replay in the same frame; an older recording without it acts along the box. - A numeric Reflect axis and a Reflect axis drawn with the tool stay lines on the page. While a Reflect drag has not drawn an axis away from the reference point, the tool uses the box's vertical axis, and Shift steps 45 degrees from the box. - Mixed-angle selections and anchors selected with Direct Selection use the page axes; an Alt-drag copy of such anchors scales the whole objects along their box. Transform Again repeats the page matrix. Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md. --- crates/engine/src/cmd/object/commands.rs | 4 +- crates/engine/src/cmd/object/transform.rs | 43 +++++-- crates/engine/src/tests/bboxrotate.rs | 88 ++++++++++++++- crates/engine/src/tests/gradient.rs | 5 +- crates/engine/src/tests/journal.rs | 2 +- crates/engine/src/tests/scalestrokes.rs | 2 +- crates/mcp/src/tools/definitions.rs | 2 +- crates/tools/src/xform/transform.rs | 130 +++++++++++++++++++--- docs/mcp.md | 18 +++ docs/target-app-parity.md | 3 +- docs/ui-parity.md | 2 +- 11 files changed, 265 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 5c66ec8442..e079b0aa8c 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Scale…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), sx: % (along the bounding box's width), sy?: % (along its height), origin?: [x,y] (a page point), basis?: \"box\"|\"page\" (box, the default: sx and sy follow the bounding box, turned with rotated objects until Reset Bounding Box; page: the page's axes), copy?, strokes?: bool (Scale Strokes & Effects: stroke weights, dashes and effect distances scale; off keeps them, type strokes included), corners?: bool (Scale Corners: live corner radii scale), objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)} (strokes/corners default to the preferences). With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, scale ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical; vertical and horizontal are the bounding box's own axes, deg a line on the page), origin?, basis?: \"box\"|\"page\" (for a named axis; box, the default: the bounding box's axes, turned with rotated objects until Reset Bounding Box; page: the page's axes), copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index d102068947..1eac08e571 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -1,7 +1,7 @@ use super::align::choice; use super::{ - Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, Point, Result, Scaling, Session, Value, Vec2, bad, bool_or, - check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, + Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, OrientedBox, Point, Rect, Result, Scaling, Session, Value, Vec2, bad, + bool_or, check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, selected_roots_to_transform, }; @@ -358,26 +358,47 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result { if sx == 0.0 || sy == 0.0 { return Err(bad("object.scale", "scale must be non-zero")); } - apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), p, true) + let frame = basis_frame(s, p, "object.scale", &ids)?; + apply_transform(s, "Scale", ids, about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), p, true) +} + +/// The frame that `object.scale` and a named `object.reflect` act in, from the `basis` param: +/// `"box"` (the default) is the bounding box of `ids`, turned with rotated objects until Reset +/// Bounding Box ([`Document::bbox_angle`]); `"page"` is the page's axes. Anchors selected with +/// Direct Selection (and no copy) transform along the page's axes, as their own box does. The +/// basis joins the journal entry, so a replay acts in the same frame. +fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result { + let page = choice(p, cmd, "basis", &["box", "page"])? == Some("page"); + let st = s.doc()?; + let anchors = !bool_or(p, "copy", false) && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); + let angle = if page || anchors { 0.0 } else { st.doc.bbox_angle(ids) }; + s.note_journal("basis", json!(if page { "page" } else { "box" })); + Ok(OrientedBox { angle, rect: Rect::ZERO }) } pub(super) fn reflect(s: &mut Session, p: &Value) -> Result { check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?; let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; - let m = match p.get("axis") { - Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0), - Some(Value::Number(n)) => { - let t = -n.as_f64().unwrap_or(90.0).to_radians(); - Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) - } - None | Some(Value::Null) => Affine::scale_non_uniform(-1.0, 1.0), - Some(Value::String(a)) if a == "vertical" => Affine::scale_non_uniform(-1.0, 1.0), + // A named axis is the bounding box's own (see [`basis_frame`]); an angle is a line on the page. + let named = match p.get("axis") { + Some(Value::String(a)) if a == "horizontal" => Some(Affine::scale_non_uniform(1.0, -1.0)), + Some(Value::Number(_)) => None, + None | Some(Value::Null) => Some(Affine::scale_non_uniform(-1.0, 1.0)), + Some(Value::String(a)) if a == "vertical" => Some(Affine::scale_non_uniform(-1.0, 1.0)), Some(v) => { let given = v.as_str().map_or_else(|| v.to_string(), str::to_string); return Err(bad("object.reflect", format!("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{given}`"))); } }; + let m = match named { + Some(m) => basis_frame(s, p, "object.reflect", &ids)?.conjugate(m), + None => { + choice(p, "object.reflect", "basis", &["box", "page"])?; + let t = -p.get("axis").and_then(Value::as_f64).unwrap_or(90.0).to_radians(); + Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t) + } + }; apply_transform(s, "Reflect", ids, about(o, m), p, true) } diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 8907b21cc0..0a6df22e10 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Node, OrientedBox}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -379,10 +379,96 @@ fn page_flips_keep_an_upright_box_upright() { s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); assert_eq!(angle(&s, a), 0.0); s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + // A named axis is the turned box's own: the box keeps its angle. s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + assert!(near(angle(&s, a), 30.0)); + s.execute("object.reflect", &json!({"axis": "vertical", "basis": "page"})).unwrap(); assert!(near(angle(&s, a), -30.0)); } +fn same_matrix(a: Affine, b: Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y)) +} + +/// After a rotation, Scale and a named Reflect act along the turned bounding box until Reset +/// Bounding Box; `basis: "page"` keeps the page's axes, and Transform Again repeats the matrix the +/// transform applied on the page. +#[test] +fn scale_and_reflect_follow_the_turned_box() { + let mut s = session(); + let a = turned_rect(&mut s); + let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap(); + let c = Point::new(200.0, 200.0); + let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let b = selection_box(&s); + assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}"); + assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0), "{b:?}"); + assert!(b.center().distance(c) < 1e-6, "{b:?}"); + let want = Affine::translate(c.to_vec2()) * frame.conjugate(Affine::scale_non_uniform(2.0, 1.0)) * Affine::translate(-c.to_vec2()); + let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") }; + assert!(same_matrix(m, want), "{m:?}"); + // The recorded step names its basis, so a replay acts in the same frame. + let steps = s.journal_for_action(0); + assert!(steps.iter().any(|(id, p)| id == "object.scale" && p["basis"] == "box"), "{steps:?}"); + + // Reflected across the box's vertical axis through its left side: the box keeps its angle and + // size, and its centre moves a width to the left along the box. + let left = b.reference_point(3); + s.execute("object.reflect", &json!({"axis": "vertical", "origin": [left.x, left.y]})).unwrap(); + let r = selection_box(&s); + let moved = c + (frame.to_doc() * Point::new(-200.0, 0.0)).to_vec2(); + assert!(near(r.angle, 45.0) && near(r.rect.width(), 200.0) && near(r.rect.height(), 50.0), "{r:?}"); + assert!(r.center().distance(moved) < 1e-6, "{r:?}"); + // Across its horizontal axis through its centre: the box stays where it is. + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + assert!(selection_box(&s).center().distance(moved) < 1e-6 && near(angle(&s, a), 45.0)); + + // Unknown bases are errors and change nothing. + let n = undo_len(&s); + assert!(s.execute("object.scale", &json!({"sx": 50, "basis": "turned"})).is_err()); + assert!(s.execute("object.reflect", &json!({"axis": "vertical", "basis": 1})).is_err()); + assert!(s.execute("object.reflect", &json!({"axis": 30, "basis": "turned"})).is_err()); + assert_eq!(undo_len(&s), n); + + // On the page's axes, the box no longer stands at 45°. + s.execute("object.scale", &json!({"sx": 50, "sy": 100, "basis": "page"})).unwrap(); + assert!(!near(angle(&s, a), 45.0)); + // Transform Again repeats the page matrix after Reset Bounding Box too. + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let Some(LastTransform::Matrix(m, _)) = s.doc().unwrap().last_transform.clone() else { panic!("no transform to repeat") }; + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.node(a).unwrap().clone(); + s.execute("object.transformAgain", &json!({})).unwrap(); + let mut want = before; + want.transform(m, false); + let (got, want) = (page(&s, &[a]), want.geometric_bounds_with(&s.doc().unwrap().doc.symbol_boxes()).unwrap()); + assert!([got.x0 - want.x0, got.y0 - want.y0, got.x1 - want.x1, got.y1 - want.y1].iter().all(|d| d.abs() < 1e-6), "{got:?} {want:?}"); +} + +/// Once Reset Bounding Box squares the box, and for objects standing at different angles, Scale +/// acts along the page's axes. +#[test] +fn scale_follows_the_page_after_a_reset_and_for_mixed_angles() { + let mut s = session(); + let a = turned_rect(&mut s); + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + let page = |s: &Session, ids: &[NodeId]| s.doc().unwrap().doc.bounds_of(ids, false).unwrap(); + let w = page(&s, &[a]); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let r = page(&s, &[a]); + assert!(near(r.width(), 2.0 * w.width()) && near(r.height(), w.height()), "{w:?} {r:?}"); + assert_eq!(angle(&s, a), 0.0); + // A turned rectangle with an upright one: the box is square to the page. + let b = turned_rect(&mut s); + let u = rect(&mut s, 400.0, 100.0, 50.0, 50.0); + sel(&mut s, &[b, u]); + let w = page(&s, &[b, u]); + s.execute("object.scale", &json!({"sx": 100, "sy": 200})).unwrap(); + let r = page(&s, &[b, u]); + assert!(near(r.width(), w.width()) && near(r.height(), 2.0 * w.height()), "{w:?} {r:?}"); +} + #[test] fn groups_type_and_multiple_selections_keep_their_angle() { let mut s = session(); diff --git a/crates/engine/src/tests/gradient.rs b/crates/engine/src/tests/gradient.rs index a2aea6341d..2ad63326f5 100644 --- a/crates/engine/src/tests/gradient.rs +++ b/crates/engine/src/tests/gradient.rs @@ -219,9 +219,10 @@ fn rotate_reflect_and_scale_carry_unplaced_gradients() { s.execute("object.rotate", &json!({"angle": 90})).unwrap(); let g = fill_geom(&s, id); assert!((g.end.x - g.start.x).abs() < 1e-9 && (g.length() - 100.0).abs() < 1e-9, "vertical after a 90° turn: {g:?}"); - s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + s.execute("object.reflect", &json!({"axis": "horizontal", "basis": "page"})).unwrap(); let r = fill_geom(&s, id); - // The vertical vector is centred on the object, so the reflection swaps its ends. + // The vertical vector is centred on the object, so the reflection across the page's + // horizontal axis swaps its ends. assert!(r.start.distance(g.end) < 1e-9 && r.end.distance(g.start) < 1e-9, "{g:?} → {r:?}"); // A circle's radial gradient becomes an ellipse under a non-uniform scale. diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 5eb66ec6c8..89b38869bd 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -353,7 +353,7 @@ fn a_batch_step_records_what_it_took_from_the_preferences() { ]); s.execute("command.batch", &json!({ "commands": steps })).unwrap(); let p = &s.journal.last().unwrap().1; - assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false})); + assert_eq!(p["commands"][2]["params"], json!({"sx": 300, "strokes": true, "corners": false, "basis": "box"})); // Replayed with other preferences, the scale still scales the stroke. let r = replayed(&s); assert!(!r.prefs.scale_strokes); diff --git a/crates/engine/src/tests/scalestrokes.rs b/crates/engine/src/tests/scalestrokes.rs index d03a9ebf8c..4d8349b6a4 100644 --- a/crates/engine/src/tests/scalestrokes.rs +++ b/crates/engine/src/tests/scalestrokes.rs @@ -216,7 +216,7 @@ fn the_journal_records_the_scale_options_used() { run(&mut s, "object.scale", json!({"sx": 150})); let (cmd, p) = s.journal.last().unwrap().clone(); assert_eq!(cmd, "object.scale"); - assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true})); + assert_eq!(p, json!({"sx": 150, "strokes": false, "corners": true, "basis": "box"})); // An explicit value stays; moves don't scale, so they record nothing. run(&mut s, "object.scale", json!({"sx": 150, "strokes": true})); assert_eq!(s.journal.last().unwrap().1["strokes"], json!(true)); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index d4d69c945f..5bc1018730 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -465,7 +465,7 @@ pub fn tool_definitions() -> Vec { tool( "transform", "Transform", - "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", + "Move, rotate, scale, reflect or shear the selection (or `ids`); several may be combined and run in that order. Angles in degrees (counter-clockwise), scale in %. Scale and reflect act along the selection's bounding box, turned with rotated objects until Reset Bounding Box. `origin` [x,y] defaults to the selection centre; `copy` transforms a copy.", obj( json!({ "ids": {"type": "array", "items": {"type": "integer"}}, diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 263c0ea5e4..8f3f3b1768 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -8,10 +8,12 @@ //! while hovering, so transforms pivot exactly on a corner. A drag starts from the point it grabbed //! snapped the same way, and the pointer snaps as a drawn point does (Smart Guides: the other //! objects' anchors, centres and edges; or the grid, pixels, Snap to Point), so a corner dragged -//! onto another object's anchor scales exactly onto it. +//! onto another object's anchor scales exactly onto it. Scale stretches along the bounding box's +//! own width and height, turned with rotated objects until Reset Bounding Box; Reflect's default +//! axis is the box's vertical one, and Shift constrains a drawn axis to 45° steps from the box. use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; +use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::target_overlays; @@ -68,9 +70,26 @@ struct Drag { /// Alt was down at the press: a click opens the dialog, a drag transforms a copy. alt: bool, bounds: Rect, + /// The angle of the selection's bounding box: Scale and Reflect act along its axes + /// ([`TransformTool::matrix_in`]). + angle: f64, + /// Anchors are selected with Direct Selection: they transform alone along the page's axes, + /// and a copy transforms the whole objects along the box ([`Drag::angle_for`]). + anchors: bool, + /// The snapped pointer and Shift at the last drag event, to work out the transform again + /// when the Alt state at the release changes what it transforms. + pointer: Point, + shift: bool, last: Affine, } +impl Drag { + /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not. + fn angle_for(&self, copy: bool) -> f64 { + if self.anchors && !copy { 0.0 } else { self.angle } + } +} + pub struct TransformTool { kind: TransformKind, /// Custom reference point and the selection it was set for. @@ -125,6 +144,16 @@ impl TransformTool { /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. pub fn matrix_for(&self, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + self.matrix_in(0.0, o, start, p, shift, bounds) + } + + /// [`TransformTool::matrix_for`] with the bounding box turned by `angle` (counter-clockwise + /// degrees): Scale stretches along the box's own width and height, and Reflect's default axis + /// and Shift's 45° steps start from the box's axes. A Reflect axis drawn by the drag is the + /// line the pointer draws on the page. + pub fn matrix_in(&self, angle: f64, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) { + let frame = OrientedBox { angle, rect: Rect::ZERO }; + let local = |v: Vec2| frame.to_doc().inverse() * v.to_point(); match self.kind { TransformKind::Rotate => { let (a, deg) = rotate_for_drag(o, start, p, shift); @@ -132,15 +161,17 @@ impl TransformTool { } TransformKind::Reflect => { let mut v = p - o; - if shift { - v = vectorcraft_geom::constrain_angle(v, 45.0); + if v.hypot() < 1e-9 { + v = (frame.to_doc() * Point::new(0.0, 1.0)).to_vec2(); + } else if shift { + v = (frame.to_doc() * vectorcraft_geom::constrain_angle(local(v).to_vec2(), 45.0).to_point()).to_vec2(); } - let theta = if v.hypot() < 1e-9 { std::f64::consts::FRAC_PI_2 } else { v.y.atan2(v.x) }; + let theta = v.y.atan2(v.x); (reflect_matrix(o, theta), format!("{:.1}°", vectorcraft_geom::normalize_deg(-theta.to_degrees()))) } TransformKind::Scale => { - let d0 = start - o; - let d1 = p - o; + let d0 = local(start - o).to_vec2(); + let d1 = local(p - o).to_vec2(); let ratio = |a: f64, b: f64| if a.abs() > 1e-6 { b / a } else { 1.0 }; let (mut sx, mut sy) = (ratio(d0.x, d1.x), ratio(d0.y, d1.y)); if shift { @@ -151,7 +182,7 @@ impl TransformTool { } let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s }; let (sx, sy) = (clamp(sx), clamp(sy)); - (about(o, Affine::scale_non_uniform(sx, sy)), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) + (about(o, frame.conjugate(Affine::scale_non_uniform(sx, sy))), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0)) } TransformKind::Shear => { // The shear axis follows the dominant drag direction. Shift keeps the drag on that @@ -227,7 +258,21 @@ impl Tool for TransformTool { // Alt: decided on release. A click sets the point and opens the dialog; a drag // transforms a copy about the reference point as it was. let origin = self.reference_point(cx).unwrap_or(bounds.center()); - self.drag = Some(Drag { start: p, pick, origin, began: false, alt: ev.mods.alt, bounds, last: Affine::IDENTITY }); + let anchors = vectorcraft_effects::preview::anchor_bounds_with(cx.selection, cx.doc, cx.bounds_measure()).is_some(); + let angle = selection_box(cx).map_or(0.0, |b| b.angle); + self.drag = Some(Drag { + start: p, + pick, + origin, + began: false, + alt: ev.mods.alt, + bounds, + angle, + anchors, + pointer: p, + shift: false, + last: Affine::IDENTITY, + }); vec![] } PointerKind::Drag => { @@ -247,8 +292,8 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_for(d.origin, d.pick, p, ev.mods.shift, d.bounds); - d.last = m; + let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, p, ev.mods.shift, d.bounds); + (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); self.measure = Some((p, text)); out.push(preview(m, ev.mods.alt, ev.backquote)); @@ -257,8 +302,11 @@ impl Tool for TransformTool { PointerKind::Up => { let Some(d) = self.end_drag() else { return vec![] }; if d.began { - // The Alt state at release decides whether the result is a copy. - vec![preview(d.last, ev.mods.alt, ev.backquote), Action::Commit] + // The Alt state at release decides whether the result is a copy, and with + // anchors selected, whether it acts along the box or the page. + let m = + if d.anchors { self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 } else { d.last }; + vec![preview(m, ev.mods.alt, ev.backquote), Action::Commit] } else { // A click sets the reference point; with Alt, it opens the dialog too. self.origin = Some((d.pick, cx.selection.objects.clone())); @@ -578,6 +626,62 @@ mod tests { assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9); } + /// With the selection turned by 30°, a Scale drag stretches along the box's own sides, and + /// Reflect's default axis and Shift steps start from the box's axes while a drawn axis stays + /// the line drawn on the page. + #[test] + fn scale_and_reflect_drags_follow_a_turned_bounding_box() { + let (mut d, id) = doc_with_rect(); + let c = Point::new(150.0, 150.0); + d.node_mut(id).unwrap().transform(about(c, Affine::rotate(-30f64.to_radians())), false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut cx = cx(&d, &s, &p); + (cx.smart_guides, cx.snap_to_point) = (false, false); + let frame = OrientedBox { angle: 30.0, rect: Rect::ZERO }; + let along = |x: f64, y: f64| c + (frame.to_doc() * Point::new(x, y)).to_vec2(); + // The right side's middle dragged out to twice its distance: the width doubles along the + // box, and the height stays. + let mut t = TransformTool::new(TransformKind::Scale); + let (from, to) = (along(50.0, 0.0), along(100.0, 0.0)); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + let m = matrix(&a[1]); + assert!((m * along(50.0, 50.0)).distance(along(100.0, 50.0)) < 1e-9, "{m:?}"); + assert!((m * along(0.0, 50.0)).distance(along(0.0, 50.0)) < 1e-9, "{m:?}"); + assert!(a.iter().any(|x| matches!(x, Action::Preview(..)))); + + let t = TransformTool::new(TransformKind::Reflect); + // No axis drawn: the box's vertical axis. + let (m, _) = t.matrix_in(30.0, c, c, c, false, Rect::ZERO); + assert!((m * along(40.0, 10.0)).distance(along(-40.0, 10.0)) < 1e-9, "{m:?}"); + // Shift: 39° from the box's horizontal axis steps to 45° from it. + let (m, text) = t.matrix_in(30.0, c, c, along(10.0, 8.0), true, Rect::ZERO); + assert!((m * along(10.0, 0.0)).distance(along(0.0, 10.0)) < 1e-9, "{m:?}"); + assert_eq!(text, "-15.0°"); + // A drawn axis is the line on the page. + let (m, _) = t.matrix_in(30.0, c, c, c + Vec2::new(0.0, -50.0), false, Rect::ZERO); + assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9, "{m:?}"); + + // Anchors selected with Direct Selection scale alone along the page's axes; Alt at the + // release copies the whole object, which scales along its box. + let mut s = s.clone(); + s.anchors.insert(id, [(0, 0), (0, 1)].into()); + let mut cx = crate::testutil::cx(&d, &s, &p); + (cx.smart_guides, cx.snap_to_point) = (false, false); + let page_axes = |m: Affine| m.as_coeffs()[1].abs() < 1e-9 && m.as_coeffs()[2].abs() < 1e-9; + let (from, to) = (along(50.0, 0.0), along(100.0, 30.0)); + for copy in [false, true] { + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y)); + assert!(page_axes(matrix(&a[1])), "{a:?}"); + let up = t.pointer(&cx, &ev(PointerKind::Up, to.x, to.y).with_mods(if copy { alt() } else { Mods::default() })); + assert_eq!(page_axes(matrix(&up[0])), !copy, "copy {copy}: {up:?}"); + } + } + #[test] fn shear_horizontal_keeps_origin_row() { let t = TransformTool::new(TransformKind::Shear); diff --git a/docs/mcp.md b/docs/mcp.md index 59bb3c78ad..b5434c6abc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5137,6 +5137,24 @@ used, as a transform's does. {"name":"run_command","arguments":{"command":"object.transformEach","params":{"scaleH":50,"scaleV":50,"strokes":false}}} ``` +## Scale and Reflect on a turned bounding box + +`object.scale` and `object.reflect` with a named axis act along the selection's bounding box: after a rotation, `sx` +stretches along the box's own width and `sy` along its height, and `"vertical"` and `"horizontal"` are the box's own +axes, until Object › Transform › Reset Bounding Box squares the box to the page. Both take `basis?: "box"|"page"`: +`"box"` is the default, and `"page"` acts along the page's axes. Any other `basis` is an error, and nothing changes. +`origin` is still a page point, and a numeric `axis` is still a line on the page at that angle. Selections whose +objects stand at different angles, and anchors selected with Direct Selection, use the page's axes. The journal entry +records the `basis` used, so a recorded action replays in the same frame; an older recording without `basis` acts +along the box. `object.transformAgain` repeats the transform's page matrix, also after Reset Bounding Box. The Scale +and Reflect dialogs, the Transform and Properties panels' flips, the Scale tool and the Reflect tool's default axis +and Shift steps follow the box the same way; a Reflect axis drawn with the tool is the line drawn on the page. + +```json +{"name":"run_command","arguments":{"command":"object.scale","params":{"sx":200,"sy":100}}} +{"name":"run_command","arguments":{"command":"object.reflect","params":{"axis":"vertical","basis":"page"}}} +``` + ## Free Distort on a turned bounding box `object.distort` maps the corners of `from` (TL, TR, BR, BL) onto `corners`, which are page coordinates. With diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..5537e9a950 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -875,7 +875,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`). - Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - - After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`). The Scale and Reflect tools, their dialogs and commands, and the Transform panel's flips keep the page's axes (VectorCraft's own rule; `bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`). + - After a rotation the Free Transform tool shows and drags the same turned bounding box as the Selection tool: its handles, their cursors and its rotate zone sit on the selection's own sides, a handle scales along those sides from the opposite handle ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html)), Cmd/Ctrl on a side handle shears along that side, and Perspective Distort and Free Distort start from the turned box's corners (`object.distort` takes `angle` with `from`) (`bboxrotate::free_transform_handles_turn_with_the_object_and_scale_along_its_axes`, `free_transform_shears_along_the_turned_side`, `free_transform_distort_starts_from_the_turned_box`, `xform::distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle`, tools `xform::free::tests::the_box_and_its_handles_turn_with_the_selection`). + - After a rotation the Scale and Reflect tools, the Scale and Reflect dialogs, `object.scale`, `object.reflect` with a named axis and the Transform and Properties panels' flips act along the selection's turned bounding box until Reset Bounding Box: the horizontal and vertical scale stretch along its own width and height, and the vertical and horizontal reflection axes are its own. The Reflect tool's default axis and its Shift steps start from the box's axes, and an axis drawn with the tool is the line drawn on the page. `basis: "page"` keeps the page's axes (VectorCraft's own rules; `bboxrotate::scale_and_reflect_follow_the_turned_box`, tools `xform::transform::tests::scale_and_reflect_drags_follow_a_turned_bounding_box`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d640d3b5d2..de4d065354 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -266,7 +266,7 @@ tools, never checked side by side) isn't captured row by row. | Bounding-box handles: Shift proportional, Alt from centre, rotate outside the corners | done | | | Rotated objects keep a rotated bounding box and their angle; Reset Bounding Box | done | | | Vector Halftone Preview keeps the selection box and hides selection marks on the temporary dots | done | #6369; Preview off and Cancel restore the original marks. | -| Free Transform, Scale and Reflect along a rotated box | missing | | +| Free Transform, Scale and Reflect along a rotated box | done | The Scale and Reflect tools, dialogs and commands and the Transform and Properties panels' flips act along the turned box until Reset Bounding Box; `basis: "page"` keeps the page's axes | | Free Transform widget: constrain, free distort, perspective distort; the key-held variants | done | | | Transform tools: click sets the reference point (snaps to anchors/centres), Alt-click opens the dialog, Alt-release copies | done | with anchors selected with Direct Selection, the tools, the dialogs and Move transform those anchors alone, about the centre of their box by default (#1408; the default point and whole-object copies are our choices) | | Move, Rotate, Scale, Reflect and Shear dialogs: live Preview; Up/Down step angles and percentages by 1, Shift by 10 | done | #1412; Preview starts off (our choice: the public documentation gives no default), Move's as it was last confirmed; OK is one undo step and Transform Again repeats it | From 13dfcdd142e9f630c60b91983d4472a07f807319 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:42:33 -0800 Subject: [PATCH 02/40] Tools: commands return the error when a tool cannot be chosen tool.toggleTouchMode reported success with the old tool when the tool switch failed, and it had already stored the outgoing tool as the drawing tool to return to. It now returns the error, and the drawing tool changes only after the switch succeeds. tool.select, ui.cropImage and the Path Precision Tools' Corner Tool and Shared Edge Merge Tool commands also return the error instead of reporting success, and the two Path Precision commands no longer reveal the Anchor Inspector after a failed choice. A failed choice no longer stores the requested tool as its toolbar group's tool. Toolbar clicks, shortcuts and other UI paths still show the error in the status bar. Docs: docs/mcp.md, docs/target-app-parity.md and the command params text. --- crates/ui-egui/src/addons/pathtools/corner.rs | 2 +- .../src/addons/pathtools/shared_edge.rs | 2 +- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/commands.rs | 42 ++++++++++---- crates/ui-egui/src/canvas/tests.rs | 58 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/menus/run.rs | 12 +--- crates/ui-egui/src/menus/ui_commands.rs | 6 +- crates/ui-egui/src/touch.rs | 21 +++---- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 11 files changed, 120 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/addons/pathtools/corner.rs b/crates/ui-egui/src/addons/pathtools/corner.rs index ace9be954c..58cc95882d 100644 --- a/crates/ui-egui/src/addons/pathtools/corner.rs +++ b/crates/ui-egui/src/addons/pathtools/corner.rs @@ -19,7 +19,7 @@ const FIELD_W: f32 = 96.0; /// Object › Plug-ins › Path Precision Tools › Corner Tool. pub fn open(app: &mut VectorcraftApp) -> Result { - app.select_tool(ID); + app.try_select_tool(ID)?; crate::panel_docking::reveal_panel(app, super::ID) } diff --git a/crates/ui-egui/src/addons/pathtools/shared_edge.rs b/crates/ui-egui/src/addons/pathtools/shared_edge.rs index d682aad776..57254e1aec 100644 --- a/crates/ui-egui/src/addons/pathtools/shared_edge.rs +++ b/crates/ui-egui/src/addons/pathtools/shared_edge.rs @@ -17,7 +17,7 @@ const LABEL_W: f32 = 80.0; /// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool. pub fn open(app: &mut VectorcraftApp) -> Result { - app.select_tool(ID); + app.try_select_tool(ID)?; crate::panel_docking::reveal_panel(app, super::ID) } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8c8a153a94..186a7891ab 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -181,6 +181,8 @@ impl VectorcraftApp { host_iteration: 0, #[cfg(test)] now_override: None, + #[cfg(test)] + fail_next_tool_choice: None, tick_iteration: None, input_frames: vec![], shortcuts_frames: vec![], diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 50c7b1be30..6000bb8a24 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -406,34 +406,54 @@ impl VectorcraftApp { } } - /// Select a tool (also used by the toolbar and shortcuts). + /// Select a tool (also used by the toolbar and shortcuts). A choice that fails shows its error + /// in the status bar. pub fn select_tool(&mut self, id: &str) { + if let Err(e) = self.try_select_tool(id) { + self.ui.status = e; + } + } + + /// Select a tool for a command ([`Self::select_tool`]): a choice that fails returns its error, + /// and the tool remembered for the toolbar group and for the Touch workspace's drawing mode + /// stays as it was. + pub(crate) fn try_select_tool(&mut self, id: &str) -> Result<(), String> { // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and // neither does letting go of a held tool shortcut. self.pen_end.forget_tool(); self.held_tool.clear(); - self.activate_tool(id, true); + self.activate_tool(id, true)?; let now = self.session.tool_id(); if crate::touch::is_drawing_tool(now) { self.ui.touch_drawing_tool = Some(now.to_string()); } + Ok(()) } /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): - /// a perspective grid hidden meanwhile stays hidden. + /// a perspective grid hidden meanwhile stays hidden. A switch that fails shows its error in the + /// status bar. pub(crate) fn return_to_tool(&mut self, id: &str) { - self.activate_tool(id, false); + if let Err(e) = self.activate_tool(id, false) { + self.ui.status = e; + } } /// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]), /// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]). - fn activate_tool(&mut self, id: &str, fresh: bool) { + /// The toolbar group remembers `id` only when the switch succeeds. + fn activate_tool(&mut self, id: &str, fresh: bool) -> Result<(), String> { let v = self.view_info(); let before = self.session.tool_id(); - let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) }; - if let Err(e) = switched { - self.ui.status = e.to_string(); - } + #[cfg(test)] + let failed = self.fail_next_tool_choice.take(); + #[cfg(not(test))] + let failed: Option = None; + let switched = match failed { + Some(e) => Err(e), + None if fresh => self.session.select_tool(id, v).map_err(|e| e.to_string()), + None => self.session.switch_tool(id, v).map_err(|e| e.to_string()), + }; if before != self.session.tool_id() { // A control gesture cannot resume after another tool has owned the pointer. self.control_rotate_press = None; @@ -445,13 +465,15 @@ impl VectorcraftApp { { panels::artboards::select(self, panels::artboards::selected(self, n)); } + self.ui.flyout = None; + switched?; if let Some(g) = vectorcraft_tools::catalog::group_of(id) && let Some(slot) = self.ui.group_tool.get_mut(g) { *slot = id.to_string(); } toolbar::remember(self, id); - self.ui.flyout = None; + Ok(()) } /// Show a transient status message. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6bf8a6fa97..38d3163ef3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1051,6 +1051,64 @@ fn switch_selection_and_drawing_modes_command() { } } +/// A Switch Selection and Drawing Modes whose tool choice fails returns the error, in both +/// directions: the tool stays, the drawing tool to return to stays, and no undo step is added. +#[test] +fn a_failed_mode_switch_returns_its_error() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let steps = undo(&app); + app.select_tool("rectangle"); + app.ui.touch_drawing_tool = Some("pen".into()); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(crate::touch::TOGGLE_MODE, json!({})); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}"); + assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("rectangle", Some("pen"), steps), "drawing mode"); + app.select_tool("selection"); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(crate::touch::TOGGLE_MODE, json!({})); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}"); + assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("selection", Some("pen"), steps), "selection mode"); + // The next switch works again. + assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pen"})); + assert_eq!(undo(&app), steps); +} + +/// The commands that choose a tool (Select Tool, Crop Image, and the Path Precision Tools' Corner +/// Tool and Shared Edge Merge Tool) return the error when the choice fails, and the tool and the +/// toolbar group's tool stay as they were. +#[test] +fn tool_commands_return_a_failed_choice() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([200, 120, 40, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap(); + app.select_tool("selection"); + let group = vectorcraft_tools::catalog::group_of("ellipse").unwrap(); + let commands = [ + ("tool.select", json!({"tool": "ellipse"})), + ("ui.cropImage", json!({})), + (crate::addons::pathtools::corner::COMMAND, json!({})), + (crate::addons::pathtools::shared_edge::COMMAND, json!({})), + ]; + for (command, params) in commands { + let slot = app.ui.group_tool[group].clone(); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(command, params.clone()); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{command}: {r:?}"); + assert_eq!(app.session.tool_id(), "selection", "{command}"); + assert_eq!(app.ui.group_tool[group], slot, "{command}"); + app.run(command, params).unwrap(); + assert_ne!(app.session.tool_id(), "selection", "{command} works again"); + app.select_tool("selection"); + } +} + /// The Artboard tool's label holds the artboard's name (never translated); the tools' own /// labels are interface text. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c527991ed0..3019886391 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -339,6 +339,10 @@ pub struct VectorcraftApp { /// can be driven one simulated frame at a time (production always leaves this `None`). #[cfg(test)] pub(crate) now_override: Option, + /// An error the next tool choice returns before the engine sees it ([`Self::try_select_tool`]), + /// so tests can drive a failed switch (production has no such field). + #[cfg(test)] + pub(crate) fail_next_tool_choice: Option, /// The host iteration [`Self::tick`] ran in (`None`: never): once per iteration, never per pass. tick_iteration: Option, /// The context, viewport and frame whose UI-phase input was already taken in this host diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index efa4e95ac4..0d46481841 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -677,14 +677,11 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::chrome::step_document(app, true), "window.previousDocument" => crate::chrome::step_document(app, false), "tool.select" => match s("tool") { - Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { - app.select_tool(&t); - Ok(json!({"tool": app.session.tool_id()})) - } + Some(t) if vectorcraft_tools::tool_info(&t).is_some() => app.try_select_tool(&t).map(|()| json!({"tool": app.session.tool_id()})), Some(t) => Err(format!("unknown tool `{t}`")), None => Err("missing `tool`".into()), }, - crate::touch::TOGGLE_MODE => Ok(crate::touch::toggle_mode(app)), + crate::touch::TOGGLE_MODE => crate::touch::toggle_mode(app), crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::open(app), crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app), crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app), @@ -696,10 +693,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), - "ui.cropImage" => { - app.select_tool(vectorcraft_tools::cropimage::ID); - Ok(json!({"tool": app.session.tool_id()})) - } + "ui.cropImage" => app.try_select_tool(vectorcraft_tools::cropimage::ID).map(|()| json!({"tool": app.session.tool_id()})), "ui.paramDialog" => { let cmd = s("command").unwrap_or_default(); let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5f6da1ca36..2d7b157706 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -771,7 +771,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"), ( "tool.setOption", "Tool Option", @@ -782,7 +782,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::touch::TOGGLE_MODE, "Switch Selection and Drawing Modes", "", - "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", + "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", ), ( "effect.dialog", @@ -801,7 +801,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.cropImage", "Crop Image", "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ( diff --git a/crates/ui-egui/src/touch.rs b/crates/ui-egui/src/touch.rs index 7cd1a4ad76..48db775c31 100644 --- a/crates/ui-egui/src/touch.rs +++ b/crates/ui-egui/src/touch.rs @@ -153,19 +153,20 @@ pub(crate) fn is_drawing_tool(id: &str) -> bool { /// [`TOGGLE_MODE`]: from a tool of selection mode, choose the drawing tool chosen last (none yet: /// [`FIRST_DRAWING_TOOL`]); from any other tool, keep it as the drawing tool and choose the -/// Selection tool. → `{tool}`, the tool now active. -pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> serde_json::Value { +/// Selection tool. → `{tool}`, the tool now active. A switch that fails returns its error and +/// leaves the drawing tool to return to as it was. +pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> Result { let now = app.session.tool_id().to_string(); - let next = if selects(&now) { - app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string()) + let (next, outgoing) = if selects(&now) { + (app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string()), None) } else { - if is_drawing_tool(&now) { - app.ui.touch_drawing_tool = Some(now); - } - "selection".to_string() + ("selection".to_string(), is_drawing_tool(&now).then_some(now)) }; - app.select_tool(&next); - serde_json::json!({"tool": app.session.tool_id()}) + app.try_select_tool(&next)?; + if let Some(tool) = outgoing { + app.ui.touch_drawing_tool = Some(tool); + } + Ok(serde_json::json!({"tool": app.session.tool_id()})) } #[cfg(test)] diff --git a/docs/mcp.md b/docs/mcp.md index 6800d7d7e5..b7c0584968 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5735,7 +5735,10 @@ desktop app) instead and undoes nothing ([the help page](https://helpx.adobe.com From the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool it chooses the drawing tool chosen last in the window, or the Pencil tool when none has been chosen yet; from any other tool it chooses the Selection tool, and the tool left becomes the drawing tool unless it is the Hand, Rotate View, Zoom or Print -Tiling tool. It returns `{tool}` and adds no undo step. Pencil as the first drawing tool and the list of selection tools +Tiling tool. It returns `{tool}` and adds no undo step. When the tool cannot be chosen it returns the error, the tool +and the drawing tool stay as they were, and it adds no undo step; `tool.select`, `ui.cropImage`, +`addon.pathtools.cornerTool` and `addon.pathtools.sharedEdgeTool` also return the error when their tool cannot be +chosen. Pencil as the first drawing tool and the list of selection tools are VectorCraft's own rules. A tap in Presentation Mode runs nothing. `gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..0ba0a72c9b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1188,6 +1188,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default). On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`). In the Touch workspace a two-finger tap switches between the Selection tool and the drawing tool chosen last (the Pencil tool when none has been chosen yet) and undoes nothing; outside Touch it still undoes, and a three-finger tap still redoes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/touch-workspace-in-illustrator.html); `tool.toggleTouchMode`; canvas test `two_finger_tap_in_touch_switches_selection_and_drawing_modes`). + When the tool cannot be chosen, `tool.toggleTouchMode` returns the error instead of `{tool}`, keeps the drawing tool it returns to, and adds no undo step; `tool.select`, `ui.cropImage` and the Path Precision Tools' Corner Tool and Shared Edge Merge Tool commands also return the error, and the toolbar group keeps its tool (canvas tests `a_failed_mode_switch_returns_its_error` and `tool_commands_return_a_failed_choice`). A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`). The status bar navigator's field shows the active artboard's number and name, and its drop-down lists every artboard by number and name, as the Artboards panel does. Choosing an artboard in the list makes it the active artboard and fits it in the window (`view.goToArtboard`). The list opens on the active artboard's row and lays out only the rows in view and at most one more. - **Geometry and effects:** From b1c9783e40bf9b83075c37fdb8bd14f43b6317bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:54:42 -0800 Subject: [PATCH 03/40] Tests: expect scale to follow the rotated bounding box A stretch of a turned star along its own box keeps it a parametric shape; the page-axes stretch that skews it now passes basis "page". The no-op transform test expects the scale's journal entry to carry its basis. --- crates/engine/src/cmd/addons/pathtools/tests.rs | 7 ++++++- crates/engine/src/tests.rs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e31aadf56e..87c61a87f3 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -385,8 +385,13 @@ fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() { s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap(); assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1)); assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays"); - // Stretched across its own axes (it is turned): skewed, so no longer. + // Stretched along its turned bounding box: still the shape, wider along its own axis. s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap(); + let r = shape_of(&s, id).unwrap(); + assert!((r.width - 300.0).abs() < 1e-6 && (r.height - 200.0).abs() < 1e-6 && (r.rotation + 30.0).abs() < 1e-6, "{r:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + // Stretched across the page's axes (it is turned): skewed, so no longer. + s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140], "basis": "page"})).unwrap(); assert_eq!(shape_of(&s, id), None); s.execute("edit.undo", &json!({})).unwrap(); assert!(shape_of(&s, id).is_some(), "undo brings it back"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c231dc7e5..e2a4129e57 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1271,7 +1271,12 @@ fn a_transform_that_moves_nothing_records_no_undo_step() { for (id, p) in calls { assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}"); assert_eq!(history(&s), before, "{id} {p}"); - assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); + // The journal adds the frame a scale acted in, so a replay acts in the same one. + let mut recorded = p; + if id == "object.scale" { + recorded["basis"] = json!("box"); + } + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), recorded)); } assert_eq!( s.doc().unwrap().last_transform, From b00e50d4c57348241e15f86dc6dd0c159fa261a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:30:48 -0800 Subject: [PATCH 04/40] Transform: pattern-only Scale and Reflect of selected anchors follow the box With anchors selected and Transform Objects off (objects: false, or the backquote key during a Scale or Reflect drag), the transform moves the whole objects' pattern tiles, so it now acts along the turned bounding box instead of the page axes that apply to the anchors alone. --- crates/engine/src/cmd/object/transform.rs | 8 +++--- crates/engine/src/tests/bboxrotate.rs | 29 ++++++++++++++++++++ crates/tools/src/xform/transform.rs | 32 ++++++++++++++++------- docs/mcp.md | 3 ++- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 1eac08e571..e7a2961285 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -365,12 +365,14 @@ pub(super) fn scale(s: &mut Session, p: &Value) -> Result { /// The frame that `object.scale` and a named `object.reflect` act in, from the `basis` param: /// `"box"` (the default) is the bounding box of `ids`, turned with rotated objects until Reset /// Bounding Box ([`Document::bbox_angle`]); `"page"` is the page's axes. Anchors selected with -/// Direct Selection (and no copy) transform along the page's axes, as their own box does. The -/// basis joins the journal entry, so a replay acts in the same frame. +/// Direct Selection (with no copy and Transform Objects on) transform along the page's axes, as +/// their own box does. The basis joins the journal entry, so a replay acts in the same frame. fn basis_frame(s: &mut Session, p: &Value, cmd: &str, ids: &[NodeId]) -> Result { let page = choice(p, cmd, "basis", &["box", "page"])? == Some("page"); let st = s.doc()?; - let anchors = !bool_or(p, "copy", false) && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); + let anchors = !bool_or(p, "copy", false) + && transforms_objects(p) + && effects::preview::anchor_bounds_with(&st.selection, &st.doc, s.bounds_measure()).is_some(); let angle = if page || anchors { 0.0 } else { st.doc.bbox_angle(ids) }; s.note_journal("basis", json!(if page { "page" } else { "box" })); Ok(OrientedBox { angle, rect: Rect::ZERO }) diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 0a6df22e10..3721e332d3 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -469,6 +469,35 @@ fn scale_follows_the_page_after_a_reset_and_for_mixed_angles() { assert!(near(r.width(), w.width()) && near(r.height(), 2.0 * w.height()), "{w:?} {r:?}"); } +/// With anchors selected, Scale and a named Reflect move the anchors along the page's axes, but +/// with Transform Objects off they move the whole objects' pattern tiles, along the turned box. +#[test] +fn pattern_only_transforms_of_selected_anchors_follow_the_turned_box() { + let c = Point::new(200.0, 200.0); + let frame = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + let about = |m: Affine| Affine::translate(c.to_vec2()) * m * Affine::translate(-c.to_vec2()); + let last = |s: &Session| match s.doc().unwrap().last_transform.clone() { + Some(LastTransform::Matrix(m, _)) => m, + other => panic!("no matrix to repeat: {other:?}"), + }; + let scale = Affine::scale_non_uniform(2.0, 1.0); + let flip = Affine::scale_non_uniform(-1.0, 1.0); + for (cmd, p, m) in [("object.scale", json!({"sx": 200, "sy": 100}), scale), ("object.reflect", json!({"axis": "vertical"}), flip)] { + for objects in [false, true] { + let mut s = session(); + let a = turned_rect(&mut s); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let mut p = p.clone(); + p["origin"] = json!([c.x, c.y]); + p["objects"] = json!(objects); + p["patterns"] = json!(true); + s.execute(cmd, &p).unwrap(); + let want = if objects { about(m) } else { about(frame.conjugate(m)) }; + assert!(same_matrix(last(&s), want), "{cmd} objects {objects}: {:?}", last(&s)); + } + } +} + #[test] fn groups_type_and_multiple_selections_keep_their_angle() { let mut s = session(); diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 8f3f3b1768..47cd5b178a 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -74,7 +74,8 @@ struct Drag { /// ([`TransformTool::matrix_in`]). angle: f64, /// Anchors are selected with Direct Selection: they transform alone along the page's axes, - /// and a copy transforms the whole objects along the box ([`Drag::angle_for`]). + /// and a copy or a pattern-only drag transforms the whole objects along the box + /// ([`Drag::angle_for`]). anchors: bool, /// The snapped pointer and Shift at the last drag event, to work out the transform again /// when the Alt state at the release changes what it transforms. @@ -84,9 +85,10 @@ struct Drag { } impl Drag { - /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not. - fn angle_for(&self, copy: bool) -> f64 { - if self.anchors && !copy { 0.0 } else { self.angle } + /// The angle Scale and Reflect act along, for a drag that copies (`copy`) or not, and that + /// transforms only pattern tiles (`patterns_only`, the backquote key) or not. + fn angle_for(&self, copy: bool, patterns_only: bool) -> f64 { + if self.anchors && !copy && !patterns_only { 0.0 } else { self.angle } } } @@ -292,7 +294,7 @@ impl Tool for TransformTool { } let (p, guides) = self.snap.as_ref().map_or((p, vec![]), |s| s.snap(cx, p)); self.hover = guides; - let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, p, ev.mods.shift, d.bounds); + let (m, text) = self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, p, ev.mods.shift, d.bounds); (d.last, d.pointer, d.shift) = (m, p, ev.mods.shift); self.drag = Some(d); self.measure = Some((p, text)); @@ -302,10 +304,14 @@ impl Tool for TransformTool { PointerKind::Up => { let Some(d) = self.end_drag() else { return vec![] }; if d.began { - // The Alt state at release decides whether the result is a copy, and with - // anchors selected, whether it acts along the box or the page. - let m = - if d.anchors { self.matrix_in(d.angle_for(ev.mods.alt), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 } else { d.last }; + // The Alt and backquote states at release decide whether the result is a copy + // or moves only pattern tiles, and with anchors selected, whether it acts + // along the box or the page. + let m = if d.anchors { + self.matrix_in(d.angle_for(ev.mods.alt, ev.backquote), d.origin, d.pick, d.pointer, d.shift, d.bounds).0 + } else { + d.last + }; vec![preview(m, ev.mods.alt, ev.backquote), Action::Commit] } else { // A click sets the reference point; with Alt, it opens the dialog too. @@ -680,6 +686,14 @@ mod tests { let up = t.pointer(&cx, &ev(PointerKind::Up, to.x, to.y).with_mods(if copy { alt() } else { Mods::default() })); assert_eq!(page_axes(matrix(&up[0])), !copy, "copy {copy}: {up:?}"); } + // With the backquote key the drag moves only the whole objects' pattern tiles, which + // scale along the box, both while dragging and at the release. + let mut t = TransformTool::new(TransformKind::Scale); + t.pointer(&cx, &ev(PointerKind::Down, from.x, from.y)); + let a = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Drag, to.x, to.y) }); + assert!(!page_axes(matrix(&a[1])), "{a:?}"); + let up = t.pointer(&cx, &PointerEvent { backquote: true, ..ev(PointerKind::Up, to.x, to.y) }); + assert!(!page_axes(matrix(&up[0])), "{up:?}"); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index b5434c6abc..ffb534472e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5144,7 +5144,8 @@ stretches along the box's own width and `sy` along its height, and `"vertical"` axes, until Object › Transform › Reset Bounding Box squares the box to the page. Both take `basis?: "box"|"page"`: `"box"` is the default, and `"page"` acts along the page's axes. Any other `basis` is an error, and nothing changes. `origin` is still a page point, and a numeric `axis` is still a line on the page at that angle. Selections whose -objects stand at different angles, and anchors selected with Direct Selection, use the page's axes. The journal entry +objects stand at different angles, and anchors selected with Direct Selection, use the page's axes; with `copy` or +with `objects: false`, the whole objects or their pattern tiles transform, along the box. The journal entry records the `basis` used, so a recorded action replays in the same frame; an older recording without `basis` acts along the box. `object.transformAgain` repeats the transform's page matrix, also after Reset Bounding Box. The Scale and Reflect dialogs, the Transform and Properties panels' flips, the Scale tool and the Reflect tool's default axis From f1cef17323980d2cdc3a9916d46d3d0add5bdcbd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:42:00 -0800 Subject: [PATCH 05/40] MCP: regenerate the UI command catalog for the tool error text crates/mcp/src/ui_catalog.rs now carries the tool.select, tool.toggleTouchMode and ui.cropImage params text from crates/ui-egui/src/menus/ui_commands.rs, so `cargo xtask ui-commands --check` passes. --- crates/mcp/src/ui_catalog.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index df2627dfc2..66c9fd3bce 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -770,7 +770,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"), ( "tool.setOption", "Tool Option", @@ -781,7 +781,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.toggleTouchMode", "Switch Selection and Drawing Modes", "", - "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", + "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", ), ( "effect.dialog", @@ -800,7 +800,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.cropImage", "Crop Image", "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ( From 01043c05cd0b33d7ee48a9b0b25b0a4f552317d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:19:44 -0800 Subject: [PATCH 06/40] Graphs: paint, stroke and transparency commands leave a design's art in a series out The graph draws the art of a column or marker design in a series again from the design when its data, type or designs change, and an edit made to that art is lost. - stroke.set, stroke.weightUp, stroke.weightDown, stroke.setAdvanced, paint.editGradient, paint.setGradientGeom, object.setOverprint, paint.swap, paint.default, and paint.invert and paint.complement with an item leave the art out and edit the other objects given. - transparency.set without an item, the paint.freeform.* commands that edit points and lines, graphicStyle.breakLink and effect.expandAppearance are an error when all their objects are design art. Expand Appearance is disabled for a selection of only design art, and it leaves the design art inside a selected graph or series group as it was. - When the first selected object is design art, the commands do not use the Appearance panel's active row, which is a row of that art. - The error names the command that ran. docs/mcp.md lists the commands. The params texts of the appearance.* commands and graphicStyle.apply name the rule, and the Graph column designs entry in docs/target-app-parity.md states it. --- crates/engine/src/cmd/appearance.rs | 78 ++++++---- crates/engine/src/cmd/effectcmd.rs | 40 ++++- crates/engine/src/cmd/effectcmd/graphics.rs | 2 +- crates/engine/src/cmd/freeform.rs | 3 +- crates/engine/src/cmd/gradient.rs | 3 +- crates/engine/src/cmd/graph/tests.rs | 154 ++++++++++++++++++++ crates/engine/src/cmd/overprint.rs | 2 +- crates/engine/src/cmd/paint.rs | 11 +- crates/engine/src/cmd/stroke.rs | 5 +- crates/engine/src/cmd/style.rs | 6 +- crates/engine/src/cmd/stylelib.rs | 2 +- docs/mcp.md | 25 ++-- docs/target-app-parity.md | 1 + 13 files changed, 276 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 02bc111746..67534ac500 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -183,7 +183,7 @@ pub(crate) struct ActiveItem { impl Session { /// The method returns explicit target parameters and the selected appearance effect list. pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> { - let mut ids = appearance_targets(self, params).ok()?; + let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?); let item = item_target(self, params, "effect.apply").ok()?; let anchor = item.anchor(self, &ids); let node = self.active()?.doc.node(*ids.get(anchor)?)?; @@ -253,6 +253,10 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), // The active item is a row of the first selected object's own stack. None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top), + // A row of a graph design's art in a series stands for nothing: the commands leave that art out. + None if s.active().and_then(|st| st.selection.subjects().first().map(|id| super::graph::is_design_art(&st.doc, *id))) == Some(true) => { + Ok(ItemTarget::Top) + } None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })), } } @@ -424,22 +428,44 @@ const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn a /// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series /// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets -/// are all such art is refused. -pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result> { +/// are all such art is refused as an error of `cmd`. +pub(crate) fn edit_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let ids = appearance_targets(s, p)?; - without_design_art(&s.doc()?.doc, ids, "appearance") + without_design_art(&s.doc()?.doc, ids, cmd) } /// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else. -pub(crate) fn without_design_art(d: &Document, mut ids: Vec, cmd: &str) -> Result> { +pub(crate) fn without_design_art(d: &Document, ids: Vec, cmd: &str) -> Result> { let given = ids.len(); - ids.retain(|id| !super::graph::is_design_art(d, *id)); + let ids = drop_design_art(d, ids); if given > 0 && ids.is_empty() { return Err(bad(cmd, DESIGN_ART)); } Ok(ids) } +/// `ids` without the art of graph designs drawn in graph series, for the paint commands, which leave it out without an +/// error. +pub(crate) fn drop_design_art(d: &Document, mut ids: Vec) -> Vec { + ids.retain(|id| !super::graph::is_design_art(d, *id)); + ids +} + +/// The outermost art of graph designs drawn in graph series inside `roots`: the objects directly in a series group that +/// [`super::graph::is_design_art`] accepts. +pub(crate) fn design_art_in(d: &Document, roots: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in roots { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |m| { + if m.extras().series_index.is_some() { + out.extend(m.children().into_iter().flatten().map(|c| c.id).filter(|c| super::graph::is_design_art(d, *c))); + } + }); + } + out +} + /// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside /// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: /// the Appearance panel lists those rows and edits them by index. @@ -476,15 +502,15 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { /// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the /// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. -pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { - let ids = edit_targets(s, p)?; +pub(crate) fn row_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = edit_targets(s, p, cmd)?; Ok(rowless_series_marks(&s.doc()?.doc, &ids)) } /// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, /// which can hold effects and a graphic style link of its own. -pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { - let own = edit_targets(s, p)?; +pub(crate) fn whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let own = edit_targets(s, p, cmd)?; let mut ids = rowless_series_marks(&s.doc()?.doc, &own); let groups = stood_for(&own, &ids); ids.extend(groups); @@ -511,7 +537,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { /// [`whole_targets`] of `cmd`, which needs at least one. fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { - let ids = whole_targets(s, p)?; + let ids = whole_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -520,7 +546,7 @@ fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result Result> { - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -599,8 +625,8 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo return Ok(b); } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; - let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; + let ids = drop_design_art(d, appearance_targets(s, p)?); Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } @@ -1023,7 +1049,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["weight", "opacity"], "appearance.setItem")?; check_switches(p, &["visible"], "appearance.setItem")?; let paint = paint_from(s, p)?; - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, "appearance.setItem")?; if ids.is_empty() { return Err(bad("appearance.setItem", "select objects or give ids")); } @@ -1271,7 +1297,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { // A graph series group's own Contents row, not its paths, which have none. - let ids = edit_targets(s, p)?; + let ids = edit_targets(s, p, C)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 6ee3905d95..2a1860af3a 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -298,7 +298,7 @@ fn ids_json(ids: &[NodeId]) -> Value { /// edits its equal item, or its equal object-level effect, each target matched on its own whatever /// the other targets have; in a target without them, `item` and `index` stand for themselves. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, cmd)?; let d = &s.doc()?.doc; let per = item.per_target(s, &roots); let first = roots.first().and_then(|f| d.node(*f)); @@ -453,7 +453,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - let targets = edit_targets(s, p)?; + let targets = edit_targets(s, p, C)?; for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; @@ -494,7 +494,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result= 2 && !grouped).then_some(roots)) } @@ -607,7 +607,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let src = item_target_at(s, p, "fromItem", C)?; let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; let copy = switch_or(p, "copy", false, C)?; - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, C)?; // Where an aligned effect would go in each of several different stacks isn't one place. if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); @@ -892,8 +892,11 @@ fn can_expand(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?; let d = &s.doc().map_err(|e| e.to_string())?.doc; + // The art of a graph design in a series, selected or inside a selected graph, is drawn again from the design. + let ids = super::appearance::drop_design_art(d, ids); // A dimension selected with the art it measures is left as it is. - let kept = super::dimension::with_their_art(d, &ids); + let mut kept = super::dimension::with_their_art(d, &ids); + kept.extend(super::appearance::design_art_in(d, &ids)); if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) { Ok(()) } else { @@ -1219,12 +1222,22 @@ fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::col } fn expand_appearance(s: &mut Session, p: &Value) -> Result { - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p, "effect.expandAppearance")?; // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the // geometry it hasn't changed. if vectorcraft_plugins::safe_mode::active() { - // A dimension selected with the art it measures is left as it is. - let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots); + // A dimension selected with the art it measures is left as it is, and so is the art of a graph design in a + // series. + let mut d = super::dimension::without_kept(&s.doc()?.doc, &roots); + let designs = super::appearance::design_art_in(&d, &roots); + if !designs.is_empty() { + let view = d.to_mut(); + for id in designs { + // Art already gone (inside other art removed) needs nothing more. + let _ = view.remove(id); + } + } + let d = &*d; let mut seen = std::collections::HashSet::new(); if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); @@ -1234,6 +1247,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); + // The art of graph designs in the graphs and series among the roots stays as the graph draws it. + let designs = super::appearance::design_art_in(d, &roots); // A dimension selected with the art it measures keeps its own lines and label: it is out // of the document while the art expands. let lifted = super::dimension::lift(d, &roots); @@ -1245,6 +1260,15 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { for id in &roots { expand_node(d, *id, &brushes, &mut out); } + for id in &designs { + if let (Some(was), Some(slot)) = (before.node(*id), d.node_mut(*id)) { + *slot = was.clone(); + } + } + if !designs.is_empty() { + // A mark that expands into a group stays listed: only what lies in the restored art is left out. + out.retain(|id| d.node(*id).is_some() && !d.ancestry(*id).is_some_and(|up| up.iter().any(|a| designs.contains(a)))); + } super::dimension::put_back(d, lifted); // A dimension whose source was replaced stays where it was, no longer linked. super::dimension::restore_unchanged(&before, d, ended); diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 9f4b89d2f4..e80c1b2d36 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { effect_targets(s, p, item, index, C)? } else { let mut out = vec![]; - for id in edit_targets(s, p)? { + for id in edit_targets(s, p, C)? { let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; out.push((id, item, index)); } diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index bdba729211..a37cce38d0 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -182,7 +182,8 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor let item = item_target(s, p, cmd)?; let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?; let item = item.of_kind(s, !stroke); - let ids = item.targets(s, p)?; + // The art of a graph design in a series keeps the design's gradient. + let ids = super::appearance::without_design_art(&s.doc()?.doc, item.targets(s, p)?, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select an object with a freeform gradient")); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 54ca07da7e..30590d5364 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -530,7 +530,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { check_switches(p, &["reverse", "dither"], C)?; check_numbers(p, &["angle"], C)?; let item = item.of_kind(s, !stroke); - let ids = item.targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); // How the gradient lies on a stroke (the panel's Stroke buttons). let mode = match str_param(p, "strokeMode") { None => None, @@ -760,6 +760,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { if targets.is_empty() { return Err(EngineError::Other("nothing selected".into())); } + let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets); let model = super::paint::new_color_model(s, &Value::Null); edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { if index.is_none() diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c997e803d5..8464f6d0b4 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -416,9 +416,162 @@ fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) { let steps = s.doc().unwrap().history.undo.len(); let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("drawn again from the design"), "{cmd}: {e}"); + // `appearance.addEffect` is an alias of `effect.apply`, whose errors name `effect.apply`. + let named = if cmd == "appearance.addEffect" { "effect.apply" } else { cmd }; + assert!(e.contains(&format!("`{named}`")), "the error names the command that ran: {e}"); assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); } +/// The look of object `id`: its appearance, opacity, blend mode, isolation, knockout and overprint. +fn look(s: &Session, id: NodeId) -> String { + format!("{:?}", s.doc().unwrap().doc.node(id).unwrap()) +} + +/// An edit of a graph design's art in a series lasts only until the graph is drawn again: the graph draws the art again +/// from the design. +#[test] +fn an_edit_of_a_graph_designs_art_is_lost_when_the_graph_is_drawn_again() { + let (mut s, id, _, art, _) = design_art(); + let path = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = None; + series(&n, 0).children().unwrap()[0].walk(&mut |m| { + if out.is_none() && matches!(m.kind, NodeKind::Path { .. }) { + out = Some(m.clone()); + } + }); + out.unwrap() + }; + assert_eq!(path(&s).id, art); + s.execute("object.setProps", &json!({"id": art.0, "opacity": 50})).unwrap(); + assert!((path(&s).opacity - 0.5).abs() < 1e-6); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap(); + assert!((path(&s).opacity - 1.0).abs() < 1e-6); +} + +/// The paint, stroke, gradient and overprint commands leave the art of a graph design in a series as the design draws +/// it, and edit the other objects they are given. +#[test] +fn paint_commands_leave_a_graph_designs_art_out() { + let (mut s, _, copy, art, mark) = design_art(); + let fill = top_row(&s, art, true); + let commands = [ + ("stroke.set", json!({"weight": 5})), + ("stroke.weightUp", json!({})), + ("stroke.setAdvanced", json!({"arrowScale": [200, 200]})), + ("paint.editGradient", json!({"kind": "linear"})), + ("paint.setGradientGeom", json!({"start": [0, 0], "end": [50, 50]})), + ("object.setOverprint", json!({"fill": true})), + ("paint.swap", json!({})), + ("paint.default", json!({})), + ("paint.invert", json!({"item": fill, "stroke": false})), + ]; + let (copy_was, art_was) = (look(&s, copy), look(&s, art)); + for (cmd, p) in &commands { + for ids in [json!([copy.0]), json!([art.0])] { + let steps = s.doc().unwrap().history.undo.len(); + let mut q = p.clone(); + q["ids"] = ids; + let _ = s.execute(cmd, &q); + assert_eq!((look(&s, copy), look(&s, art)), (copy_was.clone(), art_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + } + let mark_was = look(&s, mark); + let mut q = p.clone(); + q["ids"] = json!([art.0, mark.0]); + s.execute(cmd, &q).unwrap(); + assert_eq!(look(&s, art), art_was, "{cmd}"); + assert_ne!(look(&s, mark), mark_was, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Selected, without ids. + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 5})).unwrap(); + assert_eq!(look(&s, art), art_was); + // A freeform gradient point is refused on the art alone. + refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]})); +} + +/// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects +/// sets only theirs. +#[test] +fn object_transparency_leaves_a_graph_designs_art_out() { + let (mut s, _, copy, art, mark) = design_art(); + refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "opacity": 40})); + refused_on_design_art(&mut s, "transparency.set", json!({"ids": [copy.0], "isolate": true})); + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "opacity": 40, "blend": "multiply"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!((d.node(art).unwrap().opacity - 1.0).abs() < 1e-6); + assert!((d.node(mark).unwrap().opacity - 0.4).abs() < 1e-6); +} + +/// With the art of a graph design selected first, the Appearance panel's active row (a row of that art) stands for no +/// row of the other objects selected: an effect goes on each of them as a whole. +#[test] +fn an_active_row_of_a_graph_designs_art_is_not_applied_to_other_objects() { + let (mut s, _, _, art, mark) = design_art(); + let stroke = top_row(&s, art, false); + s.execute("select.set", &json!({"ids": [art.0, mark.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.subjects().first(), Some(&art)); + s.execute("appearance.setActiveItem", &json!({"index": stroke})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap(); + let n = s.doc().unwrap().doc.node(mark).unwrap().clone(); + assert_eq!(n.appearance.effects.len(), 1); + assert!(n.appearance.items.iter().all(|it| it.effects().is_empty())); + assert!(s.doc().unwrap().doc.node(art).unwrap().appearance.effects.is_empty()); +} + +/// Expand Appearance leaves the art of a graph design in a series as the design draws it. +#[test] +fn expand_appearance_leaves_a_graph_designs_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r]})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("graph.design", &json!({"new": "Shadow"})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Shadow"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut art = vec![]; + series(&n, 0).children().unwrap()[0].walk(&mut |m| { + if !m.appearance.effects.is_empty() { + art.push(m.id); + } + }); + let art = art[0]; + let other = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [other]})).unwrap(); + let art_was = look(&s, art); + // Given alone, by id or selected, it leaves Expand Appearance disabled. + for p in [json!({"ids": [art.0]}), json!({})] { + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + assert!(s.execute("effect.expandAppearance", &p).unwrap_err().to_string().contains("not available")); + assert_eq!(look(&s, art), art_was); + } + // Inside a selected graph or series group, the art stays as the design draws it: with nothing else to expand there, + // Expand Appearance is disabled, and with a mark's effect, the mark expands alone. + let series_group = series(&n, 0).id; + for root in [id, series_group] { + s.execute("select.set", &json!({"ids": [root.0]})).unwrap(); + assert!(s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string().contains("not available")); + assert_eq!(look(&s, art), art_was); + } + let mark = series(&n, 1).children().unwrap()[0].id; + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let expanded = s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(expanded["ids"], json!([mark.0])); + assert_eq!(look(&s, art), art_was); + s.execute("edit.undo", &json!({})).unwrap(); + // Selected with another object, the art is left as it is and the other object expands. + s.execute("select.set", &json!({"ids": [art.0, other]})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(look(&s, art), art_was); + assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none_or(|m| m.appearance.effects.is_empty())); +} + /// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by /// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design. #[test] @@ -445,6 +598,7 @@ fn appearance_commands_refuse_a_graph_designs_art_in_a_series() { refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0})); refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"})); refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids})); + refused_on_design_art(&mut s, "graphicStyle.breakLink", json!({"ids": ids})); } for cmd in ["appearance.removeItem", "appearance.duplicateItem"] { refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill})); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 4cc1fa24c3..781a8242ce 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -133,7 +133,7 @@ fn aims(s: &Session, p: &Value, cmd: &str, kinds: impl IntoIterator .into_iter() .map(|fill| { let it = item.of_kind(s, fill); - Ok((fill, it, it.targets(s, p)?)) + Ok((fill, it, super::appearance::drop_design_art(&s.doc()?.doc, it.targets(s, p)?))) }) .collect() } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 3ec28e7ce7..1f25f5b56f 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -902,7 +902,7 @@ pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option } fn swap(s: &mut Session, p: &Value) -> Result { - let ids = paint_targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?); if !ids.is_empty() { // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -936,7 +936,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { } fn default_paint(s: &mut Session, p: &Value) -> Result { - let ids = paint_targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?); if !ids.is_empty() { // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -1006,7 +1006,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( let item = item_target(s, p, cmd)?.of_kind(s, !stroke); let (r, shown_item) = match item { ItemTarget::Item { index, .. } => { - let ids = item.targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); let mut changed = 0; edit_items(s, &ids, item, cmd, label, !stroke, |n, i| { let paint = @@ -1076,6 +1076,9 @@ fn transparency(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } + // The object-level opacity, blend mode, isolation and knockout of a graph design's art in a series are drawn again + // from the design too. + let ids = super::appearance::without_design_art(&s.doc()?.doc, ids, C)?; // While an opacity mask is edited the selection is its art, so the Appearance panel's active // item (a row of that art) doesn't stand for the masked object. let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some(); @@ -1102,7 +1105,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result { return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. - let item_ids = if editing_mask { ids } else { row_targets(s, p)? }; + let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? }; if explicit { let d = &s.doc()?.doc; if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c5e5ba2a5c..74aff88fdf 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -234,6 +234,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { let item = item_target(s, p, C)?.of_kind(s, false); let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? }; let new_art = no_targets(&ids, p); + // The art of a graph design in a series keeps the design's stroke. + ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); if item == ItemTarget::Top { // Images and symbol instances take no stroke from the panel. let d = &s.doc()?.doc; @@ -368,6 +370,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { } return Ok(json!({"objects": 0})); } + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" }; edit_items(s, &ids, item, c, label, false, |n, index| { if index.is_none() @@ -509,7 +512,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "nothing to change")); } let item = item_target(s, p, C)?.of_kind(s, false); - let ids = item.targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); let arrows_and_profile = |st: &mut StrokeLayer| { if let Some(sc) = scale { st.arrow_scale = sc; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 370664f795..1ac72cdfbb 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let i = style_index(&s.doc()?.doc, name)?; let add = bool_or(p, "add", false); let chars = s.prefs.override_char_color; - let own = super::appearance::edit_targets(s, p)?; + let own = super::appearance::edit_targets(s, p, "graphicStyle.apply")?; let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own); // The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves, // and a style added on top unlinks them. @@ -516,7 +516,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } fn style_break_link(s: &mut Session, p: &Value) -> Result { - let ids = super::appearance::whole_targets(s, p)?; + let ids = super::appearance::whole_targets(s, p, "graphicStyle.breakLink")?; s.edit("Break Link to Graphic Style", |d, _| { set_link(d, &ids, None); Ok(()) diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 09b478e8c7..177447fb56 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -167,7 +167,7 @@ fn add(s: &mut Session, p: &Value) -> Result { } if apply { // Objects that can't take a style are refused before any style is added. - super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?; + super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) }, C)?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/docs/mcp.md b/docs/mcp.md index f7d99cbd04..ff49c796c3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -805,15 +805,22 @@ The other commands given a series group's id edit the group itself, and the grap `appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows. -The graph draws the art of a column or marker design in a series again from the design, so the commands that add, -change, move or remove fill and stroke rows, effects or graphic styles leave that art out: the `appearance.*` commands -listed above, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, move or remove an -effect, `graphicStyle.apply` (and `graphicStyle.addFromLibrary` with `apply`), `transparency.set` with a fill or stroke -`item`, and `appearance.copyFrom` as the target. The art is any object inside a series group other than a path directly -in it, whether given by id, selected, or reached through `target: "contents"`. The commands edit the other objects they -are given, and a command whose objects are all design art is an error. `appearance.transfer` with design art as its -`target`, or as its `source` without `copy`, is an error. The paint commands leave the art out as well, without an -error. To change the art, edit the design with `graph.design`. +When a graph is drawn again (a change to its data, its type, or its column or marker designs), it draws the art of a +column or marker design in a series again from the design, and an edit made to that art is lost. The art is any object +inside a series group other than a path directly in it, a legend swatch's included, whether given by id, selected, or +reached through `target: "contents"`. These commands leave that art out, edit the other objects they are given, and +are an error when all their objects are design art: the `appearance.*` commands listed above, `effect.apply`, +`appearance.addEffect` and the other `effect.*` commands that edit, move or remove an effect, +`effect.expandAppearance` (which also leaves the art inside a selected graph or series group as it was, unless an +effect of that group turns the whole group into other art, and is disabled when only design art is selected or +given), `threeD.mapGraphic`, +`graphicStyle.apply`, `graphicStyle.addFromLibrary` with `apply`, `graphicStyle.breakLink`, `transparency.set`, the +`paint.freeform.*` commands that edit points and lines, and `appearance.copyFrom` as the target. +`appearance.transfer` with design art as its `target`, or as its `source` without `copy`, is an error. +`paint.setFill`, `paint.setStroke`, `paint.swap`, `paint.default`, `paint.invert` and `paint.complement` with an +`item`, `stroke.set`, `stroke.weightUp`, `stroke.weightDown`, `stroke.setAdvanced`, `paint.editGradient`, +`paint.setGradientGeom` and `object.setOverprint` leave the art out without an error. When the first selected object is design art, the commands +do not use the Appearance panel's active row. To change the art, edit the design with `graph.design`. The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 19b9c8d319..2740e257a9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1112,6 +1112,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. + The transparency, paint, stroke, gradient and overprint commands and Expand Appearance also leave the art of a column or marker design inside a series unchanged. The graph draws that art again from the design (`docs/mcp.md`). - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). From c7dacd432e5ab23f9eda67f23060c4cbe302ea22 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:43:43 -0800 Subject: [PATCH 07/40] Graphs: stroke edits of a design's art alone leave the new-art stroke as it is stroke.setAdvanced and the strokeMode of paint.editGradient decided whether they were edits for new art after the art of a graph design in a series had been left out, so a selection holding only that art changed the stroke the next object drawn takes. Both now decide it from the objects given or selected, as stroke.set does. --- crates/engine/src/cmd/gradient.rs | 8 ++++++-- crates/engine/src/cmd/graph/tests.rs | 14 ++++++++++++++ crates/engine/src/cmd/stroke.rs | 7 +++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 30590d5364..d23906b6bb 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -530,7 +530,11 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { check_switches(p, &["reverse", "dither"], C)?; check_numbers(p, &["angle"], C)?; let item = item.of_kind(s, !stroke); - let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let ids = item.targets(s, p)?; + // Objects were given or selected, even when all of them are graph design art left out below: the stroke mode is then + // not one for new art. + let selected = !ids.is_empty(); + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); // How the gradient lies on a stroke (the panel's Stroke buttons). let mode = match str_param(p, "strokeMode") { None => None, @@ -605,7 +609,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { Ok(()) })?; if let Some(m) = mode - && ids.is_empty() + && !selected && p.get("ids").is_none() && let Some(st) = s.new_art_stroke_mut() { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 8464f6d0b4..4e9d0c07b5 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -493,6 +493,20 @@ fn paint_commands_leave_a_graph_designs_art_out() { refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]})); } +/// With only the art of a graph design selected, `stroke.setAdvanced` and the stroke mode of `paint.editGradient` act +/// as edits of a selection, not of the stroke the next object drawn takes. +#[test] +fn stroke_edits_of_selected_design_art_leave_the_new_art_stroke() { + let (mut s, _, _, art, _) = design_art(); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + let was = format!("{:?}", s.new_art_stroke()); + s.execute("stroke.setAdvanced", &json!({"arrowScale": [200, 200], "swapArrows": true})).unwrap(); + assert_eq!(format!("{:?}", s.new_art_stroke()), was, "stroke.setAdvanced"); + let mode = s.new_art_stroke().gradient_mode; + s.execute("paint.editGradient", &json!({"kind": "linear", "stroke": true, "strokeMode": "along"})).unwrap(); + assert_eq!(s.new_art_stroke().gradient_mode, mode, "paint.editGradient"); +} + /// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects /// sets only theirs. #[test] diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 74aff88fdf..2a0159930d 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -512,7 +512,10 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "nothing to change")); } let item = item_target(s, p, C)?.of_kind(s, false); - let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let ids = item.targets(s, p)?; + let new_art = no_targets(&ids, p); + // The art of a graph design in a series keeps the design's stroke. + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let arrows_and_profile = |st: &mut StrokeLayer| { if let Some(sc) = scale { st.arrow_scale = sc; @@ -526,7 +529,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { } }; // As stroke.set: with nothing selected, for the next object drawn. - if no_targets(&ids, p) + if new_art && (scale.is_some() || swap || flip.is_some()) && let Some(st) = s.new_art_stroke_mut() { From 99b49f8cd419f32b8d08d5569b7fb12ca498d158 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:23:59 -0800 Subject: [PATCH 08/40] i18n: Ukrainian Color Library names colors, not flowers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ukrainian catalog translated Color Library and Color Library… as "Бібліотека квітів" (library of flowers). Both rows now read "Бібліотека кольорів", the genitive plural of колір used elsewhere in the catalog. A test checks both labels through the catalog lookup. No other Ukrainian row uses квіт- for color. The Russian, Bulgarian, Bosnian, Czech and Polish labels already name colors. --- crates/ui-egui/src/i18n/tests.rs | 7 +++++++ crates/ui-egui/src/i18n/uk.tsv | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 705fc68db2..c3774c8ae0 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1834,6 +1834,13 @@ fn uk() -> Lang { Lang::from_code("uk").expect("uk registered") } +/// The Ukrainian Color Library labels use the word for colors (кольорів), not flowers (квітів). +#[test] +fn ukrainian_color_library_labels_name_colors() { + assert_eq!(uk().catalog().plain("Color Library"), Some("Бібліотека кольорів")); + assert_eq!(uk().catalog().plain("Color Library…"), Some("Бібліотека кольорів…")); +} + /// Every Ukrainian plural has all three forms; exercise different endings through real lookups. #[test] fn ukrainian_plurals_have_three_forms() { diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..731caff5af 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -540,8 +540,8 @@ font-technology Variable Варіативні Color Guide Options Параметри колірного гіда Color Guide Options… Параметри колірного гіда… Color images Кольорові зображення - Color Library Бібліотека квітів - Color Library… Бібліотека квітів… + Color Library Бібліотека кольорів + Color Library… Бібліотека кольорів… Color Management Керування кольором Color Mixer (Shift-click the chip) Змішувач кольорів (Shift+клацання на зразку) Color Mode Колірний режим From 21da73402fd8bc28264a0d13b435faf85c016934 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 22:56:22 -0800 Subject: [PATCH 09/40] Live Paint: Make keeps every fill and stroke row that draws on the source Object > Live Paint > Make gave each new face and edge one fresh row built from only the source's topmost fill or stroke paint and the top stroke's weight. A lower row that draws was lost, a hidden top row became visible, and the rows' opacity, blending mode, effects and stroke settings were dropped. - A face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, in order with their settings. A source in front whose fill does not draw no longer hides the fill behind it. - An edge takes every stroke row of its source that draws (also with a weight above 0). Faces and edges take the source's opacity and blending mode; gradients keep their fit on the source, and pen pressure stays with the source. - Where a path is cut into several edges, each edge runs the way the path does, its dashes go on from where it starts along the path, the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path. - Rows that draw nothing stay with the stored sources, which Release returns unchanged. Merge and Gap Options build their new parts the same way. Docs: livePaint.make params, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/buildcmds.rs | 274 +++++++++++++++++++++++++++-- crates/engine/src/tests/build.rs | 234 +++++++++++++++++++++++- docs/mcp.md | 15 +- docs/target-app-parity.md | 1 + 4 files changed, 510 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 3fc5953619..a1c06e6490 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView}; use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with its front-most covering source's fill; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -414,22 +414,256 @@ fn source_nodes(n: &Node) -> Vec> { if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] } } +/// Does appearance row `i` draw: it is shown, has paint and an opacity above 0, and a stroke also +/// has a weight above 0? +fn row_draws(i: &AppearanceItem) -> bool { + match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && f.opacity > 0.0, + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.opacity > 0.0 && s.width > 0.0, + } +} + +/// What a source path gives the faces and edges made from it. +struct SourceStyle { + /// The fill rows that draw, in paint order (none when the path's opacity is 0). + fills: Vec, + /// The stroke rows that draw, in paint order (none when the path's opacity is 0). + strokes: Vec, + /// The top stroke's weight, for an edge given no stroke that draws. + width: f64, + opacity: f32, + blend: BlendMode, +} + +impl SourceStyle { + /// The style of source path `n`: its rows keep their order and settings, gradients fitted to + /// its bounds keep that fit, and the strokes' pen pressure, tilt, bearing and rotation stay + /// with the path. + fn of(n: &Node) -> Self { + let mut a = (*n.appearance).clone().without_pressure(); + if a.has_unplaced_gradient() + && let Some(bounds) = n.geometric_bounds() + { + a.pin_gradients(bounds); + } + let width = a.stroke().map_or(1.0, |s| s.width); + let (mut fills, mut strokes) = (vec![], vec![]); + if n.opacity > 0.0 { + for i in a.items.into_iter().filter(row_draws) { + if i.is_fill() { fills.push(i) } else { strokes.push(i) } + } + } + Self { fills, strokes, width, opacity: n.opacity, blend: n.blend } + } + + /// Give part `n` the rows `rows` of this source with its opacity and blending mode; with no + /// rows, `n` keeps the unpainted row it has. + fn paint(&self, n: &mut Node, rows: &[AppearanceItem]) { + if rows.is_empty() { + return; + } + n.appearance = Appearance { items: rows.to_vec(), ..Default::default() }.into(); + n.opacity = self.opacity; + n.blend = self.blend; + } +} + +/// How far, in points, a place on an edge may be from its source path and still be on it. +const ON_SOURCE: f64 = 0.05; + +/// Does stroke row `i` have settings that depend on where along its path a piece of it lies: +/// dashes, arrowheads, a width profile or a brush? +fn depends_on_place(i: &AppearanceItem) -> bool { + matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some()) +} + +/// One subpath's segments with the length along it where each starts, then its whole length, and +/// a grid of square cells listing the segments whose bounds, grown by [`ON_SOURCE`], reach into +/// each cell, so finding the segments near a point does not look at every segment. +struct Track { + segs: Vec, + at: Vec, + closed: bool, + cell: f64, + grid: HashMap<(i64, i64), Vec>, + /// Segments that reach into more than [`Track::MAX_CELLS`] cells, or that come after the grid + /// holds [`Track::CELLS_PER_SEGMENT`] entries per segment, looked at for every point. + wide: Vec, +} + +impl Track { + const ACCURACY: f64 = 1e-4; + const MAX_CELLS: i64 = 4096; + const CELLS_PER_SEGMENT: usize = 16; + + fn new(sp: &SubPath) -> Self { + use kurbo::{ParamCurveArclen, Shape as _}; + let mut bp = kurbo::BezPath::new(); + sp.to_bezpath_into(&mut bp); + let segs: Vec = bp.segments().collect(); + let mut at = Vec::with_capacity(segs.len() + 1); + let mut len = 0.0; + for s in &segs { + at.push(len); + len += s.arclen(Self::ACCURACY); + } + at.push(len); + let boxes: Vec = segs.iter().map(|s| s.bounding_box().inflate(ON_SOURCE, ON_SOURCE)).collect(); + // Cells about as wide as an average segment's bounds. + let mean = boxes.iter().map(|b| b.width().max(b.height())).sum::() / boxes.len().max(1) as f64; + let cell = if mean.is_finite() { mean.max(4.0 * ON_SOURCE) } else { 4.0 * ON_SOURCE }; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + let mut wide = vec![]; + // The grid holds at most this many entries in all. + let mut budget = boxes.len().saturating_mul(Self::CELLS_PER_SEGMENT); + for (k, b) in boxes.iter().enumerate() { + let (x0, y0) = Self::cell_of(Point::new(b.x0, b.y0), cell); + let (x1, y1) = Self::cell_of(Point::new(b.x1, b.y1), cell); + let cells = x1.saturating_sub(x0).saturating_add(1).saturating_mul(y1.saturating_sub(y0).saturating_add(1)); + let Some(cells) = usize::try_from(cells).ok().filter(|&c| cells <= Self::MAX_CELLS && c <= budget) else { + wide.push(k); + continue; + }; + budget -= cells; + for x in x0..=x1 { + for y in y0..=y1 { + grid.entry((x, y)).or_default().push(k); + } + } + } + Self { segs, at, closed: sp.closed, cell, grid, wide } + } + + fn cell_of(p: Point, cell: f64) -> (i64, i64) { + ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64) + } + + fn len(&self) -> f64 { + self.at.last().copied().unwrap_or(0.0) + } + + /// The lengths along the subpath to each of its segments' places nearest `p` that are within + /// [`ON_SOURCE`] of it (more than one where the subpath passes `p` again). + fn places(&self, p: Point) -> Vec { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let near = self.grid.get(&Self::cell_of(p, self.cell)).map(Vec::as_slice).unwrap_or_default(); + let mut out = vec![]; + for &k in near.iter().chain(&self.wide) { + let Some(s) = self.segs.get(k) else { continue }; + let n = s.nearest(p, 1e-9); + if n.distance_sq <= ON_SOURCE * ON_SOURCE { + out.push(self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..n.t).arclen(Self::ACCURACY)); + } + } + out + } + + /// The signed length from `a` to `b` along the subpath (the shorter way round a closed one). + fn step(&self, a: f64, b: f64) -> f64 { + let (d, len) = (b - a, self.len()); + if self.closed && len > 0.0 { d - len * (d / len).round() } else { d } + } + + /// Of `places`, the one nearest along the subpath to `target`. + fn nearest(&self, places: &[f64], target: f64) -> Option { + places.iter().copied().min_by(|x, y| self.step(*x, target).abs().total_cmp(&self.step(*y, target).abs())) + } +} + +/// Where edge `edge` runs along its source path, whose subpaths' tracks are `tracks`: the length +/// along the source subpath to the edge's start, whether the edge starts at that subpath's start and +/// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the +/// edge's first segment places it, and its ends are taken where they lie that far along the source, +/// so a path that passes a point twice is told apart. An edge that runs against the direction of its +/// source is reversed first. `None` when the edge is not a single piece found on the source. +fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> { + use kurbo::{ParamCurve, ParamCurveArclen}; + let [sp] = edge.subpaths.as_slice() else { return None }; + if sp.segment_count() == 0 { + return None; + } + let (a, z) = (sp.anchors.first()?.p, if sp.closed { sp.anchors.first()?.p } else { sp.anchors.last()?.p }); + let first = sp.segment(0); + let mid = first.eval(0.5); + let half = first.subsegment(0.0..0.5).arclen(Track::ACCURACY); + let length = sp.anchor_arc_positions().last().copied().unwrap_or(0.0); + let edge_closed = sp.closed; + // (how far off the fit is, track, start, end, runs forward) for the subpath that fits best. + let mut found: Option<(f64, &Track, f64, f64, bool)> = None; + for track in tracks { + // The start lies half the first segment before its middle along the source, or after it + // when the edge runs the other way: take the place and way that fit best. + let starts_at = track.places(a); + let mut best: Option<(f64, f64, bool)> = None; + for pm in track.places(mid) { + for forward in [true, false] { + let Some(pa) = track.nearest(&starts_at, if forward { pm - half } else { pm + half }) else { continue }; + let miss = (if forward { track.step(pa, pm) } else { track.step(pm, pa) } - half).abs(); + if best.is_none_or(|(m, _, _)| miss < m) { + best = Some((miss, pa, forward)); + } + } + } + let Some((miss, pa, forward)) = best else { continue }; + let target = if forward { pa + length } else { pa - length }; + let Some(pz) = track.nearest(&track.places(z), target) else { continue }; + let miss = miss + track.step(pz, target).abs(); + if found.is_none_or(|(m, ..)| miss < m) { + found = Some((miss, track, pa, pz, forward)); + } + } + let (_, track, pa, pz, forward) = found?; + let (from, to) = if forward { (pa, pz) } else { (pz, pa) }; + if !forward { + edge.reverse(); + } + let open = !track.closed; + let starts = open && from <= ON_SOURCE; + let ends = open && to >= track.len() - ON_SOURCE; + let whole = if open { starts && ends } else { edge_closed }; + Some((from, starts, ends, whole)) +} + +/// Fit stroke row `row` of a source path to an edge cut from it, which runs as [`edge_run`] says +/// (`None`: not known): its dashes go on from where the edge starts along the source, its +/// arrowheads stay only at the source's own ends, and its width profile and brush, which spread +/// over the whole path, stay only on an edge that runs the whole path. +fn fit_to_edge(row: &mut AppearanceItem, run: Option<(f64, bool, bool, bool)>) { + let AppearanceItem::Stroke(s) = row else { return }; + let (from, starts, ends, whole) = run.unwrap_or((0.0, false, false, false)); + if !whole { + if let Some(d) = &mut s.dash + && run.is_some() + { + d.offset += from; + d.align_corners = false; + } + s.profile = None; + s.brush = None; + } + if !starts { + s.start_arrow = None; + } + if !ends { + s.end_arrow = None; + } +} + /// The planar map's inputs from `sources` (paths and compound paths, back → front), with the -/// fill, stroke and stroke width of each. -fn source_shapes(sources: &[Arc]) -> (Vec, Vec<(Paint, Paint, f64)>) { +/// style of each. +fn source_shapes(sources: &[Arc]) -> (Vec, Vec) { let mut lv = vec![]; for n in sources { b::leaf_shapes(n, &mut lv); } let mut shapes = vec![]; - let mut styles: Vec<(Paint, Paint, f64)> = vec![]; + let mut styles: Vec = vec![]; for l in lv { if let Some((path, rule)) = b::node_outline(l) && !path.is_empty() { shapes.push(po::Shape::new(path, rule, styles.len() as u64)); - let a = &l.appearance; - styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0))); + styles.push(SourceStyle::of(l)); } } (shapes, styles) @@ -462,17 +696,35 @@ fn build_children(d: &mut Document, sources: Vec>, max_gap: Option> = HashMap::new(); for e in edges { - let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue }; + let key = e.key as usize; + let Some(s) = styles.get(key) else { continue }; + let mut path = e.path; + let mut rows = s.strokes.clone(); + if rows.iter().any(depends_on_place) { + let source = tracks + .entry(key) + .or_insert_with(|| shapes.get(key).map(|src| src.path.subpaths.iter().map(Track::new).collect()).unwrap_or_default()); + let run = edge_run(source, &mut path); + for r in &mut rows { + fit_to_edge(r, run); + } + } let id = d.alloc_id(); - let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w)); + let mut n = Node::path(id, path, stroke_only(Paint::None, s.width)); n.name = Some(EDGE_NAME.into()); + s.paint(&mut n, &rows); children.push(Arc::new(n)); } Ok((children, nfaces, nedges)) diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 1c3ad5d8b4..23674e63d0 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::appearance::{Effect, FillLayer, StrokeLayer}; use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use vectorcraft_testkit::pdf::{PdfPage, pdf}; @@ -2597,6 +2598,237 @@ fn live_paint_make_and_expand_keep_abutting_compound_colors() { check_colors(&node(&s, group)); } +/// Gives `id` the appearance rows `items` (paint order) and returns them. +fn set_rows(s: &mut Session, id: NodeId, items: Vec) -> Vec { + s.edit("t", |d, _| { + d.node_mut(id).unwrap().appearance = vectorcraft_doc::Appearance { items: items.clone(), ..Default::default() }.into(); + Ok(()) + }) + .unwrap(); + items +} + +/// A fill row of `p`, changed by `f`. +fn fill_row(p: Paint, f: impl FnOnce(&mut FillLayer)) -> AppearanceItem { + let mut l = FillLayer::new(p); + f(&mut l); + AppearanceItem::Fill(l) +} + +/// A stroke row of `p` `width` wide, changed by `f`. +fn stroke_row(p: Paint, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> AppearanceItem { + let mut l = StrokeLayer::new(p, width); + f(&mut l); + AppearanceItem::Stroke(l) +} + +/// The faces and edges of Live Paint group `g` whose bounds lie within x `x0`..`x1`. +fn parts_within(s: &Session, g: NodeId, x0: f64, x1: f64) -> (Vec, Vec) { + let n = node(s, g); + let within = |c: &&Arc| c.geometric_bounds().is_some_and(|b| b.x0 >= x0 - 1e-6 && b.x1 <= x1 + 1e-6); + let faces = vectorcraft_tools::builder::faces(&n).into_iter().filter(within).map(|c| (**c).clone()).collect(); + let edges = vectorcraft_tools::builder::edges(&n).into_iter().filter(within).map(|c| (**c).clone()).collect(); + (faces, edges) +} + +/// Make gives each edge every stroke row of its source that draws, a lower one under an +/// unpainted or hidden top row included, with the rows' settings and the source's opacity and +/// blending mode; rows that draw nothing stay only with the stored source, which Release returns. +#[test] +fn live_paint_make_keeps_lower_drawn_stroke_rows() { + let mut s = session(); + let (a, b) = two_at(&mut s, 200.0); + let black = Paint::solid(Color::BLACK); + let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0)); + let thick = stroke_row(black.clone(), 3.0, |l| { + l.opacity = 0.5; + l.blend = BlendMode::Multiply; + l.cap = LineCap::Round; + }); + // A: the top stroke has no paint. + let a_rows = + set_rows(&mut s, a, vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {}), thick.clone(), stroke_row(Paint::None, 1.0, |_| {})]); + // B: the top stroke is hidden; a zero-weight and a transparent stroke draw nothing either. + set_rows( + &mut s, + b, + vec![ + stroke_row(green.clone(), 2.0, |_| {}), + stroke_row(black.clone(), 0.0, |_| {}), + stroke_row(black, 2.0, |l| l.opacity = 0.0), + stroke_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 5.0, |l| l.visible = false), + ], + ); + s.edit("t", |d, _| { + let n = d.node_mut(a).unwrap(); + n.opacity = 0.75; + n.blend = BlendMode::Screen; + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let (_, a_edges) = parts_within(&s, g, 0.0, 100.0); + let (_, b_edges) = parts_within(&s, g, 200.0, 300.0); + assert!(!a_edges.is_empty() && !b_edges.is_empty()); + for e in &a_edges { + assert_eq!(e.appearance.items, vec![thick.clone()], "A's edge takes the stroke that draws"); + assert_eq!((e.opacity, e.blend), (0.75, BlendMode::Screen)); + } + for e in &b_edges { + assert_eq!(e.appearance.items, vec![stroke_row(green.clone(), 2.0, |_| {})], "B's edge takes only its green stroke"); + assert_eq!((e.opacity, e.blend), (1.0, BlendMode::Normal)); + } + // Release returns the sources with every row they had. + let released = ids(&s.execute("livePaint.release", &json!({})).unwrap()); + assert!(released.contains(&a)); + assert_eq!(node(&s, a).appearance.items, a_rows); +} + +/// When Make cuts a dashed stroke with arrowheads, a width profile and a brush into edges, each +/// edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own +/// ends, and the profile and brush, which spread over the whole path, are left off the pieces. +#[test] +fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { + use vectorcraft_doc::{Arrowhead, Dash}; + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let l = line(&mut s, (-20.0, 50.0), (120.0, 50.0)); + let dash = Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: true }; + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(dash.clone()); + st.start_arrow = Some(Arrowhead::Triangle); + st.end_arrow = Some(Arrowhead::Circle); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, l, vec![row]); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let mut pieces: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&gn) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone(); + let sp = e.path_data()?.subpaths.first()?; + Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x, st)) + }) + .collect(); + pieces.sort_by(|p, q| p.0.total_cmp(&q.0)); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| ((p.0 * 1e3).round() / 1e3, (p.1 * 1e3).round() / 1e3)).collect(); + assert_eq!(ends, vec![(-20.0, 0.0), (0.0, 100.0), (100.0, 120.0)], "each piece runs the way the line does"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 22.0, 122.0], "the dashes go on along the line"); + assert!(pieces.iter().all(|p| !p.2.dash.as_ref().unwrap().align_corners && p.2.brush.is_none())); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, None), (None, Some(Arrowhead::Circle))]); + // A path that crosses itself: the pieces that start at the crossing take their place on the + // path from where they run, not from the first time the path passes that point. + let mut s3 = session(); + let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y})); + let z = NodeId(s3.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap()); + set_rows(&mut s3, z, rows.clone()); + s3.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g3 = NodeId(s3.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let mut cut: Vec<(f64, Option, Option)> = vectorcraft_tools::builder::edges(&node(&s3, g3)) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke()?; + Some(((st.dash.as_ref()?.offset * 1e3).round() / 1e3, st.start_arrow.clone(), st.end_arrow.clone())) + }) + .collect(); + cut.sort_by(|p, q| p.0.total_cmp(&q.0)); + let d = 2.0 + 50.0 * 2f64.sqrt(); + let r = |v: f64| (v * 1e3).round() / 1e3; + assert_eq!( + cut, + vec![(2.0, Some(Arrowhead::Triangle), None), (r(d), None, None), (r(d + 100.0 + 100.0 * 2f64.sqrt()), None, Some(Arrowhead::Circle))] + ); + // An uncut path keeps all of it. + let mut s2 = session(); + let l2 = line(&mut s2, (0.0, 0.0), (50.0, 0.0)); + set_rows(&mut s2, l2, rows.clone()); + s2.execute("select.set", &json!({"ids": [l2.0]})).unwrap(); + let g2 = NodeId(s2.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let edges: Vec<_> = vectorcraft_tools::builder::edges(&node(&s2, g2)).iter().map(|e| e.appearance.items.clone()).collect(); + assert_eq!(edges, vec![rows]); +} + +/// Make gives each face every fill row of its source that draws, in order and with their +/// opacity, blending mode, overprint and effects, and the source's gradients keep their fit on +/// the source; Expand keeps the rows, and Undo and Redo bring the group back with them. +#[test] +fn live_paint_make_keeps_all_drawn_fill_rows_and_attributes() { + let mut s = session(); + let (a, b) = two(&mut s); + let lower = fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |l| { + l.opacity = 0.4; + l.blend = BlendMode::Multiply; + l.overprint = true; + l.effects.push(Effect { id: "distort.roughen".into(), params: Value::Null, visible: true }); + }); + // A: two fills that draw, with a hidden one between them and an unpainted one above them. + set_rows( + &mut s, + a, + vec![ + lower.clone(), + fill_row(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), |l| l.visible = false), + fill_row(Paint::None, |_| {}), + stroke_row(Paint::None, 1.0, |_| {}), + ], + ); + // The top fill (and the stroke) become a gradient fitted to A's bounds. + let (want_fill, _) = paint_with_gradients(&mut s, a); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let want = vec![lower, fill_row(want_fill, |_| {})]; + let rows_within = |s: &Session, x0: f64, x1: f64| parts_within(s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::>(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()], "A's face takes both fills that draw, the gradient at its fit on A"); + let blue = vec![fill_row(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), |_| {})]; + assert_eq!(rows_within(&s, 50.0, 100.0), vec![blue], "B is in front"); + s.execute("livePaint.expand", &json!({})).unwrap(); + let expanded = node(&s, g); + assert!(!vectorcraft_tools::builder::is_live_paint(&expanded)); + let kept = + expanded.children().unwrap().iter().filter(|c| c.geometric_bounds().is_some_and(|b| b.x1 <= 50.0 + 1e-6)).any(|c| c.appearance.items == want); + assert!(kept, "Expand keeps both fills"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want.clone()]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(rows_within(&s, 0.0, 50.0), vec![want]); +} + +/// A face takes the fill rows of the front-most covering source whose fill draws: a source in +/// front whose fill is hidden or transparent, or whose opacity is 0, does not hide the paint of the +/// source behind it, and a face that no such source covers has no paint. +#[test] +fn live_paint_make_uses_frontmost_source_with_drawn_fill() { + for front in ["hidden", "clear", "transparent object"] { + let mut s = session(); + let (a, b) = two(&mut s); + s.edit("t", |d, _| { + let n = d.node_mut(b).unwrap(); + let mut app = (*n.appearance).clone(); + match front { + "hidden" => app.fill_mut().unwrap().visible = false, + "clear" => app.fill_mut().unwrap().opacity = 0.0, + _ => n.opacity = 0.0, + } + n.appearance = app.into(); + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let rows_within = |x0: f64, x1: f64| parts_within(&s, g, x0, x1).0.iter().map(|f| f.appearance.items.clone()).collect::>(); + let red = vec![fill_row(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), |_| {})]; + assert_eq!(rows_within(50.0, 100.0), vec![red], "{front}: the overlap shows A"); + assert_eq!(rows_within(100.0, 150.0), vec![vec![fill_row(Paint::None, |_| {})]], "{front}: B alone paints nothing"); + assert_eq!(parts_within(&s, g, 100.0, 150.0).0[0].opacity, 1.0, "{front}"); + } +} + #[test] fn live_paint_fill_assigns_the_face_under_the_point() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 1ad05ba3df..35a1b4e74f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3525,8 +3525,19 @@ already walks Image Trace. Short agent entry points: `ui.imageTracePresetDialog {params?, rename?}` opens the Image Trace panel's Save as New Preset… or Rename… dialog. - Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, - color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill of its front-most - covering source, including compound paths with shared curved boundaries. Expand keeps those face fills. + color?, closeGaps?}` (Object › Live Paint › Gap Options). Make gives each covered face the fill rows that draw of + its front-most covering source that has one, including compound paths with shared curved boundaries, and each edge + the stroke rows that draw of its source. A row draws when it is shown and has paint and an opacity above 0; a + stroke also needs a weight above 0, and a source whose opacity is 0 has no rows that draw. The rows keep their order + and settings (opacity, blending mode, effects, overprint, and a stroke's weight, caps, joins, dashes and alignment), + gradients keep their fit on the source, and the face or edge takes the source's opacity and blending mode. Where a + path is cut into several edges, each edge runs the way the path does, its dashes go on from where it starts along + the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width + profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on + each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path. + A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which + `livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried. + Expand keeps those face fills. With `detect: true`, paint stops at the gaps that run from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. `color` (the Gap Preview diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2c74302900..eb1623d225 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1457,6 +1457,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. + Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. View › Show Live Paint Gaps (`view.livePaintGaps`) draws the gaps of the selected Live Paint groups up to their Paint Stops At size, each in its group's Gap Preview Color, until Hide Live Paint Gaps or Hide Edges hides them ([Live Paint gaps](https://web.archive.org/web/20260105111201/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/find-and-close-gaps-in-live-paint-groups.html)). From 6e2123709e39a075424973c8b91202f45464d2e3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 03:39:39 -0800 Subject: [PATCH 10/40] Blob Brush: a long drag's settled outline is reused from frame to frame The outline a Blob Brush or Eraser drag shows was copied, flattened and tessellated again every frame, settled parts included, so the per-frame cost of a long stroke grew with its whole length. - The settled outline blocks are kept as shared paths and returned as a new Overlay::SharedPath; a block gets a new path only when it is joined again. - The canvas keeps the mesh each shared path was drawn with, by the path's allocation and for the view, clip and stroke it was built for. A frame with the same path and view draws the kept mesh; a pan, zoom or rotation builds it once more. Lines outside the canvas get no triangles. A frame keeps only the meshes it draws. egui still copies each kept mesh into the frame's mesh on every repaint. The release sends the same path.blob and path.eraseRegion command as before. The round Eraser tip's line is unchanged. --- crates/tools/src/draw2/gesture.rs | 6 +- crates/tools/src/draw2/gesture/area.rs | 27 ++++-- crates/tools/src/draw2/gesture/tests.rs | 108 +++++++++++++++++++++++- crates/tools/src/lib.rs | 4 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/caches.rs | 3 + crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/canvas/overlays.rs | 84 +++++++++++++++++- crates/ui-egui/src/canvas/tests.rs | 84 ++++++++++++++++++ docs/target-app-parity.md | 1 + 10 files changed, 311 insertions(+), 14 deletions(-) diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 61396e01d9..aea1b7b17e 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -1516,9 +1516,9 @@ impl Tool for GestureTool { }; let mut area = self.area.take().filter(|a| a.fits(key, &self.points)).unwrap_or_else(|| AreaPreview::new(key, self.points[0])); area.step(&self.points, pressures); - for path in area.paths(&self.points) { - o.push(Overlay::Path { path, color, width: 1.0, dashed: false }); - } + let paths = area.paths(&self.points); + o.extend(paths.settled.into_iter().map(|path| Overlay::SharedPath { path, color, width: 1.0 })); + o.extend(paths.live.into_iter().map(|path| Overlay::Path { path, color, width: 1.0, dashed: false })); self.area_pending.set(area.pending(&self.points)); self.area.set(Some(area)); } else if let Some(pressures) = self.size_pressures() { diff --git a/crates/tools/src/draw2/gesture/area.rs b/crates/tools/src/draw2/gesture/area.rs index 0bc0f13f25..a56322db40 100644 --- a/crates/tools/src/draw2/gesture/area.rs +++ b/crates/tools/src/draw2/gesture/area.rs @@ -13,6 +13,7 @@ //! most [`JOIN_PIECES_PER_CALL`] pieces' worth. The grid holds at most [`LISTINGS`] listings. use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; use vectorcraft_geom::{Affine, BezPath, Line, ParamCurve, PathData, PathEl, PathSeg, Point, SubPath}; use vectorcraft_pathops as po; @@ -84,10 +85,22 @@ pub(super) struct AreaWork { pub joined: usize, } +/// The paths of the outline to show ([`AreaPreview::paths`]). +pub(super) struct AreaPaths { + /// The settled pieces' outlines, joined [`BLOCK_PIECES`] at a time: a block that did not change + /// is the same shared path as in the call before. + pub settled: Vec>, + /// The growing last piece's outline and the thin line over the points not taken yet, drawn + /// over the settled ones. + pub live: Vec, +} + /// The joined outline of up to [`BLOCK_PIECES`] settled pieces. #[derive(Default)] struct Block { - path: BezPath, + /// Shared with the paths [`AreaPreview::paths`] returned: a new one only when the block is + /// joined again. + path: Arc, /// A piece in it changed since `path` was joined. stale: bool, } @@ -227,8 +240,9 @@ impl AreaPreview { /// The paths to show: the settled pieces' outlines, joined [`BLOCK_PIECES`] at a time, the /// growing last piece's and a thin line over the points of `pts` not taken yet. The joined /// paths with a changed piece are joined again, newest first, at most - /// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined. - pub(super) fn paths(&mut self, pts: &[Point]) -> Vec { + /// [`JOIN_PIECES_PER_CALL`] pieces a call; the others show the path last joined, the same + /// shared path each call. + pub(super) fn paths(&mut self, pts: &[Point]) -> AreaPaths { let settled = self.settled_count(); for b in (0..self.blocks.len()).rev() { if self.work.joined >= JOIN_PIECES_PER_CALL { @@ -243,9 +257,10 @@ impl AreaPreview { joined.extend(p.shown.elements().iter().copied()); } self.work.joined += pieces.len(); - self.blocks[b] = Block { path: joined, stale: false }; + self.blocks[b] = Block { path: Arc::new(joined), stale: false }; } - let mut out: Vec = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| b.path.clone()).collect(); + let settled_paths = self.blocks.iter().filter(|b| !b.path.elements().is_empty()).map(|b| Arc::clone(&b.path)).collect(); + let mut out = vec![]; if let Some(p) = self.pieces.get(settled).filter(|p| !p.shown.elements().is_empty()) { out.push(p.shown.clone()); } @@ -261,7 +276,7 @@ impl AreaPreview { } out.push(guide); } - out + AreaPaths { settled: settled_paths, live: out } } /// How many pieces no longer grow: all of them but a last one with room for more segments. diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs index ece7a369bb..1c29ee77c4 100644 --- a/crates/tools/src/draw2/gesture/tests.rs +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -2311,11 +2311,111 @@ fn the_blob_brush_and_the_eraser_outline_a_long_drag_a_bounded_amount_per_frame( } } +/// The settled paths of the outline of a long Blob Brush or Eraser drag (each the outline of up +/// to `BLOCK_PIECES` pieces) come back from one call to the next, at any zoom, as the same shared +/// paths, which the host draws again without copying or flattening them. A step of the drag +/// replaces only the paths of the pieces near it. +#[test] +fn the_settled_outline_of_a_long_drag_is_the_same_shared_path_each_frame() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; + let settled = |o: &[Overlay]| -> Vec> { + o.iter().filter_map(|o| if let Overlay::SharedPath { path, .. } = o { Some(std::sync::Arc::clone(path)) } else { None }).collect() + }; + let kept = |was: &[std::sync::Arc], now: &[std::sync::Arc]| { + now.iter().filter(|n| was.iter().any(|w| std::sync::Arc::ptr_eq(w, n))).count() + }; + for (id, angle, roundness, pressure) in + [("blobBrush", 0.0, 100.0, false), ("eraser", -60.0, 30.0, false), ("blobBrush", 0.0, 100.0, true), ("eraser", 45.0, 50.0, true)] + { + let mut t = GestureTool::new(id); + t.set_option("size", &json!(10)); + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + if pressure { + t.set_option("sizeMode", &json!("pressure")); + t.set_option("sizeVariation", &json!(4)); + } + let n = if pressure { 3000 } else { 6000 }; + long_area_drag(&mut t, &cx, n, |i| if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 }); + drain_area(&t, &cx); + let first = settled(&t.overlays(&cx)); + assert!(first.len() > 1, "{id}: {} settled paths", first.len()); + for zoom in [1.0, 4.0, 1.0] { + let again = settled(&t.overlays(&ToolContext { zoom, ..cx })); + assert_eq!(again.len(), first.len(), "{id}"); + assert_eq!(kept(&first, &again), first.len(), "{id}: at zoom {zoom}, every settled path is the one shown before"); + } + // One step more along the drag: with Size set to Pressure each path holds 64 one-segment + // pieces, part of one turn of the drag, and the paths far from the step stay the ones shown + // before. (Without it a path holds 64 pieces of 32 segments, many turns round the circle.) + if !pressure { + continue; + } + let s = n as f64; + let r = 150.0 + 100.0 * (s * 0.0071).sin(); + let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin()); + t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, x, y) }); + drain_area(&t, &cx); + let after = settled(&t.overlays(&cx)); + let replaced = after.len() - kept(&first, &after); + assert!((1..=8).contains(&replaced), "{id}: {replaced} of {} settled paths replaced", after.len()); + } +} + +/// The outline a Blob Brush or Eraser drag shows does not change what its release sends: the same +/// drag sends the same command whether or not its outline was drawn along the way. +#[test] +fn drawing_the_outline_of_a_drag_does_not_change_what_its_release_sends() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = ToolContext { precise_cursors: true, ..cx(&d, &s, &p) }; + for (id, angle, roundness, pressure) in + [("blobBrush", 0.0, 100.0, false), ("blobBrush", 30.0, 40.0, true), ("eraser", -60.0, 30.0, false), ("eraser", 45.0, 50.0, true)] + { + let release = |drawn: bool| { + let mut t = GestureTool::new(id); + t.set_option("size", &json!(10)); + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + if pressure { + t.set_option("sizeMode", &json!("pressure")); + t.set_option("sizeVariation", &json!(4)); + } + for i in 0..800 { + let s = i as f64; + let r = 150.0 + 100.0 * (s * 0.0071).sin(); + let (x, y) = (300.0 + r * (s * 0.04).cos(), 300.0 + r * (s * 0.04).sin()); + let kind = if i == 0 { PointerKind::Down } else { PointerKind::Drag }; + let pressure = if pressure { 0.3 + 0.6 * ((i % 50) as f32 / 50.0) } else { 1.0 }; + t.pointer(&cx, &PointerEvent { pressure, ..PointerEvent::new(kind, x, y) }); + if drawn { + t.overlays(&cx); + } + } + if drawn { + drain_area(&t, &cx); + } + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 300.0)) + }; + let (drawn, not_drawn) = (release(true), release(false)); + assert!(matches!(drawn.first(), Some(Action::Exec(..))), "{id}: {drawn:?}"); + assert_eq!(drawn, not_drawn, "{id}"); + } +} + /// Every flattened point of the paths `o` (the outline shown). fn shown_points(o: &[Overlay]) -> Vec<(Point, Point)> { let mut lines = vec![]; for o in o { - let Overlay::Path { path, .. } = o else { continue }; + let path = match o { + Overlay::Path { path, .. } => path, + Overlay::SharedPath { path, .. } => &**path, + _ => continue, + }; let mut pen = Point::ZERO; let mut start = Point::ZERO; vectorcraft_geom::kurbo::flatten(path.elements().iter().copied(), 0.01, |el| match el { @@ -2402,7 +2502,11 @@ fn a_long_jump_in_a_blob_brush_drag_is_outlined_a_bounded_amount_at_a_time() { let o = t.overlays(&cx); let b = o .iter() - .filter_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::Shape::bounding_box(path)) } else { None }) + .filter_map(|o| match o { + Overlay::Path { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(path)), + Overlay::SharedPath { path, .. } => Some(vectorcraft_geom::Shape::bounding_box(&**path)), + _ => None, + }) .reduce(|a, b| a.union(b)); let Some(b) = b else { panic!("{o:?}") }; assert!((b.x0 + 0.5).abs() < 0.01 && (b.x1 - 20_000.5).abs() < 0.01, "{b:?}"); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 1119bb0764..18614a9302 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -71,6 +71,10 @@ pub enum Overlay { SelectionMarquee { rect: Rect, enclosed: bool, pointer: Point }, /// A path preview (e.g. pen rubber band) in a colour (RGB). Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool }, + /// A solid path preview that later calls return again, as the same shared `path`, for as long + /// as it does not change (the settled outline of a long Blob Brush or Eraser drag). The host + /// can keep what it built to draw it while `path` is the same allocation. + SharedPath { path: std::sync::Arc, color: [u8; 3], width: f32 }, /// A line segment (smart guide, handle line). Line { a: Point, b: Point, color: [u8; 3], dashed: bool }, /// An anchor square: filled = selected. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 412dbcb4ef..658068f79b 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -85,6 +85,7 @@ impl VectorcraftApp { hidden_chars: Default::default(), spelling: Default::default(), selection_marks: Default::default(), + shared_paths: Default::default(), selection_box: None, selection_bounds: None, content_bounds: None, diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index 286b4d4d02..9ebf7d39b8 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -51,6 +51,9 @@ pub struct CanvasCache { /// The selected objects' outlines, anchors and handles as drawn in the last frame. A frame that /// does not draw them drops them ([`canvas::show`]). pub selection_marks: canvas::SelectionMarks, + /// The meshes the tools' shared paths (a long Blob Brush or Eraser drag's settled outline) were + /// drawn with in the last frame. A frame that does not draw them drops them ([`canvas::show`]). + pub shared_paths: canvas::SharedPaths, /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds and /// Measure Type by Glyph Bounds). pub selection_box: Option<((u64, u64, vectorcraft_effects::preview::Measure), Option)>, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a947dde852..210c0a7614 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -59,6 +59,7 @@ use input::handle_input; use outline::KEY_OUTLINE; #[cfg(test)] use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn}; +pub use overlays::SharedPaths; #[cfg(test)] use overlays::selected_anchor; use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; @@ -238,6 +239,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars); // The selection's kept marks. Likewise, a frame that does not draw them drops them. let selection_marks = std::mem::take(&mut app.canvas.selection_marks); + // The meshes of the tools' shared paths. Likewise, a frame that does not draw the tools' + // overlays drops them. + let mut shared_paths = std::mem::take(&mut app.canvas.shared_paths); // A release ends the Image Trace panel's eye-button hold, also when the panel is no longer // drawn, so a later press elsewhere does not show the source image. if app.ui.trace_hold_source.is_some() && !ui.input(|i| i.pointer.any_down()) { @@ -409,7 +413,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { original_path_overlay(app, &painter, &xf); let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, selection_marks, hover_still), revolve_edit, view_info); - draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); + draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs), &mut shared_paths); + app.canvas.shared_paths = shared_paths; if !revolve_edit { shaper_widget_overlay(app, &painter, &xf); crate::panels::three_d::paint_graphic_handles(app, &painter, &xf); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index aefd9253e9..65a9807ce7 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,8 +1,14 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use egui::Mesh; +use egui::epaint::{TessellationOptions, Tessellator}; + use super::artboards::names_an_artboard; use super::input::marquee_through; use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors}; use super::selection_marks::{MarkOptions, SelectionMarks}; -use super::{Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; +use super::{BezPath, Color32, CornerRadius, NodeKind, Overlay, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2}; pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let Some(h) = app.hover_doc else { return }; @@ -350,7 +356,75 @@ pub(super) fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &X app.ime_caret_rect = Some((r, r)); } -pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) { +/// The meshes the tools' shared paths ([`Overlay::SharedPath`]) were drawn with in the last frame +/// ([`crate::CanvasCache::shared_paths`]). Each is kept with its path, by the path's allocation, and +/// with the view, clip and stroke it was built for: a path that comes back unchanged at the same view +/// is drawn with the same mesh, without flattening or tessellating it again. A frame keeps only the +/// meshes it draws; a frame that draws no overlays drops them all ([`super::show`]). +#[derive(Default)] +pub struct SharedPaths { + pub(super) meshes: HashMap, +} + +pub(super) struct SharedMesh { + /// Held so that the allocation, the key of the map, is not reused while the mesh is kept. + path: Arc, + key: SharedKey, + pub(super) mesh: Arc, +} + +/// What a shared path's mesh depends on besides the path. +#[derive(Clone, Copy, PartialEq)] +struct SharedKey { + /// The view's mapping onto the screen ([`Xf::affine`]). + view: [f64; 6], + /// The painter's clip: the lines of the path outside it have no triangles in the mesh. + clip: egui::Rect, + stroke: Stroke, + pixels_per_point: f32, + tessellation: TessellationOptions, +} + +impl SharedPaths { + /// The mesh that draws `path` with `s` at the view `xf` with the painter `p`: the one kept in + /// this frame or in `last` (the last frame's) when it was built for the same, else a new one. + /// It is kept for the next frame. + fn mesh(&mut self, last: &mut HashMap, path: &Arc, xf: &Xf, s: Stroke, p: &egui::Painter) -> Arc { + let ctx = p.ctx(); + let key = SharedKey { + view: xf.affine().as_coeffs(), + clip: p.clip_rect(), + stroke: s, + pixels_per_point: ctx.pixels_per_point(), + tessellation: ctx.tessellation_options(|o| *o), + }; + let at = Arc::as_ptr(path) as usize; + let fits = |m: &SharedMesh| m.key == key && Arc::ptr_eq(&m.path, path); + if let Some(kept) = self.meshes.get(&at).filter(|m| fits(m)) { + return Arc::clone(&kept.mesh); + } + let kept = match last.remove(&at).filter(|m| fits(m)) { + Some(kept) => kept, + None => { + let font_image_size = ctx.fonts(|f| f.font_image_size()); + let mut tessellator = Tessellator::new(key.pixels_per_point, key.tessellation, font_image_size, vec![]); + tessellator.set_clip_rect(key.clip); + let mut mesh = Mesh::default(); + for line in to_screen_path(path, xf) { + tessellator.tessellate_shape(Shape::line(line, s), &mut mesh); + } + SharedMesh { path: Arc::clone(path), key, mesh: Arc::new(mesh) } + } + }; + let mesh = Arc::clone(&kept.mesh); + self.meshes.insert(at, kept); + mesh + } +} + +pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook, shared: &mut SharedPaths) { + // The meshes kept from the last frame; those this frame does not draw are dropped. + let mut last = std::mem::take(&mut shared.meshes); for o in overlays { match o { // Where the tools select: the document rectangle, turned with the view. @@ -388,6 +462,12 @@ pub(super) fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: stroke_path(p, path, xf, s); } } + Overlay::SharedPath { path, color, width } => { + let mesh = shared.mesh(&mut last, path, xf, Stroke::new(*width, c32(*color)), p); + if !mesh.is_empty() { + p.add(Shape::mesh(mesh)); + } + } Overlay::Line { a, b, color, dashed } => { let s = Stroke::new(1.0, c32(*color)); let (a, b) = (xf.to_screen(*a), xf.to_screen(*b)); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4d609f2b5e..1f41c29419 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -17,6 +17,7 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { &[Overlay::SelectionMarquee { rect: Rect::new(20.0, y - 60.0, 300.0, y), enclosed, pointer: Point::new(315.0, y) }], &t, overlays::HandleLook::of(&prefs), + &mut overlays::SharedPaths::default(), ); } }; @@ -61,6 +62,89 @@ fn marquee_feedback_explains_the_rule_and_stays_on_canvas() { } } +/// A tool's shared path ([`Overlay::SharedPath`], the settled outline of a long Blob Brush or Eraser +/// drag) is drawn with the mesh kept from the last frame while the path and the view stay the same, +/// and that mesh is the one the plain path draws. Another path, even one with the same elements, or +/// a new view builds a new mesh, and a frame keeps only the meshes it draws. +#[test] +fn a_shared_tool_path_is_drawn_with_the_mesh_kept_while_it_and_the_view_stay() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::MediumDark); + let prefs = vectorcraft_engine::Prefs::default(); + let mut shared = overlays::SharedPaths::default(); + let frame = |overlays: &[Overlay], zoom: f64, shared: &mut overlays::SharedPaths| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let t = Tokens::get(ui.ctx()); + let xf = Xf { rect: ui.max_rect(), zoom, center: Point::new(200.0, 150.0), rot: 0.3 }; + overlays::draw_overlays(ui.painter(), &xf, overlays, &t, overlays::HandleLook::of(&prefs), shared); + }); + out.textures_delta.clear(); + out.shapes + }; + let meshes = |shapes: &[egui::epaint::ClippedShape]| -> Vec> { + shapes.iter().filter_map(|c| if let Shape::Mesh(m) = &c.shape { Some(std::sync::Arc::clone(m)) } else { None }).collect() + }; + let one = |shapes: Vec| { + let found = meshes(&shapes); + assert_eq!(found.len(), 1, "one mesh"); + std::sync::Arc::clone(&found[0]) + }; + let mut path = BezPath::new(); + path.move_to((60.0, 80.0)); + path.curve_to((120.0, 20.0), (220.0, 260.0), (330.0, 120.0)); + path.close_path(); + path.move_to((150.0, 150.0)); + path.line_to((250.0, 160.0)); + let a = std::sync::Arc::new(path.clone()); + let b = std::sync::Arc::new(path.clone()); + let shared_of = |p: &std::sync::Arc| Overlay::SharedPath { path: std::sync::Arc::clone(p), color: [0x80, 0x80, 0x80], width: 1.0 }; + let first = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(!first.vertices.is_empty()); + let again = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the same path at the same view is drawn with the kept mesh"); + // The plain path draws the same triangles. + let plain = frame(&[Overlay::Path { path: path.clone(), color: [0x80, 0x80, 0x80], width: 1.0, dashed: false }], 1.0, &mut shared); + assert!(shared.meshes.is_empty(), "a frame that draws no shared path keeps no mesh"); + let lines: Vec<_> = plain.into_iter().filter(|c| matches!(c.shape, Shape::Path(_))).collect(); + assert_eq!(lines.len(), 2, "a line for each subpath"); + let tessellated = ctx.tessellate(lines, ctx.pixels_per_point()); + let triangles: Vec<_> = tessellated + .into_iter() + .filter_map(|c| if let egui::epaint::Primitive::Mesh(m) = c.primitive { Some(m) } else { None }) + .flat_map(|m| m.vertices) + .map(|v| (v.pos, v.color)) + .collect(); + assert_eq!(triangles, first.vertices.iter().map(|v| (v.pos, v.color)).collect::>()); + // A dropped mesh, another allocation of the same path and a new view each build a new mesh. + let rebuilt = one(frame(&[shared_of(&a)], 1.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&first, &rebuilt) && *first == *rebuilt); + let other = one(frame(&[shared_of(&b)], 1.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&rebuilt, &other) && *rebuilt == *other); + let zoomed = one(frame(&[shared_of(&b)], 2.0, &mut shared)); + assert!(!std::sync::Arc::ptr_eq(&other, &zoomed) && *other != *zoomed); + // Two paths in one frame: each keeps its own mesh, and only those two are kept. + let both = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared)); + let both_again = meshes(&frame(&[shared_of(&a), shared_of(&b)], 2.0, &mut shared)); + assert_eq!(both.len(), 2); + assert!(both.iter().zip(&both_again).all(|(x, y)| std::sync::Arc::ptr_eq(x, y))); + assert!(std::sync::Arc::ptr_eq(&both[1], &zoomed), "b at zoom 2 was kept from its frame"); + assert_eq!(shared.meshes.len(), 2); + // A path off the canvas gets no triangles and draws nothing, and the lines of one partly off + // it are left out of its mesh. + let mut far = path.clone(); + far.apply_affine(vectorcraft_geom::Affine::translate((5000.0, 5000.0))); + let far = std::sync::Arc::new(far); + assert!(meshes(&frame(&[shared_of(&far)], 1.0, &mut shared)).is_empty()); + assert!(shared.meshes.values().all(|m| m.mesh.is_empty()) && shared.meshes.len() == 1); + let mut half = path.clone(); + half.move_to((5000.0, 5000.0)); + half.line_to((5100.0, 5000.0)); + let half = one(frame(&[shared_of(&std::sync::Arc::new(half))], 1.0, &mut shared)); + assert_eq!(*half, *first, "the subpath off the canvas adds no triangles"); +} + #[test] fn subpixel_dock_layout_does_not_change_pointer_document_coordinates() { let mut observed = Vec::new(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..9afbca767b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1105,6 +1105,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`). The outline a Blob Brush or Eraser drag shows is built a bounded amount per frame, so a long stroke does not slow the canvas: each frame takes at most 256 new points of the drag and outlines again only the pieces of up to 32 segments they change, and the parts of a piece's outline inside the rest of the area are removed a limited amount at a time. While work is left, the canvas draws again with the pointer still, the points not outlined yet show as a thin line, and the tip shows at the pointer. With Size set to Pressure the outline also shows only the edge of the whole area. The release sends the whole stroke as before (`path.blob`, `path.eraseRegion`). + The settled part of that outline is kept in paths of up to 64 pieces each. A path is shared from one frame to the next until a piece in it changes, and the canvas keeps the triangles it drew the path with while the view stays the same, so a frame does not copy, flatten or tessellate a settled path again; a pan, zoom or rotation draws each one once more. This is VectorCraft's own rule. The outline of a drag with a round Eraser tip is drawn whole each frame. - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag). A Reshape drag stretches part of a path from the dragged point without losing the path's general form, as [Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) has it. On an open path the stretch reaches both ends: the end anchors stay in place, each anchor between moves less the nearer it lies to an end, and the handles of a smooth anchor stay in line (`path.reshape`; the reach and the shares are VectorCraft's own rule, in `docs/mcp.md`). A click sets a focal point and a click on a segment adds a highlighted anchor there; Shift-click adds more, and a drag from any of them moves them all. The anchors that are not direct-selected stay in place and a closed path selected whole moves whole (VectorCraft's own rule, in `docs/mcp.md`). Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). From 9b65454983acae58ba214e02650cdf3f60ec1d29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:25:54 -0800 Subject: [PATCH 11/40] Graphs: aligned rows given with a design's art stay rows of its stack The Appearance and effect commands leave the art of a graph design in a series out, and aligned rows were then matched against the first object left over. With the art given first, appearance.removeItem with index 1 and aligned: true on art [red fill, blue fill] and a mark [blue fill, stroke] removed the mark's stroke instead of its blue fill. When the first object given is design art, rows and effects given with aligned: true are now rows of the art's stack, and each other object edits the row or effect equal to it. An object without one is left as it is. This covers appearance.setItem, appearance.removeItem, appearance.duplicateItem, transparency.set with an item, effect.apply, appearance.addEffect and the effect commands that edit or remove an effect. When no object has one, appearance.setItem and appearance.removeItem change nothing, as they do for a row no object has, and the other commands are an error. appearance.moveItem, effect.move and appearance.duplicateItem with `to` compare the art's stack too, and are an error when it differs from the other objects'. The effect dialog reads the effects of such a row from the art's stack, and passes the art on with the other objects. docs/mcp.md states the rule. --- crates/engine/src/cmd/appearance.rs | 81 +++++++++++-- crates/engine/src/cmd/effectcmd.rs | 71 +++++++++--- crates/engine/src/cmd/graph/tests.rs | 165 +++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 13 ++- docs/mcp.md | 10 +- 5 files changed, 307 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 5896d39fd8..306ebc8557 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -186,11 +186,18 @@ impl Session { let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?); let item = item_target(self, params, "effect.apply").ok()?; let anchor = item.anchor(self, &ids); - let node = self.active()?.doc.node(*ids.get(anchor)?)?; + // Aligned effects given with a graph design's art first are effects of the art's stack, which stays first; the + // Appearance panel's active row is a row of the first selected object's. + let of = match item { + ItemTarget::Item { explicit: false, .. } => None, + _ => design_reference(self, params, "effect.apply").ok()?, + }; + let own = *ids.get(anchor)?; + let node = self.active()?.doc.node(of.unwrap_or(own))?; let at = item.effects_item(&node.appearance, "effect.apply").ok()?; let effects = node.appearance.effects_at(at)?; ids.swap(0, anchor); - let ids: Vec = ids.into_iter().map(|id| id.0).collect(); + let ids: Vec = with_reference(of, &ids).into_iter().map(|id| id.0).collect(); let aligned = match item { ItemTarget::Item { aligned, .. } => aligned, ItemTarget::Top => aligned(params, "effect.apply").ok()?, @@ -304,6 +311,21 @@ impl ItemTarget { .collect() } + /// [`ItemTarget::per_target`], with an explicit aligned item a row of the stack of `of` (see [`design_reference`]), + /// which is not among `ids`, when there is one: each target edits the item equal to it there, and a target without + /// an equal item is left out (`None`). + pub(crate) fn per_target_of(self, s: &Session, of: Option, ids: &[NodeId]) -> Vec> { + let ItemTarget::Item { index, explicit: true, aligned: true } = self else { return self.per_target(s, ids) }; + let Some(d) = s.active().map(|st| &st.doc) else { return self.per_target(s, ids) }; + let Some(r) = of.and_then(|of| d.node(of)) else { return self.per_target(s, ids) }; + ids.iter() + .map(|id| { + let index = SharedStack::of(&[&*r.appearance, &*d.node(*id)?.appearance]).item_in(index, 1)?; + Some(ItemTarget::Item { index, explicit: true, aligned: true }) + }) + .collect() + } + /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of /// is: the first selected object (or the compound path holding it) for the active item, which /// need not paint first; the first target otherwise, or when that object is not among them. @@ -388,6 +410,32 @@ pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], id } } +/// The art of a graph design in a graph series whose stack the aligned rows of `p` (`aligned: true`) are rows of: the +/// first of the [`appearance_targets`] when it is such art, which the edit leaves out ([`without_design_art`]). A +/// command reads it after its own target checks. +pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result> { + if !aligned(p, cmd)? { + return Ok(None); + } + let ids = appearance_targets(s, p)?; + let d = &s.doc()?.doc; + Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id))) +} + +/// `ids` after `of` (see [`design_reference`]), when there is one. +pub(crate) fn with_reference(of: Option, ids: &[NodeId]) -> Vec { + of.into_iter().chain(ids.iter().copied()).collect() +} + +/// [`items_per_target`] of `ids`, with the aligned rows `idx` taken from the stack of `of` (see [`design_reference`]) +/// when there is one: a row a target has no equal of is left out of that target. +pub(crate) fn rows_per_target(d: &Document, of: Option, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { + match of { + Some(of) => items_per_target(d, &with_reference(Some(of), ids), idx, true).into_iter().skip(1).collect(), + None => items_per_target(d, ids, idx, aligned), + } +} + /// Whether the `target` param asks for the contents of groups and layers (see the module docs). fn targets_contents(p: &Value) -> Result { match p.get("target") { @@ -1057,7 +1105,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { // When every object comes out as it was (for example, when none has item `index`), the document // and its undo history stay as they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?); + let of = design_reference(s, p, "appearance.setItem")?; + let per = rows_per_target(&before, of, &ids, &[idx], aligned(p, "appearance.setItem")?); super::overprint::edit_counted(s, "Appearance", |d| { let mut edited = Vec::new(); for (id, at) in ids.iter().zip(&per) { @@ -1144,7 +1193,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { // When no object has an item at any of the indices, the document and its undo history stay as // they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?); + let of = design_reference(s, p, "appearance.removeItem")?; + let per = rows_per_target(&before, of, &ids, &idx, aligned(p, "appearance.removeItem")?); super::overprint::edit_counted(s, "Remove Item", |d| { for (id, idx) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { @@ -1185,12 +1235,19 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut first_at = None; let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); let mut active_at = None; - // Aligned rows of a mixed selection are copied right above themselves in each object. - if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { + // Aligned rows of a mixed selection are copied right above themselves in each object. Rows of a graph design's art, + // which is left out, go to `to` in an object whose stack is the same as the art's. + let of = design_reference(s, p, C)?; + if aligned(p, C)? + && to.is_some() + && (shared_stack(&s.doc()?.doc, &ids).is_some() || (of.is_some() && appearances_differ(&s.doc()?.doc, &with_reference(of, &ids)))) + { return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); } let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, C)?); + // With `to`, the art's stack is the same as the object's, so its rows are the object's, each of which must exist. + let per = + if to.is_some() { items_per_target(&before, &ids, &idx, aligned(p, C)?) } else { rows_per_target(&before, of, &ids, &idx, aligned(p, C)?) }; s.edit("Duplicate Item", |d, _| { let mut any = false; // Copies that became the topmost visible row of their kind: the series stores their paint even when the object @@ -1305,7 +1362,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } let ids = require_targets(s, p, C)?; // Where an aligned row would go in each of several different stacks isn't one place. - if aligned && appearances_differ(&s.doc()?.doc, &ids) { + if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) { return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); } if p.get("indices").is_some() { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 9d86449bb9..7b3c205113 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,7 +13,9 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at}; +use super::appearance::{ + ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference, +}; use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; @@ -67,7 +69,7 @@ fn effect_specs() -> Vec { "Remove Effect", [], None, - "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}", has_doc, remove ), @@ -76,7 +78,7 @@ fn effect_specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}", has_doc, set_params ), @@ -85,7 +87,7 @@ fn effect_specs() -> Vec { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -103,7 +105,7 @@ fn effect_specs() -> Vec { "Duplicate Effect", [], None, - "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} insert a copy of each effect right after it, in one undo step → {ids}", + "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} insert a copy of each effect right after it, in one undo step → {ids}", has_doc, duplicate ), @@ -112,7 +114,7 @@ fn effect_specs() -> Vec { "Move Effect", [], None, - "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", + "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", has_doc, move_effect ), @@ -296,14 +298,20 @@ fn ids_json(ids: &[NodeId]) -> Value { /// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see /// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target /// edits its equal item, or its equal object-level effect, each target matched on its own whatever -/// the other targets have; in a target without them, `item` and `index` stand for themselves. +/// the other targets have; in a target without them, `item` and `index` stand for themselves. When the first object +/// given is the art of a graph design in a series ([`design_reference`]), which is left out, they are rows of its stack, +/// and a target without them is left out. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { let roots = edit_targets(s, p, cmd)?; + let of = reference_of(s, p, item, cmd)?; let d = &s.doc()?.doc; - let per = item.per_target(s, &roots); - let first = roots.first().and_then(|f| d.node(*f)); + let per = item.per_target_of(s, of, &roots); + let first = of.or_else(|| roots.first().copied()).and_then(|f| d.node(f)); // Whether `item` names an item in the stack it is a row of (else the edit is object-level there). - let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)); + let anchor = match of { + Some(of) => d.node(of), + None => roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)), + }; let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten(); let aligned = aligned(p, cmd)?; let mut out = Vec::with_capacity(roots.len()); @@ -312,7 +320,11 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us let at = item.effects_item(&n.appearance, cmd)?; // An object-level effect this target shares with the first is where it is in this target. let index = match (first.filter(|_| aligned), at, anchor_level) { - (Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index), + (Some(f), None, None) => match SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1) { + Some(i) => i, + None if of.is_some() && index != APPENDED => continue, + None => index, + }, _ => index, }; out.push((*id, at, index)); @@ -320,6 +332,19 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us Ok(out) } +/// The effect index `effect.apply` gives [`effect_targets`] for the effect it appends, which names no effect the targets +/// have. +const APPENDED: usize = usize::MAX; + +/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a +/// row of the first selected object's stack. +fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, @@ -452,13 +477,18 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); } let item = item_target(s, p, C)?; + // The item as each target has it, read before the effect changes their rows: a row of a graph design's art, which is + // left out of the targets, is the row equal to it there, and a target without one is left out. + let targets = edit_targets(s, p, C)?; + let of = reference_of(s, p, item, C)?; + let per: Vec<(NodeId, Option)> = targets.iter().copied().zip(item.per_target_of(s, of, &targets)).collect(); if effects::is_three_d(id) { - let targets = edit_targets(s, p, C)?; - for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { + for &(target, at) in &per { + let Some(at) = at else { continue }; let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; // A new effect goes after the item's (or the object's) others. - let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); + let on = at.effects_item(&n.appearance, C).ok().flatten(); let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len()))); let on = on.unwrap_or((None, n.appearance.effects.len())); effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; @@ -473,14 +503,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } } } - let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| { + let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", APPENDED, |fx, _| { fx.push(effect.clone()); true })?; // The item the effect landed on in the object whose row the item is (the first selected object // for the active item, which applies where it fits), and the effect's index in that object. - let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id)); - let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); + let k = item.anchor(s, &ids); + let first = ids.get(k).and_then(|id| s.doc().ok()?.doc.node(*id)); + let at = match of { + Some(_) => ids.get(k).and_then(|id| per.iter().find(|(t, _)| t == id)).and_then(|(_, at)| *at).unwrap_or(item), + None => item, + }; + let landed = first.and_then(|n| at.effects_item(&n.appearance, C).ok().flatten()); let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1)); Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } @@ -609,7 +644,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let copy = switch_or(p, "copy", false, C)?; let roots = edit_targets(s, p, C)?; // Where an aligned effect would go in each of several different stacks isn't one place. - if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { + if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &roots)) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); } // Where the effect landed in each object it moved in, in the order of `ids`. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 7b7f9974af..f8157a9fe4 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -667,6 +667,171 @@ fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() { assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1); } +/// `design_art()` with the design's art painted with a red and a blue fill and series 1's first mark with a blue fill and +/// a black stroke, so that row 1 of the art (its blue fill) is row 0 of the mark: the graph, the art and the mark. +fn aligned_design_art() -> (Session, NodeId, NodeId, NodeId) { + let (mut s, id, _, art, mark) = design_art(); + let (red, blue) = (Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + let mark_look = vectorcraft_doc::Appearance::basic(blue, Paint::solid(vectorcraft_color::Color::BLACK), 1.0); + let mut art_look = mark_look.clone(); + art_look.items = vec![art_look.items[0].clone(), art_look.items[0].clone()]; + *art_look.items[0].paint_mut() = red; + s.edit("Looks", |d, _| { + d.node_mut(art).unwrap().appearance = vectorcraft_doc::Shared::new(art_look.clone()); + d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(mark_look.clone()); + Ok(()) + }) + .unwrap(); + (s, id, art, mark) +} + +/// Aligned rows given with the art of a graph design in a series first are rows of that art's stack, which the commands +/// leave as it is: each other object edits the row equal to the given one, never the row at the same index. +#[test] +fn aligned_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + let (mut s, _, art, mark) = aligned_design_art(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).items, mark_was.items[1..], "the mark loses its blue fill and keeps its stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true, "color": "#00ff00"})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!((rows(&s, mark).items[0].paint(), &rows(&s, mark).items[1]), (&green, &mark_was.items[1])); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + let fills: Vec = rows(&s, mark).items.iter().map(|it| it.is_fill()).collect(); + assert_eq!(fills, [true, true, false], "the mark's blue fill is copied, not its stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 1, "aligned": true, "opacity": 40})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0)); + + // A row of the art past the end of the mark's stack. + let (mut s, _, art, mark) = aligned_design_art(); + s.edit("Rows", |d, _| { + let ap = &mut d.node_mut(art).unwrap().appearance; + let red = ap.items[0].clone(); + ap.items.insert(0, red); + Ok(()) + }) + .unwrap(); + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 2, "aligned": true, "opacity": 40})).unwrap(); + assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0)); + + // A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem + // and appearance.removeItem leave the document as it is. + let mark_was = rows(&s, mark); + let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err(); + assert!(e.to_string().contains("no appearance item 0"), "{e}"); + s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, mark), mark_was); +} + +/// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's +/// row in the other objects; an object without an equal row is left as it is. +#[test] +fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let effects = |s: &Session, id: NodeId| rows(s, id).items.iter().map(|it| it.effects().len()).collect::>(); + let (mut s, id, art, mark) = aligned_design_art(); + // A second mark of series 1 with a green fill, which no row of the art equals, and the same black stroke. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let other = series(&n, 1).children().unwrap()[1].id; + let mut other_look = rows(&s, mark); + *other_look.items[0].paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + s.edit("Look", |d, _| { + d.node_mut(other).unwrap().appearance = vectorcraft_doc::Shared::new(other_look.clone()); + Ok(()) + }) + .unwrap(); + let (art_was, other_was) = (rows(&s, art), rows(&s, other)); + let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill"); + assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill"); + assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row"); + + // The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's. + s.edit("Shadow", |d, _| { + let row = d.node(mark).unwrap().appearance.items[0].clone(); + d.node_mut(art).unwrap().appearance.items[1] = row; + Ok(()) + }) + .unwrap(); + let art_was = rows(&s, art); + // The effect dialog reads the shadow from the art's row and passes the art on first. + let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1))); + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(effects(&s, mark), [0, 0]); + + // An object-level effect of the art that the mark does not have: a new object-level effect still goes on the mark. + s.edit("Effect", |d, _| { + let shadow = d.node(art).unwrap().appearance.items[1].effects()[0].clone(); + d.node_mut(art).unwrap().appearance.effects.push(shadow); + Ok(()) + }) + .unwrap(); + let art_was = rows(&s, art); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).effects.len(), 1); + // The art's object-level shadow is not the mark's roughen: the effect dialog reads the art's, and removing it leaves + // the mark as it is. + let (_, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap(); + assert_eq!(fx.iter().map(|e| e.id.to_string()).collect::>(), ["stylize.dropShadow"]); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).is_err()); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was)); + // An object-level effect index past the end of the art's list names no effect of the mark either. + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err()); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was)); +} + +/// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so +/// they are not reordered, and `to` is refused for their copies; with the same stacks, the copies go to `to`. +#[test] +fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let (mut s, _, art, mark) = aligned_design_art(); + let mark_was = rows(&s, mark); + assert!(s.execute("appearance.moveItem", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [mark.0], "item": null})).unwrap(); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.move", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + + // The mark with the art's stack. + let (mut s, _, art, mark) = aligned_design_art(); + let art_was = rows(&s, art); + s.edit("Look", |d, _| { + d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(art_was.clone()); + Ok(()) + }) + .unwrap(); + assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "indices": [1, 9], "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), art_was, "a row that does not exist is refused"); + s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + let paints: Vec<_> = rows(&s, mark).items.iter().map(|it| it.paint().clone()).collect(); + assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]); +} + /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 1f25f5b56f..02875517f7 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -1106,12 +1106,21 @@ fn transparency(s: &mut Session, p: &Value) -> Result { }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? }; + // The art of a graph design whose stack an explicit aligned item is a row of goes first: appearance.setItem leaves + // it as it is and matches the other objects' rows against it. + let of = if editing_mask || !explicit { None } else { super::appearance::design_reference(s, p, C)? }; if explicit { let d = &s.doc()?.doc; - if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { + // With such art, an object has the item when it has a row equal to the art's. + let found = match of { + Some(_) => super::appearance::rows_per_target(d, of, &item_ids, &[index], true).iter().any(|r| !r.is_empty()), + None => item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())), + }; + if !found { return Err(bad(C, format!("no appearance item {index}"))); } } + let item_ids = super::appearance::with_reference(of, &item_ids); let (opacity, blend) = (q.remove("opacity"), q.remove("blend")); if let Some(b) = &blend && b.as_str().and_then(BlendMode::parse).is_none() diff --git a/docs/mcp.md b/docs/mcp.md index e186501302..7b13e9eb68 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -826,7 +826,15 @@ given), `threeD.mapGraphic`, `paint.setFill`, `paint.setStroke`, `paint.swap`, `paint.default`, `paint.invert` and `paint.complement` with an `item`, `stroke.set`, `stroke.weightUp`, `stroke.weightDown`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `object.setOverprint` leave the art out without an error. When the first selected object is design art, the commands -do not use the Appearance panel's active row. To change the art, edit the design with `graph.design`. +do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with +`aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`, +`transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, +move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as +it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do +for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and +`appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and +`appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the +art. To change the art, edit the design with `graph.design`. The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient From f4281d3cc0bb536dc471839ee75df6dabe9a4c86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:18:45 -0800 Subject: [PATCH 12/40] Graphs: test the effect edits on rows of a design's art, document aligned on effect.apply effect.apply and appearance.addEffect take `aligned`, and their params text now says so, with the rule for a graph design's art given first. The tests add appearance.addEffect, a 3D effect.apply with an object that has no equal row, effect.setParams and effect.duplicate on a row of the art, and the removal of an object-level effect equal to the art's. They also check that appearance.setItem and appearance.removeItem on a row no other object has leave the art as it is and record no undo step. --- crates/engine/src/cmd/appearance.rs | 2 +- crates/engine/src/cmd/effectcmd.rs | 2 +- crates/engine/src/cmd/graph/tests.rs | 33 +++++++++++++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 306ebc8557..09b03f2d63 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "Add Effect", [], None, - "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}", + "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?, aligned?} → {ids, index, item}", has_doc, super::effectcmd::apply ), diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 7b3c205113..7619444c74 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -51,7 +51,7 @@ fn effect_specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers), aligned?: bool (item is a row of the first target's stack: each other target gets the effect on its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, a row of the art's stack: docs/mcp.md)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", has_doc, apply ), diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index f8157a9fe4..df002ae526 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -727,12 +727,14 @@ fn aligned_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects( // A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem // and appearance.removeItem leave the document as it is. - let mark_was = rows(&s, mark); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let steps = s.doc().unwrap().history.undo.len(); let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err(); assert!(e.to_string().contains("no appearance item 0"), "{e}"); s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap(); s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap(); - assert_eq!(rows(&s, mark), mark_was); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was)); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step is recorded"); } /// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's @@ -758,6 +760,13 @@ fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objec assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill"); assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill"); assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row"); + s.execute("edit.undo", &json!({})).unwrap(); + // A 3D effect is checked on the rows it goes on only. + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was.clone(), vec![1, 0], other_was.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.addEffect", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was, vec![1, 0], other_was)); // The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's. s.edit("Shadow", |d, _| { @@ -770,6 +779,14 @@ fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objec // The effect dialog reads the shadow from the art's row and passes the art on first. let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap(); assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1))); + // Edits of the art's row effect change the mark's equal row. + s.execute("effect.setParams", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "visible": false, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert!(!rows(&s, mark).items[0].effects()[0].visible, "the mark's shadow is hidden"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("effect.duplicate", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark)), (art_was.clone(), vec![2, 0])); + s.execute("edit.undo", &json!({})).unwrap(); s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); assert_eq!(rows(&s, art), art_was); assert_eq!(effects(&s, mark), [0, 0]); @@ -796,7 +813,17 @@ fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objec s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); let mark_was = rows(&s, mark); assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err()); - assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was)); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was)); + // The mark with the art's object-level shadow after its roughen: removing the art's effect 0 takes off the mark's 1. + s.edit("Shadow", |d, _| { + let shadow = d.node(art).unwrap().appearance.effects[0].clone(); + d.node_mut(mark).unwrap().appearance.effects[1] = shadow; + Ok(()) + }) + .unwrap(); + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).effects.iter().map(|e| e.id.to_string()).collect::>(), ["distort.roughen"]); } /// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so From 9c787d2cdc88f98668e59d29dfe135a2ced60a93 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:00:49 +0530 Subject: [PATCH 13/40] Brushes: built-in Tech Circuit brush library G5: add original procedural circuit art and pattern brushes, library regression coverage, translations, and progress documentation. --- ROADMAP.md | 5 +- crates/engine/src/cmd/brushlib.rs | 54 ++++++++++++ crates/engine/src/tests/brushlib.rs | 84 ++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 6 ++ crates/ui-egui/src/i18n/bs.tsv | 6 ++ crates/ui-egui/src/i18n/ca.tsv | 6 ++ crates/ui-egui/src/i18n/cs.tsv | 6 ++ crates/ui-egui/src/i18n/da.tsv | 6 ++ crates/ui-egui/src/i18n/de.tsv | 6 ++ crates/ui-egui/src/i18n/el.tsv | 6 ++ crates/ui-egui/src/i18n/es.tsv | 6 ++ crates/ui-egui/src/i18n/fi.tsv | 6 ++ crates/ui-egui/src/i18n/fr.tsv | 6 ++ crates/ui-egui/src/i18n/gu.tsv | 6 ++ crates/ui-egui/src/i18n/hi.tsv | 6 ++ crates/ui-egui/src/i18n/hu.tsv | 6 ++ crates/ui-egui/src/i18n/id.tsv | 6 ++ crates/ui-egui/src/i18n/it.tsv | 6 ++ crates/ui-egui/src/i18n/ja.tsv | 6 ++ crates/ui-egui/src/i18n/ka.tsv | 6 ++ crates/ui-egui/src/i18n/ko.tsv | 6 ++ crates/ui-egui/src/i18n/nl.tsv | 6 ++ crates/ui-egui/src/i18n/pl.tsv | 6 ++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++ crates/ui-egui/src/i18n/ru.tsv | 6 ++ crates/ui-egui/src/i18n/sv.tsv | 6 ++ crates/ui-egui/src/i18n/tests.rs | 2 +- crates/ui-egui/src/i18n/tr.tsv | 6 ++ crates/ui-egui/src/i18n/uk.tsv | 6 ++ crates/ui-egui/src/i18n/vi.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++ .../src/panels/tests_brush_libraries.rs | 9 +- docs/gaps.md | 12 +-- docs/mcp.md | 7 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 13 +-- docs/ui-parity.md | 6 +- 38 files changed, 344 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 346c532277..b5f2d1c0fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -124,6 +124,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-14: Built-in Tech Circuit brush library: Circuit Trace (chamfered art with 45-degree elbows and terminal pads), Bus Lines (parallel conductor pattern) and Terminal Nodes (repeating solder pads, test points and leads), all original geometry generated in code. Ten built-in brush libraries; G5 remaining estimate 4–14 h (estimated), headline numbers unchanged. + - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. @@ -221,6 +223,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | | 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 16ab1c76d4..34becd7c02 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -178,6 +178,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media }, + BuiltinBrushLibrary { id: "tech-circuit", name: "Tech Circuit", make: tech_circuit }, ]; /// Built-in library `id`, made once. @@ -397,6 +398,59 @@ fn border(name: &str, side: Node, corner: Option, fit: PatternFit) -> Brus Brush { name: name.into(), kind } } +/// Tech Circuit: original conductor geometry, drawn as filled paths so widths and chamfers +/// survive brush warping. Pattern tiles meet at x = 0 and 24 with identical lead cross-sections. +fn tech_circuit() -> BrushLibrary { + let pad = |x: f64| { + black(polygon(&[ + (x - 4.0, -2.0), + (x - 2.0, -4.0), + (x + 2.0, -4.0), + (x + 4.0, -2.0), + (x + 4.0, 2.0), + (x + 2.0, 4.0), + (x - 2.0, 4.0), + (x - 4.0, 2.0), + ])) + }; + // 45-degree jogs lift the conductor to a parallel run and bring it back. + let trace = group(vec![ + black(polygon(&[ + (4.0, -1.0), + (23.0, -1.0), + (33.0, -11.0), + (61.0, -11.0), + (71.0, -1.0), + (96.0, -1.0), + (96.0, 1.0), + (70.0, 1.0), + (60.0, -9.0), + (34.0, -9.0), + (24.0, 1.0), + (4.0, 1.0), + ])), + pad(4.0), + pad(96.0), + ]); + let bus = group([-4.0, 0.0, 4.0].into_iter().map(|y| black(shapes::rectangle(Rect::new(0.0, y - 0.75, 24.0, y + 0.75)))).collect()); + let terminals = group(vec![ + // Overlap the ring's rim without filling the test point's hole. + black(shapes::rectangle(Rect::new(0.0, -0.6, 15.5, 0.6))), + black(shapes::rectangle(Rect::new(18.5, -0.6, 24.0, 0.6))), + pad(6.0), + ring(Point::new(17.0, 0.0), 3.0, 1.4), + ]); + built( + "Tech Circuit", + Document::new(100.0, 100.0), + vec![ + art("Circuit Trace", trace, Colorization::Tints), + border("Bus Lines", bus, None, PatternFit::Stretch), + border("Terminal Nodes", terminals, None, PatternFit::Stretch), + ], + ) +} + /// An invisible rectangle that fixes a tile's width. fn spacer(r: Rect) -> Node { Node::path(NodeId(0), shapes::rectangle(r), Appearance::default()) diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 98a577abed..4f0b739951 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -115,7 +115,8 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { "scatter-shapes", "game-trails", "energy-beams", - "dry-media" + "dry-media", + "tech-circuit" ] ); assert!(libs.iter().all(|l| l["category"] == "builtIn")); @@ -217,6 +218,87 @@ fn black_stroke() -> vectorcraft_doc::StrokeLayer { vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) } +/// Original circuit art and repeat tiles survive adding, applying and saving a library. +#[test] +fn the_tech_circuit_library_constructs_adds_and_applies_each_brush() { + let mut s = session(); + let listed = run(&mut s, "brush.libraries", json!({})); + let entry = listed["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "tech-circuit").unwrap(); + assert_eq!((entry["name"].as_str(), entry["category"].as_str(), entry["count"].as_u64()), (Some("Tech Circuit"), Some("builtIn"), Some(3))); + let (_, lib) = cmd::brushlib::library(&s, "Tech Circuit").unwrap(); + let expected = [("Circuit Trace", "art"), ("Bus Lines", "pattern"), ("Terminal Nodes", "pattern")]; + assert_eq!(lib.brushes.iter().map(|b| (b.name.as_str(), b.kind.type_id())).collect::>(), expected); + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 210, "y2": 20}))["id"].as_u64().unwrap(); + let curve = { + let mut p = line_to(40.0); + p.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0)); + p + }; + let corner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 60.0)).to_bezpath(); + for b in &lib.brushes { + let mut sanitized = b.clone(); + sanitized.sanitize(); + assert_eq!(&sanitized, b, "{} changes on load", b.name); + for tile in b.art() { + let pts = anchors(&[tile]); + assert!(!pts.is_empty() && pts.len() <= 100, "{}", b.name); + assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + tile.walk(&mut |n| { + if let Some(p) = n.path_data() { + assert!(p.subpaths.iter().all(|s| s.closed)); + } + }); + } + if let BrushKind::Pattern(p) = &b.kind { + assert_eq!(p.colorization, vectorcraft_brush::Colorization::Tints); + assert_eq!(p.spacing, 0.0, "conductors join adjacent tiles"); + if b.name == "Bus Lines" { + let lanes = p.side.children().unwrap(); + assert_eq!(lanes.len(), 3); + let boxes: Vec<_> = lanes.iter().map(|n| n.path_data().unwrap().bounds().unwrap()).collect(); + for r in &boxes { + assert_eq!((r.x0, r.x1), (0.0, 24.0)); + } + assert!(boxes.windows(2).all(|r| r[0].y1 < r[1].y0), "lanes stay separate"); + } + if b.name == "Terminal Nodes" { + let mut rings = 0; + p.side.walk(&mut |n| { + if matches!(n.kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }) { + rings += 1; + } + }); + assert_eq!(rings, 1, "a hollow circular test point per tile"); + } + } + for p in [line_to(200.0), line_to(2.0), curve.clone(), corner.clone()] { + let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &p, &black_stroke()); + let pts = anchors(&pieces.iter().collect::>()); + assert!(!pts.is_empty() && pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{}", b.name); + } + let before = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name, "apply": true, "ids": [path]})); + assert_eq!(strs(&r["added"]), [b.name.as_str()]); + assert_eq!(r["applied"], json!([path])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(brush(&s, &b.name), *b); + assert_eq!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(b.name.as_str())); + run(&mut s, "edit.undo", json!({})); + assert!(vectorcraft_brush::find(doc(&s), &b.name).is_none()); + run(&mut s, "edit.redo", json!({})); + let before = undo_len(&s); + let again = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name})); + assert!(strs(&again["added"]).is_empty()); + assert_eq!(strs(&again["existing"]), [b.name.as_str()]); + assert_eq!(undo_len(&s), before); + } + let names: Vec<&str> = expected.iter().map(|(name, _)| *name).collect(); + let saved = run(&mut s, "brush.saveLibrary", json!({"names": names})); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(vectorcraft_brush::library(&reopened), lib.brushes); +} + /// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two /// translucent washes, with names of their own and a few paths of art each. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f0ab11fc4a..5f18cf1905 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6886,3 +6886,9 @@ breakText Glyphs Глифи Grommets Капси Grommet Inset Отстъп на капсите от ръба Diameter Диаметър + +# Original procedural circuit brushes. + Tech Circuit Технологични схеми + Circuit Trace Проводяща писта + Bus Lines Шинни линии + Terminal Nodes Крайни възли diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f73b5a407f..ec1ef5dbc7 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6939,3 +6939,9 @@ shape Open Otvoren Grommets Ušice Grommet Inset Udaljenost ušica od ruba Diameter Prečnik + +# Original procedural circuit brushes. + Tech Circuit Tehnički sklopovi + Circuit Trace Vod sklopa + Bus Lines Linije sabirnice + Terminal Nodes Priključni čvorovi diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ddfed44ad0..efbeafa352 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7479,3 +7479,9 @@ breakText Glyphs Glifs Grommets Ullets Grommet Inset Distància dels ullets a la vora Diameter Diàmetre + +# Original procedural circuit brushes. + Tech Circuit Circuits tecnològics + Circuit Trace Pista de circuit + Bus Lines Línies de bus + Terminal Nodes Nodes terminals diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b46d0600a..9c1cf25c80 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -9311,3 +9311,9 @@ breakText Glyphs Glyfy @msg turn on at least one side zapněte alespoň jednu stranu @msg with no hem and no grommets there is nothing to mark bez lemu a bez průchodek není co označit @msg {saving} failed: {e} {saving} se nezdařilo: {e} + +# Original procedural circuit brushes. + Tech Circuit Technické obvody + Circuit Trace Vodivá cesta + Bus Lines Sběrnicové linky + Terminal Nodes Koncové uzly diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 81b6195bdd..8bc75321dc 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6901,3 +6901,9 @@ breakText Glyphs Glyffer Grommets Maljer Grommet Inset Maljernes afstand fra kanten Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Tekniske kredsløb + Circuit Trace Kredsløbsbane + Bus Lines Buslinjer + Terminal Nodes Terminalpunkter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8f6bc1aeeb..39bf7b6771 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -8899,3 +8899,9 @@ breakText Glyphs Glyphen Grommets Ösen Grommet Inset Randabstand der Ösen Diameter Durchmesser + +# Original procedural circuit brushes. + Tech Circuit Technische Schaltungen + Circuit Trace Leiterbahn + Bus Lines Busleitungen + Terminal Nodes Anschlussknoten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7b61589c18..ca4b2ecb34 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7451,3 +7451,9 @@ breakText Glyphs Γλύφοι Grommets Τρουκς Grommet Inset Απόσταση τρουκς από την άκρη Diameter Διάμετρος + +# Original procedural circuit brushes. + Tech Circuit Τεχνολογικά κυκλώματα + Circuit Trace Διαδρομή κυκλώματος + Bus Lines Γραμμές διαύλου + Terminal Nodes Τερματικοί κόμβοι diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 89ea2c088c..cd0da036e2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7442,3 +7442,9 @@ breakText Glyphs Glifos Grommets Ojales Grommet Inset Distancia de los ojales al borde Diameter Diámetro + +# Original procedural circuit brushes. + Tech Circuit Circuitos tecnológicos + Circuit Trace Pista de circuito + Bus Lines Líneas de bus + Terminal Nodes Nodos terminales diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 655936f6c2..9d2d54e441 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7533,3 +7533,9 @@ breakText Glyphs Merkit Grommets Purjerenkaat Grommet Inset Purjerenkaiden etäisyys reunasta Diameter Halkaisija + +# Original procedural circuit brushes. + Tech Circuit Tekniset virtapiirit + Circuit Trace Piirijohdin + Bus Lines Väylälinjat + Terminal Nodes Liitäntäpisteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2396640141..debc9cc3ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -9297,3 +9297,9 @@ breakText Glyphs Glyphes Grommets Œillets Grommet Inset Distance des œillets au bord Diameter Diamètre + +# Original procedural circuit brushes. + Tech Circuit Circuits techniques + Circuit Trace Piste de circuit + Bus Lines Lignes de bus + Terminal Nodes Nœuds terminaux diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..2d9da396f1 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -4680,3 +4680,9 @@ swatch [Registration] [રજિસ્ટ્રેશન] Repeat Objects પુનરાવર્તિત ઑબ્જેક્ટ્સ @msg `{path}` must be one of {_1}, not {got} `{path}` એ {_1}માંથી એક હોવું જોઈએ, {got} નહીં Path Length પાથની લંબાઈ + +# Original procedural circuit brushes. + Tech Circuit ટેક સર્કિટ + Circuit Trace સર્કિટ પથ + Bus Lines બસ રેખાઓ + Terminal Nodes ટર્મિનલ નોડ્સ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 4a11fad73d..88c326c315 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8889,3 +8889,9 @@ theme Appearance अपीयरेंस Grommets आईलेट Grommet Inset किनारे से आईलेट की दूरी Diameter व्यास + +# Original procedural circuit brushes. + Tech Circuit टेक सर्किट + Circuit Trace सर्किट पथ + Bus Lines बस रेखाएँ + Terminal Nodes टर्मिनल नोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 925670bbc3..40367ce58e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7482,3 +7482,9 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő + +# Original procedural circuit brushes. + Tech Circuit Műszaki áramkörök + Circuit Trace Vezetősáv + Bus Lines Buszvonalak + Terminal Nodes Csatlakozási pontok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f8069f6a71..15defcd4e4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6901,3 +6901,9 @@ breakText Glyphs Glif Grommets Mata ayam Grommet Inset Jarak mata ayam dari tepi Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Sirkuit teknologi + Circuit Trace Jalur sirkuit + Bus Lines Jalur bus + Terminal Nodes Simpul terminal diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index af875b4e42..8f2bbb06f5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -8932,3 +8932,9 @@ breakText Glyphs Glifi Grommets Occhielli Grommet Inset Distanza degli occhielli dal bordo Diameter Diametro + +# Original procedural circuit brushes. + Tech Circuit Circuiti tecnologici + Circuit Trace Pista del circuito + Bus Lines Linee del bus + Terminal Nodes Nodi terminali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e6d40c9ae5..78fce27c04 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7194,3 +7194,9 @@ breakText Glyphs 字形 Grommets ハトメ Grommet Inset 端からハトメまでの距離 Diameter 直径 + +# Original procedural circuit brushes. + Tech Circuit 電子回路 + Circuit Trace 回路配線 + Bus Lines バス配線 + Terminal Nodes 端子ノード diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fc649cd87a..6b106f971a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7612,3 +7612,9 @@ font-script Japanese იაპონური Grommets ლუვერები Grommet Inset ლუვერების დაშორება კიდიდან Diameter დიამეტრი + +# Original procedural circuit brushes. + Tech Circuit ტექნიკური სქემები + Circuit Trace სქემის გამტარი + Bus Lines სალტის ხაზები + Terminal Nodes ტერმინალური კვანძები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 71a45e307d..57a34e6f42 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -8903,3 +8903,9 @@ breakText Glyphs 글리프 @msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what}은(는) 한 장에 {MAX_COPIES_PER_SHEET}개보다 많이 인쇄됩니다: 크기를 키우세요 @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}은(는) 타일 {MAX_TILES}개보다 많이 나뉘어 인쇄됩니다: 크기를 줄이세요 @msg {what} {i} out of range {what} {i}이(가) 범위를 벗어났습니다 + +# Original procedural circuit brushes. + Tech Circuit 전자 회로 + Circuit Trace 회로 배선 + Bus Lines 버스 배선 + Terminal Nodes 단자 노드 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a0953094fa..0d27abcbd9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -7525,3 +7525,9 @@ breakText Glyphs Glyphs Grommets Zeilringen Grommet Inset Afstand van zeilringen tot de rand Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Technische schakelingen + Circuit Trace Printspoor + Bus Lines Buslijnen + Terminal Nodes Aansluitpunten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5b7b3e60ac..d9364a341c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -9165,3 +9165,9 @@ breakText Glyphs Glify @msg {what} can't be intertwined {what} nie można przeplatać @msg {what} doesn't fit the paper with its bleed and marks {what} nie mieści się na papierze wraz ze spadem i znacznikami @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: wydruk zająłby ponad {MAX_TILES} kafelków: zmniejsz skalę + +# Original procedural circuit brushes. + Tech Circuit Obwody techniczne + Circuit Trace Ścieżka obwodu + Bus Lines Linie magistrali + Terminal Nodes Węzły końcowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 90825f967f..43b60ffd34 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -9383,3 +9383,9 @@ swatch Yellow Green Verde-amarelado @msg turn on at least one side ative pelo menos um lado @msg with no hem and no grommets there is nothing to mark sem bainha e sem ilhoses não há nada para marcar @msg {saving} failed: {e} {saving} falhou: {e} + +# Original procedural circuit brushes. + Tech Circuit Circuitos tecnológicos + Circuit Trace Trilha de circuito + Bus Lines Linhas de barramento + Terminal Nodes Nós terminais diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a235e2c9b3..0ab13230a4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7703,3 +7703,9 @@ shape Open Открытый @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для JPEG (не более 65535 пикселей по стороне): уменьшите разрешение @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для Targa (не более 65535 пикселей по стороне): уменьшите разрешение @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} пикселей — слишком большой размер для PSD (не более {MAX_SIDE} пикселей по стороне): уменьшите разрешение + +# Original procedural circuit brushes. + Tech Circuit Технические схемы + Circuit Trace Дорожка схемы + Bus Lines Линии шины + Terminal Nodes Контактные узлы diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b491d33649..e15d91dd80 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8973,3 +8973,9 @@ digits Hindi Hindi Grommets Öljetter Grommet Inset Öljetternas avstånd från kanten Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Tekniska kretsar + Circuit Trace Kretsbana + Bus Lines Bussledningar + Terminal Nodes Anslutningspunkter diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 4f9f03e5f6..5dab646c88 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -389,7 +389,7 @@ fn built_in_library_brush_names_are_in_complete_catalogs() { .collect(); names.sort(); names.dedup(); - assert_eq!(names.len(), 43, "{names:?}"); + assert_eq!(names.len(), 46, "{names:?}"); let mut bad = vec![]; for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect(); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0ecfe1dd0a..1bf602077a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7454,3 +7454,9 @@ breakText Glyphs Glifler Grommets Kuşgözleri Grommet Inset Kuşgözlerinin kenara uzaklığı Diameter Çap + +# Original procedural circuit brushes. + Tech Circuit Teknik devreler + Circuit Trace Devre izi + Bus Lines Veriyolu hatları + Terminal Nodes Bağlantı düğümleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c375bedfa2..d98c716f3b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8919,3 +8919,9 @@ breakText Glyphs Гліфи @msg turn on at least one side увімкніть принаймні одну сторону @msg with no hem and no grommets there is nothing to mark без підгину й люверсів немає чого розмічати @msg {saving} failed: {e} {saving}: не вдалося: {e} + +# Original procedural circuit brushes. + Tech Circuit Технічні схеми + Circuit Trace Доріжка схеми + Bus Lines Лінії шини + Terminal Nodes Контактні вузли diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 442690385b..0a3d4c17e4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7109,3 +7109,9 @@ breakText Glyphs Glyph Grommets Khoen Grommet Inset Khoảng cách từ mép đến khoen Diameter Đường kính + +# Original procedural circuit brushes. + Tech Circuit Mạch công nghệ + Circuit Trace Đường mạch + Bus Lines Đường bus + Terminal Nodes Nút đầu cuối diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 810b1b4463..a5680844a7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -9267,3 +9267,9 @@ breakText Glyphs 字形 @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 JPEG 而言过大 (每边最多 65535 像素):请降低分辨率 @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 Targa 而言过大 (每边最多 65535 像素):请降低分辨率 @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} 像素对 PSD 而言过大 (每边最多 {MAX_SIDE} 像素):请降低分辨率 + +# Original procedural circuit brushes. + Tech Circuit 电子电路 + Circuit Trace 电路走线 + Bus Lines 总线 + Terminal Nodes 端子节点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 19c572e3b3..3658e174b0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -8959,3 +8959,9 @@ breakText Glyphs 字符 @msg {what} can't be intertwined {what} 無法交織 @msg {what} doesn't fit the paper with its bleed and marks {what} 連同出血和標記無法容納於紙張 @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} 會列印在超過 {MAX_TILES} 個拼貼上:請縮小 + +# Original procedural circuit brushes. + Tech Circuit 電子電路 + Circuit Trace 電路走線 + Bus Lines 匯流排 + Terminal Nodes 端子節點 diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6da376c7ca..87fe34953f 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -200,7 +200,8 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { json!("scatter-shapes"), json!("game-trails"), json!("energy-beams"), - json!("dry-media") + json!("dry-media"), + json!("tech-circuit") ] ); let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str()); @@ -225,6 +226,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { click(&mut app, &ctx, "Chalk Grain", Modifiers::NONE, 1.0); assert!(has(&app, "Chalk Grain") && !has(&app, "Wax Crayon")); assert_eq!(undo_len(&app), undo + 1); + let r = app.run("window.brushLibrary", json!({"library": "Tech Circuit"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("tech-circuit"), Some(3))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Circuit Trace", Modifiers::NONE, 2.0); + assert!(has(&app, "Circuit Trace") && !has(&app, "Bus Lines")); + assert_eq!(undo_len(&app), undo + 1); // Without brushes there is nothing to save. let all: Vec = vectorcraft_brush::library(&app.session.doc().unwrap().doc).into_iter().map(|b| b.name).collect(); app.run("brush.delete", json!({ "names": all })).unwrap(); diff --git a/docs/gaps.md b/docs/gaps.md index 8aea143a47..9f5a151ac9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: 3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups ``, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -648,8 +649,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi 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)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (nine original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one - bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams and Dry Media (grainy chalk, pencil, crayon and charcoal art brushes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (ten original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one + bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media (grainy chalk, pencil, crayon and charcoal art brushes) and Tech Circuit (Circuit Trace, Bus Lines and Terminal Nodes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -670,10 +671,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the ten shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of rotated copies as matches. -- **Estimate:** 5–15 h. +- **Estimate:** 4–14 h (estimated). ## G3 3D and Materials @@ -876,6 +877,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..05188c05c5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # VectorCraft MCP server -> **Last updated:** 2026-11-13 · **Change:** Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. +> **Last updated:** 2026-11-14 · **Change:** Tech Circuit brush library (G5); Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to @@ -4356,9 +4356,9 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: nine generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle +Brush libraries are read-only sets of brushes: ten generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and -Charcoal Dust art brushes, id `dry-media`), the `.vectorcraft` files in the user library folder of the desktop app (User +Charcoal Dust art brushes, id `dry-media`, and Tech Circuit with Circuit Trace (art), Bus Lines and Terminal Nodes (pattern), id `tech-circuit`), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). @@ -11697,6 +11697,7 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-14 | Built-in Tech Circuit brush library (id `tech-circuit`): Circuit Trace, Bus Lines and Terminal Nodes; ten built-in libraries. | | 2026-11-13 | Snap to Point with Smart Guides off draws no Smart Guides labels or lines (#7250). | | 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). | | 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9fa76dc027..101f32f82f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -165,7 +165,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | @@ -207,6 +207,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..85cfd36ed4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -58,6 +58,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Pencil and Paintbrush: a closed stroke (Alt at the release, or ending within Close Paths When Ends Are Within of its start) is fitted as the open stroke would be and closed by a straight segment back to its start, instead of curving on past both ends (`pencil_alt_closes_a_stroke_straight_back_to_its_start` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Snap to Point: with Smart Guides off it still pulls drawn and dragged points onto anchors and ruler guides, but draws none of the Smart Guides labels ("anchor") or lines (#7250; `guides::tests::snap_to_point_without_smart_guides_shows_no_guides`). Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | @@ -238,7 +239,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged; the Pen, Smart Pen and Curvature tool show the measurement readout with the rubber band off (#7043), score, gaps and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged; a point on a gradient mesh's outline locates on its edge on every side (#7006: `mesh_locate_takes_a_point_on_any_side_of_the_outline`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance`), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -507,7 +508,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (ten generated in code including Tech Circuit, Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -559,7 +560,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries ten (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -960,8 +961,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **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, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) 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; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). 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`. + - **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, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit) 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; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). 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`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). + - **Tech Circuit library:** Circuit Trace is a chamfered art brush with 45-degree elbows and terminal pads; Bus Lines repeats three parallel conductor lanes; Terminal Nodes repeats solder pads, circular test points and connecting leads. Original vector geometry generated in code, with Tints colorization and continuous pattern tile edges. Evidence: `the_tech_circuit_library_constructs_adds_and_applies_each_brush` in `crates/engine/src/tests/brushlib.rs` covers listing, finite geometry on straight, curved, short and closed paths, add/apply, undo/redo, deduplication and saved-library round trips. G5 remaining estimate 4–14 h (estimated); area scores unchanged. - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - **Brush names in library panels**: a built-in brush library's panel shows its brushes' names in the UI language, in its list rows and tooltips, and its Find field matches the name shown or the English name. This is VectorCraft's own rule. A User Defined or Other Library… library's brushes, and the document's Brushes panel, show names as they are stored. A click, a drag, Add to Brushes and `brush.addFromLibrary` still use the English name, so the brush comes into the document under that name (`built_in_library_brushes_are_listed_in_the_ui_language`, `built_in_library_brush_names_are_in_complete_catalogs`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. @@ -2379,6 +2381,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged | | 2026-11-13 | minor | File formats: in a `.ai` or EPS opened from its editing data whose layers don't draw as the page even with the drawn art of every live envelope put back, and are used all the same (trusted, their objects that differ coming in as the page's art, or opened alone), art that differs away from an envelope of type no longer costs it its editable type: it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, and where a clip, mask, group appearance, another envelope, a repeat or a blend holds it (#7141); evidence updated; scores and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9bcce16a0b..ccde9b2824 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Layers panel: right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. Scores unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | @@ -427,7 +428,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | | Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, 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`; five built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons; original, generated in code) | -| Brush libraries: Window › Brush Libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, 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`; nine built-in libraries (original, generated in code) | +| Brush libraries: Window › Brush Libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit, 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`; ten 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; 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; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | @@ -538,6 +539,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-12 | minor | Numeric fields: a focus click released more than 1 pt from its press keeps the caret; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326). Scores and estimates unchanged. | | 2026-11-12 | minor | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | From f7d1643a83de10e6598f837c6b6d49cceee38228 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:56:39 -0800 Subject: [PATCH 14/40] Live Paint: Make keeps the direction of gradient strokes along or across the path A stroke whose only setting that depends on direction is a gradient along or across the stroke now goes through the same edge fitting as dashed strokes, so each cut edge runs the way its source path does and the gradient keeps its direction. --- crates/engine/src/cmd/buildcmds.rs | 7 ++++--- crates/engine/src/tests/build.rs | 31 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 9e457212ad..13b7a3bd83 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -471,10 +471,11 @@ impl SourceStyle { /// How far, in points, a place on an edge may be from its source path and still be on it. const ON_SOURCE: f64 = 0.05; -/// Does stroke row `i` have settings that depend on where along its path a piece of it lies: -/// dashes, arrowheads, a width profile or a brush? +/// Does stroke row `i` have settings that depend on where along its path a piece of it lies or +/// which way it runs: dashes, arrowheads, a width profile, a brush, or a gradient along or across +/// the stroke? fn depends_on_place(i: &AppearanceItem) -> bool { - matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some()) + matches!(i, AppearanceItem::Stroke(s) if s.dash.is_some() || s.start_arrow.is_some() || s.end_arrow.is_some() || s.profile.is_some() || s.brush.is_some() || s.gradient_mode != vectorcraft_doc::StrokeGradientMode::Within) } /// One subpath's segments with the length along it where each starts, then its whole length, and diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 8fae2eadd4..0614f63658 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2753,6 +2753,37 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { assert_eq!(edges, vec![rows]); } +/// When Make cuts a stroke whose only setting that depends on direction is a gradient along or +/// across it, each edge runs the way its source path does, so the gradient keeps its direction. +#[test] +fn live_paint_make_keeps_the_direction_of_gradient_strokes() { + use vectorcraft_doc::StrokeGradientMode; + for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] { + for (x0, x1) in [(-20.0, 120.0), (120.0, -20.0)] { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let l = line(&mut s, (x0, 50.0), (x1, 50.0)); + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + set_rows(&mut s, l, vec![stroke_row(gradient, 2.0, |st| st.gradient_mode = mode)]); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let pieces: Vec<(f64, f64)> = vectorcraft_tools::builder::edges(&node(&s, g)) + .iter() + .filter_map(|e| { + e.appearance.stroke().filter(|st| st.gradient_mode == mode)?; + let sp = e.path_data()?.subpaths.first()?; + Some((sp.anchors.first()?.p.x, sp.anchors.last()?.p.x)) + }) + .collect(); + assert_eq!(pieces.len(), 3, "{mode:?}, from {x0}"); + assert!( + pieces.iter().all(|&(p, q)| (q - p).signum() == (x1 - x0).signum()), + "{mode:?}, from {x0}: each piece runs the way the line does: {pieces:?}" + ); + } + } +} + /// Make gives each face every fill row of its source that draws, in order and with their /// opacity, blending mode, overprint and effects, and the source's gradients keep their fit on /// the source; Expand keeps the rows, and Undo and Redo bring the group back with them. From 506f48daeab14cb05f1f9248383f5c7e0603e8ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:53:45 -0800 Subject: [PATCH 15/40] .ai save: live graphs are written as graph records Both .ai writers now write a graph that the .ai reader reads back as the same graph with its records, so a saved graph opens again live, with its table, type, axes and settings: %_Gs, the Gb, Gy, Gd, Ga/GA, Gz, Gc and GC records, the graph's group with each series' group followed by a Go tag, and %_GS (the published .ai file format specification, section 17 and appendix A). The records are the ones the reader in crates/eps/src/import/ai/read/graph.rs (#7311) reads, in the form it reads them. The shared encoder in crates/eps/src/ai_graph.rs decides first whether the reader keeps the graph whole: a column, stacked column, line or area graph with a legend, settings the records carry, a table of 1 to 10,000 categories and 1 to 256 series with finite values, labels of at most 255 bytes without control characters, and art the reader attaches the graph to. Any other graph is written as its art, with the existing warning, as is a graph whose art the editing writer's object limit cuts short. Tick-mark counts of 0 are written as 1, the count the reader keeps for both. Bar, stacked bar, scatter, pie and radar graphs, stacked column and area graphs whose series marks carry a stroke their legend swatch lacks, and line graphs with markers are still written as their art, with the warning. --- crates/engine/src/cmd/graph/tests.rs | 56 ++- crates/eps/src/ai_editing.rs | 4 + crates/eps/src/ai_editing/tests.rs | 70 +++ crates/eps/src/ai_editing/writer.rs | 58 ++- crates/eps/src/ai_graph.rs | 660 +++++++++++++++++++++++++++ crates/eps/src/legacy_ai.rs | 8 +- crates/eps/src/legacy_ai/tests.rs | 61 +++ crates/eps/src/legacy_ai/writer.rs | 34 +- crates/eps/src/lib.rs | 2 + docs/file-format-parity.md | 3 +- docs/target-app-parity.md | 3 +- 11 files changed, 949 insertions(+), 10 deletions(-) create mode 100644 crates/eps/src/ai_graph.rs diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 0a788c4330..9f46cd8a22 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -6769,8 +6769,8 @@ fn graph_commands_refuse_params_of_the_wrong_type() { } /// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A) -/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai` -/// says its data and settings aren't written, in either form. +/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`, +/// in either form, writes its records, and the file opens again with the graph's new data. #[test] fn a_graph_opened_from_ai_keeps_its_data_for_editing() { let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\ @@ -6827,6 +6827,56 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() { } let r = s.execute("file.saveAs", &p).unwrap(); let notes = r["warnings"].as_array().cloned().unwrap_or_default(); - assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); + assert!(!notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); + let mut again = Session::new(); + again.execute("document.open", &json!({"path": dir.join(name)})).unwrap(); + let mut graphs = vec![]; + again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned())); + assert_eq!(graphs.len(), 1, "{name}"); + let g = &graphs[0]; + assert_eq!( + (g.series.as_slice(), g.categories.as_slice(), g.rows.as_slice()), + (&after.series[..], &after.categories[..], &after.rows[..]), + "{name}" + ); + assert_eq!(g.kind, after.kind, "{name}"); } } + +/// A graph the graph tools draw, of each type, saved as `.ai` in either form, opens again as the same +/// graph exactly when the save gives no warning about graphs; a column graph does. +#[test] +fn a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns() { + let dir = vectorcraft_testkit::temp_dir("graph-ai-save"); + let mut live = vec![]; + for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,1,6"; + let id = s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(); + let written = spec(&s, NodeId(id)); + for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] { + let path = dir.join(format!("{ty}-{name}")); + let mut p = json!({"path": path, "format": "ai"}); + if let Some(v) = version { + p["aiVersion"] = json!(v); + } + let r = s.execute("file.saveAs", &p).unwrap(); + let notes = r["warnings"].as_array().cloned().unwrap_or_default(); + let warned = notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))); + let mut again = Session::new(); + again.execute("document.open", &json!({"path": path})).unwrap(); + let mut graphs = vec![]; + again.doc().unwrap().doc.walk(|n| graphs.extend(n.extras().graph.as_deref().cloned())); + assert_eq!(graphs.len(), usize::from(!warned), "{ty} {name}: {r}"); + if let Some(g) = graphs.first() { + assert_eq!((g.kind, g.cells()), (written.kind, written.cells()), "{ty} {name}"); + assert_eq!((g.series.as_slice(), g.categories.as_slice()), (&written.series[..], &written.categories[..]), "{ty} {name}"); + live.push(format!("{ty} {name}")); + } + } + } + assert!(live.contains(&"column v7.ai".to_string()) && live.contains(&"column pdf.ai".to_string()), "{live:?}"); +} diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 0048cb945f..1834cc66b4 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -143,6 +143,10 @@ //! A path or compound path with a brush the reader reads live is written with an art style whose //! stroke is the brush's filter (`writer::brushes`). A brush is written only when the reader reads //! it back with its settings. +//! - Graphs, as the reader reads them (`read/graph.rs`): a graph on the layers whose records the +//! reader reads back as the same graph ([`crate::ai_graph`]) is written as its group between +//! `%_Gs`, its functional records and `%_GS`, each series' group tagged `Go` right after its end; +//! any other graph, and a graph with art left out, as its art, with a warning. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index ce64e56fd5..f751cda156 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -10606,3 +10606,73 @@ fn a_clipping_path_written_as_its_paint_keeps_one_text_wrap() { same_wraps(&wraps(read_back(&doc).layers[0].children().unwrap()), &[(None, 5.0, true)]); } } + +/// A graph whose records read back whole is written with them, each series' group tagged before +/// its name, and opens again as the same live graph, also from a PDF-compatible file's first page. +#[test] +fn graphs_are_written_with_their_records_and_open_live() { + use crate::ai_graph::sample; + for (what, doc) in sample::kept() { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings); + let text = program(&doc); + assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}"); + assert!(text.contains("U\n%_2 ") && text.contains(" 0 0 Go\n%_/ArtDictionary :\n"), "{what}: the tag comes before the series' name: {text}"); + let written = sample::graphs(&doc); + let graphs = sample::graphs(&read_back(&doc)); + assert_eq!(graphs.len(), 1, "{what}"); + assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0); + assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series"); + } + let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column)); + let (p, _) = private_data(&doc, &opts()).unwrap(); + let pdf = + vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions { private_data: Some(p), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); + let (read, _) = crate::ai_alone(&vectorcraft_pdf::illustrator_data(&pdf, None).unwrap()).unwrap(); + let graphs = sample::graphs(&read); + assert_eq!(graphs.len(), 1); + assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0)); +} + +/// A graph whose records wouldn't read back as it is is written as its art, without records, with +/// the warning. +#[test] +fn graphs_the_records_cant_keep_are_written_as_their_art() { + use crate::ai_graph::sample; + for (what, doc) in sample::unkept() { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::GRAPHS), "{what}: {:?}", out.warnings); + let text = program(&doc); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}"); + let read = read_back(&doc); + assert!(sample::graphs(&read).is_empty(), "{what}"); + let paths = |d: &Document| { + let mut count = 0; + d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + count + }; + assert_eq!(paths(&read), paths(&doc), "{what}: the graph's art is kept"); + } +} + +/// A graph with art left out for the object limit is written as the art there is room for, without +/// records or tags, with the warning; with room for all of it, it is written with its records. +#[test] +fn a_graph_with_art_left_out_is_written_without_records() { + use crate::ai_graph::sample; + let doc = sample::document(sample::spec(vectorcraft_doc::GraphKind::Column)); + let all = export_ai_editing(&doc, &opts()).unwrap(); + let read = crate::import::objects_read(&all.program).unwrap(); + let room = export_within(&doc, &opts(), read).unwrap(); + assert_eq!(room.program, all.program); + for max in [0, 1, read / 2, read - 1] { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!( + out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT) && out.warnings.iter().any(|w| w == writer::GRAPHS), + "{max}: {:?}", + out.warnings + ); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{max}: {text}"); + } +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 6361efe5ff..4d4653e42b 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -292,6 +292,13 @@ pub(super) struct Writer<'a> { shown: Vec, /// The copy of an image that the last [`Self::image`] wrote as a window, when it did. windowed: Option, + /// Writing a graph's art with its records ([`Self::graph_records`]): a graph inside it is written + /// as its art. + in_graph: bool, + /// The `Go` line the next object, a graph's series group, is followed by. + series_tag: Option, + /// Where the `Go` lines of the graph being written start in [`Self::body`]. + graph_tags: Vec, } impl<'a> Writer<'a> { @@ -363,6 +370,9 @@ impl<'a> Writer<'a> { whole_images: HashMap::new(), shown: vec![], windowed: None, + in_graph: false, + series_tag: None, + graph_tags: vec![], } } @@ -638,6 +648,7 @@ impl<'a> Writer<'a> { /// One object: its state before it, its transparency, and its name and opacity mask after it. fn node(&mut self, n: &Node) { + let tag = self.series_tag.take(); if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } @@ -648,7 +659,8 @@ impl<'a> Writer<'a> { if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } - if n.extras().graph.is_some() && !self.in_look { + let graph = if n.extras().graph.is_some() && !self.in_look { self.graph_records(n) } else { None }; + if n.extras().graph.is_some() && !self.in_look && graph.is_none() { self.warn(GRAPHS); } // On the layers or in an opacity mask's art, outside art cut to a clipping object: with its @@ -675,14 +687,18 @@ impl<'a> Writer<'a> { let start = self.body.len(); // An image's copy written as the part that its opacity mask, among other regions, shows. let mut mask_window = None; + let charged = self.charges(); match &n.kind { NodeKind::Layer { .. } => {} NodeKind::Group { children, clip: true } => self.clip_group(children, xy), NodeKind::Group { children, clip: false } => { if self.open_group("u") { + self.in_graph |= graph.is_some(); for c in children { + self.series_tag = graph.as_ref().and_then(|g| g.tags.get(&c.id).cloned()); self.node(c); } + self.in_graph &= graph.is_none(); self.end_group("U", xy); } } @@ -785,6 +801,11 @@ impl<'a> Writer<'a> { // A group's dictionary goes between its end and its state block. let block = (self.block_end == self.body.len() && self.block_start >= start).then(|| self.body.split_off(self.block_start)); self.block_end = usize::MAX; + // A graph's series group is tagged right after its end, before its dictionary. + if let Some(t) = tag { + self.graph_tags.push(self.body.len()); + self.line(&t); + } let mask_written = self.dictionary(n); if let Some(b) = block { self.body.push_str(&b); @@ -798,6 +819,41 @@ impl<'a> Writer<'a> { if deform { self.line(&envelopes::plugin_line()); } + if let Some(g) = graph { + self.graph_end(g, start, charged); + } + } + + /// The records of graph `n` ([`crate::ai_graph::records`]) when it is written as a graph: on the + /// layers, outside art cut to a clipping object, symbols' art, live objects' art and another + /// graph, and without an art style anywhere in it. `None` when it is written as its art. + fn graph_records(&self, n: &Node) -> Option { + fn has_style(n: &Node, styled: &HashMap>) -> bool { + styled.contains_key(&n.id) || n.children().is_some_and(|c| c.iter().any(|c| has_style(c, styled))) + } + let placed = self.hidden == 0 && self.nest == 0 && self.drawn == 0 && self.clip.is_none() && !self.in_symbol && !self.in_graph; + if !placed || has_style(n, &self.styled) { + return None; + } + crate::ai_graph::records(n, self.to_art) + } + + /// The graph whose art was written from `start` on, charged from `charged`, ends: its records go + /// before the art and `%_GS` after it, or, when art in it was left out ([`Self::take_objects`]), + /// its `Go` lines are taken out and it stays its art, with a warning. + fn graph_end(&mut self, records: crate::ai_graph::Records, start: usize, charged: Charges) { + let tags = std::mem::take(&mut self.graph_tags); + if self.body.len() > start && !self.left_out_since(charged) { + self.body.insert_str(start, &records.head); + self.line(crate::ai_graph::END); + return; + } + for at in tags.into_iter().rev() { + if let Some(len) = self.body.get(at..).and_then(|rest| rest.find('\n')) { + self.body.replace_range(at..=at + len, ""); + } + } + self.warn(GRAPHS); } /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. diff --git a/crates/eps/src/ai_graph.rs b/crates/eps/src/ai_graph.rs new file mode 100644 index 0000000000..4af8f7e3e9 --- /dev/null +++ b/crates/eps/src/ai_graph.rs @@ -0,0 +1,660 @@ +//! A live graph's records, which the legacy `.ai` writer ([`crate::legacy_ai`]) and the editing data +//! writer ([`crate::ai_editing`]) both write around a graph's group (Adobe Illustrator File Format +//! Specification, version 7.0, §17 and appendix A): `%_Gs`, the functional records on `%_` lines +//! (`Gb`, `Gy`, `Gd`, three `Ga` … `GA` axes, `Gz`, `Gc` cells and `GC`), the drawn art in one group +//! with each series' group followed by `%_2 column 0 0 Go`, then `%_GS`. +//! +//! A graph is written so only when the `.ai` reader (`import::ai::read::graph`) reads every record +//! back as the same graph: one of the four graph types it reads (grouped and stacked column, line, +//! area), the settings it keeps, a table it holds, and drawn art it attaches the graph to (each +//! series' group a direct member of the graph's, of paths painted with one solid fill and one plain +//! line, and the plot's left and bottom edges drawn as stroked lines). Any other graph is written as +//! its art, with a warning. + +use std::collections::HashMap; +use std::fmt::Write; + +use kurbo::{PathSeg, Point}; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, Node, NodeId, NodeKind, PiePosition, + PieSort, TickLength, ValueAxisSide, +}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, Rect}; + +use crate::ps; + +/// Most cells of a graph's table, its label row and column included. +const MAX_CELLS: usize = 2_600_000; + +/// Longest cell string, in bytes (appendix A, `Gc`). +const MAX_CELL_TEXT: usize = 255; + +/// Most characters of the text before or after a value axis' numbers (appendix A, `Ga`, `GA`). +const MAX_AFFIX: usize = 9; + +/// Most tick marks per division the reader keeps. +const MAX_TICK_MARKS: usize = 20; + +/// Most divisions of a value axis with set values the reader keeps. +const MAX_DIVISIONS: usize = 100; + +/// How far an axis line may be from the plot's edge, in points. +const EDGE: f64 = 0.01; + +/// Most values one `Gc` takes. +const CELLS_PER_RECORD: usize = 15; + +/// A graph's records: what goes before its group (`%_Gs` to `%_GC`), and the `Go` line that follows +/// each series' group, by the group's id. `%_GS` follows the group. +pub(crate) struct Records { + pub(crate) head: String, + pub(crate) tags: HashMap, +} + +/// The line that ends a graph's records, after its group. +pub(crate) const END: &str = "%_GS"; + +/// The records of graph `n` (a group holding [`vectorcraft_doc::NodeExtras::graph`]), its art written +/// through `to_art`; `None` when the reader would not read them back as the same graph. +pub(crate) fn records(n: &Node, to_art: Affine) -> Option { + let g = n.extras().graph.as_deref()?; + let NodeKind::Group { children, clip: false } = &n.kind else { return None }; + (n.visible && n.extras().mask.is_none() && all_shown(n)).then_some(())?; + let ty = kind_code(g.kind)?; + settings_kept(g).then_some(())?; + let (left_manual, left_min, left_max, left_step) = axis(g.ticks, g.axis_min, g.axis_max)?; + let (right_manual, right_min, right_max, right_step) = axis(g.right_ticks, g.right_axis_min, g.right_axis_max)?; + // The table as the graph draws it: `cells` leaves out rows and series past the model's limits. + let rows = g.rows.len(); + let width = g.rows.iter().map(Vec::len).max().unwrap_or(0); + ((1..=MAX_GRAPH_CATEGORIES).contains(&rows) && (1..=MAX_GRAPH_SERIES).contains(&width)).then_some(())?; + let cells = g.cells(); + cells.iter().flatten().all(|v| v.is_none_or(f64::is_finite)).then_some(())?; + cells.iter().flatten().any(Option::is_some).then_some(())?; + let first_row = usize::from(!g.series.is_empty()); + let first_col = usize::from(!g.categories.is_empty()); + (g.series.is_empty() || g.series.len() == width).then_some(())?; + (g.categories.is_empty() || g.categories.len() == rows).then_some(())?; + let (total_rows, total_cols) = (rows + first_row, width + first_col); + total_rows.checked_mul(total_cols).filter(|c| *c <= MAX_CELLS)?; + g.series.iter().chain(&g.categories).all(|l| l.len() <= MAX_CELL_TEXT && !l.chars().any(char::is_control)).then_some(())?; + [&g.prefix, &g.suffix, &g.right_prefix, &g.right_suffix] + .iter() + .all(|a| a.chars().count() <= MAX_AFFIX && !a.chars().any(char::is_control)) + .then_some(())?; + // The plot, and the art the reader attaches the graph to. + let r = g.rect; + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(())?; + let plot = to_art.transform_rect_bbox(r); + (plot.is_finite() && plot.width() > 0.0 && plot.height() > 0.0).then_some(())?; + let tags = series_groups(children, width, first_col, g.kind == GraphKind::Line)?; + draws_axes(n, r).then_some(())?; + let placed = n.geometric_bounds()?; + (r.x0 >= placed.x0 - EDGE && r.y0 >= placed.y0 - EDGE && r.x1 <= placed.x1 + EDGE && r.y1 <= placed.y1 + EDGE).then_some(())?; + + let mut head = String::from("%_Gs\n"); + let _ = writeln!(head, "%_{} {} {} {} Gb", real(plot.x0), real(plot.y1), real(plot.x1), real(plot.y0)); + let flag = |b: bool| u8::from(b); + let _ = writeln!( + head, + "%_{ty} {} {} 14 {} {} 0 {} 6 44 Gy", + flag(g.drop_shadow), + flag(g.first_row_in_front), + flag(g.mark_points), + flag(g.connect_points), + flag(g.legend_across_top) + ); + let _ = + writeln!(head, "%_10 2 {} 0 {} {} {} Gd", flag(g.first_column_in_front), real(g.column_width), real(g.cluster_width), flag(g.edge_to_edge)); + // Tick marks per division: 0 and 1 both mean one, at the labels, and the reader reads 0 as 1. + let (category_marks, left_marks, right_marks) = (g.category_tick_marks.max(1), g.tick_marks.max(1), g.right_tick_marks.max(1)); + let _ = writeln!(head, "%_1 () Ga\n%_0 {} 0 0 0 {category_marks} {} () GA", tick(g.category_tick_length), flag(g.ticks_between_labels)); + let _ = writeln!(head, "%_2 {} Ga", ps::bytes(g.prefix.as_bytes())); + let _ = writeln!( + head, + "%_{} {} {} {} {} {} 0 {} GA", + flag(left_manual), + tick(g.tick_length), + real(left_min), + real(left_max), + real(left_step), + left_marks, + ps::bytes(g.suffix.as_bytes()) + ); + let _ = writeln!(head, "%_4 {} Ga", ps::bytes(g.right_prefix.as_bytes())); + let _ = writeln!( + head, + "%_{} {} {} {} {} {} 0 {} GA", + flag(right_manual), + tick(g.right_tick_length), + real(right_min), + real(right_max), + real(right_step), + right_marks, + ps::bytes(g.right_suffix.as_bytes()) + ); + let _ = writeln!(head, "%_{total_rows} {total_cols} {first_row} {first_col} Gz"); + let mut table = Table::new(&mut head); + if first_row == 1 { + if first_col == 1 { + table.text(""); + } + for s in &g.series { + table.text(s); + } + } + for (i, row) in cells.iter().enumerate() { + if let Some(c) = g.categories.get(i) { + table.text(c); + } + for v in row { + match v { + Some(v) => table.num(*v), + None => table.text(""), + } + } + } + table.finish(); + head.push_str("%_GC\n"); + Some(Records { head, tags }) +} + +/// The `Gc` records of a table, its cells in rows: at most [`CELLS_PER_RECORD`] values each, one +/// string at most. +struct Table<'a> { + out: &'a mut String, + values: usize, + string: bool, +} + +impl<'a> Table<'a> { + fn new(out: &'a mut String) -> Self { + Self { out, values: 0, string: false } + } + + fn start(&mut self, string: bool) { + if self.values == CELLS_PER_RECORD || (string && self.string) { + self.finish(); + } + self.out.push_str(if self.values == 0 { "%_" } else { " " }); + self.values += 1; + self.string |= string; + } + + fn text(&mut self, t: &str) { + self.start(true); + self.out.push_str(&ps::bytes(t.as_bytes())); + } + + fn num(&mut self, v: f64) { + self.start(false); + self.out.push_str(&real(v)); + } + + fn finish(&mut self) { + if self.values > 0 { + self.out.push_str(" Gc\n"); + } + (self.values, self.string) = (0, false); + } +} + +/// `v` as a PostScript number that reads back as the same value. +fn real(v: f64) -> String { + if v == 0.0 { + "0".into() + } else if (1e-4..1e15).contains(&v.abs()) { + format!("{v}") + } else { + format!("{v:e}") + } +} + +/// `graphType`: 5 grouped column, 6 stacked column, 7 line, 10 area; the reader reads no other type. +fn kind_code(k: GraphKind) -> Option { + match k { + GraphKind::Column => Some(5), + GraphKind::StackedColumn => Some(6), + GraphKind::Line => Some(7), + GraphKind::Area => Some(10), + _ => None, + } +} + +/// `tickMarkType`: 13 none, 14 short, 15 the length of the plot. +fn tick(t: TickLength) -> u8 { + match t { + TickLength::None => 13, + TickLength::Short => 14, + TickLength::Full => 15, + } +} + +/// Whether `g`'s settings are ones the records give and the reader keeps: a legend beside or across +/// the top, every series of the graph's type, one value axis on the left, lines not filled, no graph +/// designs, label formatting or pie settings, column and cluster widths of 1% to 1000%, and at most +/// [`MAX_TICK_MARKS`] tick marks a division. +fn settings_kept(g: &GraphSpec) -> bool { + let percent = |p: f64| (1.0..=1000.0).contains(&p); + g.legend + && g.series_kinds.iter().all(|k| k.is_none_or(|k| k == g.kind)) + && g.value_axis == ValueAxisSide::Left + && !g.separate_scales + && g.right_series.is_empty() + && !g.filled_lines + && g.series_markers.iter().all(Option::is_none) + && g.series_columns.iter().all(Option::is_none) + && g.label_styles == LabelStyles::default() + && !g.pie_legend_in_wedges + && g.pie_position == PiePosition::Even + && g.pie_sort == PieSort::None + && percent(g.column_width) + && percent(g.cluster_width) + && [g.tick_marks, g.right_tick_marks, g.category_tick_marks].iter().all(|m| *m <= MAX_TICK_MARKS) +} + +/// A value axis' `useManualValues`, lowest and highest value and `betweenValue`: automatic (no tick +/// count and no values), or a whole number of divisions (1 to [`MAX_DIVISIONS`]) between a lowest +/// and a higher highest value, which the reader reads back as the same count. +fn axis(ticks: usize, min: Option, max: Option) -> Option<(bool, f64, f64, f64)> { + match (ticks, min, max) { + (0, None, None) => Some((false, 0.0, 0.0, 0.0)), + (1..=MAX_DIVISIONS, Some(lo), Some(hi)) if lo.is_finite() && hi.is_finite() && hi > lo => { + let step = (hi - lo) / ticks as f64; + let n = (hi - lo) / step; + let exact = step > 0.0 && step.is_finite() && (n - n.round()).abs() <= 1e-6 * n.round().max(1.0) && n.round() == ticks as f64; + exact.then_some((true, lo, hi, step)) + } + _ => None, + } +} + +/// Whether `n` and everything in it is shown. +fn all_shown(n: &Node) -> bool { + n.visible && n.children().is_none_or(|c| c.iter().all(|c| all_shown(c))) +} + +/// Whether `n` is shown, fully opaque, in Normal mode, not isolated and without an opacity mask or +/// effects: the reader keeps a series' paint only from groups and paths drawn so. +fn as_drawn(n: &Node) -> bool { + n.visible + && n.opacity == 1.0 + && n.blend == BlendMode::Normal + && !n.isolate + && n.extras().mask.is_none() + && !n.appearance.effects.iter().any(|e| e.visible) +} + +/// The visible fill and line `p` is written with as one path object: a solid fill, and a solid +/// plain line with its weight, each opaque, in Normal mode and without effects of its own. `None` +/// when it has other paint, more than one of either, or isn't one path of one subpath. +#[allow(clippy::type_complexity)] +fn plain_paint(p: &Node) -> Option<(Option, Option<(Paint, f64)>)> { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &p.kind else { return None }; + (path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count() == 1).then_some(())?; + let (mut fill, mut line) = (None, None); + for item in &p.appearance.items { + let drawn = match item { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && s.width.is_finite(), + }; + if !drawn { + // The writers leave out what draws nothing; the reader reads nothing in its place. + continue; + } + let plain = + item.opacity() == 1.0 && item.blend() == BlendMode::Normal && item.effects().is_empty() && matches!(item.paint(), Paint::Solid { .. }); + plain.then_some(())?; + match item { + AppearanceItem::Fill(f) if fill.is_none() && line.is_none() => fill = Some(f.paint.clone()), + AppearanceItem::Stroke(s) if line.is_none() && stroke::is_plain(s) => line = Some((s.paint.clone(), s.width)), + _ => return None, + } + } + Some((fill, line)) +} + +/// The `Go` line of each series' group among `children`: a group whose series index is one of the +/// `series` the table has, each once, of paths [`as_drawn`] with one fill and one line alike +/// ([`plain_paint`]), every path filled unless the graph draws lines (`line`). Its column is the +/// table's: after the label column when there is one (`first_col`). `None` when a series has no +/// such group. +fn series_groups(children: &[std::sync::Arc], series: usize, first_col: usize, line: bool) -> Option> { + let mut tags = HashMap::new(); + let mut seen = vec![false; series]; + for c in children { + let Some(s) = c.extras().series_index else { continue }; + let s = usize::try_from(s).ok().filter(|s| *s < series)?; + let slot = seen.get_mut(s)?; + (!*slot).then_some(())?; + *slot = true; + let NodeKind::Group { children: paths, clip: false } = &c.kind else { return None }; + (as_drawn(c) && *c.appearance == Appearance::default() && !paths.is_empty()).then_some(())?; + let mut paints = vec![]; + for p in paths { + as_drawn(p).then_some(())?; + paints.push(plain_paint(p)?); + } + let fill = paints.iter().find_map(|(f, _)| f.clone()); + paints.iter().all(|(f, _)| f.is_none() || *f == fill).then_some(())?; + (line || paints.iter().all(|(f, _)| f.is_some())).then_some(())?; + let stroke = paints.first().and_then(|(_, s)| s.clone()); + paints.iter().all(|(_, s)| *s == stroke).then_some(())?; + tags.insert(c.id, format!("%_2 {} 0 0 Go", s + first_col)); + } + seen.iter().all(|s| *s).then_some(tags) +} + +/// Whether `art` draws plot `plot`'s left edge and its bottom edge (the document's y runs down) as +/// straight lines, each a shown path of one subpath with a plain line ([`plain_paint`]) in a shown +/// group, as the reader finds the axes. +fn draws_axes(art: &Node, plot: Rect) -> bool { + let near = |a: Point, b: Point| (a - b).hypot() <= EDGE; + let on = |s: &PathSeg, a: Point, b: Point| match s { + PathSeg::Line(l) => (near(l.p0, a) && near(l.p1, b)) || (near(l.p0, b) && near(l.p1, a)), + _ => false, + }; + let (top_left, bottom_left, bottom_right) = (Point::new(plot.x0, plot.y0), Point::new(plot.x0, plot.y1), Point::new(plot.x1, plot.y1)); + let (mut left, mut bottom) = (false, false); + let mut stack = vec![art]; + while let Some(n) = stack.pop() { + if !n.visible || n.opacity <= 0.0 || (left && bottom) { + continue; + } + match &n.kind { + NodeKind::Group { children, clip: false } => stack.extend(children.iter().map(|c| &**c)), + NodeKind::Path { path, .. } if plain_paint(n).is_some_and(|(_, line)| line.is_some()) => { + for s in path.to_bezpath().segments() { + left |= on(&s, top_left, bottom_left); + bottom |= on(&s, bottom_left, bottom_right); + } + } + _ => {} + } + } + left && bottom +} + +/// Graphs built as the graph tools draw them, for the writers' tests. +#[cfg(test)] +pub(crate) mod sample { + use std::sync::Arc; + + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Document, GraphKind, GraphSpec, Node, PieSort, TickLength, ValueAxisSide}; + use vectorcraft_geom::{PathData, Point, Rect, shapes}; + + use super::Appearance; + + /// A graph of kind `kind`: series East and West, categories Q1 to Q3, West blank in Q2, on the + /// plot 100 100 300 250. + pub(crate) fn spec(kind: GraphKind) -> GraphSpec { + let mut g = GraphSpec { + kind, + rect: Rect::new(100.0, 100.0, 300.0, 250.0), + series: vec!["East".into(), "West".into()], + categories: vec!["Q1".into(), "Q2".into(), "Q3".into()], + ..GraphSpec::default() + }; + g.set_cells(vec![vec![Some(5.0), Some(3.0)], vec![Some(7.0), None], vec![Some(4.0), Some(6.0)]]); + g + } + + fn path(doc: &mut Document, p: PathData, fill: Paint, stroke: Paint) -> Arc { + Arc::new(Node::path(doc.alloc_id(), p, Appearance::basic(fill, stroke, 1.0))) + } + + /// Graph `g` drawn as a group: a group of each series' marks (a line graph's series as an unfilled + /// line, the others' as filled columns), its axes on the plot's left and bottom edges, and a + /// legend of a swatch for each series; its series groups named after the series. + pub(crate) fn graph(doc: &mut Document, g: GraphSpec) -> Node { + let r = g.rect; + let cells = g.cells(); + let width = cells.first().map_or(0, Vec::len); + let mut members = vec![]; + let axes = vec![ + path(doc, shapes::line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)), Paint::None, Paint::solid(Color::BLACK)), + path(doc, shapes::line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)), Paint::None, Paint::solid(Color::BLACK)), + ]; + members.push(Arc::new(Node::group(doc.alloc_id(), axes))); + for s in 0..width { + let gray = Paint::solid(Color::gray(0.2 + 0.3 * s as f32)); + let points: Vec = cells + .iter() + .enumerate() + .filter_map(|(c, row)| { + row.get(s).copied().flatten().map(|v| Point::new(r.x0 + 20.0 + 60.0 * c as f64 + 15.0 * s as f64, r.y1 - v * 10.0)) + }) + .collect(); + let marks: Vec> = if g.kind == GraphKind::Line { + let mut bp = kurbo::BezPath::new(); + for (i, p) in points.iter().enumerate() { + if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) } + } + vec![path(doc, PathData::from_bezpath(&bp), Paint::None, gray)] + } else { + points.iter().map(|p| path(doc, shapes::rectangle(Rect::new(p.x, p.y, p.x + 10.0, r.y1)), gray.clone(), Paint::None)).collect() + }; + let mut series = Node::group(doc.alloc_id(), marks); + series.name = Some(g.series.get(s).cloned().unwrap_or_else(|| format!("Series {}", s + 1))); + series.extras_mut().series_index = Some(s as u32); + members.push(Arc::new(series)); + } + let swatches: Vec> = (0..width) + .map(|s| { + let y = r.y0 + 12.0 * s as f64; + path( + doc, + shapes::rectangle(Rect::new(r.x1 + 10.0, y, r.x1 + 18.0, y + 8.0)), + Paint::solid(Color::gray(0.2 + 0.3 * s as f32)), + Paint::None, + ) + }) + .collect(); + members.push(Arc::new(Node::group(doc.alloc_id(), swatches))); + let mut n = Node::group(doc.alloc_id(), members); + n.extras_mut().graph = Some(Box::new(g)); + n + } + + /// A 400 × 300 document holding `g` drawn by [`graph`] on its layer. + pub(crate) fn document(g: GraphSpec) -> Document { + let mut doc = Document::new(400.0, 300.0); + let layer = doc.layers[0].id; + let n = graph(&mut doc, g); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc + } + + /// Graphs whose records read back whole: each type the records give, every setting they keep + /// changed, labels with parentheses, backslashes and non-ASCII characters, a label of 255 bytes, + /// and a table without labels. + pub(crate) fn kept() -> Vec<(&'static str, Document)> { + let mut cases = vec![ + ("column", document(spec(GraphKind::Column))), + ("stacked column", document(spec(GraphKind::StackedColumn))), + ("line", document(spec(GraphKind::Line))), + ("area", document(spec(GraphKind::Area))), + ]; + let mut g = spec(GraphKind::Column); + (g.drop_shadow, g.first_row_in_front, g.first_column_in_front, g.legend_across_top) = (true, true, true, true); + (g.mark_points, g.connect_points, g.edge_to_edge) = (false, false, true); + (g.column_width, g.cluster_width) = (120.0, 50.0); + (g.tick_length, g.tick_marks, g.ticks, g.axis_min, g.axis_max) = (TickLength::Full, 3, 4, Some(0.0), Some(20.0)); + (g.right_tick_length, g.right_tick_marks, g.right_ticks, g.right_axis_min, g.right_axis_max) = (TickLength::None, 2, 5, Some(1.0), Some(6.0)); + (g.category_tick_length, g.category_tick_marks, g.ticks_between_labels) = (TickLength::Short, 2, true); + (g.prefix, g.suffix, g.right_prefix, g.right_suffix) = ("($".into(), "\\)".into(), "é".into(), " u".into()); + cases.push(("settings", document(g))); + let mut g = spec(GraphKind::Column); + g.series = vec!["A (1)".into(), "B\\2".into()]; + g.categories = vec![String::new(), "naïve".into(), "a".repeat(255)]; + g.set_cells(vec![vec![Some(1.0 / 3.0), Some(1e-7)], vec![None, Some(2.5)], vec![Some(12.0), None]]); + cases.push(("labels", document(g))); + let mut g = spec(GraphKind::Column); + (g.series, g.categories) = (vec![], vec![]); + cases.push(("no labels", document(g))); + cases + } + + /// Graphs the records can't give as they are, which are written as their art: the five other + /// types, settings the reader doesn't keep, a table it can't hold, and art it can't attach the + /// graph to. + pub(crate) fn unkept() -> Vec<(&'static str, Document)> { + let with = |f: &dyn Fn(&mut GraphSpec)| { + let mut g = spec(GraphKind::Column); + f(&mut g); + document(g) + }; + let mut cases: Vec<(&'static str, Document)> = [GraphKind::Bar, GraphKind::StackedBar, GraphKind::Scatter, GraphKind::Pie, GraphKind::Radar] + .into_iter() + .map(|k| (k.id(), document(spec(k)))) + .collect(); + cases.extend([ + ("no legend", with(&|g| g.legend = false)), + ("filled lines", with(&|g| (g.kind, g.filled_lines) = (GraphKind::Line, true))), + ("a series of another type", with(&|g| g.series_kinds = vec![None, Some(GraphKind::Line)])), + ("a marker design", with(&|g| g.series_markers = vec![Some("Star".into())])), + ("a column design", with(&|g| g.series_columns = vec![Some(Default::default())])), + ("label formatting", with(&|g| g.label_styles.values = Some(Default::default()))), + ("a value axis on both sides", with(&|g| g.value_axis = ValueAxisSide::Both)), + ("a series on the right axis", with(&|g| g.right_series = vec![1])), + ("pie settings", with(&|g| g.pie_sort = PieSort::All)), + ("a manual axis without its values", with(&|g| g.ticks = 4)), + ("values without a tick count", with(&|g| (g.axis_min, g.axis_max) = (Some(0.0), Some(10.0)))), + ("101 divisions", with(&|g| (g.ticks, g.axis_min, g.axis_max) = (101, Some(0.0), Some(101.0)))), + ("too many tick marks", with(&|g| g.tick_marks = 21)), + ("a column width past 1000%", with(&|g| g.column_width = 1001.0)), + ("a long prefix", with(&|g| g.prefix = "0123456789".into())), + ("a line break in a label", with(&|g| g.series[0] = "East\nside".into())), + ("a label of 256 bytes", with(&|g| g.categories[0] = "a".repeat(256))), + ("a value that isn't finite", with(&|g| g.rows[0][0] = f64::NAN)), + ("only blank cells", with(&|g| g.set_cells(vec![vec![None, None]; 3]))), + ("fewer labels than series", with(&|g| g.series.truncate(1))), + ]); + let drawn = |what: &'static str, f: &dyn Fn(&mut Vec>)| { + let mut doc = Document::new(400.0, 300.0); + let mut n = graph(&mut doc, spec(GraphKind::Column)); + if let Some(c) = n.children_mut() { + f(c); + } + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + (what, doc) + }; + cases.extend([ + drawn("a series without its group", &|c| { + c.remove(2); + }), + drawn("no axes", &|c| { + c.remove(0); + }), + drawn("a hidden legend", &|c| { + if let Some(l) = c.last_mut() { + Arc::make_mut(l).visible = false; + } + }), + drawn("a series drawn half opaque", &|c| Arc::make_mut(&mut c[1]).opacity = 0.5), + drawn("a series mark with a gradient", &|c| { + let g = vectorcraft_color::Gradient::new( + vectorcraft_color::GradientKind::Linear, + vec![vectorcraft_color::GradientStop::new(0.0, Color::BLACK), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)], + ); + let series = Arc::make_mut(&mut c[1]); + if let Some(marks) = series.children_mut() { + let mark = Arc::make_mut(&mut marks[0]); + mark.appearance = Appearance::basic(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g))), Paint::None, 0.0).into(); + } + }), + ]); + cases + } + + /// The graphs in `doc`, each with the series index of its members. + pub(crate) fn graphs(doc: &Document) -> Vec<(GraphSpec, Vec>)> { + let mut out = vec![]; + doc.walk(|n| { + if let Some(g) = n.extras().graph.as_deref() { + out.push((g.clone(), n.children().map(|c| c.iter().map(|c| c.extras().series_index).collect()).unwrap_or_default())); + } + }); + out + } + + /// Whether `read` is the graph `written` was: the same data and settings, the plot in the same + /// place; its series' paint is the art's and its bounds are the art's as read. + pub(crate) fn same(written: &GraphSpec, read: &GraphSpec) -> bool { + let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6; + let plain = |g: &GraphSpec| GraphSpec { + rect: Rect::ZERO, + rows: vec![], + blanks: vec![], + series_paints: vec![], + placed: None, + series_kinds: vec![], + ..g.clone() + }; + near(written.rect, read.rect) && written.cells() == read.cells() && plain(written) == plain(read) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Numbers are written so they read back the same, small and large ones included. + #[test] + fn numbers_read_back_as_written() { + for v in [0.0, 1.0, -2.5, 1.0 / 3.0, 1e-7, 123_456_789_012_345_678.0, f64::MAX, f64::MIN_POSITIVE] { + assert_eq!(real(v).parse::().ok(), Some(v), "{v}"); + } + } + + /// A graph of `categories` rows of `series` values, drawn by [`sample::graph`]. + fn sized(categories: usize, series: usize) -> Node { + let mut g = sample::spec(GraphKind::Column); + (g.series, g.categories) = (vec![], vec![]); + g.set_cells(vec![vec![Some(1.0); series]; categories]); + sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g) + } + + /// The table the reader holds: up to 10,000 categories and 256 series; past them, the graph is + /// its art. + #[test] + fn tables_up_to_the_readers_limits_are_written() { + assert!(records(&sized(MAX_GRAPH_CATEGORIES, 1), Affine::IDENTITY).is_some_and(|r| r.head.contains("%_10000 1 0 0 Gz\n"))); + assert!(records(&sized(MAX_GRAPH_CATEGORIES + 1, 1), Affine::IDENTITY).is_none()); + assert!(records(&sized(1, MAX_GRAPH_SERIES), Affine::IDENTITY).is_some_and(|r| r.tags.len() == MAX_GRAPH_SERIES)); + assert!(records(&sized(1, MAX_GRAPH_SERIES + 1), Affine::IDENTITY).is_none()); + } + + /// Tick-mark counts of 0, which mean one tick mark as 1 does, are written as 1, the count the + /// reader reads back. + #[test] + fn tick_mark_counts_of_0_are_written_as_1() { + let mut g = sample::spec(GraphKind::Column); + (g.tick_marks, g.right_tick_marks, g.category_tick_marks) = (0, 0, 0); + let n = sample::graph(&mut vectorcraft_doc::Document::new(400.0, 300.0), g); + let head = records(&n, Affine::IDENTITY).map(|r| r.head).unwrap_or_default(); + let marks: Vec<&str> = head.lines().filter(|l| l.ends_with(" GA")).map(|l| l.split(' ').nth(5).unwrap_or("")).collect(); + assert_eq!(marks, ["1", "1", "1"], "{head}"); + } + + /// A table's cells go fifteen to a record, with one string at most in each. + #[test] + fn cells_go_fifteen_to_a_record_with_one_string_each() { + let mut out = String::new(); + let mut t = Table::new(&mut out); + t.text("a"); + t.text("b"); + for v in 0..16 { + t.num(f64::from(v)); + } + t.finish(); + let records: Vec<&str> = out.lines().collect(); + assert_eq!(records[0], "%_(a) Gc"); + assert_eq!(records[1], "%_(b) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 Gc"); + assert_eq!(records[2], "%_14 15 Gc"); + } +} diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 7e38b5ba51..f2146a8872 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -43,13 +43,17 @@ //! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to //! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding //! strings are in. +//! - Graphs (§17): a graph whose records VectorCraft's reader reads back as the same graph +//! (`crate::ai_graph`) as `%_Gs`, its functional records, its group with each series' group +//! followed by `%_2 column 0 0 Go`, and `%_GS`. //! //! The format has one artboard, no transparency, sublayers, object names, hidden objects (only //! layers hide), live effects, symbols or appearances with several fills and strokes. Callers write //! one file per artboard and expand appearances first; what is left is written as the format can //! hold it, each loss a warning: transparent objects are written opaque, sublayers become groups, -//! live objects and symbols their art, strokes a plain line can't draw their outlines, pattern -//! fills their tiles clipped to the shape, freeform gradients their average colour, elliptical +//! live objects and symbols their art, other graphs their art, strokes a plain line can't draw +//! their outlines, pattern fills their tiles clipped to the shape, freeform gradients their average +//! colour, elliptical //! radial gradients circular (the gradient matrix is relative to a canvas the specification //! doesn't size), transparent image pixels over white. Hidden objects go to a hidden layer of //! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 663e21bec6..dce22ec550 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -2122,3 +2122,64 @@ fn a_stained_instance_is_written_in_its_stained_color() { let text = String::from_utf8(write(&doc).bytes).unwrap(); assert!(text.contains("0 0 1 Xa") && !text.contains("1 0 0 Xa"), "{text}"); } + +/// A graph whose records read back whole is written with them (§17), and opens again as the same +/// live graph: its table, type and settings, its series' groups tagged in order, without a warning +/// about graphs. +#[test] +fn graphs_are_written_with_their_records_and_open_live() { + use crate::ai_graph::sample; + for (what, doc) in sample::kept() { + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + assert_eq!((text.matches("%_Gs\n").count(), text.matches("%_GS\n").count()), (1, 1), "{what}: {text}"); + let read = crate::import(&out.bytes).unwrap(); + let written = sample::graphs(&doc); + let graphs = sample::graphs(&read.document); + assert_eq!(graphs.len(), 1, "{what}: {:?}", read.warnings); + assert!(sample::same(&written[0].0, &graphs[0].0), "{what}: {:#?}\n{:#?}", written[0].0, graphs[0].0); + assert_eq!(graphs[0].1, written[0].1, "{what}: the members and their series"); + } +} + +/// A graph inside a group is written with its records too, and opens on its own group. +#[test] +fn a_graph_in_a_group_is_written_with_its_records() { + use crate::ai_graph::sample; + let mut doc = Document::new(400.0, 300.0); + let graph = sample::graph(&mut doc, sample::spec(vectorcraft_doc::GraphKind::Column)); + let frame = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), red(), Paint::None); + let outer = Node::group(doc.alloc_id(), vec![Arc::new(frame), Arc::new(graph)]); + let l = doc.layers[0].id; + doc.insert(Some(l), 0, outer).unwrap(); + let out = write(&doc); + assert!(!out.warnings.iter().any(|w| w == GRAPHS), "{:?}", out.warnings); + let read = crate::import(&out.bytes).unwrap(); + let graphs = sample::graphs(&read.document); + assert_eq!(graphs.len(), 1, "{:?}", read.warnings); + assert!(sample::same(&sample::graphs(&doc)[0].0, &graphs[0].0)); + let layer = children(&read.document.layers[0]); + assert!(layer[0].extras().graph.is_none() && layer[0].children().is_some_and(|c| c.len() == 2), "{layer:#?}"); +} + +/// A graph whose records wouldn't read back as it is is written as its art, without records, with +/// the warning, and opens as that art. +#[test] +fn graphs_the_records_cant_keep_are_written_as_their_art() { + use crate::ai_graph::sample; + for (what, doc) in sample::unkept() { + let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap(); + assert!(out.warnings.iter().any(|w| w == GRAPHS), "{what}: {:?}", out.warnings); + let text = String::from_utf8_lossy(&out.bytes).into_owned(); + assert!(!text.contains("%_Gs") && !text.contains(" Go\n") && !text.contains("%_GS"), "{what}: {text}"); + let read = crate::import(&out.bytes).unwrap(); + assert!(sample::graphs(&read.document).is_empty(), "{what}"); + let paths = |d: &Document| { + let mut count = 0; + d.walk(|n| count += usize::from(matches!(n.kind, NodeKind::Path { .. }))); + count + }; + assert_eq!(paths(&read.document), paths(&doc), "{what}: the graph's art is kept"); + } +} diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 0b41c3defa..d8f88e0d0b 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -52,6 +52,8 @@ pub(super) struct Writer<'a> { last_image: Option<(ImageBlob, Option>)>, /// The images written whole so far, by the address of their data (which each entry keeps). whole_images: std::collections::HashMap>>, + /// Writing a graph's art with its records: a graph inside it is written as its art. + in_graph: bool, } impl<'a> Writer<'a> { @@ -78,6 +80,7 @@ impl<'a> Writer<'a> { clips: vec![], last_image: None, whole_images: std::collections::HashMap::new(), + in_graph: false, } } @@ -264,7 +267,12 @@ impl<'a> Writer<'a> { if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() { self.warn(NAMES); } - if n.extras().graph.is_some() { + // A graph on the layers keeps its data and settings when its records read back whole. + let graph = match n.extras().graph.is_some() { + true if self.nest == 0 && !self.in_graph => crate::ai_graph::records(n, self.to_art), + _ => None, + }; + if n.extras().graph.is_some() && graph.is_none() { self.warn(GRAPHS); } let locked = locked || n.locked; @@ -273,7 +281,10 @@ impl<'a> Writer<'a> { self.warn(SUBLAYERS); self.group(children, *clip, locked); } - NodeKind::Group { children, clip } => self.group(children, *clip, locked), + NodeKind::Group { children, clip } => match &graph { + Some(records) => self.graph(children, locked, records), + None => self.group(children, *clip, locked), + }, NodeKind::Path { path, rule, guide, .. } => { self.lock(locked); if *guide { @@ -352,6 +363,25 @@ impl<'a> Writer<'a> { } } + /// A graph's group (`u` … `U`) between its records (§17): `%_Gs` and its functional records + /// before it, `%_GS` after it, and each series' group followed by its `Go` tag. + fn graph(&mut self, children: &[Arc], locked: bool, records: &crate::ai_graph::Records) { + self.lock(locked); + self.body.push_str(&records.head); + self.line("u"); + self.in_graph = true; + for c in children { + self.node(c, locked); + if let Some(tag) = records.tags.get(&c.id) { + self.line(tag); + } + } + self.in_graph = false; + self.lock(locked); + self.line("U"); + self.line(crate::ai_graph::END); + } + /// A clipping group: its first member's outline clips the others (`h W n`). That member's fills /// paint behind the art, inside the clip, and its strokes over it, outside. fn clip_group(&mut self, children: &[Arc], locked: bool) { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 49b64f4bce..4cc0210e23 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -31,9 +31,11 @@ //! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries //! ([`export_ai_editing`], [`ai_private_data`]). //! - `ai_ops`: the path and line style operators those two write. +//! - `ai_graph`: the records of a live graph those two write around its group. //! - [`dcs`]: DCS files, an EPS file with one program per printing plate, read for placing. mod ai_editing; +mod ai_graph; mod ai_ops; pub mod dcs; mod import; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a5ff38be13..57422e179a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -245,7 +245,8 @@ private editing data. VectorCraft: group is tagged once and paints its marks with one fill and one stroke, which the graph keeps as that series' paint; Graph Data then edits it. Other graphs (pie and scatter graphs, graphs with designs or customizations, records of later versions) come in as plain groups, paths and type, with a note. Saving a graph to - `.ai`, in either form, writes its art without its data or settings, with a warning. + `.ai`, in either form, writes these records around its art when the reader above reads them back as the same graph, + so the file opens again with its data and settings; any other graph is written as its art, with a warning. Legacy `.ai` patterns in the version 7 form (a list of `@` and `&` layers, `_ &` for the tile's background) open as pattern swatches, with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Legacy `.ai` linked images in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..aec1fd696b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -614,6 +614,7 @@ since 2026-10-01. - **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged. - **`.ai`/EPS graphic styles that paint with a named gradient:** a graphic style library's style (or a file's listed style) whose fill, or the fill or stroke of a style of one paint, paints with one of the file's gradients by name loads with that gradient at the style's angle, fitted to the bounds of each object it is applied to (VectorCraft's own placement; the style stores only the gradient's name and angle). A style naming a gradient the file doesn't define, or painting with a pattern, is still left out. Evidence: `fileio::tests_aiimport::graphic_styles_in_a_ai_library_that_paint_with_a_named_gradient_load_with_it`, `style::tests::a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on`. Scores unchanged. - **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. +- **Graphs saved to `.ai` keep their data and settings:** a grouped column, stacked column, line or area graph is written to a `.ai` file, in either form, with the records the `.ai` reader reads (`crates/eps/src/import/ai/read/graph.rs`, #7311): `%_Gs`, its plot, type, settings, axes and table, its series' groups each tagged `Go`, and `%_GS` ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), §17 and appendix A). The file opens again with the same live graph. A graph with settings the reader doesn't keep, graph designs, label formatting, more than 10,000 categories or 256 series, or art the reader doesn't attach a graph to is written as its art, with a warning. `crates/eps/src/legacy_ai/tests.rs` and `crates/eps/src/ai_editing/tests.rs` write and read back each kind of graph; `a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns` checks every graph type the graph tools draw. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. @@ -1723,7 +1724,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`). `.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`). In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`). - A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). + A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note. Saving to `.ai` writes these records for a graph that reads back as the same graph, and warns that any other graph's data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`). Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in None or Tints keeps that colorization, and one in the newer form also keeps its fixed width, Flip Along and Flip Across, and Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with its guides ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193). Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form that bends art of solid-filled paths with those options, open as the path with the brush on its stroke, except paths whose brush scales its art proportionately. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). From a4c513f101e11709788d42cc045e32c4721e9791 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:39:47 -0800 Subject: [PATCH 16/40] Live Paint: Make starts a closed edge where its path starts The edge built from a closed path that nothing cuts started at the anchor the planar map gave it, so dashes, a width profile or a brush began at a different place than on the path. The edge now starts at the anchor where its path starts. An edge with no anchor there takes its dashes from its own start and leaves off the profile and brush. The command description and the progress doc say that dashes aligned to corners and path ends are not fitted to cut edges. --- crates/engine/src/cmd/buildcmds.rs | 27 ++++++++++++++++++----- crates/engine/src/tests/build.rs | 35 ++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 13b7a3bd83..a0decb33d7 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -72,7 +72,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths (paths, compound paths and groups of them; type, images and other objects are refused, nothing changes); each covered face starts with the fill rows that draw (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge with the stroke rows that draw (also a weight above 0) of its source, in order with their settings and with that source's opacity and blending mode (on a path cut into several edges, dashes go on along the path with the lengths set, without the fit of dashes aligned to corners and path ends, arrowheads stay at its own ends, and a width profile or brush stays only on an edge that runs the whole path); a face or edge given no such row has one unpainted row; with a Live Paint group among them, add the others to it as livePaint.merge does; a Live Paint group alone has nothing to add, so it is selected and nothing changes (no undo step) → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -576,7 +576,9 @@ impl Track { /// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the /// edge's first segment places it, and its ends are taken where they lie that far along the source, /// so a path that passes a point twice is told apart. An edge that runs against the direction of its -/// source is reversed first. `None` when the edge is not a single piece found on the source. +/// source is reversed first, and a closed edge is started at the anchor where its source starts +/// (one with no anchor there does not count as running the whole subpath). `None` when the edge is +/// not a single piece found on the source. fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> { use kurbo::{ParamCurve, ParamCurveArclen}; let [sp] = edge.subpaths.as_slice() else { return None }; @@ -621,13 +623,26 @@ fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, b let open = !track.closed; let starts = open && from <= ON_SOURCE; let ends = open && to >= track.len() - ON_SOURCE; - let whole = if open { starts && ends } else { edge_closed }; - Some((from, starts, ends, whole)) + if open { + return Some((from, starts, ends, starts && ends)); + } + // A closed edge runs the whole closed subpath, from the anchor the planar map starts it at. + // Start it at the anchor where the source starts, so settings that follow the path begin there. + let start = track.segs.first().map(|s| s.start())?; + let moved = edge_closed + && edge.subpaths.first_mut().is_some_and(|sp| match sp.anchors.iter().position(|a| a.p.distance(start) <= ON_SOURCE) { + Some(k) => { + sp.anchors.rotate_left(k); + true + } + None => false, + }); + Some((if moved { 0.0 } else { from }, false, false, moved)) } /// Fit stroke row `row` of a source path to an edge cut from it, which runs as [`edge_run`] says -/// (`None`: not known): its dashes go on from where the edge starts along the source, its -/// arrowheads stay only at the source's own ends, and its width profile and brush, which spread +/// (`None`: not known): its dashes go on from where the edge starts along the source, with the +/// lengths set and without the fit of dashes aligned to corners and path ends, its arrowheads stay only at the source's own ends, and its width profile and brush, which spread /// over the whole path, stay only on an edge that runs the whole path. fn fit_to_edge(row: &mut AppearanceItem, run: Option<(f64, bool, bool, bool)>) { let AppearanceItem::Stroke(s) = row else { return }; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 0614f63658..960cab107e 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2753,6 +2753,41 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { assert_eq!(edges, vec![rows]); } +/// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts +/// at and runs the way the path does, whichever anchor that is, so its dashes and brush lie where +/// they do on the path, and it keeps the path's stroke row as it is. +#[test] +fn live_paint_make_starts_a_closed_edge_where_its_path_starts() { + use vectorcraft_doc::Dash; + let corners = [(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]; + for clockwise in [true, false] { + for start in 0..4 { + let mut s = session(); + let mut pts: Vec<(f64, f64)> = (0..4).map(|k| corners[(k + start) % 4]).collect(); + if !clockwise { + pts.reverse(); + } + let anchors: Vec<_> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, z, vec![row]); + s.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let edges = vectorcraft_tools::builder::edges(&gn); + let [e] = edges.as_slice() else { panic!("one edge, got {}", edges.len()) }; + let sp = e.path_data().unwrap().subpaths.first().unwrap().clone(); + let at: Vec<(f64, f64)> = sp.anchors.iter().map(|a| ((a.p.x * 1e3).round() / 1e3, (a.p.y * 1e3).round() / 1e3)).collect(); + assert!(sp.closed); + assert_eq!(at, pts, "clockwise {clockwise}, start {start}"); + assert_eq!(e.appearance.items, rows, "clockwise {clockwise}, start {start}"); + } + } +} + /// When Make cuts a stroke whose only setting that depends on direction is a gradient along or /// across it, each edge runs the way its source path does, so the gradient keeps its direction. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index da890f6079..11211e2c2e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1494,7 +1494,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Live Paint Bucket selected, Alt-click on vector art samples its fill, stroke and stroke weight into the paint defaults ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)). On a placed image it samples the image pixel as a solid fill with no stroke. Alt+Shift-click samples only the color under the pointer, including an intermediate gradient color or image pixels, into the active fill or stroke ([Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). Neither click paints or changes the selection or selected art, and the next Bucket click paints with the sampled paint. Evidence: `tests_build::live_paint_bucket_alt_sample_paints_next_face_without_changing_selection`. Double-clicking the Live Paint Bucket opens Live Paint Bucket Options ([Paint with the Live Paint Bucket tool](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html)): Paint Fills (on) and Paint Strokes (off) set whether a click paints faces, edges or both, Highlight turns the outline of the face or edge under the pointer on or off, and Color and Width set it (red and 4 px by default). Faces are outlined at the Width and edges at half of it, as the page says faces are highlighted with a thick line and edges with a thin line. With both on, a click paints the edge within 4 px of the pointer, else the face, and Shift switches to Paint Fills only; with one on, Shift switches to the other. The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintBucket"}`). Evidence: `builder::tests::{bucket_options_choose_what_a_click_paints, bucket_options_set_the_highlight, bucket_highlights_an_edge_thinner_than_a_face}`, `dialogs::live_paint_bucket::tests::double_clicking_the_tool_opens_its_options_and_ok_sets_them`. Live Paint Bucket Options has Cursor Swatch Preview, as the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) lists: with the Bucket selected, the active fill or stroke shows above the pointer between the color swatches before and after it in the Swatches panel, and the Left and Right arrow keys pick the swatch before or after it, as the page describes. The selected art keeps its paint. As VectorCraft's own rules, the option is on at first, the arrows step the stroke when the Stroke proxy is in front, they wrap around at the ends and still step with the preview off, a color that is no swatch shows alone until Right picks the first swatch or Left the last, and the arrows do nothing during a fill drag or with no color swatch. Evidence: `tests::build::{live_paint_bucket_arrow_keys_step_the_active_paint_through_the_swatches, live_paint_bucket_arrow_keys_handle_unmatched_paint_few_swatches_and_drags, live_paint_bucket_arrow_keys_pass_swatches_of_the_same_color_for_fill_and_stroke, swatch_arrow_keys_leave_modified_arrows_to_the_shortcuts}`. - Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path, the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`. + Object › Live Paint › Make turns the selected paths into a Live Paint group of faces and edges ([the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, each face takes every fill row that draws (shown, with paint and an opacity above 0) of its front-most covering source that has one and an opacity above 0, and each edge every stroke row that draws (also with a weight above 0) of its source, in order with their opacity, blending mode, effects and stroke settings, with gradients kept at their fit on the source and with the source's opacity and blending mode. Where a path is cut into several edges, each edge's dashes go on from where it starts along the path with the lengths set (dashes aligned to corners and path ends are not fitted to the cut edges), the arrowheads stay at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path; each edge runs the way its path does, a gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an open piece of a closed path. A painted lower row under an unpainted or hidden top row is kept, a hidden top row does not become visible, and a source in front whose fill does not draw does not hide the fill behind it. Rows that do not draw stay with the stored sources, which Release returns unchanged; the source's object effects are not carried. Evidence: `tests::build::{live_paint_make_keeps_lower_drawn_stroke_rows, live_paint_make_fits_dashes_and_arrowheads_to_each_edge, live_paint_make_starts_a_closed_edge_where_its_path_starts, live_paint_make_keeps_the_direction_of_gradient_strokes, live_paint_make_keeps_all_drawn_fill_rows_and_attributes, live_paint_make_uses_frontmost_source_with_drawn_fill}`. Live Paint Make preserves the front-most covering source's fill when compound paths share a curved boundary, and Expand keeps the face fills. Regression evidence: `planar::tests::coincident_curves_keep_their_source_faces` checks source order, winding, fill rules and nearby boundaries; `tests::build::live_paint_make_and_expand_keep_abutting_compound_colors` checks native compound paths, Make, Expand and Undo/Redo. This preserves VectorCraft's source-fill rule without changing arrangement precision. Object › Live Paint › Expand keeps the faces and edges that draw on the canvas, with all their fill and stroke rows, so the expanded paths look as the painted group did ([Create Live Paint groups](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/create-live-paint-groups.html)). As VectorCraft's own rule, a face or edge draws when it is shown, its opacity is above 0, and any of its fill or stroke rows is shown, has paint and an opacity above 0 (a stroke also needs a weight above 0), whichever row is on top: a painted lower row under an unpainted top row is kept, and a part whose only painted row is hidden is removed. Merge copies the fill and stroke rows, visibility, opacity and blending mode of each face that draws by this rule, and of each edge, to the rebuilt parts, with their gradients kept at their fit on the old outline and without the strokes' pen pressure, tilt, bearing and rotation. Evidence: `tests::build::{live_paint_expand_keeps_the_faces_and_edges_that_draw, live_paint_merge_carries_the_faces_that_draw, live_paint_merge_leaves_pen_pressure_behind, live_paint_merge_keeps_the_fit_of_carried_gradients, live_paint_merge_keeps_the_visibility_opacity_and_blending_of_carried_parts}`. Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. From 53d28fc9bfc7693374e79e68551e7e80dbc6a214 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:33:14 -0800 Subject: [PATCH 17/40] Docs: the File formats row names the graphs .ai saves keep live The File formats area row no longer lists grouped column, stacked column, line and area graphs among the live objects a saved .ai writes as their art, and its evidence names the graph round-trip tests. --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aec1fd696b..b91aa68adf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -258,8 +258,8 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers row menu commands beyond Collect for Export and menus on guide and blend spine rows (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658) | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | From 67545c790303f435931a50f2481ba236d7abc0fe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:58:12 -0800 Subject: [PATCH 18/40] Attributes: overprint reads the art of graph designs in series attributes.info read its overprint values through the same targets as object.setOverprint, which leave out the art of graph designs drawn in graph series. With only that art selected, overprintFill and overprintStroke came back null while ids listed the art. The edits still leave the art out; the panel's read now includes it. --- crates/engine/src/cmd/graph/tests.rs | 10 ++++++++++ crates/engine/src/cmd/overprint.rs | 11 ++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index b4e7216808..3bca175e12 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -493,6 +493,16 @@ fn paint_commands_leave_a_graph_designs_art_out() { refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]})); } +/// `attributes.info` reads the overprint of a graph design's art in a series, which the overprint edits leave out. +#[test] +fn attributes_info_reads_a_graph_designs_art() { + let (mut s, _, _, art, _) = design_art(); + let info = s.execute("attributes.info", &json!({"ids": [art.0]})).unwrap(); + assert_eq!(info["ids"], json!([art.0])); + assert_eq!(info["overprintFill"], json!(false), "{info}"); + assert_eq!(info["overprintStroke"], json!(false), "{info}"); +} + /// With only the art of a graph design selected, `stroke.setAdvanced` and the stroke mode of `paint.editGradient` act /// as edits of a selection, not of the stroke the next object drawn takes. #[test] diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 781a8242ce..8149701334 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -133,7 +133,7 @@ fn aims(s: &Session, p: &Value, cmd: &str, kinds: impl IntoIterator .into_iter() .map(|fill| { let it = item.of_kind(s, fill); - Ok((fill, it, super::appearance::drop_design_art(&s.doc()?.doc, it.targets(s, p)?))) + Ok((fill, it, it.targets(s, p)?)) }) .collect() } @@ -156,10 +156,15 @@ pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Doc pub(super) type OverprintJob = ((bool, ItemTarget, Vec), bool); /// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as -/// `object.setOverprint` aims. +/// `object.setOverprint` aims, leaving out the art of graph designs drawn in graph series. pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result> { let kinds = kinds(p, cmd, keys)?; - Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect()) + let d = &s.doc()?.doc; + Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))? + .into_iter() + .map(|(fill, it, ids)| (fill, it, super::appearance::drop_design_art(d, ids))) + .zip(kinds.iter().map(|k| k.1)) + .collect()) } /// Apply `jobs` to `d`, adding the objects changed to `changed`. From 6a5e6fa2125dda4af3cf166f865f83bbd52454d8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:16:14 -0800 Subject: [PATCH 19/40] Live Paint: Gap Options fits an edge's dashes and arrowheads to each piece it cuts When Gap Options cut an edge in two, each piece took the old edge's stroke rows unchanged: both arrowheads, the dash phase of the edge's start, and a brush or width profile spread over the whole edge. Each piece now takes those rows fitted to its part of the old edge, as Make fits a source path's rows to the edges cut from it. --- crates/engine/src/cmd/buildcmds.rs | 19 +++++++++++-- crates/engine/src/tests/build.rs | 44 ++++++++++++++++++++++++++++++ docs/mcp.md | 3 +- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index a0decb33d7..7c404571a5 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -749,8 +749,9 @@ fn build_children(d: &mut Document, sources: Vec>, max_gap: Option = old_path.subpaths.iter().map(Track::new).collect(); + let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path)); + let mut a = (*n.appearance).clone(); + for r in &mut a.items { + fit_to_edge(r, run); + } + n.appearance = a.into(); + } *c = Arc::new(n); } } diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 960cab107e..e503dfe2fc 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2753,6 +2753,50 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { assert_eq!(edges, vec![rows]); } +/// When Gap Options cuts an edge in two, each piece keeps the edge's dashes going on from where it +/// starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush. +#[test] +fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { + use vectorcraft_doc::{Arrowhead, Dash}; + let mut s = session(); + let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); + let stub = line(&mut s, (50.0, 0.0), (50.0, 48.0)); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.start_arrow = Some(Arrowhead::Triangle); + st.end_arrow = Some(Arrowhead::Circle); + st.brush = Some("Charcoal".into()); + }); + let rows = set_rows(&mut s, l, vec![row]); + s.execute("select.set", &json!({"ids": [l.0, stub.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let dashed = |s: &Session| { + let mut out: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&node(s, g)) + .iter() + .filter_map(|e| { + let st = e.appearance.stroke().filter(|st| st.dash.is_some())?.clone(); + let sp = e.path_data()?.subpaths.first()?; + Some(((sp.anchors.first()?.p.x * 1e3).round() / 1e3, (sp.anchors.last()?.p.x * 1e3).round() / 1e3, st)) + }) + .collect(); + out.sort_by(|p, q| p.0.total_cmp(&q.0)); + out + }; + let before = dashed(&s); + assert_eq!(before.len(), 1, "nothing cuts the line yet"); + assert_eq!(vec![AppearanceItem::Stroke(before[0].2.clone())], rows); + let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap(); + assert_eq!(r["closed"], json!(1)); + let pieces = dashed(&s); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect(); + assert_eq!(ends, vec![(0.0, 50.0), (50.0, 100.0)], "the line is cut where the gap closes"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 52.0], "the dashes go on along the line"); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); + assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); +} + /// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts /// at and runs the way the path does, whichever anchor that is, so its dashes and brush lie where /// they do on the path, and it keeps the path's stroke row as it is. diff --git a/docs/mcp.md b/docs/mcp.md index 86724f5b7f..823c8bc577 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3614,7 +3614,8 @@ already walks Image Trace. Short agent entry points: the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path. - A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which + When `livePaint.gapOptions` cuts an edge into pieces, each piece takes the edge's rows fitted to its part of the + edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which `livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried. Expand keeps those face fills. `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity From 4d4acd97c3429fca67d4a4507fd9fef14de6fa98 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:49:39 -0800 Subject: [PATCH 20/40] Live Paint: a loop cut from a self-crossing closed path keeps no brush A closed path that crosses itself is cut into closed loops. The loop holding the path's first anchor counted as the whole path, so it kept the path's width profile and brush. A closed edge now counts as the whole path only when it is as long as the path. --- crates/engine/src/cmd/buildcmds.rs | 6 ++++-- crates/engine/src/tests/build.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 7c404571a5..07b178bacc 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -576,8 +576,9 @@ impl Track { /// ends at its end (an open subpath), and whether it runs the whole subpath. The middle of the /// edge's first segment places it, and its ends are taken where they lie that far along the source, /// so a path that passes a point twice is told apart. An edge that runs against the direction of its -/// source is reversed first, and a closed edge is started at the anchor where its source starts -/// (one with no anchor there does not count as running the whole subpath). `None` when the edge is +/// source is reversed first, and a closed edge as long as its closed source is started at the anchor +/// where its source starts (one with no anchor there, or a shorter loop cut from a source that +/// crosses itself, does not count as running the whole subpath). `None` when the edge is /// not a single piece found on the source. fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, bool)> { use kurbo::{ParamCurve, ParamCurveArclen}; @@ -630,6 +631,7 @@ fn edge_run(tracks: &[Track], edge: &mut PathData) -> Option<(f64, bool, bool, b // Start it at the anchor where the source starts, so settings that follow the path begin there. let start = track.segs.first().map(|s| s.start())?; let moved = edge_closed + && (length - track.len()).abs() <= ON_SOURCE.max(track.len() * 1e-4) && edge.subpaths.first_mut().is_some_and(|sp| match sp.anchors.iter().position(|a| a.p.distance(start) <= ON_SOURCE) { Some(k) => { sp.anchors.rotate_left(k); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index e503dfe2fc..f45644d951 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2830,6 +2830,22 @@ fn live_paint_make_starts_a_closed_edge_where_its_path_starts() { assert_eq!(e.appearance.items, rows, "clockwise {clockwise}, start {start}"); } } + // A closed path that crosses itself is cut into loops, and the loop that holds the path's + // first anchor is still only part of the path: it keeps no brush. + let mut s = session(); + let anchors = [(0.0, 0.0), (100.0, 100.0), (100.0, 0.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y})); + let z = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap()); + let row = stroke_row(Paint::solid(Color::BLACK), 2.0, |st| { + st.dash = Some(Dash { pattern: vec![10.0, 5.0], offset: 2.0, align_corners: false }); + st.brush = Some("Charcoal".into()); + }); + set_rows(&mut s, z, vec![row]); + s.execute("select.set", &json!({"ids": [z.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let gn = node(&s, g); + let edges = vectorcraft_tools::builder::edges(&gn); + assert!(edges.len() > 1, "the crossing cuts the path, got {} edge", edges.len()); + assert!(edges.iter().all(|e| e.appearance.stroke().is_some_and(|st| st.brush.is_none())), "no loop is the whole path"); } /// When Make cuts a stroke whose only setting that depends on direction is a gradient along or From 09110ce7bf2b18ceb063561d3dccd8fb1754e3d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:15:07 -0800 Subject: [PATCH 21/40] Live Paint: Merge fits a carried edge look to the piece it lands on When Merge added a path that cut a painted edge, the piece holding the old edge's middle took the old edge's stroke rows unchanged, so its dashes started again and it kept both arrowheads. It now takes them fitted to its part of the old edge, as Gap Options does. --- crates/engine/src/cmd/buildcmds.rs | 25 +++++++++++----- crates/engine/src/cmd/buildcmds/live_paint.rs | 18 +++++++----- crates/engine/src/tests/build.rs | 29 +++++++++++++++---- docs/mcp.md | 4 +-- 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 07b178bacc..63acde9722 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -779,18 +779,27 @@ fn repaint_from(g: &mut Node, old: &Node) { // An edge cut from the old one takes the old edge's dashes, arrowheads, width profile // and brush as they fall on its part of the old edge. if c.name.as_deref() == Some(EDGE_NAME) - && n.appearance.items.iter().any(depends_on_place) && let Some(old_path) = was.path_data() { - let tracks: Vec = old_path.subpaths.iter().map(Track::new).collect(); - let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path)); - let mut a = (*n.appearance).clone(); - for r in &mut a.items { - fit_to_edge(r, run); - } - n.appearance = a.into(); + fit_to_old_edge(&mut n, old_path); } *c = Arc::new(n); } } } + +/// Fit the stroke rows of edge `n`, which it took from an old edge whose outline is `old`, to the +/// part of the old edge that `n` runs, as [`fit_to_edge`] fits a source's rows to an edge cut from +/// it; `n` is reversed when it runs against the old edge. +fn fit_to_old_edge(n: &mut Node, old: &PathData) { + if !n.appearance.items.iter().any(depends_on_place) { + return; + } + let tracks: Vec = old.subpaths.iter().map(Track::new).collect(); + let run = n.path_data_mut().and_then(|path| edge_run(&tracks, path)); + let mut a = (*n.appearance).clone(); + for r in &mut a.items { + fit_to_edge(r, run); + } + n.appearance = a.into(); +} diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 9c3c750996..ac07fb92a3 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,8 @@ use super::{ Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect, - Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json, - number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, fit_to_old_edge, ids_json, + is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, + switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -125,15 +126,15 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result check_live_paint_takes(d, roots)?; let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; // Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge, - // paint) for every edge. + // paint, outline) for every edge. let keep_faces: Vec<(Point, Carried)> = b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect(); - let keep_edges: Vec<(Point, Carried)> = b::edges(&old) + let keep_edges: Vec<(Point, Carried, PathData)> = b::edges(&old) .iter() .filter_map(|e| { let pd = e.path_data()?; let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e))) + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e), pd.clone())) }) .collect(); let mut sources = source_nodes(&old); @@ -161,12 +162,15 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result carried.onto(Arc::make_mut(f)); } } - for (pt, carried) in keep_edges { + // An edge that an added path cuts from an old one takes the old edge's rows fitted to its part. + for (pt, carried, outline) in keep_edges { if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) && let Some(ch) = g.children_mut() && let Some(e) = ch.iter_mut().find(|c| c.id == eid) { - carried.onto(Arc::make_mut(e)); + let e = Arc::make_mut(e); + carried.onto(e); + fit_to_old_edge(e, &outline); } } let mut merged = Beside::replacing(d, roots); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index f45644d951..e6a548647b 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2753,10 +2753,10 @@ fn live_paint_make_fits_dashes_and_arrowheads_to_each_edge() { assert_eq!(edges, vec![rows]); } -/// When Gap Options cuts an edge in two, each piece keeps the edge's dashes going on from where it -/// starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush. +/// When Gap Options or Merge cuts an edge in two, each piece keeps the edge's dashes going on from +/// where it starts along the edge, an arrowhead only at the end of the edge it ends at, and no brush. #[test] -fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { +fn live_paint_gap_options_and_merge_fit_an_edge_look_to_each_piece() { use vectorcraft_doc::{Arrowhead, Dash}; let mut s = session(); let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); @@ -2770,7 +2770,7 @@ fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { let rows = set_rows(&mut s, l, vec![row]); s.execute("select.set", &json!({"ids": [l.0, stub.0]})).unwrap(); let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); - let dashed = |s: &Session| { + let dashed = |s: &Session, g: NodeId| { let mut out: Vec<(f64, f64, vectorcraft_doc::StrokeLayer)> = vectorcraft_tools::builder::edges(&node(s, g)) .iter() .filter_map(|e| { @@ -2782,12 +2782,12 @@ fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { out.sort_by(|p, q| p.0.total_cmp(&q.0)); out }; - let before = dashed(&s); + let before = dashed(&s, g); assert_eq!(before.len(), 1, "nothing cuts the line yet"); assert_eq!(vec![AppearanceItem::Stroke(before[0].2.clone())], rows); let r = s.execute("livePaint.gapOptions", &json!({"id": g.0, "closeGaps": true})).unwrap(); assert_eq!(r["closed"], json!(1)); - let pieces = dashed(&s); + let pieces = dashed(&s, g); let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect(); assert_eq!(ends, vec![(0.0, 50.0), (50.0, 100.0)], "the line is cut where the gap closes"); let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); @@ -2795,6 +2795,23 @@ fn live_paint_gap_options_fits_an_edge_look_to_each_piece() { let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); + // Merge: the piece of the edge that an added path cuts off, which takes the edge's look, gets + // it fitted to where it lies along the edge in the same way. + let mut s = session(); + let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); + set_rows(&mut s, l, rows.clone()); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let cut = line(&mut s, (25.0, 0.0), (25.0, 100.0)); + s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap(); + let pieces = dashed(&s, g); + let ends: Vec<(f64, f64)> = pieces.iter().map(|p| (p.0, p.1)).collect(); + assert_eq!(ends, vec![(0.0, 25.0), (25.0, 100.0)], "the added line cuts the edge"); + let offsets: Vec = pieces.iter().map(|p| (p.2.dash.as_ref().unwrap().offset * 1e3).round() / 1e3).collect(); + assert_eq!(offsets, vec![2.0, 27.0], "the dashes go on along the line"); + let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); + assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); + assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); } /// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts diff --git a/docs/mcp.md b/docs/mcp.md index 823c8bc577..4a85f8b671 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3614,8 +3614,8 @@ already walks Image Trace. Short agent entry points: the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path. - When `livePaint.gapOptions` cuts an edge into pieces, each piece takes the edge's rows fitted to its part of the - edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which + When `livePaint.gapOptions` or `livePaint.merge` cuts an edge into pieces, a piece that takes the edge's rows takes + them fitted to its part of the edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which `livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried. Expand keeps those face fills. `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity From 031fb6565e7e3b47788d58e4aa7c6332b5291251 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:17:29 -0800 Subject: [PATCH 22/40] Appearance: aligned edits with a graph design's art follow the art's stack - appearance.moveItem with from "contents" and aligned: true is refused when the objects' stacks differ, as it is for rows. - paint.invert and paint.complement with an item remember the paint of an object they recolored, not that of design art they left out. - effect.remove and effect.duplicate with indices and aligned: true are an error, and change nothing, when the design art given first has no effect at one of the indices. --- crates/engine/src/cmd/appearance.rs | 4 ++ crates/engine/src/cmd/effectcmd.rs | 18 ++++++- crates/engine/src/cmd/graph/tests.rs | 71 ++++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 12 +++-- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 68d52eed4f..630da097ba 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1365,6 +1365,10 @@ fn move_item(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } + // The Contents row's place counts the rows below it, which isn't one place in several different stacks either. + if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) { + return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); + } return move_contents(s, &ids, to); } let ids = require_targets(s, p, C)?; diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 7619444c74..f8d1719877 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -387,6 +387,20 @@ fn edit_effects_at( indices: &[usize], mut f: impl FnMut(&mut Vec, &[usize]) -> bool, ) -> Result> { + // With a graph design's art given first, the indices are effects of its list: one it doesn't have names no effect, + // and the edit is refused as a whole, as it is in a list that lacks one of them. + if let Some(of) = reference_of(s, p, item, cmd)? + && let Some(n) = s.doc()?.doc.node(of) + { + let ap = &n.appearance; + let len = match item.effects_item(ap, cmd)? { + None => ap.effects.len(), + Some(i) => ap.items.get(i).map_or(0, |it| it.effects().len()), + }; + if indices.iter().any(|i| *i >= len) { + return Err(EngineError::Other(none.into())); + } + } // Each target's list and the positions in it, in the order the targets first come up. let mut targets: Vec<(NodeId, Option, Vec)> = vec![]; for &index in indices { @@ -1284,7 +1298,9 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); - // The art of graph designs in the graphs and series among the roots stays as the graph draws it. + // The art of graph designs in the graphs and series among the roots stays as the graph draws it. A group whose + // own effect turns it into other art as a whole (a blur, feather or inner glow makes it an image) takes its + // art into that art, which draws it, so that art has no place to go back to. let designs = super::appearance::design_art_in(d, &roots); // A dimension selected with the art it measures keeps its own lines and label: it is out // of the document while the art expands. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 3bca175e12..625bb24fdb 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -869,6 +869,77 @@ fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() { assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]); } +/// The Contents row of a graph design's copy and of a group whose stack differs is not moved with `aligned: true`, as +/// the rows are not. +#[test] +fn aligned_contents_row_of_a_graph_designs_copy_and_a_different_stack_is_not_moved() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let (mut s, _, copy, _, _) = design_art(); + s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap(); + assert_ne!(rows(&s, copy), rows(&s, g)); + let (copy_was, g_was) = (rows(&s, copy), rows(&s, g)); + let steps = s.doc().unwrap().history.undo.len(); + let e = s.execute("appearance.moveItem", &json!({"ids": [copy.0, g.0], "from": "contents", "to": 1, "aligned": true})).unwrap_err(); + assert!(e.to_string().contains("appearances differ"), "{e}"); + assert_eq!((rows(&s, copy), rows(&s, g)), (copy_was, g_was)); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +/// Invert with an `item` remembers the paint of an object it recolored, not that of a graph design's art given first, +/// which it leaves out. +#[test] +fn invert_of_an_item_remembers_the_recolored_paint_not_the_design_arts() { + let (mut s, _, art, mark) = aligned_design_art(); + s.execute("paint.invert", &json!({"ids": [art.0, mark.0], "item": 0, "stroke": false})).unwrap(); + let Paint::Solid { color, .. } = s.doc().unwrap().doc.node(mark).unwrap().appearance.items[0].paint().clone() else { + panic!("the mark's fill stays a solid color"); + }; + assert_ne!(color, vectorcraft_color::Color::rgb(0.0, 0.0, 1.0), "the mark's blue fill is inverted"); + assert_eq!(s.last_solid, color, "the mark's inverted fill is remembered, not the art's red one"); +} + +/// With a graph design's art given first and `aligned: true`, `effect.remove` and `effect.duplicate` with `indices` are an +/// error and change nothing when one of the indices names no effect of the art. +#[test] +fn aligned_effect_indices_past_the_end_of_a_graph_designs_art_are_refused() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let (mut s, _, art, mark) = aligned_design_art(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + s.edit("Shadow", |d, _| { + let shadow = d.node(mark).unwrap().appearance.effects[0].clone(); + d.node_mut(art).unwrap().appearance.effects.push(shadow); + Ok(()) + }) + .unwrap(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let steps = s.doc().unwrap().history.undo.len(); + for cmd in ["effect.remove", "effect.duplicate"] { + let e = s.execute(cmd, &json!({"ids": [art.0, mark.0], "item": null, "indices": [0, 9], "aligned": true})).unwrap_err(); + assert!(e.to_string().contains("no effects at indices 0, 9"), "{cmd}: {e}"); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + } + // With index 0 alone, which the art has, the mark's equal effect is removed. + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "indices": [0], "aligned": true})).unwrap(); + assert!(rows(&s, mark).effects.is_empty()); +} + +/// A blur on a series group makes the whole group an image, which draws the design's art with the series' other art: +/// Expand Appearance does not put the art back beside the image. +#[test] +fn expanding_a_blurred_series_group_takes_a_graph_designs_art_into_the_image() { + let (mut s, id, copy, _, _) = design_art(); + let series_group = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id; + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [series_group.0]})).unwrap(); + s.execute("effect.expandAppearance", &json!({"ids": [series_group.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(matches!(d.node(series_group).unwrap().kind, NodeKind::Image(_))); + assert!(d.node(copy).is_none()); +} + /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c2d670e983..a5d5f16d01 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1070,7 +1070,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( return Ok(json!({ "changed": changed as usize })); } let item = item_target(s, p, cmd)?.of_kind(s, !stroke); - let (r, shown_item) = match item { + let (r, shown_item, edited) = match item { ItemTarget::Item { index, .. } => { let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); let mut changed = 0; @@ -1080,15 +1080,19 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize; Ok(()) })?; - (json!({ "changed": changed }), Some(index)) + (json!({ "changed": changed }), Some(index), Some(ids)) } // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, None, None) } }; - let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; + // The paint remembered is one the edit recolored: a graph design's art that the item edit leaves out gives none. + let shown_ids = match edited { + Some(ids) => ids, + None => leaf_targets(s, &ids)?, + }; if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) { s.remember_paint(&shown); } From 1ceff4dec8ad8e5062ff9488bc551ecafa1a5a55 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:59:20 -0800 Subject: [PATCH 23/40] .ai import: pen-samples test checks a brushed path after the samples The check that pen samples mark only the object before them used an unbrushed filled rectangle as the following object. A style with no brush ignores the samples, so the check passed even when the samples marked every later object. The following object is now an unsampled path with the same pen-varying Calligraphic brush, and the test asserts that it comes in as a path stroked with that brush. --- crates/eps/src/import/ai/tests.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index d160248a2d..58e009f020 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -10329,11 +10329,14 @@ fn a_brushed_path_with_samples_of_the_pen_keeps_its_drawn_look() { assert_eq!(a.len(), 1, "{a:#?}"); assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the drawn look: {a:#?}"); assert!(r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings); - // The samples belong to the path before them: a path after them is read as it was. - let after = format!("{path}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + // The samples belong to the path before them: a path after them with the same brush and no + // samples of its own comes in brushed. + let after = format!("{path}{}", brushed_path()); let a = art(&read_ok(&format!("{def}{style}{}", layer(&after))).doc); assert_eq!(a.len(), 2, "{a:#?}"); - assert!(matches!(a[1].kind, NodeKind::Path { .. }) && a[1].appearance.fill().is_some(), "{a:#?}"); + assert!(matches!(a[0].kind, NodeKind::Group { .. }), "the sampled path's drawn look: {a:#?}"); + assert!(matches!(a[1].kind, NodeKind::Path { .. }), "the path after the samples, brushed: {a:#?}"); + assert_eq!(a[1].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Six D"), "{a:#?}"); // Samples with no object before them in the layer mark nothing: the path after them is read. let first = format!("{PEN_SAMPLES}{}", brushed_path()); let a = art(&read_ok(&format!("{def}{style}{}", layer(&first))).doc); From de5a46a2ab448104a734d9991aafa9d37abeee23 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 14:56:41 -0800 Subject: [PATCH 24/40] Live Paint: Merge keeps a painted edge's look on every piece an added path cuts Merge carried each old edge's paint only to the new edge nearest the old edge's middle, so when an added path cut a repainted edge, the other piece went back to its source's stroke. Every new edge whose middle lies on an old edge now takes that edge's paint, with its rows fitted to its part of the edge, as Gap Options does. --- crates/engine/src/cmd/buildcmds/live_paint.rs | 43 +++++++++++-------- crates/engine/src/tests/build.rs | 21 +++++++++ docs/mcp.md | 4 +- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index ac07fb92a3..88057c9b57 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -85,6 +85,9 @@ pub(super) fn lp_make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } +/// How far, in points, the middle of an edge Merge makes may be from the old edge it lies on. +const ON_OLD_EDGE: f64 = 1e-3; + /// What Merge carries from a face or edge to the part it makes again in its place, another path: /// the appearance, visibility, opacity and blending mode. Gradients fitted to the old outline keep /// that fit, and the strokes' pen pressure, tilt, bearing and rotation stay behind. @@ -125,18 +128,11 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; check_live_paint_takes(d, roots)?; let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, paint) for faces that draw, and (point on edge, - // paint, outline) for every edge. + // Paint to carry over: (point inside face, paint) for faces that draw; edges are matched to the + // old edges below. let keep_faces: Vec<(Point, Carried)> = b::faces(&old).iter().filter(|f| draws(f)).filter_map(|f| Some((po::interior_point(f.path_data()?)?, Carried::of(f)))).collect(); - let keep_edges: Vec<(Point, Carried, PathData)> = b::edges(&old) - .iter() - .filter_map(|e| { - let pd = e.path_data()?; - let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), Carried::of(e), pd.clone())) - }) - .collect(); + let old_parts = old.clone(); let mut sources = source_nodes(&old); for r in roots { if *r != target { @@ -162,15 +158,24 @@ fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result carried.onto(Arc::make_mut(f)); } } - // An edge that an added path cuts from an old one takes the old edge's rows fitted to its part. - for (pt, carried, outline) in keep_edges { - if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) - && let Some(ch) = g.children_mut() - && let Some(e) = ch.iter_mut().find(|c| c.id == eid) - { - let e = Arc::make_mut(e); - carried.onto(e); - fit_to_old_edge(e, &outline); + // Each edge whose middle lies on an old edge, every piece of an edge an added path cuts + // included, takes the old edge's paint, with its rows fitted to its part of the old edge. + if let Some(ch) = g.children_mut() { + for c in ch.iter_mut().filter(|c| c.name.as_deref() == Some(EDGE_NAME)) { + let Some(mid) = c + .path_data() + .and_then(|pd| pd.subpaths.first()) + .filter(|sp| sp.segment_count() > 0) + .map(|sp| kurbo::ParamCurve::eval(&sp.segment(0), 0.5)) + else { + continue; + }; + let Some(was) = edge_near(&old_parts, mid, ON_OLD_EDGE) else { continue }; + let e = Arc::make_mut(c); + Carried::of(was).onto(e); + if let Some(outline) = was.path_data() { + fit_to_old_edge(e, outline); + } } } let mut merged = Beside::replacing(d, roots); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index e6a548647b..83d685aeba 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2812,6 +2812,27 @@ fn live_paint_gap_options_and_merge_fit_an_edge_look_to_each_piece() { let arrows: Vec<_> = pieces.iter().map(|p| (p.2.start_arrow.clone(), p.2.end_arrow.clone())).collect(); assert_eq!(arrows, vec![(Some(Arrowhead::Triangle), None), (None, Some(Arrowhead::Circle))]); assert!(pieces.iter().all(|p| p.2.brush.is_none()), "a brush spreads over the whole line"); + // A repainted edge that Merge cuts keeps its paint on every piece. + let mut s = session(); + let l = line(&mut s, (0.0, 50.0), (100.0, 50.0)); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + let g = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [50, 50], "color": "#ff0000", "width": 3})).unwrap(); + let cut = line(&mut s, (50.0, 0.0), (50.0, 100.0)); + s.execute("livePaint.merge", &json!({"ids": [g.0, cut.0]})).unwrap(); + let gn = node(&s, g); + let red = Paint::solid(Color::from_hex("#ff0000").unwrap()); + let mut on_line: Vec<(f64, bool)> = vectorcraft_tools::builder::edges(&gn) + .iter() + .filter_map(|e| { + let sp = e.path_data()?.subpaths.first()?; + let (a, z) = (sp.anchors.first()?.p, sp.anchors.last()?.p); + ((a.y - 50.0).abs() < 1e-6 && (z.y - 50.0).abs() < 1e-6) + .then(|| ((a.x.min(z.x) * 1e3).round() / 1e3, e.appearance.stroke().is_some_and(|st| st.paint == red))) + }) + .collect(); + on_line.sort_by(|p, q| p.0.total_cmp(&q.0)); + assert_eq!(on_line, vec![(0.0, true), (50.0, true)], "both pieces keep the edge's paint"); } /// The edge Make builds from a closed path that nothing cuts starts at the anchor the path starts diff --git a/docs/mcp.md b/docs/mcp.md index 4a85f8b671..730ba3a68e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3614,8 +3614,8 @@ already walks Image Trace. Short agent entry points: the path (Align Dashes to Corners is turned off on it), the arrowheads stay only at the path's own ends, and a width profile or brush stays only on an edge that runs the whole path. A gradient applied along a stroke starts again on each cut edge, and an inside or outside stroke is drawn centered on an edge that is an open piece of a closed path. - When `livePaint.gapOptions` or `livePaint.merge` cuts an edge into pieces, a piece that takes the edge's rows takes - them fitted to its part of the edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which + When `livePaint.gapOptions` or `livePaint.merge` cuts an edge into pieces, each piece takes the edge's paint, with + its rows fitted to its part of the edge in the same way. A face or edge given no such row has one unpainted row. Rows that do not draw stay with the stored sources, which `livePaint.release` returns unchanged. The source's object effects and the strokes' pen pressure are not carried. Expand keeps those face fills. `livePaint.expand` keeps each face and edge that draws, with all its fill and stroke rows: it is shown, its opacity From 21f79816a16d86e4d7d43392c87564256509d00b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 14:57:41 -0800 Subject: [PATCH 25/40] Panels: ui.panelView sets the Swatches, Graphic Styles and Symbols panel views The view items of these panels' menus kept their choice in egui panel state, so no command could set the view. The views now live in the UI state for the session, and ui.panelView {panel, view?} sets or reads them; the menu items and the Swatches panel's list and thumbnail buttons run it. An unknown panel or view is an error and changes nothing. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 6 ++ crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 6 ++ crates/ui-egui/src/panels.rs | 36 ++++++++++ crates/ui-egui/src/panels/graphic_styles.rs | 27 ++++---- crates/ui-egui/src/panels/swatches/actions.rs | 4 +- crates/ui-egui/src/panels/swatches/body.rs | 10 ++- crates/ui-egui/src/panels/swatches/tests.rs | 4 +- crates/ui-egui/src/panels/symbols.rs | 68 +++++++++++++++---- crates/ui-egui/src/state.rs | 11 +++ docs/mcp.md | 7 ++ docs/target-app-parity.md | 2 +- 12 files changed, 149 insertions(+), 33 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 1bc2d14442..9304c01741 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1087,6 +1087,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", ), + ( + "ui.panelView", + "Set Panel View", + "", + "{panel: swatches|graphicStyles|symbols, view?} set a panel's view, as the view items of its panel menu do: for swatches smallThumb|mediumThumb (the default)|largeThumb|smallList|largeList, for graphicStyles and symbols thumbnail (the default)|smallList|largeList; without `view`, answer the current one. The view lasts the session and does not change the document; an unknown panel or view is an error and changes nothing → {panel, view}", + ), ( "ui.tileEdgeColor", "Tile Edge Color…", diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 7b7f7a6b21..6cd6bedc60 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -845,6 +845,7 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::navigator::set_options(app, p), "ui.layersExpand" => crate::panels::layers::expand(app, p), "ui.layersFilter" => crate::panels::layers::set_filter(app, p), + "ui.panelView" => crate::panels::set_view(app, p), "ui.flattenTransparencyDialog" => { crate::dialogs::flatten::open(app); Ok(Value::Null) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 21b8795662..07104c3771 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1088,6 +1088,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{objects?: [paths|shapes|text|images|effects|groups|linkedFiles|guides], layers?: [locked|hidden]} set the Layers panel's Filter (By Object, By Layer): a row stays when it is of a kind in `objects` and in a state in `layers` (its own lock or eye; an empty list takes every row), or when a row inside it stays; Search All narrows the rows further. A key left out keeps its list. The filter lasts the session and does not change the document, its open rows or the selection → {objects, layers}", ), + ( + "ui.panelView", + "Set Panel View", + "", + "{panel: swatches|graphicStyles|symbols, view?} set a panel's view, as the view items of its panel menu do: for swatches smallThumb|mediumThumb (the default)|largeThumb|smallList|largeList, for graphicStyles and symbols thumbnail (the default)|smallList|largeList; without `view`, answer the current one. The view lasts the session and does not change the document; an unknown panel or view is an error and changes nothing → {panel, view}", + ), ( "ui.tileEdgeColor", "Tile Edge Color…", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bbe91da5a6..a56bcd7128 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -514,6 +514,42 @@ pub(crate) fn set_pstate(ctx: &egui::Context, ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("panel-state", key)), v)); } +/// `ui.panelView {panel, view?}`: set the view of the Swatches, Graphic Styles or Symbols panel +/// (`panel`: swatches, graphicStyles or symbols), as the view items of its panel menu do; without +/// `view`, answer the current one. An unknown panel or view is an error, and then nothing changes. +/// → {panel, view} +pub(crate) fn set_view(app: &mut VectorcraftApp, p: &Value) -> Result { + fn given(p: &Value, err: &str) -> Result, String> { + match p.get("view") { + None | Some(Value::Null) => Ok(None), + Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| err.to_string()), + } + } + let ui = &mut app.ui; + let (panel, view) = match p.get("panel").and_then(Value::as_str) { + Some("swatches") => { + if let Some(v) = given(p, "`view` is smallThumb, mediumThumb, largeThumb, smallList or largeList")? { + ui.swatches_view = v; + } + ("swatches", json!(ui.swatches_view)) + } + Some("graphicStyles") => { + if let Some(v) = given(p, "`view` is thumbnail, smallList or largeList")? { + ui.graphic_styles_view = v; + } + ("graphicStyles", json!(ui.graphic_styles_view)) + } + Some("symbols") => { + if let Some(v) = given(p, "`view` is thumbnail, smallList or largeList")? { + ui.symbols_view = v; + } + ("symbols", json!(ui.symbols_view)) + } + _ => return Err("`panel` is swatches, graphicStyles or symbols".to_string()), + }; + Ok(json!({ "panel": panel, "view": view })) +} + /// Whether the panel whose state is `key` has the keyboard for its rows (Delete and Backspace /// remove its highlighted rows): it took it with a click on a row ([`set_row_keys`]) and kept it in /// every frame since, so it lapses when the panel isn't drawn. diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index b87f291e67..d7b622ba77 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -30,9 +30,10 @@ use crate::menus::Item; use crate::theme::Tokens; use crate::widgets::{self, PanelDrag, menu_item}; -/// The panel's views. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum View { +/// The panel's views (`ui.panelView {panel: "graphicStyles", view}`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum View { #[default] Thumbnail, SmallList, @@ -463,7 +464,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let looks = look_hashes(ui.ctx(), app); let shown = Shown { d: &doc, looks: &looks, preview: pstate(ui.ctx(), "gs-preview") }; let sel = selected(ui.ctx(), &doc); - let view: View = pstate(ui.ctx(), "gs-view"); + let view = app.ui.graphic_styles_view; let mut ev = Events::default(); // In a sized panel the list fills the space above the footer (#2992), as the Artboards list // does; elsewhere it keeps its 220 pt. @@ -605,10 +606,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("graphicStyle.sortByName", json!({})).ok(); } ui.separator(); - let view: View = pstate(ui.ctx(), "gs-view"); + let view = app.ui.graphic_styles_view; for (v, label) in View::ALL { - if menu_item(ui, tl!(label), true, view == v) { - set_pstate(ui.ctx(), "gs-view", v); + if menu_item(ui, tl!(label), true, view == v) + && let Err(e) = app.run("ui.panelView", json!({ "panel": "graphicStyles", "view": v })) + { + app.status(e); } } ui.separator(); @@ -865,7 +868,7 @@ mod tests { app.session.execute("graphicStyle.new", &json!({"name": format!("Style {i:02}")})).unwrap(); } let ctx = egui::Context::default(); - set_pstate(&ctx, "gs-view", View::LargeList); + app.ui.graphic_styles_view = View::LargeList; // The clip rect of the first style's name: the list's visible area. let mut list = |bottom: Option| { let mut clip = None; @@ -891,7 +894,7 @@ mod tests { let mut app = app(); app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap(); let ctx = egui::Context::default(); - set_pstate(&ctx, "gs-view", View::LargeList); + app.ui.graphic_styles_view = View::LargeList; let texts = frame(&ctx, |ui| { show(&mut app, ui); menu(&mut app, ui); @@ -906,7 +909,7 @@ mod tests { let unused: Vec = serde_json::from_value(app.session.execute("graphicStyle.unused", &json!({})).unwrap()["names"].clone()).unwrap(); assert_eq!(unused.len(), 4); set_pstate(&ctx, "gs-sel", unused.clone()); - set_pstate(&ctx, "gs-view", View::SmallList); + app.ui.graphic_styles_view = View::SmallList; frame(&ctx, |ui| show(&mut app, ui)); // Delete asks first; OK deletes, and the panel selection follows. delete(&mut app, &unused, false); @@ -967,7 +970,7 @@ mod tests { // A style dragged onto the top half of the first row lands first (Small List View: rows // of 18 points from the top of the list). - set_pstate(&ctx, "gs-view", View::SmallList); + app.ui.graphic_styles_view = View::SmallList; frame(&ctx, |ui| show(&mut app, ui)); let release = |at: egui::Pos2| { let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; @@ -992,7 +995,7 @@ mod tests { let n = names(&app); assert_eq!(n.len(), 5); let ctx = egui::Context::default(); - set_pstate(&ctx, "gs-view", View::SmallList); + app.ui.graphic_styles_view = View::SmallList; frame(&ctx, |ui| show(&mut app, ui)); // A click on row `i` (Small List View: rows of 18 points, 3 apart) with `modifiers` held, // a second after the last one so that two clicks on a row are not a double-click. diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs index 06c8935d30..b183787366 100644 --- a/crates/ui-egui/src/panels/swatches/actions.rs +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -121,7 +121,7 @@ pub(super) fn new_swatch(app: &mut VectorcraftApp, sel: &[String], m: egui::Modi } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let view: View = pstate(ui.ctx(), "swatch-view"); + let view = app.ui.swatches_view; let selected = selection(app, ui); let del = deletable(&selected); let opts = editable(app, &selected); @@ -174,7 +174,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); for (v, label) in View::ALL { if menu_item(ui, label, true, v == view) { - set_pstate(ui.ctx(), "swatch-view", v); + app.run("ui.panelView", json!({ "panel": "swatches", "view": v })).ok(); } } ui.separator(); diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index ef830c8c43..96e003bcf1 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -16,17 +16,21 @@ pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { crate::panels::empty_state(ui, "swatch-book", tl!("No document"), tl!("Open a document to see its swatches.")); return; } - let view: View = pstate(ui.ctx(), "swatch-view"); + let view = app.ui.swatches_view; let kind: Kind = pstate(ui.ctx(), "swatch-kind"); // Top row: proxy at left, list / grid view toggles at right. ui.horizontal(|ui| { crate::panels::proxy(app, ui, 34.0); ui.add_space(ui.available_width() - 58.0); + let mut to = None; if widgets::icon_button(ui, "dc-list-view", tl!("Show List View"), view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::SmallList); + to = Some(View::SmallList); } if widgets::icon_button(ui, "dc-grid-view", tl!("Show Thumbnail View"), !view.is_list(), 26.0).clicked() { - set_pstate(ui.ctx(), "swatch-view", View::MediumThumb); + to = Some(View::MediumThumb); + } + if let Some(v) = to { + app.run("ui.panelView", json!({ "panel": "swatches", "view": v })).ok(); } }); ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 2de0dd2a3d..601c8dc627 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -489,7 +489,7 @@ fn find_field_filters_and_shift_extends_over_what_is_shown() { fn list_view_rows_take_double_clicks_and_folder_clicks_select_the_group() { let mut app = app(); let ctx = context(); - set_pstate(&ctx, "swatch-view", View::SmallList); + app.ui.swatches_view = View::SmallList; double_click_tile(&mut app, &ctx, "Red"); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "double-click in a list opens Swatch Options"); let d = &app.session.active().unwrap().doc; @@ -1013,7 +1013,7 @@ fn the_list_fills_a_tall_panel_above_its_footer() { app.run("swatch.new", json!({"name": format!("Tone {i:02}"), "color": format!("#{:02x}3060", i * 4)})).unwrap(); } let ctx = context(); - set_pstate(&ctx, "swatch-view", View::SmallList); + app.ui.swatches_view = View::SmallList; // The clip rect of the first swatch row's name: the list's visible area. let mut list = |bottom: Option| { let mut clip = None; diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index c0abeb9dac..195d6172f4 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -286,7 +286,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some((doc, keys)) = app.session.active().map(|d| (d.doc.clone(), document_symbols(d))) else { return }; let names: Vec = doc.symbols.iter().map(|s| s.name.clone()).collect(); - let view: View = pstate(ui.ctx(), VIEW); + let view = app.ui.symbols_view; let sel = current(app); let uid = app.session.active().map(|d| d.uid); let marked = marked(ui.ctx(), uid, &names); @@ -458,10 +458,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.status(e); } ui.separator(); - let view: View = pstate(ui.ctx(), VIEW); + let view = app.ui.symbols_view; for (v, label) in View::ALL { - if menu_item(ui, tl!(label), true, view == v) { - set_pstate(ui.ctx(), VIEW, v); + if menu_item(ui, tl!(label), true, view == v) + && let Err(e) = app.run("ui.panelView", json!({ "panel": "symbols", "view": v })) + { + app.status(e); } } ui.separator(); @@ -475,13 +477,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// The panel state key of the symbols Select All Unused picked. const MARKED: &str = "sym-unused"; -/// The panel state key of the panel's [`View`]. -const VIEW: &str = "sym-view"; - /// The panel's views: Thumbnail View (the default) and the Small and Large List Views, whose rows -/// show each symbol's thumbnail and name. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum View { +/// show each symbol's thumbnail and name (`ui.panelView {panel: "symbols", view}`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum View { #[default] Thumbnail, SmallList, @@ -1170,7 +1170,7 @@ mod tests { // Large List View: the row named Dot. let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - set_pstate(&ctx, VIEW, View::LargeList); + app.ui.symbols_view = View::LargeList; let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); out.textures_delta.clear(); @@ -1238,7 +1238,6 @@ mod tests { assert_eq!((editing(&app), current(&mut app)), (None, Some("Cat".into())), "{view}"); let ctx2 = egui::Context::default(); crate::theme::install_fonts(&ctx2); - set_pstate(&ctx2, VIEW, pstate::(&ctx, VIEW)); frame(&ctx2, &mut app, vec![]); frame(&ctx2, &mut app, double_click(row)); assert_eq!(editing(&app).as_deref(), Some("Cat"), "{view}"); @@ -1253,6 +1252,49 @@ mod tests { assert_eq!(app.session.active().unwrap().history.undo.len(), undo); } + /// `ui.panelView` sets the Symbols, Graphic Styles and Swatches panels' views as the view items + /// of their panel menus do, and answers the current view without `view`. An unknown panel or + /// view, or a view of another panel, is an error and changes nothing. + #[test] + fn panel_view_sets_the_view_of_each_panel() { + use super::super::{graphic_styles, swatches}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (i, name) in ["ant", "bee"].into_iter().enumerate() { + app.run("shape.rectangle", json!({"x": 10.0 + 40.0 * i as f64, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("symbol.new", json!({"name": name})).unwrap(); + } + let views = |checked: &str| ["Thumbnail View", "Small List View", "Large List View"].map(|v| (v.to_string(), v == checked)).to_vec(); + assert_eq!(app.run("ui.panelView", json!({"panel": "symbols"})).unwrap(), json!({"panel": "symbols", "view": "thumbnail"})); + assert!(panel_labels(&ctx, &mut app, &["ant", "bee"]).is_empty(), "the thumbnails draw no names"); + let r = app.run("ui.panelView", json!({"panel": "symbols", "view": "largeList"})).unwrap(); + assert_eq!(r, json!({"panel": "symbols", "view": "largeList"})); + assert_eq!(view_items(&ctx, &mut app), views("Large List View"), "the panel menu checks it"); + let names: Vec = panel_labels(&ctx, &mut app, &["ant", "bee"]).into_iter().map(|(t, _)| t).collect(); + assert_eq!(names, ["ant", "bee"], "the list names the symbols"); + click_menu(&ctx, &mut app, "Small List View"); + assert_eq!(app.run("ui.panelView", json!({"panel": "symbols"})).unwrap()["view"], "smallList", "the menu item sets it too"); + for bad in [ + json!({"panel": "symbols", "view": "mediumThumb"}), + json!({"panel": "symbols", "view": 3}), + json!({"panel": "brushes", "view": "smallList"}), + json!({"view": "largeList"}), + ] { + assert!(app.run("ui.panelView", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(app.ui.symbols_view, View::SmallList, "unchanged"); + let r = app.run("ui.panelView", json!({"panel": "graphicStyles", "view": "largeList"})).unwrap(); + assert_eq!(r, json!({"panel": "graphicStyles", "view": "largeList"})); + assert_eq!(app.ui.graphic_styles_view, graphic_styles::View::LargeList); + assert_eq!(app.run("ui.panelView", json!({"panel": "swatches"})).unwrap()["view"], "mediumThumb"); + app.run("ui.panelView", json!({"panel": "swatches", "view": "largeThumb"})).unwrap(); + assert!(app.run("ui.panelView", json!({"panel": "swatches", "view": "thumbnail"})).is_err()); + assert_eq!(app.ui.swatches_view, swatches::View::LargeThumb, "unchanged by the error"); + assert_eq!(app.ui.symbols_view, View::SmallList, "each panel keeps its own view"); + } + /// The panel menu's Edit Symbol edits the selected instance's symbol, and with no instance /// selected the symbol current in the panel. #[test] @@ -1724,7 +1766,7 @@ mod tests { assert!(!symbols(h.state_mut()).contains(&"B".to_string())); // Also test list view - set_pstate(&h.ctx, VIEW, View::SmallList); + h.state_mut().ui.symbols_view = View::SmallList; h.run_steps(2); let node_a = h.get_by_label("A"); let pos_a = node_a.rect().center(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 80a87b4563..14ce1c3532 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -835,6 +835,14 @@ pub struct UiState { /// Search All text does. #[serde(skip)] pub layers_filter: crate::panels::layers::Filter, + /// The Swatches, Graphic Styles and Symbols panels' views, chosen in their panel menus + /// (`ui.panelView`). They are not saved and last the session. + #[serde(skip)] + pub swatches_view: crate::panels::swatches::View, + #[serde(skip)] + pub graphic_styles_view: crate::panels::graphic_styles::View, + #[serde(skip)] + pub symbols_view: crate::panels::symbols::View, /// The Image Trace panel's Advanced section is open (as it was last left). #[serde(default = "yes")] pub image_trace_advanced: bool, @@ -1238,6 +1246,9 @@ impl Default for UiState { layers_panel: Default::default(), navigator_panel: Default::default(), layers_filter: Default::default(), + swatches_view: Default::default(), + graphic_styles_view: Default::default(), + symbols_view: Default::default(), image_trace_advanced: true, image_trace_preview: true, glyphs_preview: 0, diff --git a/docs/mcp.md b/docs/mcp.md index fc03d070b6..9191facf4f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3204,6 +3204,13 @@ already walks Image Trace. Short agent entry points: unchanged. No undo step is added when the symbols are already in order. The panel menu's Thumbnail View, Small List View and Large List View change only how the panel shows the symbols: thumbnails, or rows with a thumbnail and the name (smaller rows in Small List View). +- Panel views: `ui.panelView {panel, view?}` sets the view of the Swatches, Graphic Styles or Symbols panel, as the + view items of its panel menu (and the Swatches panel's list and thumbnail buttons) do. `panel` is `swatches`, + `graphicStyles` or `symbols`. The Swatches panel takes `smallThumb`, `mediumThumb` (the default), `largeThumb`, + `smallList` or `largeList`; the Graphic Styles and Symbols panels take `thumbnail` (the default), `smallList` or + `largeList`. Without `view` the command answers the current one. Each panel keeps its own view for the session, and + the document does not change. An unknown panel or view is an error, and then nothing changes. It answers + `{panel, view}`. - The Brushes panel menu's Select All Unused: `brush.unused` (a query) lists the brushes no stroke paints with, in library order, and the panel selects them. Strokes in the art (opacity masks included), symbols, graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and graphic styles count, and so do the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0a1f7c81b8..4fe639f47c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -979,7 +979,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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 panel context menu** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): right-clicking a symbol in thumbnail view or list view displays a context menu with Place Symbol Instance (passes view center or artboard center), Edit Symbol, Duplicate Symbol, Delete Symbol, Break Link to Symbol (enabled when canvas selection includes instances of this symbol), and Select All Instances. Right-clicking an unselected symbol selects it first (`symbol.setCurrent`). Tests: `panels::symbols::tests::right_click_context_menu_in_symbols_panel_runs_actions`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. - - **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`. + - **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. `ui.panelView` sets the view of the Symbols panel, and of the Swatches and Graphic Styles panels, as their panel menus do. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`, `panels::symbols::tests::panel_view_sets_the_view_of_each_panel`. - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). From 0431809643181892b9ea75ca5b5ca77bafe12b31 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 15:22:36 -0800 Subject: [PATCH 26/40] OpenType panel: H or V Style and Roman Italics set kana and Latin forms With Show East Asian Options on, the OpenType panel menu has H or V Style and Roman Italics. H or V Style is the character feature tag `hkna`: kana take the forms the font draws for horizontal type, and in vertical type the shaper applies `vkna` in its place. Roman Italics is `ital`, the font's italic half-width alphanumerics. Both are on or off only (`hkna=2` is refused), set through `features` and `featureChanges`, cleared by Reset OpenType Features, and are switches in Character Style Options > OpenType Features. Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md. --- crates/engine/src/cmd/textedit.rs | 4 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/engine/src/cmd/typecmd/area_tests.rs | 34 ++++++++ crates/text/src/features.rs | 49 ++++++++++-- .../ui-egui/src/dialogs/char_style_options.rs | 42 +++++++++- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 4 +- crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 4 +- crates/ui-egui/src/i18n/gu.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 4 +- crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 4 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/nl.tsv | 4 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 4 +- crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/panels/opentype.rs | 80 ++++++++++++++++++- docs/mcp.md | 18 +++++ docs/target-app-parity.md | 1 + docs/type-parity.md | 1 + 37 files changed, 285 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 9ed7f3252b..001ca39717 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), warichu?: bool (Character panel menu › Warichu, with Show East Asian Options: the characters are set at warichuScale percent of their size and stacked on warichuLines lines inside their line, one above the other in horizontal type and side by side in vertical type, the first line above or to the right; a stretch of them is one block with the settings of its first character that stays whole on one line and may run past its end; type on a path sets them at their own size), warichuLines?: 1..6 (default 2), warichuGap?: pt 0..100 (the space between the lines, default 0), warichuScale?: % 10..100 (default 50), warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Warichu Settings: how the lines line up along the block, which is as long as its longest line; the justify values spread every line but the last, which is set left, centered or right, or spread with justifyAll; auto, the default, sets them as justifyLeft; left and right are top and bottom in vertical type; none of them turns warichu on), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options: the share of the blank space on each side of each character's ink, inside its own width, that is taken away in horizontal type, the glyph keeping its shape; 100 sets the ink edge to edge; a character without ink, such as a space, keeps its width; a ligature or a letter with its marks takes the value of its first character; tracking, kerning and Insert Aki are added after it, and justification keeps it; vertical type keeps the characters' spacing; 0 by default; a value that is not a number or is outside the range is refused), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -644,7 +644,7 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result> return Err(bad( cmd, format!( - "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", + "unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {}, as `salt=2`)", vectorcraft_text::MAX_FEATURE_VALUE ), )); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 79b835398c..3f4b69518e 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -140,7 +140,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; \"hkna\" is H or V Style, kana in the font's forms for the writing direction, shaped as `vkna` in vertical type, and \"ital\" is Roman Italics, half-width alphanumerics in the font's italic forms, both on or off only), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 36a0f965b7..3e195a977f 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1589,6 +1589,40 @@ fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); } +/// H or V Style (`hkna`) and Roman Italics (`ital`, OpenType panel menu, East Asian options) are +/// feature tags of the characters: set on a range and turned off again, kept by a character style +/// and in a saved document, on or off only (`hkna=2` is refused and changes nothing). +#[test] +fn kana_style_and_roman_italics_are_character_features() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "かなAB"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.features.clone())).collect::>(), + _ => panic!("text"), + }; + // か and な are 3 bytes each. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 6, "features": ["hkna"]})).unwrap(); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 8, "featureChanges": ["ital"]})).unwrap(); + assert_eq!(runs(&s), [("かな".to_string(), vec!["hkna".to_string()]), ("AB".to_string(), vec!["ital".to_string()])]); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &saved.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.runs.iter().map(|r| r.style.features.clone()).collect::>(), [vec!["hkna".to_string()], vec!["ital".to_string()]]); + let before = runs(&s); + for bad in [json!({"id": id, "features": ["hkna=2"]}), json!({"id": id, "featureChanges": ["ital=1"]})] { + let e = s.execute("text.setRangeStyle", &bad).unwrap_err().to_string(); + assert!(e.contains("unknown OpenType feature"), "{e}"); + } + assert_eq!(runs(&s), before); + s.execute("text.setRangeStyle", &json!({"id": id, "featureChanges": ["-hkna", "-ital"]})).unwrap(); + assert!(runs(&s).iter().all(|(_, f)| f.is_empty()), "{:?}", runs(&s)); + s.execute("charStyle.new", &json!({"name": "Kana", "attrs": {"features": ["hkna", "ital"]}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Kana", "id": id})).unwrap(); + assert!(runs(&s).iter().all(|(_, f)| *f == ["hkna", "ital"]), "{:?}", runs(&s)); + let e = s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"features": ["ital=2"]}})).unwrap_err().to_string(); + assert!(e.contains("unknown OpenType feature `ital=2`"), "{e}"); +} + /// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only /// when on. Documents written before it read as off. #[test] diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 9f2b555e23..2a12be5d43 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -43,7 +43,7 @@ pub struct OtFeatures { pub swash: bool, /// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic /// sets, alternates, titling, proportional and lining figures, superscript, subscript, - /// numerators and denominators, `aalt`. + /// numerators and denominators, `aalt`, H or V Style (`hkna`) and Roman Italics (`ital`). pub extra: u32, /// The value of each feature of [`EXTRA_FEATURES`] that is on, as a tag `salt=2` gives it: for /// a feature with alternates, which of them the font draws (the HarfBuzz feature value, 1 for @@ -64,13 +64,20 @@ pub const MAX_STYLE_FEATURES: usize = 256; /// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]: /// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum` /// and lining `lnum` figures, superscript `sups`, subscript `subs`, numerators `numr`, -/// denominators `dnom` and access all alternates `aalt`. +/// denominators `dnom`, access all alternates `aalt`, and the OpenType panel menu's H or V Style +/// `hkna` (shaped as `vkna` in vertical type) and Roman Italics `ital`. /// -pub const EXTRA_FEATURES: [&str; 29] = [ +/// +/// +pub const EXTRA_FEATURES: [&str; 31] = [ "ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18", - "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", + "ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs", "numr", "dnom", "aalt", "hkna", "ital", ]; +/// The features of [`EXTRA_FEATURES`] that are only on or off: they substitute one form for each +/// glyph, so they take no alternate's number (`hkna=2` is refused). +const SWITCH_FEATURES: [&str; 2] = ["hkna", "ital"]; + impl Default for OtFeatures { fn default() -> Self { Self { @@ -107,10 +114,11 @@ struct ParsedTag<'a> { value: u16, } -/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] and `swsh` can: -/// each substitutes glyphs, and the value says which alternate. +/// Can feature `name` take a value (`salt=2`)? The features of [`EXTRA_FEATURES`] but +/// [`SWITCH_FEATURES`], and `swsh`, can: each substitutes glyphs, and the value says which +/// alternate. fn takes_value(name: &str) -> bool { - name == "swsh" || EXTRA_FEATURES.contains(&name) + name == "swsh" || (EXTRA_FEATURES.contains(&name) && !SWITCH_FEATURES.contains(&name)) } /// Read a feature tag: `tag`, `+tag`, `-tag` or, for a feature that takes a value, `tag=N` (`N` @@ -316,6 +324,8 @@ impl OtFeatures { } for (i, tag) in EXTRA_FEATURES.iter().enumerate() { if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) { + // H or V Style: the kana forms made for the writing direction, `vkna` in vertical type. + let t = if self.vertical && &t == b"hkna" { *b"vkna" } else { t }; v.push(f(&t, u32::from(s.extra_values.get(i).copied().unwrap_or(0).max(1)))); } } @@ -431,6 +441,31 @@ mod tests { } } + /// H or V Style (`hkna`) and Roman Italics (`ital`) are known, round-trip through the stored + /// tags and reach the shaper: `hkna` as itself in horizontal type and as `vkna` in vertical + /// type, `ital` in both. Neither takes an alternate's number. + #[test] + fn kana_style_and_roman_italics_are_kept_and_shaped() { + for t in ["hkna", "-hkna", "+ital", "ital"] { + assert!(OtFeatures::known_tag(t), "{t}"); + } + for t in ["hkna=2", "ital=0", "vkna"] { + assert!(!OtFeatures::known_tag(t), "{t}"); + } + assert_eq!(OtFeatures::from_tags(["ital", "hkna", "smcp"]).to_tags(), ["smcp", "hkna", "ital"]); + assert_eq!(OtFeatures::from_tags(["hkna", "ital", "-hkna"]).to_tags(), ["ital"]); + let st = CharStyle { features: vec!["hkna".into(), "ital".into()], ..CharStyle::default() }; + let on = |vertical: bool| -> Vec { + OtFeatures { vertical, ..OtFeatures::default() }.resolve(&st).iter().filter(|f| f.value == 1).map(|f| f.tag.to_string()).collect() + }; + assert_eq!(on(false), ["hkna", "ital"]); + assert_eq!(on(true), ["vkna", "ital", "vert"]); + let plain = |vertical: bool| OtFeatures { vertical, ..OtFeatures::default() }.resolve(&CharStyle::default()); + for vertical in [false, true] { + assert!(!plain(vertical).iter().any(|f| [b"hkna", b"vkna", b"ital"].iter().any(|t| f.tag == Tag::new(t))), "off by default"); + } + } + /// `changes` lists the features a change turns on and off and leaves out the ones it keeps, /// and applying them to any set makes the same change. #[test] diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index e807df7b01..8915c48216 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -48,6 +48,8 @@ const OT_SWITCHES: &[(&str, &str)] = &[ ("ot_titl", "titl"), ("ot_ordn", "ordn"), ("ot_frac", "frac"), + ("ot_hkna", "hkna"), + ("ot_ital", "ital"), ]; /// The feature tags the Figure menu sets. const FIGURE_TAGS: [&str; 4] = ["tnum", "onum", "pnum", "lnum"]; @@ -56,8 +58,21 @@ const FIGURE_VALUES: [&str; 5] = ["default", "tabularLining", "proportionalLinin /// The values of the Position field, in the order of the OpenType panel's Position menu. const POSITION_VALUES: [&str; 5] = ["default", "superscript", "subscript", "numerator", "denominator"]; /// The fields of OpenType Features. -const OT_FIELDS: &[&str] = - &["ot_figure", "ot_position", "ot_liga", "ot_calt", "ot_dlig", "ot_swsh", "ot_salt", "ot_titl", "ot_ordn", "ot_frac", "ot_sets"]; +const OT_FIELDS: &[&str] = &[ + "ot_figure", + "ot_position", + "ot_liga", + "ot_calt", + "ot_dlig", + "ot_swsh", + "ot_salt", + "ot_titl", + "ot_ordn", + "ot_frac", + "ot_hkna", + "ot_ital", + "ot_sets", +]; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Character Style Options").into(), @@ -106,6 +121,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "ot_titl", "ot_ordn", "ot_frac", + "ot_hkna", + "ot_ital", "ot_sets", "section", ] @@ -926,6 +943,8 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) { ("ot_titl", tl!("Titling Alternates")), ("ot_ordn", tl!("Ordinals")), ("ot_frac", tl!("Fractions")), + ("ot_hkna", tl!("H or V Style")), + ("ot_ital", tl!("Roman Italics")), ] { switch(ui, d, key, caption, true); } @@ -2042,6 +2061,23 @@ mod tests { assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["frac"])); } + /// H or V Style and Roman Italics are on, off or unset switches of OpenType Features: they show + /// the style's `hkna` and `ital` tags, a changed switch replaces its tag and keeps the others, an + /// unset one removes it, and a style without them keeps none. + #[test] + fn kana_style_and_roman_italics_are_opentype_switches() { + let mut d = dialog_for(json!({"features": ["hkna", "-ital", "dlig"]})); + assert_eq!((&d.fields["ot_hkna"], &d.fields["ot_ital"]), (&json!(true), &json!(false))); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["hkna", "-ital", "dlig"]), "untouched"); + d.fields.insert("ot_ital".into(), json!(true)); + d.fields.insert("ot_hkna".into(), Value::Null); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["dlig", "ital"])); + let mut d = dialog_for(json!({})); + assert!(d.fields["ot_hkna"].is_null() && d.fields["ot_ital"].is_null()); + d.fields.insert("ot_hkna".into(), json!(true)); + assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["hkna"])); + } + #[test] fn color_and_opentype_sections_show_their_controls() { let mut d = dialog_for(json!({})); @@ -2066,6 +2102,8 @@ mod tests { "Titling Alternates", "Ordinals", "Fractions", + "H or V Style", + "Roman Italics", "Stylistic Sets", "ss01", "ss20", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d0a770b2b1..10946a14cc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2590,6 +2590,8 @@ font-technology Variable Променливи Proof Setup Настройка на пробен печат Properties Свойства Proportional Metrics Пропорционални метрики + H or V Style Хоризонтален или вертикален стил + Roman Italics Римски курсив Insert Aki Left Вмъкване на аки отляво Insert Aki Right Вмъкване на аки отдясно Tsume Цуме diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 5fca937f98..c986419868 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1571,6 +1571,8 @@ lighting Light Svjetlo Leading Prored No Break Bez preloma Proportional Metrics Proporcionalna metrika + H or V Style Horizontalni ili vertikalni stil + Roman Italics Latinični kurziv Insert Aki Left Umetni aki lijevo Insert Aki Right Umetni aki desno Tsume Tsume diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b6fdc2a6a6..6e70ac477f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3360,6 +3360,8 @@ digits Malayalam Malaiàlam Proof Setup Configuració de prova Properties Propietats Proportional Metrics Mètriques proporcionals + H or V Style Estil horitzontal o vertical + Roman Italics Cursiva romana Insert Aki Left Insereix aki a l'esquerra Insert Aki Right Insereix aki a la dreta Tsume Tsume diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 47ca092cc0..5e7fcbf578 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1599,6 +1599,8 @@ lighting Light Světlo Leading Proklad No Break Nezalamovat Proportional Metrics Proporcionální metrika + H or V Style Vodorovný nebo svislý styl + Roman Italics Kurzíva latinky Insert Aki Left Vložit aki vlevo Insert Aki Right Vložit aki vpravo Tsume Tsume @@ -9239,7 +9241,7 @@ breakText Glyphs Glyfy @msg unexpected resources neočekávané prostředky @msg unknown CMYK profile `{name}` neznámý profil CMYK `{name}` @msg unknown File Info field `{k}` neznámé pole informací o souboru `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) neznámá funkce OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; předpona - funkci vypne; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom a aalt přijímají číslo alternativy jako `tag=N` s N od 0 do {_1}, například `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) neznámá funkce OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; předpona - funkci vypne; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom a aalt přijímají číslo alternativy jako `tag=N` s N od 0 do {_1}, například `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) neznámá funkce OpenType `{t}` ve featureChanges (značky, které přijímá `features`) @msg unknown PDF preset `{name}` (presets: {_1}) neznámé přednastavení PDF `{name}` (přednastavení: {_1}) @msg unknown Warp style neznámý styl deformace diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index ca27bdd2f9..a7e3f4a1eb 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2116,6 +2116,8 @@ lighting Light Lys Leading Linjeafstand No Break Ingen ombrydning Proportional Metrics Proportionale metrikker + H or V Style Vandret eller lodret stil + Roman Italics Romersk kursiv Insert Aki Left Indsæt aki til venstre Insert Aki Right Indsæt aki til højre Tsume Tsume diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index df4d5df82e..4ad718d77c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2634,6 +2634,8 @@ mark-style Japanese Japanisch Proof Setup Proof einrichten Properties Eigenschaften Proportional Metrics Proportionale Metrik + H or V Style Horizontaler oder vertikaler Stil + Roman Italics Lateinische Kursive Insert Aki Left Aki links einfügen Insert Aki Right Aki rechts einfügen Tsume Tsume @@ -7461,7 +7463,7 @@ breakText Glyphs Glyphen @msg toString is not a function toString ist keine Funktion @msg too many arguments zu viele Argumente @msg too many quantifiers zu viele Quantifizierer -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) unbekannte OpenType-Funktion `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; mit vorangestelltem - ausschalten; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom und aalt nehmen die Nummer einer Alternative als `tag=N` mit N von 0 bis {_1}, etwa `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) unbekannte OpenType-Funktion `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; mit vorangestelltem - ausschalten; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom und aalt nehmen die Nummer einer Alternative als `tag=N` mit N von 0 bis {_1}, etwa `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) unbekanntes Dithering `{d}` (diffusion, pattern, noise oder none) @msg unknown panel `{panel}` unbekanntes Bedienfeld `{panel}` @msg unknown parameter `{key}` unbekannter Parameter `{key}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9f3ee177e5..65ac6b16ee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3430,6 +3430,8 @@ theme Appearance Εμφάνιση Profile inclusion: Συμπερίληψη προφίλ: Projecting Cap Προεξέχον άκρο Proportional Metrics Αναλογικές μετρικές + H or V Style Οριζόντιο ή κατακόρυφο στυλ + Roman Italics Λατινικά πλάγια Insert Aki Left Εισαγωγή aki αριστερά Insert Aki Right Εισαγωγή aki δεξιά Tsume Tsume diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 555e51b8c7..fb7adaf1f5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2668,6 +2668,8 @@ font-technology Variable Variables Proof Setup Ajuste de prueba Properties Propiedades Proportional Metrics Métrica proporcional + H or V Style Estilo horizontal o vertical + Roman Italics Cursiva romana Insert Aki Left Insertar aki a la izquierda Insert Aki Right Insertar aki a la derecha Tsume Tsume diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 363042b43d..e4fcbe8ac6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1574,6 +1574,8 @@ lighting Light Valo Leading Riviväli No Break Ei katkoa Proportional Metrics Suhteelliset metriikat + H or V Style Vaaka- tai pystytyyli + Roman Italics Latinalainen kursiivi Insert Aki Left Lisää aki vasemmalle Insert Aki Right Lisää aki oikealle Tsume Tsume diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 494a2d944a..cb51705edc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2660,6 +2660,8 @@ font-technology Variable Variables Proof Setup Format d’épreuve Properties Propriétés Proportional Metrics Métrique proportionnelle + H or V Style Style horizontal ou vertical + Roman Italics Italique des caractères romains Insert Aki Left Insérer un aki à gauche Insert Aki Right Insérer un aki à droite Tsume Tsume @@ -9097,7 +9099,7 @@ breakText Glyphs Glyphes @msg unexpected resources ressources inattendues @msg unknown CMYK profile `{name}` profil CMJN inconnu `{name}` @msg unknown File Info field `{k}` champ Informations sur le fichier inconnu `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) fonctionnalité OpenType inconnue `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt ; préfixe - pour désactiver ; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom et aalt acceptent un numéro de variante sous la forme `tag=N` avec N de 0 à {_1}, comme `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) fonctionnalité OpenType inconnue `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital ; préfixe - pour désactiver ; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom et aalt acceptent un numéro de variante sous la forme `tag=N` avec N de 0 à {_1}, comme `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) fonctionnalité OpenType inconnue `{t}` dans featureChanges (les balises qu’accepte `features`) @msg unknown PDF preset `{name}` (presets: {_1}) paramètre prédéfini PDF inconnu `{name}` (paramètres prédéfinis : {_1}) @msg unknown Warp style style de déformation inconnu diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 7382b4fe9e..7597d0e99b 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -2742,6 +2742,8 @@ Proof Setup પ્રૂફ સેટઅપ Properties ગુણધર્મો Proportional Metrics પ્રમાણસર મેટ્રિક્સ + H or V Style આડી અથવા ઊભી શૈલી + Roman Italics રોમન ઇટાલિક Pucker પકર Pucker & Bloat… પકર અને બ્લોટ… Pucker Tool પકર ટૂલ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index edfbe0818f..6dd6506792 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3700,6 +3700,8 @@ antiAlias Sharp शार्प Properties प्रॉपर्टीज़ Proportional Lining आनुपातिक लाइनिंग Proportional Metrics आनुपातिक मीट्रिक + H or V Style क्षैतिज या ऊर्ध्वाधर शैली + Roman Italics रोमन इटैलिक Insert Aki Left बाईं ओर आकी जोड़ें Insert Aki Right दाईं ओर आकी जोड़ें Tsume त्सुमे diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 27dda10840..c439b8365c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3226,6 +3226,8 @@ mark-style Japanese Japán Proof Setup Színpróba beállítása Properties Tulajdonságok Proportional Metrics Arányos metrika + H or V Style Vízszintes vagy függőleges stílus + Roman Italics Latin betűk dőlt változata Insert Aki Left Aki beszúrása balra Insert Aki Right Aki beszúrása jobbra Tsume Tsume @@ -9109,7 +9111,7 @@ breakText Glyphs Glifák @msg unexpected resources váratlan erőforrások @msg unknown CMYK profile `{name}` ismeretlen CMYK-profil: `{name}` @msg unknown File Info field `{k}` ismeretlen fájlinformációs mező: `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) ismeretlen OpenType-funkció: `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; a - előtag kikapcsolja; a swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom és aalt egy alternatíva számát `tag=N` alakban fogadja, ahol N 0 és {_1} között lehet, például `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) ismeretlen OpenType-funkció: `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; a - előtag kikapcsolja; a swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom és aalt egy alternatíva számát `tag=N` alakban fogadja, ahol N 0 és {_1} között lehet, például `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) ismeretlen OpenType-funkció a featureChanges értékben: `{t}` (a `features` által fogadott címkék használhatók) @msg unknown PDF preset `{name}` (presets: {_1}) ismeretlen PDF-előbeállítás: `{name}` (előbeállítások: {_1}) @msg unknown Warp style ismeretlen hajlítási stílus diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f870492477..2c348c76d5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1507,6 +1507,8 @@ lighting Light Cahaya Leading Leading No Break Tanpa pemenggalan Proportional Metrics Metrik proporsional + H or V Style Gaya horizontal atau vertikal + Roman Italics Miring Romawi Insert Aki Left Sisipkan aki kiri Insert Aki Right Sisipkan aki kanan Tsume Tsume diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7ef7dedc00..484fa5a6a4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2710,6 +2710,8 @@ mark-style Japanese Giapponese Proof Setup Impostazione prova Properties Proprietà Proportional Metrics Metrica proporzionale + H or V Style Stile orizzontale o verticale + Roman Italics Corsivo romano Insert Aki Left Inserisci aki a sinistra Insert Aki Right Inserisci aki a destra Tsume Tsume @@ -7509,7 +7511,7 @@ breakText Glyphs Glifi @msg toString is not a function toString non è una funzione @msg too many arguments troppi argomenti @msg too many quantifiers troppi quantificatori -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) funzione OpenType sconosciuta `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; anteponi - per disattivarla; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt accettano il numero di un'alternativa come `tag=N` con N da 0 a {_1}, ad esempio `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) funzione OpenType sconosciuta `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; anteponi - per disattivarla; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt accettano il numero di un'alternativa come `tag=N` con N da 0 a {_1}, ad esempio `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) dithering sconosciuto `{d}` (diffusion, pattern, noise o none) @msg unknown panel `{panel}` pannello sconosciuto `{panel}` @msg unknown parameter `{key}` parametro sconosciuto `{key}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c18d4c55ca..085409990e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -50,6 +50,8 @@ font-technology Variable バリアブル x-Height x ハイト ICF Box 平均字面 Proportional Metrics プロポーショナルメトリクス + H or V Style 横組み・縦組み用かな + Roman Italics 欧文イタリック Insert Aki Left 前にアキを挿入 Insert Aki Right 後ろにアキを挿入 Tsume 文字ツメ @@ -6716,7 +6718,7 @@ axis Y Axis Y 軸 @msg toString is not a function toString は関数ではありません @msg too many arguments 引数が多すぎます @msg too many quantifiers 量指定子が多すぎます -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) `{t}` は不明な OpenType 機能です (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital。オフにするには先頭に - を付けます。swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt には、`salt=2` のように `tag=N` の形式で代替字形の番号を指定できます。N は 0 から {_1} まで) @msg unknown dither `{d}` (diffusion, pattern, noise or none) `{d}` は不明なディザの指定です (diffusion、pattern、noise、none のいずれか) @msg unknown panel `{panel}` `{panel}` は不明なパネルです @msg unknown parameter `{key}` `{key}` は不明なパラメーターです diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5ab6945aec..7d3a2f8ace 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1444,6 +1444,8 @@ lighting Light სინათლე Leading სტრიქონთა დაშორება No Break გადატანის გარეშე Proportional Metrics პროპორციული მეტრიკა + H or V Style ჰორიზონტალური ან ვერტიკალური სტილი + Roman Italics ლათინური კურსივი Insert Aki Left აკის ჩასმა მარცხნივ Insert Aki Right აკის ჩასმა მარჯვნივ Tsume ცუმე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9ed9cd6a98..661cfaac3e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -44,6 +44,8 @@ font-technology Variable 가변 x-Height x 높이 ICF Box ICF 박스 Proportional Metrics 비례 메트릭 + H or V Style 가로 또는 세로 스타일 + Roman Italics 로마자 이탤릭 Insert Aki Left 앞 아키 삽입 Insert Aki Right 뒤 아키 삽입 Tsume 츠메 @@ -8842,7 +8844,7 @@ breakText Glyphs 글리프 @msg unexpected resources 예상하지 못한 리소스 @msg unknown CMYK profile `{name}` 알 수 없는 CMYK 프로파일 `{name}` @msg unknown File Info field `{k}` 알 수 없는 파일 정보 필드 `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 알 수 없는 OpenType 기능 `{t}`(liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt. 끄려면 앞에 -를 붙이세요. swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt는 대체 글리프 번호를 `tag=N` 형식으로 받으며 N은 0에서 {_1} 사이입니다. 예: `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 알 수 없는 OpenType 기능 `{t}`(liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital. 끄려면 앞에 -를 붙이세요. swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt는 대체 글리프 번호를 `tag=N` 형식으로 받으며 N은 0에서 {_1} 사이입니다. 예: `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges의 알 수 없는 OpenType 기능 `{t}`(`features`가 받는 태그를 사용하세요) @msg unknown PDF preset `{name}` (presets: {_1}) 알 수 없는 PDF 프리셋 `{name}`(프리셋: {_1}) @msg unknown Warp style 알 수 없는 왜곡 스타일 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 711ce4c8a8..e1e8af1e30 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2025,6 +2025,8 @@ lighting Light Licht Leading Regelafstand No Break Niet afbreken Proportional Metrics Proportionele metingen + H or V Style Horizontale of verticale stijl + Roman Italics Romeinse cursief Insert Aki Left Aki links invoegen Insert Aki Right Aki rechts invoegen Tsume Tsume @@ -7189,7 +7191,7 @@ imageTrace Outline Contour @msg unexpected resources onverwachte resources @msg unknown CMYK profile `{name}` onbekend CMYK-profiel `{name}` @msg unknown File Info field `{k}` onbekend veld `{k}` van Bestandsinfo -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) onbekende OpenType-functie `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; zet er - voor om uit te schakelen; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom en aalt accepteren het nummer van een alternatief als `tag=N` met N van 0 tot {_1}, zoals `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) onbekende OpenType-functie `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; zet er - voor om uit te schakelen; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom en aalt accepteren het nummer van een alternatief als `tag=N` met N van 0 tot {_1}, zoals `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) onbekende OpenType-functie `{t}` in featureChanges (de tags die `features` accepteert) @msg unknown PDF preset `{name}` (presets: {_1}) onbekende PDF-voorinstelling `{name}` (voorinstellingen: {_1}) @msg unknown Warp style onbekende warpstijl diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7e29ed89c5..bdaa04724a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1512,6 +1512,8 @@ lighting Light Światło Leading Interlinia No Break Bez podziału Proportional Metrics Metryka proporcjonalna + H or V Style Styl poziomy lub pionowy + Roman Italics Kursywa łacińska Insert Aki Left Wstaw aki z lewej Insert Aki Right Wstaw aki z prawej Tsume Tsume @@ -7750,7 +7752,7 @@ breakText Glyphs Glify @msg too many arguments zbyt wiele argumentów @msg too many quantifiers zbyt wiele kwantyfikatorów @msg turn on at least one side włącz co najmniej jedną stronę -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) nieznana funkcja OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefiks - wyłącza funkcję; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom i aalt przyjmują numer wariantu w postaci `tag=N`, gdzie N to liczba od 0 do {_1}, na przykład `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) nieznana funkcja OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefiks - wyłącza funkcję; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom i aalt przyjmują numer wariantu w postaci `tag=N`, gdzie N to liczba od 0 do {_1}, na przykład `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) nieznane rozpraszanie `{d}` (diffusion, pattern, noise lub none) @msg unknown panel `{panel}` nieznany panel `{panel}` @msg unknown parameter `{key}` nieznany parametr `{key}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b9d233f1c9..9c6684a111 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2234,6 +2234,8 @@ antiAlias Sharp Nítido Proof Setup Configuração de Prova Properties Propriedades Proportional Metrics Métrica Proporcional + H or V Style Estilo Horizontal ou Vertical + Roman Italics Itálico Romano Insert Aki Left Inserir Aki à Esquerda Insert Aki Right Inserir Aki à Direita Tsume Tsume @@ -9302,7 +9304,7 @@ swatch Yellow Green Verde-amarelado @msg unexpected resources recursos inesperados @msg unknown CMYK profile `{name}` perfil CMYK desconhecido `{name}` @msg unknown File Info field `{k}` campo de Informações do Arquivo desconhecido `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) recurso OpenType desconhecido `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefixe com - para desativar; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt aceitam o número de uma alternativa como `tag=N` com N de 0 a {_1}, como `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) recurso OpenType desconhecido `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefixe com - para desativar; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom e aalt aceitam o número de uma alternativa como `tag=N` com N de 0 a {_1}, como `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) recurso OpenType desconhecido `{t}` em featureChanges (as tags que `features` aceita) @msg unknown PDF preset `{name}` (presets: {_1}) predefinição de PDF desconhecida `{name}` (predefinições: {_1}) @msg unknown Warp style estilo de Deformação desconhecido diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5845c34c1d..f82e8d30ea 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2697,6 +2697,8 @@ mark-style Japanese Японский Proof Setup Настройка цветопробы Properties Свойства Proportional Metrics Пропорциональные метрики + H or V Style Горизонтальный или вертикальный стиль + Roman Italics Римский курсив Insert Aki Left Вставить аки слева Insert Aki Right Вставить аки справа Tsume Цумэ diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 9e37b924b0..e2d6a79a37 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2605,6 +2605,8 @@ font-technology Variable Variabla Proof Setup Provtrycksinställningar Properties Egenskaper Proportional Metrics Proportionella mått + H or V Style Vågrät eller lodrät stil + Roman Italics Romersk kursiv Insert Aki Left Infoga aki till vänster Insert Aki Right Infoga aki till höger Tsume Tsume diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e5278e58c6..1645ac98f4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2452,6 +2452,8 @@ shape Open Açık Proof Setup Prova Kurulumu Properties Özellikler Proportional Metrics Orantılı Metrikler + H or V Style Yatay veya Dikey Stil + Roman Italics Roma İtalik Insert Aki Left Sola Aki Ekle Insert Aki Right Sağa Aki Ekle Tsume Tsume diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 160d9a2c09..9cc6926367 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2702,6 +2702,8 @@ mark-style Japanese Японський Proof Setup Налаштування кольоропроби Properties Властивості Proportional Metrics Пропорційні метрики + H or V Style Горизонтальний або вертикальний стиль + Roman Italics Римський курсив Insert Aki Left Вставити акі ліворуч Insert Aki Right Вставити акі праворуч Tsume Цуме @@ -8715,7 +8717,7 @@ breakText Glyphs Гліфи @msg unexpected resources неочікувані ресурси @msg unknown CMYK profile `{name}` невідомий профіль CMYK `{name}` @msg unknown File Info field `{k}` невідоме поле відомостей про файл `{k}` -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) невідома функція OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; префікс - вимикає функцію; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom і aalt приймають номер альтернативи у вигляді `tag=N`, де N від 0 до {_1}, наприклад `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) невідома функція OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; префікс - вимикає функцію; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom і aalt приймають номер альтернативи у вигляді `tag=N`, де N від 0 до {_1}, наприклад `salt=2`) @msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) невідома функція OpenType `{t}` у featureChanges (допустимі теги, які приймає `features`) @msg unknown PDF preset `{name}` (presets: {_1}) невідомий набір PDF `{name}` (набори: {_1}) @msg unknown Warp style невідомий стиль деформації diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 45312793f3..849fc53720 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3015,6 +3015,8 @@ mark-style Japanese Kiểu Nhật Proof Setup Thiết lập thử nghiệm Properties Thuộc tính Proportional Metrics Số liệu theo tỷ lệ + H or V Style Kiểu ngang hoặc dọc + Roman Italics Nghiêng Roman Insert Aki Left Chèn aki bên trái Insert Aki Right Chèn aki bên phải Tsume Tsume diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0b3c38c9ca..123b2520b1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2565,6 +2565,8 @@ font-script Japanese 汉字 Proof Setup 校样设置 Properties 属性 Proportional Metrics 比例度量 + H or V Style 横排或直排样式 + Roman Italics 西文斜体 Insert Aki Left 在前方插入空白 Insert Aki Right 在后方插入空白 Tsume 比例间距 @@ -7731,7 +7733,7 @@ breakText Glyphs 字形 @msg too many arguments 参数过多 @msg too many quantifiers 量词过多 @msg turn on at least one side 请至少打开一条边 -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 特性 `{t}` (liga、calt、dlig、smcp、frac、onum、tnum、ordn、swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom、aalt;加前缀 - 表示关闭;swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom 和 aalt 可用 `tag=N` 指定替代字形的编号,N 为 0 到 {_1},例如 `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 特性 `{t}` (liga、calt、dlig、smcp、frac、onum、tnum、ordn、swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom、aalt、hkna、ital;加前缀 - 表示关闭;swsh、ss01–ss20、salt、titl、pnum、lnum、sups、subs、numr、dnom 和 aalt 可用 `tag=N` 指定替代字形的编号,N 为 0 到 {_1},例如 `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) 未知的仿色 `{d}` (diffusion、pattern、noise 或 none) @msg unknown panel `{panel}` 未知的面板 `{panel}` @msg unknown parameter `{key}` 未知的参数 `{key}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 4c8b7f2d3e..55b2f30f92 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3784,6 +3784,8 @@ font-script Japanese 漢字 Proof Setup 校樣設定 Properties 內容 Proportional Metrics 比例度量 + H or V Style 橫排或直排樣式 + Roman Italics 羅馬字斜體 Insert Aki Left 在前方插入空白 Insert Aki Right 在後方插入空白 Tsume 比例間距 @@ -7659,7 +7661,7 @@ breakText Glyphs 字符 @msg too many arguments 引數過多 @msg too many quantifiers 量詞過多 @msg turn on at least one side 請至少開啟一側 -@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 功能 `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt;加上前置的 - 可關閉;swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom 和 aalt 可用 `tag=N` 指定替代字的編號,N 為 0 到 {_1},例如 `salt=2`) +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) 未知的 OpenType 功能 `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital;加上前置的 - 可關閉;swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom 和 aalt 可用 `tag=N` 指定替代字的編號,N 為 0 到 {_1},例如 `salt=2`) @msg unknown dither `{d}` (diffusion, pattern, noise or none) 未知的仿色 `{d}` (diffusion、pattern、noise 或 none) @msg unknown panel `{panel}` 未知的面板 `{panel}` @msg unknown parameter `{key}` 未知的參數 `{key}` diff --git a/crates/ui-egui/src/panels/opentype.rs b/crates/ui-egui/src/panels/opentype.rs index 5718aea0ab..4d50fd5dad 100644 --- a/crates/ui-egui/src/panels/opentype.rs +++ b/crates/ui-egui/src/panels/opentype.rs @@ -1,6 +1,7 @@ //! OpenType panel: per-character OpenType features (ligatures, alternates, swashes, small caps, -//! ordinals, fractions, figure style, position) for the selected text (every frame of each -//! selected threaded story) or the Type tool's selected characters. +//! ordinals, fractions, figure style, position, and with the East Asian options the menu's H or V +//! Style and Roman Italics) for the selected text (every frame of each selected threaded story) or +//! the Type tool's selected characters. use egui::Ui; use serde_json::json; @@ -294,10 +295,29 @@ fn rows() -> [Toggle; 9] { ] } +/// The panel menu's East Asian items, with Show East Asian Options: label and the feature each +/// turns on and off. H or V Style sets kana in the forms the font draws for the writing direction +/// (`hkna`, shaped as `vkna` in vertical type), and Roman Italics sets half-width alphanumerics +/// in the font's italic forms (`ital`). +fn east_asian_items() -> [Toggle; 2] { + [(tl!("H or V Style"), Flag::Extra("hkna")), (tl!("Roman Italics"), Flag::Extra("ital"))] +} + pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - if menu_item(ui, tl!("Reset OpenType Features"), text_style(app).is_some(), false) { + let has = text_style(app).is_some(); + if menu_item(ui, tl!("Reset OpenType Features"), has, false) { set(app, OtFeatures::default()); } + if app.session.prefs.show_east_asian_options { + ui.separator(); + // Checked when every character the panel acts on has it; chosen then, it turns it off. + for (label, flag) in east_asian_items() { + let on = shown_flag(app, flag) == Some(true); + if menu_item(ui, label, has, on) { + edit(app, Change::Toggle(flag, !on)); + } + } + } } #[cfg(test)] @@ -665,4 +685,58 @@ mod tests { edit(&mut app, Change::Position(4)); assert_eq!(undo_steps(&app), undo + 1); } + + /// H or V Style and Roman Italics are in the panel menu with the East Asian options only. + #[test] + fn kana_style_and_roman_italics_show_with_the_east_asian_options() { + let mut app = selected_text(); + let shown = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, menu); + (text.contains("H or V Style"), text.contains("Roman Italics")) + }; + assert_eq!(shown(&mut app), (false, false)); + app.session.prefs.show_east_asian_options = true; + assert_eq!(shown(&mut app), (true, true)); + } + + /// With the Type tool's characters only partly set, H or V Style and Roman Italics show + /// unchecked; chosen, each turns its feature on for every selected character in one undo step, + /// each run keeping its other features, and shows checked; chosen again, it turns it off. + /// Reset OpenType Features clears both. + #[test] + fn kana_style_and_roman_italics_turn_on_and_off() { + for (label, tag) in [("H or V Style", "hkna"), ("Roman Italics", "ital")] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.prefs.show_east_asian_options = true; + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "かなAB"})).unwrap()["id"].clone(); + app.session.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 3, "features": [tag, "smcp"]})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // "かなAB" is 8 bytes. + app.session.set_tool_option("select", &json!({"start": 0, "end": 8})); + let ctx = egui::Context::default(); + let row = |app: &mut VectorcraftApp, label: &str| { + let texts = super::super::tests_appearance::frame_events(&ctx, app, vec![], menu); + texts.into_iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == label).unwrap_or_else(|| panic!("{label} missing")) + }; + let has = |runs: &[(String, Value)], t: &str| runs.iter().all(|(_, f)| f.as_array().is_some_and(|f| f.iter().any(|x| x == t))); + let (text, at) = row(&mut app, label); + assert!(!text.starts_with('✓'), "{label}: {text:?}"); + let undo = undo_steps(&app); + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(undo_steps(&app), undo + 1, "{label}"); + let runs = run_features(&mut app, &id, 8); + assert!(has(&runs, tag), "{label}: {runs:?}"); + assert!(has(&runs[..1], "smcp") && runs[0].0 == "か", "{label} keeps the other features: {runs:?}"); + let (text, at) = row(&mut app, label); + assert!(text.starts_with('✓'), "{label}: {text:?}"); + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(run_features(&mut app, &id, 8), [("か".to_string(), json!(["smcp"])), ("なAB".to_string(), Value::Null)], "{label} off"); + app.session.execute("text.setRangeStyle", &json!({"id": id, "features": ["hkna", "ital", "dlig"]})).unwrap(); + let at = row(&mut app, "Reset OpenType Features").1; + super::super::tests_appearance::click(&ctx, &mut app, at.center(), menu); + assert_eq!(run_features(&mut app, &id, 8), [("かなAB".to_string(), Value::Null)], "{label}: reset"); + } + } } diff --git a/docs/mcp.md b/docs/mcp.md index fc03d070b6..eb7109eafe 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9386,6 +9386,24 @@ not kerned). Off by default and saved only when on. One undo step. {"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}} ``` +## H or V Style and Roman Italics + +With Show East Asian Options on, the OpenType panel menu has H or V Style and Roman Italics +([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)). They are the character +feature tags `hkna` and `ital` of `text.setStyle` and `text.setRangeStyle {features, featureChanges}` +([feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)). `hkna` draws kana with the +forms the font makes for horizontal type, and in vertical type the shaper applies `vkna`, the forms for vertical type, +in its place. `ital` draws half-width alphanumerics with the font's italic forms. A font without the feature draws the +characters unchanged. Both are on or off only: `hkna=2` and `ital=0` are refused as unknown features, and `vkna` is +not a tag of its own. A menu item is checked when every character the panel acts on has it; choosing it turns the +feature on for all of them, or off when it is checked, each character keeping its other features, in one undo step. +Reset OpenType Features turns both off. Character styles carry them, and Character Style Options › OpenType Features +has an on, off or unset switch for each. Live SVG text writes them in `font-feature-settings`. + +```json +{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":6,"featureChanges":["hkna","ital"]}}} +``` + ## Variable fonts `text.setFormat {fontAxes: {"wght": 650}}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0a1f7c81b8..044964f0fc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1371,6 +1371,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`. With a Type tool and exactly one character selected, the canvas shows that character's alternate glyphs in a row beside it, and a click on one replaces the character's glyph, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes ("Replace a character with on-canvas alternate glyphs"). The row shows at most five alternates, and an arrow at its end opens the Glyphs panel on Alternates for Current Selection when the character has more, as the [reference manual](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479) describes. A click runs the command the panel's double-click runs, as one undo step, so a letter of an Arabic word keeps the word joined. These rules are VectorCraft's own: the row leaves out the glyph the character is drawn with and puts the default glyph first for a character drawn with an alternate; it stands under a character of horizontal type and on the right of a character of vertical type, or on the other side when the canvas ends there or the Contextual Task Bar is there; Preferences › Type › Show Character Alternates (`showCharacterAlternates`, on by default) turns it off. Tests: `canvas::alternates_bar::tests`, `tests::prefs::show_character_alternates_preference_is_on_by_default_and_saved`. The OpenType panel's Stylistic Sets menu offers `ss01`–`ss20` sets present in the selected text's font, using the font's custom labels when available and numbered labels otherwise. Sets can be combined and deselected individually. With mixed fonts, the menu shows the first selected character's font's sets and applies the feature tag to all selected text; a font without that set draws unchanged. The mixed-font rule is a VectorCraft design choice. Behavior reference: [Add stylistic sets to selected text](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html) and the [OpenType specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt). + With Show East Asian Options on, the OpenType panel menu has H or V Style and Roman Italics, as [Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) describes. H or V Style sets the feature tag `hkna`, which draws kana with the forms the font makes for horizontal type, and with `vkna` for vertical type in vertical type; Roman Italics sets `ital`, the font's italic forms of half-width alphanumerics ([OpenType feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)). Each is checked when every selected character has it and turns it on or off for all of them in one undo step; character styles carry both in OpenType Features. Tests: `panels::opentype::tests::kana_style_and_roman_italics_turn_on_and_off`, `features::tests::kana_style_and_roman_italics_are_kept_and_shaped`. `text.setStyle {stroke, strokeWidth}` sets the character stroke's color and weight of the selected type or of a range of its characters (the weight in document points, as `stroke.set` sets it), in the same undo step as the command's other changes. Tests: `tests::textedit::set_style_sets_the_character_stroke`. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). diff --git a/docs/type-parity.md b/docs/type-parity.md index 72b8bcfc9d..924b59065c 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -34,6 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet | | Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | +| OpenType panel menu: H or V Style, Roman Italics | done | With Show East Asian Options, as [Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) describes: H or V Style is the character feature `hkna` (kana in the font's horizontal forms, shaped as `vkna` in vertical type) and Roman Italics is `ital` (the font's italic half-width alphanumerics), set with `text.setStyle` / `text.setRangeStyle {features, featureChanges}`, on or off only, and in Character Style Options › OpenType Features ([OpenType feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)). | | Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972); wherever the font menu opens, its filters keep the same four rows, and it opens at least wide enough for them and for a family name up to 220 points wide with its sample (up to 60% of the window), each sample filling its row's height (a VectorCraft layout); a filter chosen in the font menu of the Character panel, the Control bar or the Properties panel is on in the others, and the filters are cleared when the app quits ([Font browser overview](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/font-options-overview.html)); the font menus of the Glyphs panel and of the dialogs that pick a font share the same filters (a VectorCraft choice). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | From bc2b5dbae61dfe00eea08a5f79266997cc9d9d25 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 15:37:51 -0800 Subject: [PATCH 27/40] .ai save: graph round trips leave out the art bounds the reader measures Opening an .ai graph now sets placed_art from the art as read (main's d3789f2b0c), so the comparison in the graph round-trip tests leaves it out, as it already leaves out placed. --- crates/eps/src/ai_graph.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/eps/src/ai_graph.rs b/crates/eps/src/ai_graph.rs index 4af8f7e3e9..3e1cebd9f9 100644 --- a/crates/eps/src/ai_graph.rs +++ b/crates/eps/src/ai_graph.rs @@ -592,6 +592,7 @@ pub(crate) mod sample { blanks: vec![], series_paints: vec![], placed: None, + placed_art: None, series_kinds: vec![], ..g.clone() }; From 094971e3aca1d5f594f6238bb82ad0ac1d79ea68 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:20:34 -0800 Subject: [PATCH 28/40] Type on a path: type on an open path can start before the path Type on an open path can now start before the path's start: its start is stored below 0, the span runs from there, and the characters whose middle falls before the path's start are not drawn, as SVG 2 lays out text on a path (https://www.w3.org/TR/SVG2/text.html#TextLayoutPath). The text keeps every character, and the start bracket stays at the path's start. Past the path's end, text that does not fit is overflow, as for other type on a path. Type that starts on the path draws every glyph as before. - SVG import keeps a start before an open path's start (text-anchor end or middle, or a negative startOffset) instead of starting the text at the path's start, which drew every character. - SVG export writes it as a negative startOffset. - PDF export writes only the characters drawn on the path. - The legacy .ai and .ai editing data writers write the type from the path's start with the type attributes warning. - type.pathOptions takes a start below 0 for type on an open path; on a closed path it is the path's start, as before. A flip starts the type at its start bracket. Dragging the center bracket slides it from where it starts, and no further before the path. - A click at the path's start places the caret by the first drawn character, and the caret in the hidden characters stands at the path's start. The highlight of substituted fonts and glyphs leaves them out. - Release Type on a Path keeps the first drawn glyph where it was drawn, with the hidden characters before it. - The Touch Type tool shows no box for a hidden character, and text.touchType returns an error for one. A character with a drawn glyph is changed by that glyph. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/lib.rs | 1 + crates/doc/src/text.rs | 1 + crates/doc/src/text/object.rs | 5 +- crates/doc/src/text/runs.rs | 20 ++++- crates/doc/src/text/tests.rs | 27 +++++++ crates/engine/src/cmd/addons/typetools.rs | 40 ++++++++-- .../engine/src/cmd/addons/typetools/tests.rs | 38 +++++++++ crates/engine/src/cmd/textedit.rs | 27 +++++-- crates/engine/src/cmd/typemenu.rs | 23 ++++-- crates/engine/src/tests/pathtype.rs | 26 ++++++ crates/engine/src/tests/textedit.rs | 38 +++++++++ crates/eps/src/ai_editing/tests.rs | 10 +++ crates/eps/src/ai_editing/writer/texts.rs | 8 +- crates/eps/src/legacy_ai/encoding.rs | 6 +- crates/eps/src/legacy_ai/tests.rs | 16 ++++ crates/pdf/src/export/text.rs | 3 +- crates/pdf/src/tests/realtext.rs | 20 +++++ crates/render/src/tests/fontchange.rs | 18 +++++ crates/render/src/text_geom.rs | 3 +- crates/svg/src/export/text.rs | 3 + crates/svg/src/import/text.rs | 16 +++- crates/svg/tests/it/import_text.rs | 54 ++++++++++++- crates/svg/tests/it/roundtrip.rs | 25 ++++++ crates/text/src/layout.rs | 1 + crates/text/src/layout/on_path.rs | 40 +++++++++- crates/text/src/layout/ruby.rs | 1 + crates/text/src/lib.rs | 16 +++- crates/text/src/tests.rs | 79 +++++++++++++++++++ crates/text/src/tests/bidi.rs | 1 + crates/text/src/tests/harakat.rs | 1 + crates/tools/src/pathtype.rs | 43 +++++++++- crates/tools/src/touchtype.rs | 13 +-- crates/tools/src/touchtype/tests.rs | 54 +++++++++++++ docs/mcp.md | 27 +++++-- docs/target-app-parity.md | 3 +- 35 files changed, 652 insertions(+), 55 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d6b797a371..b32a4485f4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -120,6 +120,7 @@ pub use style_libs::StyleLibrary; pub use text::FontHeightReference; pub use text::GlyphChoice; pub use text::GlyphForm; +pub use text::PATH_LEAD_MAX; pub use text::WarichuAlign; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 89388d8598..06b71f8fc8 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -29,6 +29,7 @@ pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, Mojikumi, ParaDirection, ParaStyle, SingleWordJustify, }; +pub use runs::PATH_LEAD_MAX; pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias}; pub use tabs_lists::{ Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign, diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs index 9af4d9c5dc..b787cebfe0 100644 --- a/crates/doc/src/text/object.rs +++ b/crates/doc/src/text/object.rs @@ -352,7 +352,10 @@ impl TextObject { } /// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its /// centre bracket dragged across the path): the path runs the other way and the brackets swap - /// ends, so the type keeps its stretch of the path. False, changing nothing, for other type. + /// ends, so the type keeps its stretch of the path. Type that starts before an open path's start + /// ([`TextKind::path_text_start`]) starts at its start bracket after the flip: the part of its + /// span before the path's start would come after the path's end. False, changing nothing, for + /// other type. pub fn flip_on_path(&mut self) -> bool { let Some((s, e)) = self.kind.path_span() else { return false }; let TextKind::OnPath { path, start, end } = &mut self.kind else { return false }; diff --git a/crates/doc/src/text/runs.rs b/crates/doc/src/text/runs.rs index 0bf62ed155..5bf6d1bf79 100644 --- a/crates/doc/src/text/runs.rs +++ b/crates/doc/src/text/runs.rs @@ -93,8 +93,10 @@ pub enum TextKind { /// Area type flowed inside `frame` (document coordinates, untransformed by `xf`). Area { frame: PathData }, /// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the - /// path length; no `end`: the end of the path, or once round a closed path). See - /// [`TextKind::path_span`]. + /// path length; no `end`: the end of the path, or once round a closed path). On an open path + /// `start` may be below 0: the type starts that far before the path's start, and the characters + /// whose middle falls before the path's start are not drawn ([`TextKind::path_text_start`]). + /// See [`TextKind::path_span`]. OnPath { path: PathData, start: f64, @@ -117,8 +119,22 @@ impl TextKind { (s, end.map_or(1.0, |e| fraction(e).max(s))) }) } + /// Where type on a path's text starts, as a fraction of its path's length: its start bracket + /// ([`Self::path_span`]), or on an open path a start before the path's start (below 0, at most + /// [`PATH_LEAD_MAX`] path lengths before it). None for other type. + pub fn path_text_start(&self) -> Option { + let TextKind::OnPath { path, start, .. } = self else { return None }; + if !path.is_closed() && start.is_finite() && *start < 0.0 { Some(start.max(-PATH_LEAD_MAX)) } else { self.path_span().map(|(s, _)| s) } + } + /// Does type on an open path start before its path's start ([`Self::path_text_start`])? + pub fn starts_before_path(&self) -> bool { + self.path_text_start().is_some_and(|s| s < 0.0) + } } +/// How far before its path's start type on an open path may start, in lengths of the path. +pub const PATH_LEAD_MAX: f64 = 1e6; + /// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the /// document → text map). False when an anchor doesn't exist. pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool { diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs index c3afafb6e1..0976edbb93 100644 --- a/crates/doc/src/text/tests.rs +++ b/crates/doc/src/text/tests.rs @@ -563,6 +563,33 @@ fn area_anchors_move_in_document_space() { assert_eq!(t, before); } +/// Type on an open path may start before the path's start: the start is kept, saved and read back, +/// while the start bracket stays at the path's start. On a closed path, and for a start that is not +/// finite, the start is the start bracket as before. +#[test] +fn type_on_an_open_path_starts_before_the_path() { + let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)]; + let on = |closed: bool, start: f64| TextKind::OnPath { + path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, closed)]), + start, + end: None, + }; + let open = on(false, -0.25); + assert_eq!(open.path_text_start(), Some(-0.25)); + assert!(open.starts_before_path()); + assert_eq!(open.path_span(), Some((0.0, 1.0))); + let saved = serde_json::to_value(&open).unwrap(); + assert_eq!(saved["start"], -0.25); + assert_eq!(serde_json::from_value::(saved).unwrap(), open); + assert_eq!(on(false, -1e9).path_text_start(), Some(-PATH_LEAD_MAX)); + assert_eq!(on(false, f64::NEG_INFINITY).path_text_start(), Some(0.0)); + assert_eq!(on(false, 0.25).path_text_start(), Some(0.25)); + assert!(!on(false, 0.0).starts_before_path()); + assert_eq!(on(true, -0.25).path_text_start(), Some(0.0)); + assert!(!on(true, -0.25).starts_before_path()); + assert_eq!(TextKind::Point.path_text_start(), None); +} + /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore /// `justify_auto`); this build reads Auto back. #[test] diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 10a00b1b45..d0c5447c42 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Release Type on a Path…", ["Object", "Plug-ins", "Type Tools"], None, - "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path (when the type starts before an open path's start, the first character drawn on the path stays where it was drawn and the characters that were not drawn come before it); Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", has_selection, release_path ), @@ -229,6 +229,28 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { lay.glyphs.iter().filter(|g| g.origin.x.is_finite() && g.origin.y.is_finite()).min_by_key(|g| (g.byte, g.len != 0)).map(|g| g.origin) } +/// A glyph of a character: its byte, whether it stands for characters (a list marker does not), +/// and its place among the glyphs of that byte and kind (an All Caps ß is two glyphs). +type GlyphKey = (usize, bool, usize); + +/// The key and pen position of the first glyph drawn in `lay`, in text order: characters before an +/// open path's start are not drawn ([`vectorcraft_text::PositionedGlyph::off_path`]). None without +/// a drawn glyph. +fn first_drawn_pen(lay: &vectorcraft_text::TextLayout) -> Option<(GlyphKey, Point)> { + let (gi, g) = (lay.glyphs.iter().enumerate()) + .filter(|(_, g)| !g.off_path && g.origin.x.is_finite() && g.origin.y.is_finite()) + .min_by_key(|(gi, g)| (g.byte, g.len != 0, *gi))?; + let kind = g.len != 0; + let nth = lay.glyphs.iter().take(gi).filter(|h| h.byte == g.byte && (h.len != 0) == kind).count(); + Some(((g.byte, kind, nth), g.origin)) +} + +/// The pen position of the glyph `key` names in `lay`. None without one. +fn pen_at(lay: &vectorcraft_text::TextLayout, (byte, kind, nth): GlyphKey) -> Option { + let g = lay.glyphs.iter().filter(|g| g.byte == byte && (g.len != 0) == kind).nth(nth)?; + (g.origin.x.is_finite() && g.origin.y.is_finite()).then_some(g.origin) +} + /// `t`, type on a path, as point type on one straight line: the same runs and character and /// paragraph attributes, its baseline starting where its first character sat on the path (in the /// object's own text space, so its transform stays). Type on a Path Options are dropped, and @@ -237,13 +259,14 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { fn released_text(t: &TextObject) -> Option { let TextKind::OnPath { path, .. } = &t.kind else { return None }; let db = vectorcraft_text::FontDb::global(); - // Where the first character sat. Text that didn't fit on the path has no glyph there: it - // starts at the start bracket. - let at = first_pen(&vectorcraft_text::layout(db, t)).or_else(|| { + // Where the first drawn character sat; characters before an open path's start come before it + // on the line. Text that didn't fit on the path has no glyph there: it starts at the start + // bracket. + let (key, at) = first_drawn_pen(&vectorcraft_text::layout(db, t)).map(|(b, p)| (Some(b), p)).or_else(|| { let ap = ArcPath::new(path); let (from, _) = t.kind.path_span()?; let (p, _) = ap.at(from * ap.len()); - (p.x.is_finite() && p.y.is_finite()).then_some(p) + (p.x.is_finite() && p.y.is_finite()).then_some((None, p)) })?; let mut out = t.clone(); // Point type has no brackets to fit: it keeps the sizes the path drew it at. @@ -254,9 +277,10 @@ fn released_text(t: &TextObject) -> Option { out.path_spacing = 0.0; // Point type is in no thread: its first paragraph starts in it. out.first_para_continues = false; - // Where the point type puts its own first character; the origin moves so it lands at `at`, - // whatever the paragraph's alignment and direction. - let own = first_pen(&vectorcraft_text::layout(db, &out)).unwrap_or(Point::ZERO); + // Where the point type puts that glyph; the origin moves so it lands at `at`, whatever the + // paragraph's alignment and direction. + let own_lay = vectorcraft_text::layout(db, &out); + let own = key.and_then(|k| pen_at(&own_lay, k)).or_else(|| first_pen(&own_lay)).unwrap_or(Point::ZERO); let shift = at - own; if !(shift.x.is_finite() && shift.y.is_finite()) { return None; diff --git a/crates/engine/src/cmd/addons/typetools/tests.rs b/crates/engine/src/cmd/addons/typetools/tests.rs index e597525c97..a8518719be 100644 --- a/crates/engine/src/cmd/addons/typetools/tests.rs +++ b/crates/engine/src/cmd/addons/typetools/tests.rs @@ -482,6 +482,44 @@ mod release { } } + /// Type that starts before its open path: the first drawn character stays where it was drawn, + /// and the characters that were not drawn come before it on the line. + #[test] + fn type_starting_before_its_open_path_keeps_its_drawn_characters_in_place() { + let mut s = session(); + let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Hidden start"); + s.execute("type.pathOptions", &json!({"start": -0.05})).unwrap(); + let before = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before); + let first = lay.glyphs.iter().filter(|g| !g.off_path).min_by_key(|g| g.byte).unwrap(); + let (byte, drawn) = (first.byte, before.xf * first.origin); + assert!(byte > 0, "some characters are before the path"); + s.execute(C, &json!({"mode": "text"})).unwrap(); + let after = text(&s, id); + assert_eq!(after.kind, TextKind::Point); + assert_eq!(after.plain_text(), before.plain_text()); + let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after); + let same = back.glyphs.iter().find(|g| g.byte == byte).unwrap(); + assert!(close(after.xf * same.origin, drawn), "{:?} vs {drawn:?}", after.xf * same.origin); + let lead = back.glyphs.iter().find(|g| g.byte == 0).unwrap(); + assert!((after.xf * lead.origin).x < drawn.x, "the hidden characters come before it"); + // An All Caps ß is two glyphs: with the first before the path's start, the second stays + // where it was drawn. + let id = path_type(&mut s, "M100 400 L500 400", [100.0, 400.0], "ße"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).glyphs[0].advance; + s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap(); + let before = text(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before); + assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path); + let drawn = before.xf * lay.glyphs[1].origin; + s.execute(C, &json!({"mode": "text"})).unwrap(); + let after = text(&s, id); + let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after); + let second = back.glyphs.iter().filter(|g| g.byte == 0).nth(1).unwrap(); + assert!(close(after.xf * second.origin, drawn), "{:?} vs {drawn:?}", after.xf * second.origin); + } + #[test] fn flipped_type_releases_its_path_as_it_runs() { let mut s = session(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index ee843df8dd..43fd1a0dc5 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -49,7 +49,7 @@ pub fn specs() -> Vec { "Touch Type", [], None, - "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)", + "{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width. An error for a character of type on an open path that is not drawn because it falls before the path's start → {id, start, end} (the character's bytes)", has_doc, touch_type ), @@ -1550,8 +1550,21 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { change.localize(&t, C)?; let points = t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points; let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - let g = lay.glyphs.iter().find(|g| g.byte <= start && start < g.byte + g.len).ok_or_else(|| bad(C, format!("no character at byte {start}")))?; + let holds = |g: &&vectorcraft_text::PositionedGlyph| g.byte <= start && start < g.byte + g.len; + lay.glyphs.iter().find(holds).ok_or_else(|| bad(C, format!("no character at byte {start}")))?; + // A character before an open path's start is not drawn; one whose glyphs straddle the start + // is changed by its first drawn glyph. + let g = lay + .glyphs + .iter() + .filter(holds) + .find(|g| !g.off_path) + .ok_or_else(|| bad(C, format!("the character at byte {start} is not drawn: it falls before its path's start")))?; let (a, b) = (g.byte, g.byte + g.len); + // The same glyph of the character in another layout of the type: its place among the glyphs + // of the character. + let nth = lay.glyphs.iter().filter(|h| h.byte == a && h.len > 0).position(|h| std::ptr::eq(h, g)).unwrap_or(0); + let same = |l: &vectorcraft_text::TextLayout| l.glyphs.iter().filter(|h| h.byte == a && h.len > 0).nth(nth).cloned(); // The line's direction in reading order. let d = Vec2::new(g.angle.cos(), g.angle.sin()); let along = if g.rtl { -d } else { d }; @@ -1623,7 +1636,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // rises, which would make its horizontal drift count as baseline movement. touch(&mut trial, pr.map(|_| 0.0), Some(0.0), to - shift); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO) + same(&l).map(|h| h.xf * Point::ZERO) }; let (per_step, across) = match (pen_at(from + step), pen_at(from + 2.0 * step)) { (Some(p), Some(q)) => (linear(t.xf, q - p).hypot() / step.abs(), (q - p).dot(up) / step), @@ -1653,7 +1666,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { touch(&mut trial, None, None, 0.0); let l = vectorcraft_text::layout(db, &trial); let intrinsic = |h: &vectorcraft_text::PositionedGlyph| h.xf * Point::ZERO - h.xf * h.pivot; - l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map_or(Vec2::ZERO, |h| intrinsic(h) - intrinsic(g)) + same(&l).map_or(Vec2::ZERO, |h| intrinsic(&h) - intrinsic(g)) } else { Vec2::ZERO }; @@ -1681,7 +1694,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); let l = vectorcraft_text::layout(db, &trial); - let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0).ok_or_else(|| bad(C, "the character's size can't be measured"))?; + let h = same(&l).ok_or_else(|| bad(C, "the character's size can't be measured"))?; let across_goal = (g.xf * Point::ZERO).to_vec2().dot(up) + dy / doc_len(up) + intrinsic_delta.dot(up); raise += (across_goal - (h.xf * Point::ZERO).to_vec2().dot(up)) / baseline_step; if !raise.is_finite() { @@ -1695,7 +1708,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0)?; + let h = same(&l)?; let next = match (l.glyphs.iter().find(|n| n.byte == b && n.len > 0 && n.line == h.line), next_goal) { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, @@ -1704,7 +1717,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, }; - Some([pen(h) - goal, next, previous]) + Some([pen(&h) - goal, next, previous]) }; let placed = |m: [f64; 3]| m.iter().all(|v| v.abs() <= 1e-9); // A manual kerning value turns off the font's kerning for its character, each style run is diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index d793fc12a3..01615e5f4f 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Type on a Path Options…", ["Type", "Type on a Path"], None, - "{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", + "{ids?|id?, start?: -1000000..1 (below 0 only on an open path), end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path); on an open path a start below 0 starts the text that far before the path's start, and the characters whose middle falls before it are not drawn (on a closed path it is the path's start), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}", has_selection, path_options ), @@ -1018,7 +1018,18 @@ fn path_options(s: &mut Session, p: &Value) -> Result { Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))), } }; - let start = fraction("start")?; + // On an open path the text may start before the path's start (below 0, see + // `TextKind::path_text_start`); on a closed path a start below 0 is the path's start. + let start = match p.get("start") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_f64() + .filter(|x| x.is_finite()) + .map(|x| x.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0)) + .ok_or_else(|| bad(C, "`start` is a number up to 1 (below 0: before an open path's start)"))?, + ), + }; + let start_on = |path: &vectorcraft_geom::PathData, v: f64| if path.is_closed() { v.max(0.0) } else { v }; // `end: null` puts the end bracket back at the end of the path. let end = p.get("end").map(|_| fraction("end")).transpose()?; let flip = switch_or(p, "flip", false, C)?; @@ -1037,8 +1048,8 @@ fn path_options(s: &mut Session, p: &Value) -> Result { // Without a flip, when every object already has every value given, the document and its undo // history stay as they are. let has_values = |t: &TextObject| match &t.kind { - TextKind::OnPath { start: st, end: en, .. } => { - start.is_none_or(|v| v == *st) + TextKind::OnPath { path, start: st, end: en } => { + start.is_none_or(|v| start_on(path, v) == *st) && end.is_none_or(|v| v == *en) && effect.is_none_or(|e| t.path_effect == e) && align.is_none_or(|a| t.path_align == a) @@ -1053,9 +1064,9 @@ fn path_options(s: &mut Session, p: &Value) -> Result { s.edit("Type on a Path Options", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; - let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue }; + let TextKind::OnPath { path, start: st, end: en } = &mut t.kind else { continue }; if let Some(v) = start { - *st = v; + *st = start_on(path, v); } if let Some(v) = end { *en = v; diff --git a/crates/engine/src/tests/pathtype.rs b/crates/engine/src/tests/pathtype.rs index 8aa8c05684..5ceba16c10 100644 --- a/crates/engine/src/tests/pathtype.rs +++ b/crates/engine/src/tests/pathtype.rs @@ -127,6 +127,32 @@ fn options_keep_the_brackets() { } } +/// `start` below 0 starts type on an open path before the path's start, one undo step; on a closed +/// path it is the path's start, and a start that is not a number is an error. +#[test] +fn options_start_type_before_an_open_path() { + let mut s = session(); + let id = on_line(&mut s); + let n = undo_len(&s); + let r = s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap(); + assert_eq!(brackets(&s, id), (-0.25, None)); + assert!(near(r["start"].as_f64().unwrap(), -0.25), "{r}"); + assert_eq!(undo_len(&s), n + 1); + // The same start again records nothing. + s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + // 100 pt before a 400 pt line: the 12 pt word is drawn nowhere. + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)); + assert!(!laid.glyphs.is_empty() && laid.glyphs.iter().all(|g| g.outline.elements().is_empty())); + assert!(s.execute("type.pathOptions", &json!({"start": "-0.5"})).is_err()); + // A flip keeps the stretch from the path's start: the type then starts at the start bracket. + s.execute("type.pathOptions", &json!({"flip": true})).unwrap(); + assert_eq!(brackets(&s, id), (0.0, None)); + let ring = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring"); + s.execute("type.pathOptions", &json!({"ids": [ring.0], "start": -0.25})).unwrap(); + assert_eq!(brackets(&s, ring).0, 0.0); +} + /// Type on a Path Options given the values the type has (OK in the dialog with nothing changed, the /// effect it has from the Type on a Path menu, the end bracket at the end of the path, or the /// query's own reply) records no undo step: the step to redo stays and the document stays diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 9dc35ff99b..62587f4824 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -724,6 +724,44 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() { assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there"); } +/// Touch Type does not change a character of type on an open path that is not drawn because it +/// falls before the path's start; a drawn character of the same type still changes. +#[test] +fn touch_type_refuses_a_character_before_an_open_paths_start() { + let mut s = session(); + let pid = s.execute("path.create", &json!({"d": "M100 300 L500 300"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "Hidden", "size": 12, "at": [100, 300]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("type.pathOptions", &json!({"start": -0.02})).unwrap(); + let steps = undo_labels(&s).len(); + assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).is_err(), "the H is not drawn"); + assert_eq!(undo_labels(&s).len(), steps); + s.execute("text.touchType", &json!({"id": id.0, "start": 5, "dy": 2})).unwrap(); + // An All Caps ß is two glyphs: with the first before the path's start and the second drawn, it + // still changes. + let pid = s.execute("path.create", &json!({"d": "M100 400 L500 400"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "ße", "size": 40, "at": [100, 400]})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)).glyphs[0].advance; + s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path); + let drawn = |s: &Session| { + let t = obj(s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let g = lay.glyphs.iter().rfind(|g| g.byte == 0 && !g.off_path).unwrap(); + t.xf * (g.xf * Point::ZERO) + }; + let before = drawn(&s); + s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).unwrap(); + // The drawn glyph rises 2 points. + let after = drawn(&s); + assert!((after.x - before.x).abs() < 1e-6 && (after.y - (before.y - 2.0)).abs() < 1e-6, "{before:?} -> {after:?}"); +} + /// Touch Type on the first character of a list item moves that character and leaves the bullet /// where it is. The bullet stands for no character, so the first character still moves only up /// and down. diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a01f631638..fb7fcd606b 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -5128,6 +5128,16 @@ fn a_first_baseline_minimum_and_a_path_end_the_reader_cant_read_are_reported() { }; assert_eq!(with(on_path(Some(0.999))).0, [writer::TYPE_ATTRIBUTES]); assert!(with(on_path(None)).0.is_empty()); + // Type that starts before its open path comes back starting at the path's start. + let mut before = on_path(None); + if let TextKind::OnPath { start, .. } = &mut before.kind { + *start = -0.25; + } + let (warnings, back) = with(before); + assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; + let TextKind::OnPath { start, .. } = t.kind else { panic!("type on a path") }; + assert!(start.abs() < 1e-3, "{start}"); let (warnings, back) = with(on_path(Some(0.5))); assert!(warnings.is_empty(), "{warnings:?}"); let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 2e90817b98..8503500007 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -440,7 +440,13 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { // The text document holds the Ascent first baseline by leaving it out, without a minimum. || (t.area.first_baseline == FirstBaseline::Ascent && t.area.first_baseline_min != 0.0))) || (matches!(t.kind, TextKind::OnPath { .. }) - && (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_read_as_none(t))) + && (t.path_effect != PathEffect::default() + || t.path_align != PathAlign::default() + || t.path_spacing != 0.0 + || end_read_as_none(t) + // The writer writes type that starts before its open path starting at the + // path's start. + || t.kind.starts_before_path())) } /// Key 35 of a run in style `st`, as the reader's `Texts::style` reads it: 3 for Tate-chu-yoko, 1 diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index d971e5249e..a1c40ca28f 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -137,7 +137,11 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool { || !t.fractional_widths || (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default()) || (matches!(t.kind, TextKind::OnPath { .. }) - && (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_bracket(t))) + && (t.path_effect != PathEffect::default() + || t.path_align != PathAlign::default() + || t.path_spacing != 0.0 + || end_bracket(t) + || t.kind.starts_before_path())) || several_paths(&t.kind) } diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index ad27eb4a03..c1e4a2555e 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1560,6 +1560,22 @@ fn type_on_a_path_alignment_and_end_bracket_are_warned_as_left_out() { assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.7, end: Some(0.5) })); // An open path's end bracket before its start bracket. assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.5, end: Some(0.3) })); + // Type that starts before its open path: the text path holds a start on the path only. + assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: -0.25, end: None })); + assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.0, end: None })); + // It is written starting at the path's start: the same text object as a start of 0. + let written = |start: f64| { + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(12.0, Paint::solid(Color::BLACK))); + t.kind = TextKind::OnPath { path: along(false), start, end: None }; + t.xf = Affine::IDENTITY; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + write(&doc).bytes + }; + assert_eq!(written(-0.25), written(0.0)); + assert_ne!(written(0.25), written(0.0)); let two = |first_closed: bool| { let mut path = along(first_closed); let pts = [(150.0, 100.0).into(), (250.0, 100.0).into()]; diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs index c9ef8203c7..c6946f5416 100644 --- a/crates/pdf/src/export/text.rs +++ b/crates/pdf/src/export/text.rs @@ -246,7 +246,8 @@ impl Exporter<'_> { // Spaces are written too (text extraction needs them); control characters and soft // hyphens draw nothing. let blank = g.outline.elements().is_empty() && !coloured; - if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) { + // Characters before the start of an open path are not drawn, spaces included. + if g.off_path || (blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control())) { continue; } // The glyph at the font size of its vertical scale: `frame` maps that size's glyph diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index a36cea3ee7..77be459e9f 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -304,6 +304,26 @@ fn real_text_lands_within_half_a_point_of_the_outlines() { } } +/// Type on an open path that starts before the path writes only the characters drawn on the path, +/// as text and as outlines. +#[test] +fn type_starting_before_its_open_path_writes_only_what_is_drawn() { + let mut line = BezPath::new(); + line.move_to((20.0, 150.0)); + line.line_to((380.0, 150.0)); + let mut t = TextObject::point(Point::ZERO, "A C DEF", style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + let laid = vectorcraft_text::layout(FontDb::global(), &t); + // Four glyphs, two of them spaces, and a little more before the start: "DEF" is drawn. + let lead: f64 = laid.glyphs.iter().take(4).map(|g| g.advance).sum::() + 1.0; + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -lead / 360.0, end: None }; + let d = doc(vec![t]); + assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat(), "DEF"); + let outlined = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)); + assert!(outlined.x0 > 20.0 - 1e-6, "{outlined:?}"); +} + /// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt /// hanging indent. fn listed(text: &str, lists: &[Option]) -> TextObject { diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 27e1e7e1e9..d2d228154f 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -117,6 +117,24 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() { assert!(g.alternate_glyphs(&t).elements().is_empty()); } +/// Type in a missing font that starts before its open path: only the characters drawn on the path +/// are highlighted as substituted. +#[test] +fn characters_before_an_open_path_are_not_highlighted() { + let _steady = fonts_steady(); + let style = CharStyle { size: 20.0, font_family: "No Such Family 5932".into(), ..Default::default() }; + let mut t = TextObject::point(Point::ZERO, "MMMM", style); + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let whole = text_geom(&t).substituted_fonts; + assert!(!whole.elements().is_empty()); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None }; + let g = text_geom(&t); + assert!(g.all.elements().is_empty() && g.substituted_fonts.elements().is_empty(), "{:?}", g.substituted_fonts); +} + /// Type in a composite font draws each class of character in its part's face, which is not a /// substitute: Document Setup's highlight of substituted glyphs leaves it alone. #[test] diff --git a/crates/render/src/text_geom.rs b/crates/render/src/text_geom.rs index 05228eb209..eaa15a6841 100644 --- a/crates/render/src/text_geom.rs +++ b/crates/render/src/text_geom.rs @@ -183,7 +183,8 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeo let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]); let plain = t.plain_text(); let (mut images, mut colours) = (vec![], vec![]); - for g in &layout.glyphs { + // Glyphs before the start of an open path draw nothing and have no highlight cells. + for g in layout.glyphs.iter().filter(|g| !g.off_path) { push(&mut all, g.run, &g.outline); if let Some((image, xf)) = g.placed_image() { images.push((g.run, image.clone(), xf)); diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index ccd5f56806..2f8e8b8a1b 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -571,6 +571,9 @@ impl Writer<'_> { let base = self.char_props(&drawn.first_style(), space); let mut props = base.clone(); let (from, to) = t.kind.path_span().unwrap_or((start, 1.0)); + // Type on an open path may start before the path: a negative `startOffset`, and a reader + // does not draw the characters before the path's start, as the layout does not. + let from = t.kind.path_text_start().unwrap_or(from); // The anchor sits where the layout aligns the line within its span. let at = match t.para_at(0).justify { Justify::Center | Justify::JustifyCenter => { diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index cbffbfc348..981fc8ff9a 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1432,16 +1432,26 @@ impl Ctx { let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; // On a path of one closed subpath, text that starts before the path's start or past its - // end wraps around the path. Otherwise it starts at the path's start or end. + // end wraps around the path. On an open path text may start before the path's start, + // and its characters before the start are not drawn; text that starts past the end + // starts at the end. Other paths start it at the path's start or end. let path = PathData::from_bezpath(&bp); let wraps = path.subpaths.len() == 1 && path.is_closed(); + let open = !path.is_closed(); let wrap = |s: f64| { // `rem_euclid` can round a tiny negative value up to 1. let s = s.rem_euclid(1.0); if s < 1.0 { s } else { 0.0 } }; - let start = - Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) }); + let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| { + if wraps { + wrap(s) + } else if open { + s.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0) + } else { + s.clamp(0.0, 1.0) + } + }); obj.kind = TextKind::OnPath { path, start, end: None }; obj.xf = Affine::IDENTITY; } else if vertical { diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 816e17f15d..72b476eb24 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -335,10 +335,12 @@ fn text_on_a_closed_path_wraps_past_the_path_start() { // A negative startOffset, in user units or as a percentage, counts back from the path's end. assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4])); assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5])); - // A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's - // start or end. + // A path of two closed subpaths does not wrap: the text starts at the path's start. An open + // path does not wrap either: text anchored before its start starts there + // (`text_on_an_open_path_starts_before_the_path`), and text offset past its end starts at + // its end. assert_eq!(start_of(t[6]), 0.0); - assert_eq!(start_of(t[7]), 0.0); + assert!(close(start_of(t[7]), -width / 300.0, 1e-6), "{} {width}", start_of(t[7])); assert_eq!(start_of(t[8]), 1.0); // Laid out, the end-anchored text draws every glyph up the left side and ends at the start @@ -372,6 +374,52 @@ fn text_on_a_closed_path_wraps_past_the_path_start() { assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::>()); } +#[test] +fn text_on_an_open_path_starts_before_the_path() { + // An open path 300 long. Text anchored, or offset, before its start starts that far before it, + // and only the characters whose middle is on the path are drawn; the text keeps every character. + let d = open( + r##" + Wave + Wave + Wave + Wave"##, + ); + let t = texts(&d); + assert_eq!(t.len(), 4); + let start_of = |t: &TextObject| match t.kind { + TextKind::OnPath { start, .. } => start, + _ => panic!("{:?}", t.kind), + }; + let whole = layout(FontDb::global(), t[3]); + let width: f64 = whole.glyphs.iter().map(|g| g.advance).sum(); + assert!(width > 0.0 && width < 60.0, "{width}"); + assert!(close(start_of(t[0]), -width / 300.0, 1e-6), "{} {width}", start_of(t[0])); + assert!(close(start_of(t[1]), -width / 600.0, 1e-6), "{} {width}", start_of(t[1])); + assert!(close(start_of(t[2]), -0.2, 1e-9), "{}", start_of(t[2])); + let drawn = |t: &TextObject| { + let lay = layout(FontDb::global(), t); + assert!(!lay.overflow && lay.glyphs.len() == 4, "{} {}", lay.overflow, lay.glyphs.len()); + assert_eq!(t.plain_text(), "Wave"); + lay.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::>() + }; + // Ending at the path's start: nothing is drawn. + assert_eq!(drawn(t[0]), [false; 4]); + // Centered on the path's start: the characters whose middle is past the start are drawn, the + // last ones of the text. + // (A hidden glyph has no advance: the advances are the ones the text takes on the path.) + let middle = drawn(t[1]); + let mut x = -width / 2.0; + for (g, shown) in whole.glyphs.iter().zip(&middle) { + assert_eq!(*shown, x + g.advance / 2.0 >= 0.0, "{x} {middle:?}"); + x += g.advance; + } + assert!(middle.iter().any(|d| *d) && !middle[0] && middle[3], "{middle:?}"); + // 60 before the start, more than the text's width: nothing is drawn. + assert_eq!(drawn(t[2]), [false; 4]); + assert_eq!(drawn(t[3]), [true; 4]); +} + /// The unit vector along a glyph's baseline. fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 { vectorcraft_geom::Vec2::new(angle.cos(), angle.sin()) diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index 127e47846c..0846d694f2 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -478,6 +478,31 @@ fn aligned_text_on_path_anchors_within_its_span() { } } +/// Type on an open path that starts before the path is written with a negative `startOffset` and +/// reads back starting there, with the same characters drawn. +#[test] +fn text_on_an_open_path_starting_before_it_round_trips() { + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 100.0)); + line.line_to((200.0, 100.0)); + let mut d = Document::new(200.0, 200.0); + let mut t = TextObject::point(Point::ZERO, "Before", CharStyle { size: 14.0, ..CharStyle::default() }); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.1, end: None }; + let shown = |t: &TextObject| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::>() + }; + let before = shown(&t); + assert!(before.contains(&true) && before.contains(&false), "{before:?}"); + let id = d.alloc_id(); + let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default()); + assert!(svg.contains("startOffset=\"-10%\""), "{svg}"); + let r = import(&svg).unwrap(); + let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() }; + let TextKind::OnPath { start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) }; + assert!((start + 0.1).abs() < 1e-6, "start {start}"); + assert_eq!(shown(t), before); +} + /// An end bracket, Align to Path or Spacing, which `` can't express, keep their look as /// outlines (#429). #[test] diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index a7d7d1d3f3..9bab6fea66 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -624,6 +624,7 @@ impl Ctx<'_> { width_start, warichu: g.wari.map(|w| w.start), warichu_line: g.wari.map_or(0.0, |w| w.across), + off_path: false, }); } diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index d119f5d780..d1072d3781 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -380,6 +380,35 @@ pub(crate) fn layout_trying_each_start(db: &crate::fontdb::FontDb, t: &TextObjec l } +/// For type that starts before its open path `ap` (`lead`), a glyph whose middle is `mid` along the +/// path and falls before the path's start is not drawn, as SVG 2 lays out text on a path +/// (https://www.w3.org/TR/SVG2/text.html#TextLayoutPath): glyph `at` keeps its place in the layout +/// for the caret, with no length at the path's start (moved off the path by `rise`, as Align to Path +/// moves the glyph), and draws nothing ([`crate::PositionedGlyph::off_path`]). True when it is hidden. +fn hide_before_open_path(cx: &mut Ctx<'_>, ap: &ArcPath, lead: bool, at: usize, mid: f64, rise: f64) -> bool { + if !lead || ap.is_closed() || mid >= 0.0 { + return false; + } + let (p, dir) = ap.at(0.0); + let start = p + Vec2::new(-dir.y, dir.x) * rise; + if let Some(glyph) = cx.out.glyphs.get_mut(at) { + glyph.origin = start; + glyph.advance = 0.0; + glyph.width = 0.0; + glyph.width_start = 0.0; + glyph.outline = BezPath::new(); + glyph.image = None; + glyph.colour = None; + // A ligature's caret stops stay, at the path's start with the glyph. + for c in &mut glyph.carets { + c.1 = 0.0; + } + glyph.off_path = true; + } + cx.out.inlines.retain(|i| i.glyph != at); + true +} + /// Lay type on a path out along `path` (text space), between its brackets. pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &PathData) { cx.out.on_path = true; @@ -453,6 +482,11 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, } let centre = path.bounds().unwrap_or_default().center(); let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0)); + // On an open path the text may start before the path's start: the span runs from there, so + // Dynamic Text fills it to the end bracket, and the drawn text runs from the path's start to the + // end bracket. + let from = t.kind.path_text_start().unwrap_or(from); + let lead = t.kind.starts_before_path(); let s_start = from * ap.len(); let avail = (to - from) * ap.len(); let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 }; @@ -491,7 +525,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, } } } else { - (l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0))) + (l.min(s_start.max(0.0)), r.min((ap.len() - s_start - avail).max(0.0))) }; (l, r, hung_r) }; @@ -673,10 +707,12 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, m.tab = at; } } + let at = cx.out.glyphs.len(); cx.emit(g, pre, origin, angle, g.adv, 0); + let hidden = hide_before_open_path(cx, &ap, lead, at, s + step * 0.5, rise); // A glyph of a script whose letters join is bent along a curved path (Rainbow), so its // joining strokes meet its neighbours' instead of standing off the curve. - if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() { + if !hidden && t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() { let bend = GlyphBend { s, step, advance: g.adv, rise }; if let Some(last) = cx.out.glyphs.last_mut() && last.image.is_none() diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 9b8da39d80..f51b6e64b4 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -379,5 +379,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi width_start: 0.0, warichu: None, warichu_line: 0.0, + off_path: false, } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 362a1c2408..07d761926b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -188,6 +188,12 @@ pub struct PositionedGlyph { /// line's baseline before Character Alignment moves the glyph (y down). It names the block /// line whatever the alignment of each character on it. 0 for any other glyph. pub warichu_line: f64, + /// Type on an open path that starts before the path: the glyph's middle falls before the + /// path's start, so it draws nothing (its `outline` is empty and it has no image or color + /// art), hit testing and Touch Type skip it while a glyph is drawn, and exports leave it out. + /// It stays in the layout for the caret, at the path's start with no advance or width, and its + /// ligature carets stand there too. + pub off_path: bool, } /// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its @@ -600,11 +606,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { .map(|(_, d)| (p - (g.origin + along * if g.rtl { g.advance - d } else { d })).hypot2()) .fold(mid, f64::min) }; - let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))); + // Glyphs before the start of an open path draw nothing: a click goes to a drawn one when + // there is one. + let drawn = layout.glyphs.iter().any(|g| !g.off_path); + let best = layout.glyphs.iter().enumerate().filter(|(_, g)| !(drawn && g.off_path)).min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b))); let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) }; // `first` and `last` are the first and last glyphs of the cluster. A click before the middle of - // the cluster, measured along the first glyph's direction, goes to the cluster's start. - let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len; + // the cluster, measured along the first glyph's direction, goes to the cluster's start. A + // cluster's glyphs before an open path's start are left out when one of its glyphs is drawn. + let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len && !(drawn && o.off_path); let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g); let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g); let along = dir(first.angle); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 423984a630..3837a1adf4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -635,6 +635,85 @@ fn on_path_placement() { assert_eq!(hit_byte(&l, mid), 1); } +/// Type on an open path that starts before the path's start draws only the glyphs whose middle is +/// on the path; the others stay in the layout, drawing nothing. On a closed path a start below 0 is +/// the path's start, as before. +#[test] +fn on_path_open_start_before_the_path_hides_the_glyphs_before_it() { + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut t = point("MMMM", style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let whole = layout(db(), &t); + let adv = whole.glyphs[0].advance; + assert!(adv > 1.0 && whole.glyphs.iter().all(|g| !g.outline.elements().is_empty())); + // 2.4 glyphs before the start: the first two are before it, and the third's middle is just past + // the path's start, so it is drawn. + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -2.4 * adv / 500.0, end: None }; + let l = layout(db(), &t); + assert!(l.on_path && !l.overflow); + assert_eq!(l.glyphs.len(), 4); + let drawn: Vec = l.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect(); + assert_eq!(drawn, [false, false, true, true]); + assert_eq!(l.glyphs.iter().map(|g| g.off_path).collect::>(), [true, true, false, false]); + assert!(l.glyphs[..2].iter().all(|g| g.carets.iter().all(|c| c.1 == 0.0) && g.advance == 0.0 && g.width == 0.0)); + assert!(whole.glyphs.iter().all(|g| !g.off_path)); + // Type that starts on the path keeps drawing every glyph, even where negative tracking steps + // its glyphs back before the path's start. + let mut packed = t.clone(); + packed.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + packed.runs[0].style.tracking = -1000.0; + let p = layout(db(), &packed); + assert!(p.glyphs.len() == 4 && p.glyphs.iter().all(|g| !g.off_path && !g.outline.elements().is_empty())); + assert!((l.glyphs[2].origin.x + 0.4 * adv).abs() < 1e-6, "{:?}", l.glyphs[2].origin); + assert!((l.glyphs[3].origin.x - 0.6 * adv).abs() < 1e-6, "{:?}", l.glyphs[3].origin); + assert_eq!(l.lines[0].end, 4); + // A click at the path's start goes before the first drawn character, not into a hidden one, and + // the caret in the hidden characters stands at the path's start. + assert_eq!(hit_byte(&l, Point::new(0.0, 50.0)), 2); + for b in [0, 1] { + let (top, bottom) = caret_position(&l, b); + assert!(top.x.abs() < 1e-6 && bottom.x.abs() < 1e-6, "{b}: {top:?} {bottom:?}"); + } + // An All Caps ß is two glyphs. With the first before the path's start, a click is measured + // against the drawn one: just past its middle, the caret goes after the character. + let mut caps = point("ße", style(20.0)); + caps.runs[0].style.all_caps = true; + caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let s = layout(db(), &caps).glyphs[0].advance; + caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -1.2 * s / 500.0, end: None }; + let c = layout(db(), &caps); + assert!(c.glyphs[0].byte == 0 && c.glyphs[1].byte == 0 && c.glyphs[0].off_path && !c.glyphs[1].off_path); + let mid = c.glyphs[1].origin.x + c.glyphs[1].advance / 2.0; + assert_eq!(hit_byte(&c, Point::new(mid + 0.05 * s, 50.0)), 2); + assert_eq!(hit_byte(&c, Point::new(mid - 0.05 * s, 50.0)), 0); + // With Align to Path, the hidden characters' caret is moved off the path as the drawn ones are. + let mut aligned = t.clone(); + aligned.path_align = vectorcraft_doc::PathAlign::Ascender; + let a = layout(db(), &aligned); + let rise = a.glyphs[2].origin.y - 50.0; + assert!(rise.abs() > 1.0, "{rise}"); + for g in &a.glyphs[..2] { + assert!(g.off_path && g.origin.x.abs() < 1e-6 && (g.origin.y - 50.0 - rise).abs() < 1e-6, "{:?}", g.origin); + } + // The whole text before the path: nothing is drawn, and nothing overflows. + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None }; + let l = layout(db(), &t); + assert!(!l.overflow && l.glyphs.len() == 4 && l.glyphs.iter().all(|g| g.outline.elements().is_empty())); + // A closed path starts the text at the path's start. + let mut square = BezPath::new(); + square.move_to((0.0, 0.0)); + square.line_to((200.0, 0.0)); + square.line_to((200.0, 200.0)); + square.line_to((0.0, 200.0)); + square.close_path(); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&square), start: -0.1, end: None }; + let l = layout(db(), &t); + assert!(l.glyphs.iter().all(|g| !g.outline.elements().is_empty())); + assert!(l.glyphs[0].origin.x.abs() < 1e-6 && l.glyphs[0].origin.y.abs() < 1e-6, "{:?}", l.glyphs[0].origin); +} + #[test] fn on_path_effects_orient_glyphs() { use vectorcraft_doc::PathEffect; diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index f02db481ab..a8f035aa6e 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -170,6 +170,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout width_start: 0.0, warichu: None, warichu_line: 0.0, + off_path: false, }); x += advance; } diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index ea84bcf346..b22eca006d 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -611,6 +611,7 @@ fn duplicate_authored_glyphs_keep_occurrence_order() { width_start: 0.0, warichu: None, warichu_line: 0.0, + off_path: false, }; let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() }; assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0); diff --git a/crates/tools/src/pathtype.rs b/crates/tools/src/pathtype.rs index 9f888de6cb..e54e23b7a7 100644 --- a/crates/tools/src/pathtype.rs +++ b/crates/tools/src/pathtype.rs @@ -59,6 +59,8 @@ pub struct Brackets { arc: ArcPath, /// [`TextKind::path_span`]. span: (f64, f64), + /// [`TextKind::path_text_start`]: below 0 when the type starts before an open path's start. + text_start: f64, /// The end bracket was placed (not the end of the path, or once round it). end_set: bool, } @@ -78,7 +80,9 @@ impl Brackets { if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() { return None; } - Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() }) + let span = t.kind.path_span()?; + let text_start = t.kind.path_text_start().unwrap_or(span.0); + Some(Self { id, xf: t.xf, arc, span, text_start, end_set: end.is_some() }) } /// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path). @@ -269,7 +273,13 @@ impl BracketDrag { d -= d.round(); } let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) }; - let start = place(s + d); + // Type that starts before an open path's start slides from there, and goes no + // further before the path than it was: both brackets stay where they are. + let lead = !closed && b.text_start < 0.0; + if lead { + d = d.max(0.0); + } + let start = if lead { (b.text_start + d).min(1.0) } else { place(s + d) }; m.insert("start".into(), json!(start)); if b.end_set { m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) })); @@ -409,6 +419,35 @@ mod tests { assert!(v.get("flip").is_none(), "{v}"); } + /// Type that starts before its open path slides from where it starts, not from the path's start. + #[test] + fn dragging_the_center_bracket_slides_type_that_starts_before_the_path() { + let (mut d, id) = doc_with_path_type(); + if let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) + && let TextKind::OnPath { start, .. } = &mut t.kind + { + *start = -0.25; + } + let s = selected(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // The center bracket stands in the middle of the brackets (the start bracket at the path's start). + let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(280.0, 295.0), Mods::default())).clone(); + assert!((v["start"].as_f64().unwrap() + 0.15).abs() < 1e-6, "{v}"); + // Back past where it started: it stays there, and so does a placed end bracket. + let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(150.0, 295.0), Mods::default())).clone(); + assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6, "{v}"); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) + && let TextKind::OnPath { end, .. } = &mut t.kind + { + *end = Some(0.5); + } + let ended = crate::testutil::cx(&d, &s, &p); + // The center bracket stands at 0.25 of the path, between the start bracket (0) and the end. + let v = preview(&drag(&ended, Point::new(175.0, 295.0), Point::new(130.0, 295.0), Mods::default())).clone(); + assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6 && (v["end"].as_f64().unwrap() - 0.5).abs() < 1e-6, "{v}"); + } + #[test] fn dragging_the_start_and_end_brackets_moves_them() { let (d, id) = doc_with_path_type(); diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index 018a65aefc..e57b744fb3 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -150,7 +150,7 @@ fn is_inline(lay: &TextLayout, gi: usize) -> bool { /// inline graphics, glyphs whose font is gone and type whose transform is collapsed. fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { let g: &PositionedGlyph = lay.glyphs.get(gi)?; - if is_inline(lay, gi) { + if is_inline(lay, gi) || g.off_path { return None; } let m = g.xf; @@ -161,7 +161,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { let corners: Vec = lay .glyphs .iter() - .filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line) + .filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line && !h.off_path) .filter_map(glyph_quad) .flatten() .map(|p| to_glyph * p) @@ -218,7 +218,8 @@ impl TouchTypeTool { let selected = std::iter::successors(Some(id), |c| cx.doc.parent_of(*c)).any(|c| cx.selection.contains(c)); let t = text_of(cx, id).filter(|_| selected)?; let lay = self.layout(t); - let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len)?; + // The character's first drawn glyph: glyphs before an open path's start are not drawn. + let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len && !g.off_path)?; Some((id, t, char_box(t, &lay, gi)?)) } @@ -230,7 +231,7 @@ impl TouchTypeTool { let lay = self.layout(t); let local = t.xf.inverse() * p; let boxes: Vec<(usize, [Point; 4])> = (lay.glyphs.iter().enumerate()) - .filter(|(gi, g)| g.len > 0 && !is_inline(&lay, *gi)) + .filter(|(gi, g)| g.len > 0 && !g.off_path && !is_inline(&lay, *gi)) .filter_map(|(_, g)| Some((g.byte, glyph_quad(g)?))) .collect(); let center = |q: &[Point; 4]| q[0].midpoint(q[2]); @@ -360,7 +361,9 @@ impl Tool for TouchTypeTool { let Some(t) = text_of(cx, id) else { return out }; let lay = self.layout(t); let start = self.target.map_or(0, |(_, s)| s); - let Some(b) = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0).and_then(|gi| char_box(t, &lay, gi)) else { + // The character's first drawn glyph: glyphs before an open path's start are not drawn. + let first = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0 && !g.off_path); + let Some(b) = first.and_then(|gi| char_box(t, &lay, gi)) else { return out; }; (Part::Move, b) diff --git a/crates/tools/src/touchtype/tests.rs b/crates/tools/src/touchtype/tests.rs index 3b551950d1..54c6c86e30 100644 --- a/crates/tools/src/touchtype/tests.rs +++ b/crates/tools/src/touchtype/tests.rs @@ -388,6 +388,60 @@ fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() { } } +/// A character of type on an open path that is not drawn, because it falls before the path's +/// start, has no box, so the tool shows no handles for it; a drawn character of the same type has +/// one. +#[test] +fn a_character_before_an_open_paths_start_has_no_box() { + let mut d = Document::new(600.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut text = TextObject::point(Point::ZERO, "Type", CharStyle { size: 40.0, ..Default::default() }); + let line = + vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(500.0, 200.0)], false)]); + text.kind = vectorcraft_doc::TextKind::OnPath { path: line, start: -0.05, end: None }; + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let lay = vectorcraft_text::layout(FontDb::global(), &text); + assert!(lay.glyphs[0].off_path && !lay.glyphs[1].off_path, "the T is before the path's start, the y is on it"); + assert!(char_box(&text, &lay, 0).is_none()); + assert!(TouchTypeTool { target: Some((id, 0)), ..Default::default() }.current(&cx).is_none()); + assert!(TouchTypeTool { target: Some((id, 1)), ..Default::default() }.current(&cx).is_some()); + // An All Caps ß is two glyphs: with the first before the path's start and the second drawn, the + // character has the second's box. + let mut caps = text.clone(); + caps.runs = vec![vectorcraft_doc::TextRun::new("ße", CharStyle { size: 40.0, all_caps: true, ..Default::default() })]; + if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind { + *start = 0.0; + } + let adv = vectorcraft_text::layout(FontDb::global(), &caps).glyphs[0].advance; + if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind { + *start = -1.2 * adv / 400.0; + } + let mut d = Document::new(600.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(caps.clone())))).unwrap(); + let mut s = Selection::default(); + s.add(id); + let cx = crate::testutil::cx(&d, &s, &p); + let lay = vectorcraft_text::layout(FontDb::global(), &caps); + assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path); + let t = TouchTypeTool { target: Some((id, 0)), ..Default::default() }; + let (_, _, b) = t.current(&cx).expect("the ß has a box"); + assert!((b.pen - lay.glyphs[1].xf * Point::ZERO).hypot() < 1e-9, "{:?}", b.pen); + // A press on its drawn glyph, on the path, starts a drag of it. + let q = glyph_quad(&lay.glyphs[1]).unwrap(); + let at = Point::new(q[0].midpoint(q[2]).x, 200.0); + assert!(in_quad(&q, at), "{at:?} {q:?}"); + let mut t = TouchTypeTool::default(); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -5.0)); + assert_eq!(preview(&acts[1])["start"], 0, "{:?}", acts[1]); +} + /// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box. #[test] fn a_skewed_glyph_keeps_its_own_box() { diff --git a/docs/mcp.md b/docs/mcp.md index 99b0bfef38..cf0ed665a4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9597,7 +9597,9 @@ neighbors, the kerning of the two characters keeps it and the characters after i wider or narrower character moves the characters after it by the change in its width. Touch Type gives the character before it a kerning value of its own (with `dx`, and with any other change where the font kerned the character with its neighbors), and that character can then move by the font's kerning pair with the one before -it, as with any style change to one character (`text.setRangeStyle`). The type stays editable. +it, as with any style change to one character (`text.setRangeStyle`). The type stays editable. The characters of type +on an open path that are not drawn because they fall before the path's start cannot be selected with the tool, and +`text.touchType` returns an error for them. ```json {"name":"run_command","arguments":{"command":"text.touchType","params":{"id":7,"start":1,"dx":10,"dy":4}}} @@ -9687,9 +9689,10 @@ same in any language. Saved documents keep what they say, and imported text (PDF Type on a path flows between a start and an end bracket, stored as fractions of its path's length. `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them and the rest of Type on a -Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip` -then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so -the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the +Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); on an open +path a `start` below 0 starts the text that far before the path's start (on a closed path it is the path's start); +`flip` then turns the type to the other side of its path, the path running the other way and the brackets swapping +ends so the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the default) along the path, each character by its own font, size and vertical scale: `ascender` runs the top of the font's em box along it (its ideographic em box, else an em box centered on the cap height), `descender` runs the bottom of the font's bounding box along it, and `center` runs a line at half the height of the top of the font's H @@ -9702,6 +9705,13 @@ show selected type on a path's brackets: dragging the start or end bracket (`poi begins or ends, dragging the centre bracket slides the type along its path and, dragged across the path, flips it (with Cmd/Ctrl held it only slides). Each is one undo step. +Type on an open path that starts before the path's start keeps every character, and the characters whose middle falls +before the path's start are not drawn. A `flip` starts it at the start bracket, and dragging the center bracket slides +it from where it starts and no further before the path. Past the path's end, text that does not fit is overflow, as +for other type on a path. SVG import reads a `` anchored or offset before an open path's start this way, and +SVG export writes it as a negative `startOffset`. Releasing it to point type keeps the first drawn character where it +was drawn. + ```json {"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}} ``` @@ -12066,10 +12076,11 @@ VectorCraft addition and changes no parity score. the path's fill and stroke), so the released path has no fill and no stroke. - **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on one straight line along the object's own axis, with the baseline starting where the first character sat on the path - (type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip, - brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point - type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor. - Without `keepOriginal` the type object itself becomes the point + (type with no characters starts at its start bracket; type that starts before an open path's start keeps its first + drawn character where it was drawn, with the characters that were not drawn before it). Type on a Path Options + (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. Type with Dynamic Text + on (`type.dynamicText`) becomes point type with it off, at the sizes it was drawn at: each size, leading and + baseline shift is multiplied by the fit factor. Without `keepOriginal` the type object itself becomes the point type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as `type.convertToPointType` does for area type. - It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..e9901cfa6d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1725,7 +1725,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `