From eee96556e629b7fc01b0672b606882e34ede77d4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 01:09:38 -0800 Subject: [PATCH 01/20] Simplify: a refitted curve becomes a line only when its joins stay smooth Simplify, Object > Path > Smooth, the Smooth tool and freehand strokes turned every refitted curve whose handles lay near its chord into a straight line. A short curve whose end directions were off the chord passed that test, and the anchor between it and the next curve became a corner the path did not have: a Smooth tool drag along a line jagged by up to 6 pt left corners of 7.7 to 40.6 degrees. A curve now becomes a line only when the line runs within 0.5 degrees of the directions the path arrives and leaves along. A nearly straight piece that would turn more stays a flat curve. Convert to Straight Lines is unchanged. --- crates/engine/src/tests/draw2.rs | 31 ++++++++++++++++++++++ crates/pathops/src/edit.rs | 25 +++++++++++++++--- crates/pathops/tests/it/ops.rs | 45 ++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 5986b618fd..d365fc83a8 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1367,6 +1367,37 @@ fn smooth_tool_reduces_jagged_path() { assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0)); } +/// A drag of the Smooth tool along a line jagged by up to 6 pt leaves no corner between a curve and +/// a line: every anchor between the path's ends turns by at most 1°. +#[test] +fn smooth_tool_leaves_no_corner_where_a_curve_meets_a_line() { + let mut s = session(); + let mut seed = 7u64; + let anchors: Vec<_> = (0..=60) + .map(|i| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let noise = ((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0; + json!({"x": 100.0 + f64::from(i) * 5.0, "y": 200.0 + 6.0 * noise}) + }) + .collect(); + let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let drag_points: Vec<(f64, f64)> = (0..=64).map(|i| (90.0 + f64::from(i) * 5.0, 200.0)).collect(); + drag(&mut s, "smooth", &drag_points, Mods::default()); + let p = path(&s, id); + let sp = &p.subpaths[0]; + assert!(sp.anchors.len() < 61, "the path is smoothed: {}", sp.anchors.len()); + let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord); + for i in 1..sp.segment_count() { + let (prev, next) = (sp.segment(i - 1), sp.segment(i)); + let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0); + let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0); + let turn = arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees(); + let kinds = (sp.segment_is_line(i - 1), sp.segment_is_line(i)); + assert!(turn <= 1.0, "anchor {i} turns {turn}° (lines before and after: {kinds:?}): {:?}", sp.anchors[i]); + } +} + /// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight /// sides next to the bump stay straight lines from their far ends, which keep their place. #[test] diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index d8d121ecb9..19089dfd2e 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -328,14 +328,33 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Op } // The fit may be `tol * 0.98` from the lines between the samples, those lines may be // `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up - // to 1.5% of `tol`: 3/4 of how far its handles may be from the line. + // to 1.5% of `tol`: 3/4 of how far its handles may be from the line. The fitted curves join + // smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d)); - for c in fit_cubics(&pts, t0, t1, tol * 0.98) { - let line = is_straight(&c, tol * 0.02); + let fitted = fit_cubics(&pts, t0, t1, tol * 0.98); + let mut arrive = t0; + for (k, &c) in fitted.iter().enumerate() { + let leave = fitted.get(k + 1).map_or(t1, start_tangent); + let line = is_straight(&c, tol * 0.02) && line_keeps_joins(&c, arrive, leave); + arrive = if line { unit(c.p3 - c.p0).unwrap_or(arrive) } else { end_tangent(&c) }; result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); } } +/// Most degrees between the line from a refitted curve's start to its end and the directions the +/// path arrives and leaves along, for the curve to become that line: so every join next to such a +/// line turns by at most this much. VectorCraft's own value. +const LINE_JOIN_DEG: f64 = 0.5; + +/// Whether the line from `c.p0` to `c.p3` is within [`LINE_JOIN_DEG`] of `arrive`, the direction the +/// path arrives at `c.p0` along, and of `leave`, the direction it leaves `c.p3` along. A curve whose +/// ends are at one point, or a direction of no length, gives `false`. +fn line_keeps_joins(c: &CubicBez, arrive: Vec2, leave: Vec2) -> bool { + let (Some(dir), Some(arrive), Some(leave)) = (unit(c.p3 - c.p0), unit(arrive), unit(leave)) else { return false }; + let min = LINE_JOIN_DEG.to_radians().cos(); + dir.dot(arrive) >= min && dir.dot(leave) >= min +} + /// `pts` (a run's samples, in order) smoothed along the path: each point becomes the Gaussian-weighted /// mean of the points around it, `sigma` points of arc length wide, the window narrowing towards the /// ends so that the first and last points stay where they are. Consecutive samples within diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 492aaf56f1..d48e1c5dd8 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -417,6 +417,51 @@ fn simplify_keeps_square_corners() { assert!(!has_curves(&s)); } +/// The largest turn, in degrees, at an anchor of `sp` that is not an end of an open subpath: the +/// angle between the direction a segment arrives along and the one the next leaves along. +fn largest_join_turn(sp: &SubPath) -> f64 { + let dir = |v: kurbo::Vec2, w: kurbo::Vec2, chord: kurbo::Vec2| [v, w, chord].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or(chord); + let n = sp.segment_count(); + let first = usize::from(!sp.closed); + (first..n) + .map(|i| { + let (prev, next) = (sp.segment((i + n - 1) % n), sp.segment(i)); + let arrive = dir(prev.p3 - prev.p2, prev.p3 - prev.p1, prev.p3 - prev.p0); + let leave = dir(next.p1 - next.p0, next.p2 - next.p0, next.p3 - next.p0); + arrive.cross(leave).atan2(arrive.dot(leave)).abs().to_degrees() + }) + .fold(0.0, f64::max) +} + +/// A refitted curve becomes a line only when the line keeps the smooth joins on both sides of it: +/// with no corners kept, the refit of a jagged line or of an arc running into a line turns by at most +/// 0.5° at every anchor. Points that all lie on one line still become one line. +#[test] +fn simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth() { + let mut seed = 7u64; + let mut noise = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + ((seed >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0 + }; + let jagged: Vec = (0..=60).map(|i| Point::new(100.0 + f64::from(i) * 5.0, 200.0 + 6.0 * noise())).collect(); + let mut arc_into_line: Vec = + (0..=40).map(|i| Point::new(50.0 * (f64::from(i) / 80.0 * PI).cos(), 50.0 * (f64::from(i) / 80.0 * PI).sin())).collect(); + arc_into_line.extend((1..=20).map(|i| Point::new(-f64::from(i) * 2.0, 50.0))); + for pts in [&jagged, &arc_into_line] { + let path = PathData::single(SubPath::polyline(pts, false)); + for tolerance in [1.0, 2.5, 5.0] { + let s = simplify_with(&path, &SimplifyOptions { tolerance, corner_angle_deg: 179.0, ..Default::default() }); + let turn = largest_join_turn(&s.subpaths[0]); + assert!(turn <= 0.5 + 1e-9, "{tolerance} pt: an anchor turns {turn}°: {s:?}"); + assert!(max_deviation(&path, &s) <= tolerance + 1e-6, "{tolerance} pt: {}", max_deviation(&path, &s)); + } + } + let in_line: Vec = [0.0, 3.0, 10.0, 11.0, 40.0, 41.5, 90.0, 100.0].map(|x| Point::new(x, 0.5 * x)).to_vec(); + let s = simplify_with(&PathData::single(SubPath::polyline(&in_line, false)), &SimplifyOptions { tolerance: 1.0, ..Default::default() }); + assert_eq!(s.anchor_count(), 2, "{s:?}"); + assert!(!has_curves(&s), "{s:?}"); +} + #[test] fn simplify_straight_lines_option() { let c = dense_circle(50.0, 200); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..1ae705ab40 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1204,6 +1204,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Nearly unchanged appearance is expected when reducing points. The checklist is partial, without raising scores. Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape. Simplify keeps its result within the tolerance of the path, on long arcs and on paths of many anchors alike (`fit.rs` tests `no_fitted_piece_is_farther_than_the_tolerance` and `long_arcs_stay_within_the_tolerance`). A closed subpath that refits to one curve is dropped, so this does not hold for it. A segment that would need more than 65,536 samples at that tolerance stays as it is, and with Convert to Straight Lines it becomes evenly spaced lines instead, up to 65,536, which past the limits in `docs/mcp.md` (Simplify) can be farther than the tolerance from the segment. + Simplify, Object › Path › Smooth, the Smooth tool and freehand strokes (`path.freehand`) turn a refitted curve into a straight line only when the line runs within 0.5° of the directions the path arrives and leaves along, so no corner appears where a curve meets a line; a nearly straight piece that would turn more stays a flat curve (`ops.rs` test `simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth`). The Simplify dialog also has Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path. The threshold is a slider from 0° to 180° and opens at 150°: an anchor whose corner is sharper than the threshold stays a corner, so moving the slider right keeps more corners, and the dialog sends `cornerAngle` as 180° less the threshold. While Preview is on, Show Original Path draws the paths as they were before the preview in a wide, faint band of their layer's color under the selection's outlines. Object › Path › Simplify opens a slider under the selection ([Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html), [Auto simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/auto-simplify-paths.html)) with Auto-Simplify, a rail from the fewest anchor points to the closest fit, and More Options, which opens the dialog at the slider's tolerance. The rail's levels are VectorCraft's own scale (`object.path.simplify {level}`): level 50 is 1 pt, and every 10 levels halve or double the tolerance, from 32 pt at 0 to 1/32 pt at 100. Auto-Simplify picks the lowest level whose tolerance is at most 1/400 of the selection's longer side, or level 100 when that side is under 12.5 pt; the slider opens at that level, with its result previewed, as the manual-simplify page describes. The dialog's "Open this dialog with these settings next time" makes the menu item open the dialog on the settings of its last OK. With anchors or segments of a path selected with Direct Selection, Simplify acts on just that part of the path, as the [Manually simplify paths](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) page puts it: the slider, Auto-Simplify and the dialog refit only each run of selected segments, between its end anchors, which keep their place and their handles on the side away from the run. The anchors and segments outside the runs stay exactly as they were, a closed path's run may cross its start point, separate runs on one path are refitted independently in one undo step, and the runs stay selected. A path with only lone anchors selected is still simplified whole, and `object.path.simplify {wholePaths: true}` simplifies partly selected paths whole. Tests: `crates/engine/src/tests/simplify_part.rs` and `simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path`. From 3990c748bdf68a9c6dd6eddecb49f4dbda889ea8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 04:29:55 -0800 Subject: [PATCH 02/20] Toolbars: toolbars made with New Toolbar dock along the window's edges A toolbar made with New Toolbar now docks in a lane along the window's left or right edge (on the left, right of the Tools panel docked there). Dropping its title bar on an edge docks it, with the target lit while the title bar is dragged over it; dragging it out of its lane floats it under the pointer, and Escape or losing the window's focus during the drag puts it back. The title bar's context menu has Dock to Left, Dock to Right and Float Toolbar. - Toolbars docked at one edge stack from the top, each with an equal share of the lane's height, and the lane is as wide as its widest toolbar. A lane too short for its stack wraps it into more columns, up to half the width beside it; past that the toolbars show only their title bars, a lone toolbar included, and a column too short for those scrolls with the mouse wheel. - With Keep Tools Beside the Canvas on and Tools docked on the left, the left lane is laid out inside the canvas panel right of Tools, and the canvas keeps room for one column of it and its 200 points. - A release in the frame the pointer leaves the lane floats the toolbar with its corner where the drag put it. - window.toolbar.dock {name, side?, order?} and window.toolbar.float {name, x?, y?} do the same from the control protocol; bad params are errors and change nothing. window.toolbar.list reports floating, side and order. - CustomToolbar keeps dock (place in the edge's stack) and dock_right; both default, so toolbars saved before load floating. Workspaces and preferences keep the placement; New Toolbar still starts floating, a copy floats, and deleting a docked toolbar closes up its stack. - Dock to Left, Dock to Right, Dock Toolbar and Float Toolbar have rows in every catalog. Part of #1759 --- crates/mcp/src/ui_catalog.rs | 19 +- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/input.rs | 1 + crates/ui-egui/src/custom_toolbars.rs | 1053 +++++++++++++++++++++- crates/ui-egui/src/edit_toolbar/tests.rs | 5 +- crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + 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 | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/menus/ui_commands.rs | 19 +- crates/ui-egui/src/panel_docking.rs | 5 +- crates/ui-egui/src/state.rs | 13 +- crates/ui-egui/src/toolbar.rs | 2 + crates/ui-egui/src/tools_float.rs | 9 +- docs/control-protocol.md | 5 +- docs/gaps.md | 5 +- docs/mcp.md | 37 + docs/target-app-parity.md | 1 + 41 files changed, 1249 insertions(+), 35 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..bfea75d60a 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -563,7 +563,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", ), - ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ( + "window.toolbar.dock", + "Dock Toolbar", + "", + "{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.float", + "Float Toolbar", + "", + "{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.list", + "List Toolbars", + "", + "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)", + ), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( "window.pathfinder.compoundShapes", diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 67e67ac444..60d9b61027 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -599,6 +599,8 @@ impl VectorcraftApp { // Toolbar too. toolbar::show_custom(self, ui.ctx()); } + // The lanes along the window's edges where toolbars made with New Toolbar dock. + crate::custom_toolbars::lanes(self, ui); panels::library_panel::tidy(self); // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas. egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui)); diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 29baa37d23..95f719d08b 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -16,6 +16,7 @@ impl VectorcraftApp { native_menu::run(self, ctx); if self.fonts_ready { tools_float::cancel_move_on_escape(self, ctx); + crate::custom_toolbars::cancel_move_on_escape(self, ctx); shortcuts::handle(self, ctx); } // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index f356f18b68..2b4618048a 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -8,6 +8,17 @@ //! item shows or hides it, and is kept with the workspace it was made in (`Workspace::toolbars`). //! Window › Toolbars › Manage Toolbars (also in the drawer's menu) renames, copies and deletes them. //! +//! A toolbar docks in a lane along the window's left or right edge (on the left, right of the Tools +//! panel docked there or beside the canvas), when its title bar is dropped on the edge (lit up while it is dragged), or with +//! Dock to Left and Dock to Right in its title bar's context menu (`window.toolbar.dock`). Several +//! toolbars docked at one edge stack from the top in the order they docked, each with its share of +//! the lane's height, and the lane is as wide as its widest toolbar (a lane too short for its +//! stack wraps it into more columns, up to half the width beside it, and past that the toolbars +//! show only their title bars, which the mouse wheel scrolls through when even they do not fit, so +//! none covers another). Dragging a docked toolbar's +//! title bar out of it floats it under the pointer, as Float Toolbar does where it floated last +//! (`window.toolbar.float`); Escape during the drag puts it back. These are VectorCraft's own rules. +//! //! Behavior from the public documentation: //! (Create and manage toolbars: New Toolbar //! from Window › Toolbars or the drawer's flyout menu, a name and OK make a blank toolbar, its Edit @@ -41,6 +52,14 @@ const GRIP: f32 = 14.0; const TITLE_BUTTON: f32 = 12.0; /// Where the first toolbar made floats, right of the Tools panel; the next ones go lower. const NEW_AT: [f32; 2] = [96.0, 120.0]; +/// The space around each toolbar docked in a lane, and between two of them. +const LANE_GAP: f32 = 6.0; +/// Width of the strip along the window's edge where a dropped title bar docks its toolbar, while +/// that edge's lane is narrower or empty. +const ZONE: f32 = 48.0; +/// Points the pointer travels from its press before a drop docks (a click on the title bar of a +/// toolbar floating near the edge doesn't dock it). +const TRAVEL: f32 = 4.0; /// A toolbar's name without leading and trailing spaces, in lowercase. Names with the same key are /// the same name: lookups, the check that a name is free, sanitizing and each toolbar's area @@ -105,6 +124,8 @@ pub(crate) fn create(ui: &mut UiState, name: &str) -> Result { slots: vec![], shown: true, pos, + dock: None, + dock_right: false, slot_tool: Default::default(), double: false, floating: vec![], @@ -113,6 +134,95 @@ pub(crate) fn create(ui: &mut UiState, name: &str) -> Result { Ok(name.to_string()) } +/// Number the toolbars docked at each edge 0, 1, 2… from the top, in their order (in the list's +/// order where two have the same place). +fn renumber(bars: &mut [CustomToolbar]) { + for right in [false, true] { + let mut on: Vec<(Option, usize)> = + bars.iter().enumerate().filter(|(_, b)| b.dock.is_some() && b.dock_right == right).map(|(k, b)| (b.dock, k)).collect(); + on.sort_unstable(); + for (n, (_, k)) in on.into_iter().enumerate() { + if let Some(b) = bars.get_mut(k) { + b.dock = Some(n as u32); + } + } + } +} + +/// Dock the toolbar at `k` at the right edge or the left, at place `at` of that edge's stack (none +/// or past the end: at the end); the toolbars from that place down move down one. Returns its place. +fn dock_at(bars: &mut [CustomToolbar], k: usize, right: bool, at: Option) -> u32 { + if let Some(b) = bars.get_mut(k) { + b.dock = None; + } + renumber(bars); + let n = bars.iter().filter(|b| b.dock.is_some() && b.dock_right == right).count() as u32; + let at = at.map_or(n, |at| at.min(n)); + for b in bars.iter_mut().filter(|b| b.dock_right == right) { + if let Some(o) = b.dock.as_mut() + && *o >= at + { + *o += 1; + } + } + if let Some(b) = bars.get_mut(k) { + b.dock = Some(at); + b.dock_right = right; + } + at +} + +/// Where the toolbar is: `{toolbar, floating, side, order, pos}`, the edge it docks at (while it +/// floats, the edge it docked at last), its place in that edge's stack while docked (else null) and +/// where it floats (while docked, where it floated last). +fn placement(bar: &CustomToolbar) -> Value { + let side = if bar.dock_right { "right" } else { "left" }; + json!({ "toolbar": bar.name, "floating": bar.dock.is_none(), "side": side, "order": bar.dock, "pos": bar.pos }) +} + +/// The toolbars that show docked at the right edge or the left, by index, from the top. +fn docked(ui: &UiState, right: bool) -> Vec { + let mut on: Vec<(Option, usize)> = ui + .custom_toolbars + .iter() + .enumerate() + .filter(|(_, b)| b.shown && b.dock.is_some() && b.dock_right == right) + .map(|(k, b)| (b.dock, k)) + .collect(); + on.sort_unstable(); + on.into_iter().map(|(_, k)| k).collect() +} + +/// Width of `bar` inside its border: one column of tool buttons, or two. +fn bar_width(bar: &CustomToolbar) -> f32 { + WIDTH + if bar.double { BUTTON } else { 0.0 } +} + +/// The least height a docked toolbar draws at: its border, its title bar and the gap under it, the +/// least height its tools scroll in (`egui::ScrollArea`'s default `min_scrolled_height`, 64 points) +/// and its bottom margin. +const DOCKED_LEAST: f32 = 2.0 + GRIP + 4.0 + 64.0 + 4.0; + +/// The columns a lane `height` points tall lays `n` docked toolbars out in, at most `most` of them, +/// and the most toolbars each column holds: one column while each toolbar gets at least +/// [`DOCKED_LEAST`] points of the lane's height, else as few more columns as give every toolbar +/// that much, so none covers another. With `most` columns too few for that, the toolbars spread +/// evenly over `most` columns, and those with less than [`DOCKED_LEAST`] points show only their +/// title bars ([`docked_places`]). +fn lane_columns(n: usize, height: f32, most: usize) -> (usize, usize) { + let fit = ((height - LANE_GAP) / (DOCKED_LEAST + LANE_GAP)).floor(); + let fit = if fit.is_finite() && fit >= 1.0 { fit as usize } else { 1 }; + let per = n.div_ceil(n.div_ceil(fit).clamp(1, most.max(1))).max(1); + (n.div_ceil(per).max(1), per) +} + +/// The most columns of toolbars at most `widest` points wide inside their borders that `width` +/// points hold with [`LANE_GAP`] around each: at least one. +fn most_columns(width: f32, widest: f32) -> usize { + let most = ((width - LANE_GAP) / (widest.max(1.0) + LANE_GAP) + 1e-3).floor(); + if most.is_finite() && most >= 1.0 { most as usize } else { 1 } +} + /// Toolbar `name`, shown after Tab hid the toolbars, shows alone, as Window › Tools shows the /// Tools panel: the other toolbars Tab hid are turned off, and the next Tab hides it. fn show_alone_after_tab(ui: &mut UiState, name: &str) { @@ -193,6 +303,8 @@ pub(crate) fn duplicate(ui: &mut UiState, name: Option<&str>) -> Result, name: &str) let k = index(ui, name)?; close_popups(ui, ctx, name, None); ui.custom_toolbars.remove(k); + renumber(&mut ui.custom_toolbars); Ok(()) } /// Keep named toolbars, each name once whatever its case and at most [`MAX_NAME`] characters, of /// known tools, each once in its toolbar, at a position that is a number, with one strip per group -/// torn off them, and each group's tool chosen last only when the group holds it (a hand-edited -/// preferences file or workspace). +/// torn off them, each group's tool chosen last only when the group holds it, and the docked ones +/// numbered 0, 1, 2… at each edge (a hand-edited preferences file or workspace). pub(crate) fn sanitize(bars: &mut Vec) { let mut names = std::collections::BTreeSet::new(); bars.retain_mut(|b| { @@ -237,6 +350,7 @@ pub(crate) fn sanitize(bars: &mut Vec) { !b.name.is_empty() && b.name.chars().count() <= MAX_NAME && names.insert(key(&b.name)) }); bars.truncate(MAX); + renumber(bars); } /// Keep the strips torn off `bar` whose group it still holds, each with the group's tools as they @@ -306,11 +420,54 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + let k = index(&app.ui, name)?; + let bar = app.ui.custom_toolbars.get(k).ok_or_else(|| format!("no toolbar named `{name}`"))?; + // Docking: at the edge it docked at last unless `side` says which. + let right = match p.get("side") { + None | Some(Value::Null) => bar.dock_right, + // `Start` and `End`, as panels take them, too. + Some(Value::String(s)) if s.eq_ignore_ascii_case("left") || s.eq_ignore_ascii_case("start") => false, + Some(Value::String(s)) if s.eq_ignore_ascii_case("right") || s.eq_ignore_ascii_case("end") => true, + Some(Value::String(s)) => return Err(format!("side is left or right, not `{s}`")), + Some(v) => return Err(format!("side is left or right, not `{v}`")), + }; + let others = app.ui.custom_toolbars.iter().enumerate().filter(|&(i, b)| i != k && b.dock.is_some() && b.dock_right == right).count(); + let at = match p.get("order") { + None | Some(Value::Null) => None, + Some(v) => match v.as_u64().filter(|&n| n <= others as u64) { + Some(n) => Some(n as u32), + None => return Err(format!("order is a whole number from 0 to {others}, not `{v}`")), + }, + }; + // Docked at that edge already, with no place asked for, it stays where it is. + if !(bar.dock.is_some() && bar.dock_right == right && at.is_none()) { + dock_at(&mut app.ui.custom_toolbars, k, right, at); + } + app.ui.custom_toolbars.get(k).map(placement).ok_or_else(|| format!("no toolbar named `{name}`")) + })(), + "window.toolbar.float" => (|| { + let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?; + let k = index(&app.ui, name)?; + let at = crate::tools_float::position(p)?; + if let Some(b) = app.ui.custom_toolbars.get_mut(k) { + b.dock = None; + if let Some(at) = at { + b.pos = [at.x, at.y]; + } + } + renumber(&mut app.ui.custom_toolbars); + app.ui.custom_toolbars.get(k).map(placement).ok_or_else(|| format!("no toolbar named `{name}`")) + })(), "window.toolbar.list" => Ok(json!( app.ui .custom_toolbars .iter() - .map(|b| json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots})) + .map(|b| { + let side = if b.dock_right { "right" } else { "left" }; + json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots, "floating": b.dock.is_none(), "side": side, "order": b.dock}) + }) .collect::>() )), _ => return None, @@ -563,17 +720,268 @@ enum Action { Hide(String), /// The title bar's double arrow: two columns (true) or one. Columns(String, bool), + /// Dock to Left (false) or Dock to Right (true) in the title bar's context menu. + Dock(String, bool), + /// Float Toolbar in the title bar's context menu. + Float(String), } -/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged: the -/// title bar's × hides it and its double arrow lays the tools out in one column or two; then its -/// tool buttons (a group's flyout opens as the Tools panel's do), the + button whose menu adds and -/// removes tools, and the ••• button that opens the All Tools drawer beside it. +/// The lane along the window's right edge (true) or left edge where docked toolbars show, as last +/// laid out; removed while it is empty. +fn lane_id(right: bool) -> egui::Id { + egui::Id::unique(("custom-toolbar-lane", right)) +} + +/// Where a title bar dropped docks its toolbar at the right edge (true) or the left, as last laid +/// out. +fn zone_id(right: bool) -> egui::Id { + egui::Id::unique(("custom-toolbar-zone", right)) +} + +/// A drag of a toolbar's title bar in progress: the toolbar, where the press began and how far that +/// is from the toolbar's corner, where the toolbar was before the drag (to put it back on Escape) +/// and whether Escape put it back. +#[derive(Clone, Debug)] +struct Move { + name: String, + from: egui::Pos2, + grab: egui::Vec2, + dock: Option, + right: bool, + pos: [f32; 2], + cancelled: bool, +} + +fn move_id() -> egui::Id { + egui::Id::unique("custom-toolbar-move") +} + +/// The lanes along the window's left and right edges where the docked toolbars that show are drawn, +/// each as wide as its widest toolbar with [`LANE_GAP`] either side (times the columns of +/// [`lane_columns`] when the lane is too short for one, at most half the width left for the lane), +/// and the targets where a title +/// bar dropped docks its toolbar: from the window's edge across the lane, or across a strip +/// [`ZONE`] points wide when the lane is narrower or empty, and on the left across the Tools panel +/// docked there too. Laid out before the canvas and the panels, which take the space left, except +/// the left lane while Tools is docked beside the canvas ([`left_lane_beside_canvas`]). A lane +/// laid out anew draws again, since its toolbars were placed by the lane of the frame before. +pub(crate) fn lanes(app: &VectorcraftApp, ui: &mut egui::Ui) { + for right in [false, true] { + if right || !left_lane_beside_canvas(&app.ui) { + let (edge, room) = (ui.ctx().content_rect(), ui.available_rect_before_wrap().width() / 2.0); + lane(app, ui, right, edge, room); + } + } +} + +/// The width the canvas panel keeps for the left lane while it stands beside the canvas +/// ([`left_lane_beside_canvas`]): one column of its widest toolbar, none while no docked toolbar +/// shows there. +pub(crate) fn beside_canvas_reserve(ui: &UiState) -> f32 { + if !left_lane_beside_canvas(ui) || ui.tab_hid.is_some() { + return 0.0; + } + let widest = docked(ui, false).iter().filter_map(|&k| ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + if widest > 0.0 { widest + 2.0 * LANE_GAP } else { 0.0 } +} + +/// With Keep Tools Beside the Canvas on and Tools docked on the left, Tools draws inside the canvas +/// panel, right of the panels docked on the left, and the left lane goes right of it there +/// ([`lane_beside_canvas`]). +pub(crate) fn left_lane_beside_canvas(ui: &UiState) -> bool { + ui.tools_beside_canvas && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() && !ui.toolbar_right +} + +/// The left lane inside the canvas panel, right of the Tools panel docked beside the canvas +/// (`toolbar::show_beside_canvas`, after Tools). Its target reaches from the canvas's left edge, +/// across Tools, over the lane. It wraps into more columns only as far as the canvas keeps its +/// least width, 200 points (`panel_docking`'s canvas limits, which add [`beside_canvas_reserve`]). +pub(crate) fn lane_beside_canvas(app: &VectorcraftApp, ui: &mut egui::Ui) { + if left_lane_beside_canvas(&app.ui) { + let avail = ui.available_rect_before_wrap(); + let tools = ui.ctx().data(|d| d.get_temp::(crate::toolbar::docked_rect_id())); + let edge = tools.map_or(avail, |t| t.union(avail)); + lane(app, ui, false, edge, (avail.width() / 2.0).min(avail.width() - 200.0)); + } +} + +/// The lane at the right edge (true) or the left of `ui`'s space, at most `room` points wide unless +/// one column is wider, and its target, which reaches out to `edge`'s side. +fn lane(app: &VectorcraftApp, ui: &mut egui::Ui, right: bool, edge: egui::Rect, room: f32) { + let ctx = ui.ctx().clone(); + let shown = app.ui.tab_hid.is_none() && app.ui.screen_mode < 3; + let on = if shown { docked(&app.ui, right) } else { vec![] }; + let widest = on.iter().filter_map(|&k| app.ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + let avail = ui.available_rect_before_wrap(); + let most = most_columns(room, widest); + let cols = lane_columns(on.len(), avail.height(), most).0 as f32; + let width = if widest > 0.0 { cols * widest + (cols - 1.0) * LANE_GAP } else { 0.0 }; + let reach = (width + 2.0 * LANE_GAP).max(ZONE); + let zone = if right { + egui::Rect::from_min_max(pos2(avail.right() - reach, avail.top()), pos2(edge.right(), avail.bottom())) + } else { + egui::Rect::from_min_max(pos2(edge.left(), avail.top()), pos2(avail.left() + reach, avail.bottom())) + }; + let lane = (width > 0.0).then(|| { + let panel = if right { egui::Panel::right("custom-toolbar-lane-right") } else { egui::Panel::left("custom-toolbar-lane-left") }; + panel.resizable(false).exact_size(width + 2.0 * LANE_GAP).frame(egui::Frame::NONE).show(ui, |_| {}).response.rect + }); + if ctx.data(|d| d.get_temp::(lane_id(right))) != lane { + ctx.request_repaint(); + } + ctx.data_mut(|d| { + d.insert_temp(zone_id(right), zone); + match lane { + Some(lane) => { + d.insert_temp(lane_id(right), lane); + } + None => d.remove::(lane_id(right)), + } + }); +} + +/// Where each docked toolbar that shows is drawn, by index: its top-left corner and the most height +/// it takes, and whether it shows only its title bar. Each lane stacks its toolbars from the top in +/// their order, each with an equal share of its column's height; a toolbar shorter than its share +/// leaves the rest to the ones below it. A lane too short for one column wraps its stack into the +/// columns of [`lane_columns`], left to right, as many as the lane is wide enough for, so every +/// toolbar's title bar and tools stay in view; when even those columns give a toolbar less than +/// [`DOCKED_LEAST`] points, the toolbars of that column show only their title bars, which still +/// float or dock them. When a column is too short for even their title bars, it shows as many as +/// fit and the mouse wheel over it scrolls through the rest; those scrolled out of view have no +/// corner. Every toolbar stays inside the lane. A toolbar docked since the lanes were last laid +/// out is not drawn until they are. +fn docked_places(ui: &UiState, ctx: &egui::Context) -> Vec<(usize, Option, f32, bool)> { + let mut out = vec![]; + // The least height a toolbar takes: its title bar, its border and its bottom margin. + let least = GRIP + LANE_GAP; + for right in [false, true] { + let Some(lane) = ctx.data(|d| d.get_temp::(lane_id(right))) else { continue }; + let on = docked(ui, right); + let widest = on.iter().filter_map(|&k| ui.custom_toolbars.get(k)).map(bar_width).fold(0.0, f32::max); + let (cols, per) = lane_columns(on.len(), lane.height(), most_columns(lane.width(), widest)); + let pitch = (lane.width() - LANE_GAP) / cols as f32; + for (c, column) in on.chunks(per).enumerate() { + let middle = lane.left() + LANE_GAP / 2.0 + pitch * (c as f32 + 0.5); + // More than even their title bars fit: the column shows as many as fit, and the wheel + // over the column scrolls through the others, one toolbar a notch. + let fits = ((lane.height() - LANE_GAP) / (least + LANE_GAP) + 1e-3).floor().max(1.0) as usize; + let column = if column.len() > fits { + let id = lane_id(right).with(("first", c)); + let left = lane.left() + LANE_GAP / 2.0 + pitch * c as f32; + let strip = egui::Rect::from_x_y_ranges(left..=left + pitch, lane.y_range()); + let notch = ctx.input(|i| { + let over = i.pointer.hover_pos().is_some_and(|p| strip.contains(p)); + let wheel = i.events.iter().filter_map(|e| if let egui::Event::MouseWheel { delta, .. } = e { Some(delta.y) } else { None }); + if over { wheel.sum::() } else { 0.0 } + }); + let mut first = ctx.data(|d| d.get_temp::(id)).unwrap_or(0); + if notch > 0.0 { + first = first.saturating_sub(1); + } else if notch < 0.0 { + first += 1; + } + let first = first.min(column.len() - fits); + ctx.data_mut(|d| d.insert_temp(id, first)); + let (before, rest) = column.split_at(first); + let (shown, after) = rest.split_at(fits); + out.extend(before.iter().chain(after).map(|&k| (k, None, 0.0, true))); + shown + } else { + column + }; + let n = column.len() as f32; + let share = (lane.height() - LANE_GAP * (n + 1.0)) / n.max(1.0); + // Too little height for its tools: the title bar alone. + let bare = share < DOCKED_LEAST; + let share = share.max(least); + let mut y = lane.top() + LANE_GAP; + for &k in column { + let Some(bar) = ui.custom_toolbars.get(k) else { continue }; + let drawn = ctx.memory(|m| m.area_rect(area_id(&bar.name))); + let w = drawn.map_or(bar_width(bar) + 2.0, |r| r.width()); + // Drawn last frame taller than its share (its tools scroll only above a least + // height), it moves up to end at the lane's bottom. + let tall = drawn.map_or(least, |r| r.height().max(least)); + let top = y.min(lane.bottom() - tall).max(lane.top()); + let room = share.min(lane.bottom() - top).max(least); + out.push((k, Some(pos2((middle - w / 2.0).round(), top)), room, bare)); + y = top + drawn.map_or(room, |r| r.height().min(room)) + LANE_GAP; + } + } + } + out +} + +/// The edge a title bar released at the pointer docks its toolbar at (true: the right), with the +/// target to light up: none until the pointer has moved [`TRAVEL`] points from the press at `from`. +fn drop_target(ctx: &egui::Context, from: egui::Pos2) -> Option<(egui::Rect, bool)> { + let at = ctx.input(|i| i.pointer.interact_pos()).filter(|at| at.distance(from) > TRAVEL)?; + [false, true].into_iter().find_map(|right| ctx.data(|d| d.get_temp::(zone_id(right))).filter(|z| z.contains(at)).map(|z| (z, right))) +} + +/// Put the toolbar being dragged back where the drag began; returns whether a drag was in progress. +fn cancel(ui: &mut UiState, ctx: &egui::Context) -> bool { + let Some(mut m) = ctx.data(|d| d.get_temp::(move_id())).filter(|m| !m.cancelled) else { return false }; + if let Ok(k) = index(ui, &m.name) { + match m.dock { + Some(at) => { + dock_at(&mut ui.custom_toolbars, k, m.right, Some(at)); + } + None => { + if let Some(b) = ui.custom_toolbars.get_mut(k) { + b.dock = None; + b.dock_right = m.right; + } + renumber(&mut ui.custom_toolbars); + } + } + if let Some(b) = ui.custom_toolbars.get_mut(k) { + b.pos = m.pos; + } + } + m.cancelled = true; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + true +} + +/// Escape while a toolbar's title bar is dragged puts the toolbar back where the drag began, and the +/// press runs no shortcut ([`crate::tools_float::escape_cancelled_move`]). +pub(crate) fn cancel_move_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) && cancel(&mut app.ui, ctx) { + crate::tools_float::mark_escape_cancelled(ctx); + } +} + +/// Light up the target a title bar dropped now docks its toolbar at. +fn highlight(ctx: &egui::Context, zone: egui::Rect) { + let t = Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("custom-toolbar-drop"))); + painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); +} + +/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged or +/// docked in its edge's lane ([`lanes`]): the title bar's × hides it, its double arrow lays the +/// tools out in one column or two, and its context menu docks or floats it; then its tool buttons +/// (a group's flyout opens as the Tools panel's do), the + button whose menu adds and removes +/// tools, and the ••• button that opens the All Tools drawer beside it. pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { + // A drag ends with the press, also for a toolbar hidden while it was dragged. + let end_move = || { + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(move_id())); + } + }; // Tab hides them, also when Window › Tools shows the Tools panel again. if app.ui.tab_hid.is_some() || app.ui.custom_toolbars.iter().all(|b| !b.shown) { + end_move(); return; } + // Losing the window's focus puts a toolbar being dragged back, as Escape does. + if !ctx.input(|i| i.focused) { + cancel(&mut app.ui, ctx); + } + let places = docked_places(&app.ui, ctx); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let active = app.session.tool_id(); @@ -592,11 +1000,27 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let id = area_id(&bar.name); let cols = if bar.double { 2 } else { 1 }; let width = WIDTH + BUTTON * (cols - 1) as f32; - // Kept on screen by its title bar and first tools: the rest scrolls. - let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0))); - let pos = crate::tools_float::clamp(bar.pos, size, screen); + // Docked: where its lane puts it, its tools scrolling in its share of the lane, or only its + // title bar when that share is too small for them. + let (pos, limit, bare) = if bar.dock.is_some() { + let Some(&(_, pos, share, bare)) = places.iter().find(|(i, ..)| *i == k) else { + // Docked since the lanes were laid out: drawn in the next frame. + ctx.request_repaint(); + continue; + }; + // Scrolled out of its column's view. + let Some(pos) = pos else { continue }; + (pos, share - LANE_GAP, bare) + } else { + // Kept on screen by its title bar and first tools: the rest scrolls. + let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0))); + let pos = crate::tools_float::clamp(bar.pos, size, screen); + (pos, screen.bottom() - pos.y - 12.0, false) + }; let edit_this = editing.as_deref() == Some(bar.name.as_str()); let mut moved = None; + // The title bar's drag this frame: started, going on, ended. + let mut drag = (false, false, false); let mut drops = None; // A Learn link locating a tool of this toolbar: its button, scrolled into view once. let locate = crate::panels::learn::locate::wanted(&app.ui, Some(&bar.name)); @@ -616,9 +1040,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| { ui.set_width(width); - // The tools and the ••• button scroll when they don't fit in the window under the - // title bar. - ui.set_max_height(screen.bottom() - pos.y - 12.0); + // The tools and the ••• button scroll when they don't fit in the window (docked: in + // its share of the lane) under the title bar. + ui.set_max_height(limit); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); let middle = ui.max_rect().center().x; // The title bar: × at its left end, the double arrow at its right end, and the grip @@ -641,14 +1065,31 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if aresp.on_hover_text(tl!("Toggle single/double column")).clicked() { actions.push(Action::Columns(bar.name.clone(), !bar.double)); } - // Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not stop on it. - let gresp = ui.interact(grip, id.with("grip"), Sense::DRAG); + // Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not + // stop on it. It senses clicks for its context menu. + let gresp = ui.interact(grip, id.with("grip"), Sense::CLICK | Sense::DRAG); for n in 0..3 { let y = grip.center().y - 2.0 + n as f32 * 2.0; let half = (grip.width() / 2.0 - 2.0).clamp(0.0, 8.0); ui.painter().line_segment([pos2(grip.center().x - half, y), pos2(grip.center().x + half, y)], Stroke::new(0.5, t.text_disabled)); } gresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &bar.name)); + // Its context menu: Dock to Left and Dock to Right, the edge it is docked at checked, + // and Float Toolbar while it is docked. + gresp.context_menu(|ui| { + for (right, label) in [(false, tl!("Dock to Left")), (true, tl!("Dock to Right"))] { + let here = bar.dock.is_some() && bar.dock_right == right; + if widgets::menu_item(ui, label, !here, here) { + actions.push(Action::Dock(bar.name.clone(), right)); + ui.close(); + } + } + if widgets::menu_item(ui, tl!("Float Toolbar"), bar.dock.is_some(), false) { + actions.push(Action::Float(bar.name.clone())); + ui.close(); + } + }); + drag = (gresp.drag_started(), gresp.dragged(), gresp.drag_stopped()); // Held where the press took it, from the toolbar's corner, while it is dragged. let grab = id.with("grab"); if gresp.dragged() { @@ -662,6 +1103,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.data_mut(|d| d.remove::(grab)); gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(&bar.name); } + if bare { + return; + } // A gap keeps a press near the grip bar's lower edge on the bar. ui.add_space(4.0); let mut buttons: Vec<(usize, egui::Rect)> = vec![]; @@ -713,11 +1157,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds, Some(&bar.name)); } }); - if let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite()) - && let Some(b) = app.ui.custom_toolbars.get_mut(k) - { - b.pos = [p.x, p.y]; - } + drag_title_bar(app, ctx, k, drag, moved); // The button is marked when it shows the located tool, else the group's flyout opens on it; // a group torn off into a strip is marked there. if did_scroll { @@ -788,8 +1228,71 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { app.status(e); } } + Action::Dock(name, right) => { + if let Err(e) = app.run("window.toolbar.dock", json!({ "name": name, "side": if right { "right" } else { "left" } })) { + app.status(e); + } + } + Action::Float(name) => { + if let Err(e) = app.run("window.toolbar.float", json!({ "name": name })) { + app.status(e); + } + } } } + end_move(); +} + +/// The drag of the title bar of the toolbar at `k` this frame (`drag`: started, going on, ended), +/// with the pointer holding its corner at `moved`. A floating toolbar follows the pointer, and the +/// target under the pointer lights up; released there, it docks at the end of that edge's stack. A +/// docked toolbar stays in its lane until the pointer leaves the lane, and then floats under the +/// pointer, also when the press is released in the frame it leaves the lane. +/// After Escape, the rest of the drag moves nothing. +fn drag_title_bar(app: &mut VectorcraftApp, ctx: &egui::Context, k: usize, drag: (bool, bool, bool), moved: Option) { + let (started, dragging, stopped) = drag; + let Some(bar) = app.ui.custom_toolbars.get(k) else { return }; + if started && let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) { + let grab = ctx.memory(|m| m.area_rect(area_id(&bar.name))).map_or(egui::Vec2::ZERO, |r| from - r.min); + let m = Move { name: bar.name.clone(), from, grab, dock: bar.dock, right: bar.dock_right, pos: bar.pos, cancelled: false }; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + } + let Some(m) = ctx.data(|d| d.get_temp::(move_id())).filter(|m| !m.cancelled && key(&m.name) == key(&bar.name)) else { + return; + }; + let from = m.from; + let mut docked = bar.dock.is_some(); + if docked && (dragging || stopped) { + let at = ctx.input(|i| i.pointer.interact_pos()); + // Where the pointer holds its corner; on the release frame, which reports no move, from + // where the press took it. + let corner = moved.or_else(|| at.map(|at| at - m.grab)).filter(|p| p.x.is_finite() && p.y.is_finite()); + let lane = ctx.data(|d| d.get_temp::(lane_id(bar.dock_right))).or_else(|| ctx.memory(|m| m.area_rect(area_id(&bar.name)))); + let inside = lane.zip(at).is_some_and(|(r, at)| r.contains(at)); + if !inside && let Some(p) = corner { + if let Some(b) = app.ui.custom_toolbars.get_mut(k) { + b.dock = None; + b.pos = [p.x, p.y]; + } + renumber(&mut app.ui.custom_toolbars); + docked = false; + } + } else if dragging + && let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite()) + && let Some(b) = app.ui.custom_toolbars.get_mut(k) + { + b.pos = [p.x, p.y]; + } + if docked { + return; + } + let target = drop_target(ctx, from); + if dragging && let Some((zone, _)) = target { + highlight(ctx, zone); + } + if stopped && let Some((_, right)) = target { + dock_at(&mut app.ui.custom_toolbars, k, right, None); + } } /// What a toolbar's tool buttons need from the frame: the toolbar, the active tool, whether the @@ -970,10 +1473,8 @@ mod tests { let names: Vec<&str> = toolbars_menu(&app).into_iter().filter(|(_, id, _)| *id == "window.toolbar.custom").map(|(l, ..)| l).collect(); assert_eq!(names, ["Mine", "Toolbar 1"]); let list = app.run("window.toolbar.list", json!({})).unwrap(); - assert_eq!( - list, - json!([{"name": "Mine", "shown": true, "double": false, "slots": []}, {"name": "Toolbar 1", "shown": true, "double": false, "slots": []}]) - ); + let blank = |name: &str| json!({"name": name, "shown": true, "double": false, "slots": [], "floating": true, "side": "left", "order": null}); + assert_eq!(list, json!([blank("Mine"), blank("Toolbar 1")])); } /// The New Toolbar dialog: a name typed in it and Enter make the toolbar; a name that is taken is @@ -1691,6 +2192,8 @@ mod tests { slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(), shown: true, pos: [f32::NAN, 0.0], + dock: None, + dock_right: false, slot_tool: Default::default(), double: false, floating: ["pen", "nope", "pen"] @@ -1712,7 +2215,10 @@ mod tests { bars[0].slot_tool = chosen(&[("pen", "curvature"), ("curvature", "pen"), ("nope", "pen")]); bars[4].slot_tool = chosen(&[("pen", "curvature")]); bars[5].slot_tool = chosen(&[("pen", "curvature")]); + // Docked places with gaps and repeats, and one dropped with its toolbar, are numbered from 0. + (bars[0].dock, bars[1].dock, bars[4].dock, bars[5].dock) = (Some(7), Some(0), Some(7), Some(3)); sanitize(&mut bars); + assert_eq!(bars.iter().map(|b| b.dock).collect::>(), [Some(1), Some(2), Some(0)]); assert_eq!(bars.iter().map(|b| b.name.as_str()).collect::>(), ["A", "B", "C"], "a name past {MAX_NAME} characters goes too"); assert_eq!(bars[0].slot_tool, chosen(&[("pen", "curvature")])); assert!(bars[1].slot_tool.is_empty() && bars[2].slot_tool.is_empty(), "{:?} {:?}", bars[1].slot_tool, bars[2].slot_tool); @@ -1722,4 +2228,503 @@ mod tests { assert_eq!(bars[0].floating.iter().map(|f| f.tools.join(",")).collect::>(), ["pen,curvature"]); assert!(bars[1].floating.is_empty(), "B holds no group"); } + + /// Where the toolbar named `name` is: its place in its edge's stack (none: floating) and + /// whether that edge is the right one. + fn place(app: &VectorcraftApp, name: &str) -> (Option, bool) { + let b = find(&app.ui, name).unwrap(); + (b.dock, b.dock_right) + } + + /// `window.toolbar.dock` docks a toolbar at an edge, at the end of its stack or at the place + /// asked for, and `window.toolbar.float` floats it where it floated last or at x, y; bad params + /// change nothing. Hiding and renaming keep its place, a copy floats, and deleting closes the + /// stack up. The places are listed, and kept with the workspace and the preferences. + #[test] + fn toolbars_dock_and_float_by_their_commands() { + let mut app = app(); + app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap(); + for name in ["A", "B", "C"] { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + } + assert_eq!(place(&app, "A"), (None, false), "New Toolbar floats"); + let a_pos = find(&app.ui, "A").unwrap().pos; + let r = app.run("window.toolbar.dock", json!({"name": "a"})).unwrap(); + assert_eq!(r, json!({"toolbar": "A", "floating": false, "side": "left", "order": 0, "pos": a_pos})); + app.run("window.toolbar.dock", json!({"name": "B", "side": "left"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "C", "side": "start", "order": 0})).unwrap(); + assert_eq!([place(&app, "C"), place(&app, "A"), place(&app, "B")], [(Some(0), false), (Some(1), false), (Some(2), false)]); + // Docked again at the edge it is docked at, it stays where it is. + app.run("window.toolbar.dock", json!({"name": "A"})).unwrap(); + assert_eq!(place(&app, "A"), (Some(1), false)); + app.run("window.toolbar.dock", json!({"name": "A", "order": 2})).unwrap(); + assert_eq!([place(&app, "C"), place(&app, "B"), place(&app, "A")], [(Some(0), false), (Some(1), false), (Some(2), false)]); + // Bad params are errors and change nothing. + let before = app.ui.custom_toolbars.clone(); + for p in [ + json!({"name": "A", "side": "top"}), + json!({"name": "A", "side": 1}), + json!({"name": "A", "order": 3}), + json!({"name": "A", "order": -1}), + json!({"name": "A", "order": 0.5}), + json!({"name": "A", "order": "1"}), + json!({"name": "Nope"}), + json!({}), + ] { + assert!(app.run("window.toolbar.dock", p.clone()).is_err(), "{p}"); + } + for p in + [json!({"name": "A", "x": 10}), json!({"name": "A", "x": 1e7, "y": 0}), json!({"name": "A", "x": "1", "y": 2}), json!({"name": "Nope"})] + { + assert!(app.run("window.toolbar.float", p.clone()).is_err(), "{p}"); + } + assert_eq!(app.ui.custom_toolbars, before); + // At the right edge; the left edge's stack closes up. + let r = app.run("window.toolbar.dock", json!({"name": "A", "side": "RIGHT"})).unwrap(); + assert_eq!((&r["side"], &r["order"]), (&json!("right"), &json!(0))); + assert_eq!([place(&app, "C"), place(&app, "B")], [(Some(0), false), (Some(1), false)]); + // Floating where it floated last or at x, y; docked again at the edge it docked at last. + let r = app.run("window.toolbar.float", json!({"name": "A"})).unwrap(); + assert_eq!(r, json!({"toolbar": "A", "floating": true, "side": "right", "order": null, "pos": a_pos})); + app.run("window.toolbar.float", json!({"name": "A", "x": 300, "y": 200})).unwrap(); + assert_eq!(find(&app.ui, "A").unwrap().pos, [300.0, 200.0]); + assert_eq!(app.run("window.toolbar.dock", json!({"name": "A"})).unwrap()["side"], json!("right")); + // Hidden and renamed, it keeps its place; a copy floats; deleted, the stack closes up. + app.run("window.toolbar.custom", json!({"name": "C", "show": false})).unwrap(); + app.run("window.toolbar.rename", json!({"name": "C", "to": "Cee"})).unwrap(); + assert_eq!(place(&app, "Cee"), (Some(0), false)); + let copy = app.run("window.toolbar.duplicate", json!({"name": "B"})).unwrap(); + assert_eq!(place(&app, copy["toolbar"].as_str().unwrap()), (None, false)); + app.run("window.toolbar.delete", json!({"name": "Cee"})).unwrap(); + assert_eq!(place(&app, "B"), (Some(0), false)); + let list = app.run("window.toolbar.list", json!({})).unwrap(); + let rows: Vec<_> = list.as_array().unwrap().iter().map(|b| json!([b["name"], b["floating"], b["side"], b["order"]])).collect(); + assert_eq!(rows, [json!(["A", false, "right", 0]), json!(["B", false, "left", 0]), json!(["B Copy", true, "left", null])]); + // Kept with the workspace and the preferences. + let kept = app.ui.custom_toolbars.clone(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + app.run("window.workspace", json!({"name": "Drawing"})).unwrap(); + assert_eq!(app.ui.custom_toolbars, kept); + let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(prefs.sanitized().custom_toolbars, kept); + // Saved before toolbars docked, a toolbar floats. + let mut old = serde_json::to_value(&kept[0]).unwrap(); + let fields = old.as_object_mut().unwrap(); + assert!(fields.remove("dock").is_some() && fields.remove("dock_right").is_some()); + let old: CustomToolbar = serde_json::from_value(old).unwrap(); + assert_eq!((old.dock, old.dock_right), (None, false)); + } + + /// Docked toolbars show in lanes along the window's edges, the left one right of the Tools + /// panel: stacked from the top, centered, each in its share of the lane, the lane as wide as its + /// widest toolbar and gone while none shows. The canvas and the panels take the space left. + #[test] + fn docked_toolbars_stack_in_lanes_along_the_window_edges() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + for name in ["A", "B", "R"] { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + } + for tool in vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).take(30) { + app.run("toolbar.addTool", json!({"tool": tool.id, "toolbar": "A"})).unwrap(); + } + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "B"})).unwrap(); + let mut time = 0.0; + let mut frames = |app: &mut VectorcraftApp| { + for _ in 0..4 { + time += 0.05; + window_frame(app, &ctx, time, 600.0); + } + }; + frames(&mut app); + let rect = |name: &str| ctx.memory(|m| m.area_rect(area_id(name))).unwrap(); + let lane = |right: bool| ctx.data(|d| d.get_temp::(lane_id(right))); + assert!(lane(false).is_none() && lane(true).is_none(), "no lane while every toolbar floats"); + for (name, side) in [("A", "left"), ("B", "left"), ("R", "right")] { + app.run("window.toolbar.dock", json!({ "name": name, "side": side })).unwrap(); + } + frames(&mut app); + let tools = ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).unwrap(); + let (left, right) = (lane(false).unwrap(), lane(true).unwrap()); + let (a, b, r) = (rect("A"), rect("B"), rect("R")); + assert!((left.left() - tools.right()).abs() < 1.0 && (left.width() - WIDTH - 2.0 * LANE_GAP).abs() < 0.5, "{left:?} {tools:?}"); + assert!(left.contains_rect(a) && left.contains_rect(b) && right.contains_rect(r), "{left:?} {a:?} {b:?} {right:?} {r:?}"); + assert!((right.right() - 1200.0).abs() < 0.5, "{right:?}"); + assert!(b.top() >= a.bottom() && (a.center().x - left.center().x).abs() < 1.0 && (b.center().x - a.center().x).abs() < 1.0, "{a:?} {b:?}"); + // A's 30 tools scroll in its half of the lane. + assert!(a.height() <= left.height() / 2.0, "{a:?} {left:?}"); + // The canvas ends left of the right lane. + let canvas_right = ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_right_id())).unwrap().right(); + assert!(canvas_right <= right.left() + 0.5, "{canvas_right} {right:?}"); + // Two columns widen the lane, and the toolbar stays docked. + app.run("window.toolbarColumns", json!({"toolbar": "B", "double": true})).unwrap(); + frames(&mut app); + assert!((lane(false).unwrap().width() - WIDTH - BUTTON - 2.0 * LANE_GAP).abs() < 0.5); + assert!(lane(false).unwrap().contains_rect(rect("B")) && place(&app, "B") == (Some(1), false)); + // Hidden, it keeps its place, and an empty lane goes. + app.run("window.toolbar.custom", json!({"name": "R", "show": false})).unwrap(); + frames(&mut app); + assert!(lane(true).is_none() && !drawn(&ctx, area_id("R"), egui::Order::Middle) && place(&app, "R") == (Some(0), true)); + // Tab hides the lanes with the toolbars. + app.run("window.dock", json!({})).unwrap(); + frames(&mut app); + assert!(lane(false).is_none() && !drawn(&ctx, area_id("A"), egui::Order::Middle)); + } + + /// With Keep Tools Beside the Canvas on, the left lane stands right of Tools, which stands right + /// of the panels docked on the left, and its target covers Tools and the lane, not the panels. In a + /// narrow window the lane leaves the canvas its least width of 200 points. With the setting off + /// again, the lane goes back to the window's edge, right of Tools. + #[test] + fn the_left_lane_follows_tools_docked_beside_the_canvas() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "A"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "A"})).unwrap(); + app.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + app.run("window.toolsBesideCanvas", json!({"on": true})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "A", "side": "left"})).unwrap(); + let mut time = 0.0; + let mut frames = |app: &mut VectorcraftApp, size: egui::Vec2| { + for _ in 0..6 { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + }; + frames(&mut app, vec2(1200.0, 600.0)); + let tools = || ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id())).unwrap(); + let lane = || ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + let zone = ctx.data(|d| d.get_temp::(zone_id(false))).unwrap(); + let a = ctx.memory(|m| m.area_rect(area_id("A"))).unwrap(); + assert!(tools().left() > 100.0, "Tools stands right of the left dock: {:?}", tools()); + assert!((lane().left() - tools().right()).abs() < 1.0, "{:?} {:?}", lane(), tools()); + assert!(lane().contains_rect(a), "{a:?} {:?}", lane()); + assert!(zone.contains(tools().center()) && zone.right() >= lane().right() - 0.5, "{zone:?}"); + assert!(!zone.contains(pos2(4.0, tools().center().y)), "the left dock is not a target: {zone:?}"); + // In a narrow, short window, a stack that wraps leaves the canvas its 200 points. + for k in 0..12 { + let name = format!("T{k}"); + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": name})).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "left" })).unwrap(); + } + frames(&mut app, vec2(700.0, 320.0)); + // The canvas panel's right edge, and the canvas drawn right of the lane. + let right = ctx.data(|d| d.get_temp::(crate::tools_float::tools_zone_right_id())).unwrap().right(); + let canvas = app.canvas_rect.unwrap(); + assert!(right - lane().right() >= 199.5 && canvas.left() >= lane().right() - 0.5, "{right} {canvas:?} {:?} {:?}", lane(), tools()); + app.run("window.toolsBesideCanvas", json!({"on": false})).unwrap(); + frames(&mut app, vec2(1200.0, 600.0)); + assert!(tools().left() < 1.0 && (lane().left() - tools().right()).abs() < 1.0, "{:?} {:?}", lane(), tools()); + } + + /// More docked toolbars than a short lane holds in one column wrap into more columns: each stays + /// inside the lane with its title bar, and none covers another. With more than half the window's + /// width of columns needed (60 toolbars), the lane stops at half the width and the toolbars show + /// only their title bars, still each in view. + #[test] + fn a_short_lane_keeps_every_docked_toolbar_inside_it() { + for count in [12, 60] { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let names: Vec = (0..count).map(|k| format!("T{k}")).collect(); + for name in &names { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": name})).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "right" })).unwrap(); + } + for k in 0..6 { + window_frame(&mut app, &ctx, 0.05 * f64::from(k + 1), 320.0); + } + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + let rects: Vec = names.iter().map(|name| ctx.memory(|m| m.area_rect(area_id(name))).unwrap()).collect(); + for (name, r) in names.iter().zip(&rects) { + assert!(lane.expand(0.5).contains_rect(*r), "{count}: {name} {r:?} {lane:?}"); + assert!(r.height() >= GRIP, "{count}: {name} {r:?}"); + } + for (i, a) in rects.iter().enumerate() { + for (j, b) in rects.iter().enumerate().skip(i + 1) { + assert!(a.intersect(*b).area() < 1.0, "{count}: {} {a:?} covers {} {b:?}", names[i], names[j]); + } + } + assert!(lane.width() > WIDTH + 2.0 * LANE_GAP + 1.0, "the stack wraps into more columns: {lane:?}"); + assert!(lane.width() <= 600.0, "{count}: the lane leaves the canvas at least half the window: {lane:?}"); + assert!((lane.right() - 1200.0).abs() < 0.5, "{lane:?}"); + let bare = rects.iter().all(|r| r.height() < DOCKED_LEAST - 1.0); + assert_eq!(bare, count == 60, "{count}: title bars alone only when the columns run out: {rects:?}"); + } + } + + /// A lone docked toolbar in a lane too short for its tools shows only its title bar, inside the + /// lane. + #[test] + fn a_lone_toolbar_in_a_short_lane_stays_inside_it() { + let mut short = 0; + for height in (120..=240).step_by(10) { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "A"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "A"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "A", "side": "right"})).unwrap(); + for k in 0..6 { + window_frame(&mut app, &ctx, 0.05 * f64::from(k + 1), height as f32); + } + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + if lane.height() >= DOCKED_LEAST + 2.0 * LANE_GAP { + continue; + } + short += 1; + let a = ctx.memory(|m| m.area_rect(area_id("A"))).unwrap(); + assert!(lane.expand(0.5).contains_rect(a), "{height}: {a:?} {lane:?}"); + assert!(a.height() >= GRIP, "{height}: the title bar shows: {a:?}"); + } + assert!(short > 0, "some window heights leave a lane shorter than one toolbar"); + } + + /// A column too short for even its toolbars' title bars shows as many as fit, none covering + /// another, and the mouse wheel over it scrolls through the rest, one toolbar a notch. + #[test] + fn a_column_too_short_for_its_title_bars_scrolls() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let names: Vec = (0..64).map(|k| format!("T{k}")).collect(); + for name in &names { + app.run("window.toolbar.new", json!({ "name": name })).unwrap(); + app.run("window.toolbar.dock", json!({ "name": name, "side": "right" })).unwrap(); + } + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 200.0)); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + for _ in 0..6 { + frame(&mut app, vec![]); + } + let shown = + |ctx: &egui::Context| -> Vec { (0..names.len()).filter(|&k| drawn(ctx, area_id(&names[k]), egui::Order::Middle)).collect() }; + let lane = ctx.data(|d| d.get_temp::(lane_id(true))).unwrap(); + let before = shown(&ctx); + assert!(!before.is_empty() && before.len() < names.len(), "{before:?} {lane:?}"); + let rects: Vec = before.iter().map(|&k| ctx.memory(|m| m.area_rect(area_id(&names[k]))).unwrap()).collect(); + for (i, a) in rects.iter().enumerate() { + assert!(lane.expand(0.5).contains_rect(*a), "{a:?} {lane:?}"); + for b in rects.iter().skip(i + 1) { + assert!(a.intersect(*b).area() < 1.0, "{a:?} covers {b:?}"); + } + } + assert!(before.contains(&0)); + // The wheel over the first column shows the next toolbar of that column. + let at = pos2(lane.left() + LANE_GAP + WIDTH / 2.0, lane.center().y); + let wheel = |dy: f32| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }; + frame(&mut app, vec![egui::Event::PointerMoved(at), wheel(-1.0)]); + frame(&mut app, vec![]); + let after = shown(&ctx); + assert!(!after.contains(&0) && after.len() == before.len(), "{before:?} {after:?}"); + frame(&mut app, vec![egui::Event::PointerMoved(at), wheel(1.0)]); + frame(&mut app, vec![]); + assert_eq!(shown(&ctx), before, "the wheel back shows the first again"); + } + + /// A lane holds one column while each docked toolbar gets [`DOCKED_LEAST`] points, and as few + /// more columns as give each that much, the toolbars spread evenly over them, up to the most + /// columns the lane may take. + #[test] + fn short_lanes_wrap_their_stack_into_columns() { + let tall = 2.0 * (DOCKED_LEAST + LANE_GAP) + LANE_GAP; + assert_eq!(lane_columns(0, 500.0, 8), (1, 1)); + assert_eq!(lane_columns(2, tall, 8), (1, 2)); + assert_eq!(lane_columns(3, tall, 8), (2, 2)); + assert_eq!(lane_columns(5, tall, 8), (3, 2)); + assert_eq!(lane_columns(4, 10.0, 8), (4, 1), "a lane shorter than one toolbar puts each in its own column"); + assert_eq!(lane_columns(3, f32::NAN, 8), (3, 1)); + assert_eq!(lane_columns(9, tall, 2), (2, 5), "at most the columns allowed"); + assert_eq!(lane_columns(9, tall, 0), (1, 9)); + assert_eq!(most_columns(3.0 * WIDTH + 4.0 * LANE_GAP, WIDTH), 3); + assert_eq!(most_columns(10.0, WIDTH), 1); + assert_eq!(most_columns(f32::NAN, WIDTH), 1); + } + + /// The frame that docks a toolbar lays its lane out after the toolbars are drawn, so it asks for + /// the next frame at once, which draws the toolbar in the lane, without other input. + #[test] + fn a_new_lane_draws_its_toolbar_in_the_next_frame() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 600.0)); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.viewport_output.values().map(|v| v.repaint_delay).min().unwrap_or(std::time::Duration::MAX) + }; + for _ in 0..4 { + frame(&mut app); + } + app.run("window.toolbar.dock", json!({"name": "Mine"})).unwrap(); + assert_eq!(frame(&mut app), std::time::Duration::ZERO, "the frame that lays the new lane out asks for another"); + frame(&mut app); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + assert!(lane.contains_rect(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap())); + } + + /// A docked toolbar dragged inside its lane stays docked. A toolbar's title bar dropped on the + /// window's edge docks it there; dragged out of its lane, + /// it floats under the pointer; Escape during the drag puts it back; a click near the edge does + /// not dock it. The title bar's context menu docks and floats it. + #[test] + fn a_title_bar_dropped_on_an_edge_docks_the_toolbar() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap(); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0)); + let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + // The texts drawn, with where each is. + let texts = out.shapes.iter().filter_map(|c| { + if let egui::Shape::Text(t) = &c.shape { Some((t.galley.text().to_string(), t.visual_bounding_rect())) } else { None } + }); + texts.collect::>() + }; + let button = |pos, pressed, button| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + let primary = egui::PointerButton::Primary; + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + // Docked and dragged down its lane, past its own bottom, it stays docked. + app.run("window.toolbar.dock", json!({"name": "Mine", "side": "left"})).unwrap(); + for _ in 0..3 { + frame(&mut app, vec![]); + } + let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap(); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + let (from, below) = (pos2(r.center().x, r.top() + 1.0 + GRIP / 2.0), pos2(lane.center().x, lane.bottom() - 20.0)); + assert!(!r.contains(below), "{r:?} {lane:?}"); + frame(&mut app, vec![egui::Event::PointerMoved(from), button(from, true, primary)]); + for k in 1..=6 { + frame(&mut app, vec![egui::Event::PointerMoved(from + (below - from) * (k as f32 / 6.0))]); + assert_eq!(place(&app, "Mine"), (Some(0), false), "a drag inside the lane keeps it docked, step {k}"); + } + frame(&mut app, vec![button(below, false, primary)]); + app.run("window.toolbar.float", json!({"name": "Mine"})).unwrap(); + for _ in 0..3 { + frame(&mut app, vec![]); + } + // A press on the title bar's grip, moved in steps to `to` (`cut` after the step it names), + // then released there. + let mut drag = |app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, cut: Option| { + frame(app, vec![egui::Event::PointerMoved(from), button(from, true, primary)]); + for k in 1..=6 { + let mut events = vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]; + if cut == Some(k) { + events.push(escape.clone()); + } + frame(app, events); + } + frame(app, vec![button(to, false, primary)]); + frame(app, vec![]); + frame(app, vec![]); + }; + let grip = |ctx: &egui::Context| { + let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap(); + pos2(r.center().x, r.top() + 1.0 + GRIP / 2.0) + }; + // Dropped on the window's left edge, it docks there. + drag(&mut app, grip(&ctx), pos2(20.0, 400.0), None); + assert_eq!(place(&app, "Mine"), (Some(0), false)); + let lane = ctx.data(|d| d.get_temp::(lane_id(false))).unwrap(); + assert!(lane.contains_rect(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap())); + // Escape while it is dragged out of the lane puts it back in its place. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), Some(4)); + assert_eq!(place(&app, "Mine"), (Some(0), false), "Escape keeps it docked"); + // Dragged out of the lane, it floats under the pointer. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), None); + assert_eq!(place(&app, "Mine"), (None, false)); + assert!(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap().contains(pos2(600.0, 300.0))); + // Escape while it is dragged to the right edge leaves it floating where it was. + let at = find(&app.ui, "Mine").unwrap().pos; + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), Some(5)); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at)); + // Dropped on the right edge, it docks there. + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), None); + assert_eq!(place(&app, "Mine"), (Some(0), true)); + // Floating over the edge, a click on its title bar does not dock it. + app.run("window.toolbar.float", json!({"name": "Mine", "x": 2, "y": 300})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let at = grip(&ctx); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![button(at, false, primary)]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), (None, true)); + // The title bar's context menu: Dock to Left docks it, and Float Toolbar floats it again. + for (item, after) in [("Dock to Left", (Some(0), false)), ("Float Toolbar", (None, false))] { + let at = grip(&ctx); + let secondary = egui::PointerButton::Secondary; + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, secondary)]); + frame(&mut app, vec![button(at, false, secondary)]); + let texts = frame(&mut app, vec![]); + let found = texts.iter().find(|(t, _)| t.trim_start_matches(['✓', ' ']) == item); + let at = found.unwrap_or_else(|| panic!("no {item} in {texts:?}")).1.center(); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![button(at, false, primary)]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), after, "after {item}"); + } + // Docked, and released in the frame the pointer leaves the lane, it floats there. + app.run("window.toolbar.dock", json!({"name": "Mine"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let at = grip(&ctx); + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true, primary)]); + frame(&mut app, vec![egui::Event::PointerMoved(at + vec2(0.0, 8.0))]); + assert_eq!(place(&app, "Mine"), (Some(0), false), "still in its lane"); + let out = pos2(600.0, 300.0); + frame(&mut app, vec![egui::Event::PointerMoved(out), button(out, false, primary)]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + assert_eq!(place(&app, "Mine"), (None, false), "released outside its lane"); + assert!(ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap().contains(out)); + } } diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index e47561094e..cac6338418 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -508,7 +508,10 @@ fn the_add_tool_menu_fills_a_new_toolbar_without_a_drag() { assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched"); assert!(!app.ui.tools_drawer, "no drawer opened"); let list = app.run("window.toolbar.list", json!({})).unwrap(); - assert_eq!(list, json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]]}])); + assert_eq!( + list, + json!([{"name": "Mine", "shown": true, "double": false, "slots": [["lasso"]], "floating": true, "side": "left", "order": null}]) + ); // A second tool goes after the first. let shown = open(&mut app, 19.0); click(&mut app, &ctx, 20.0, items(&shown, "Magic Wand Tool")[0].0); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..cf3dec273d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4052,6 +4052,10 @@ axis Both И двете Dock Panel Закотвен панел Dock to Top Закотвяне горе Dock to Bottom Закотвяне долу + Dock to Left Закотвяне вляво + Dock to Right Закотвяне вдясно + Dock Toolbar Закотвяне на лентата с инструменти + Float Toolbar Плаваща лента с инструменти Control Bar Options Опции на контролната лента Tool Options Опции за инструмент Object Options Опции за обект diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..1c6efe09b4 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -908,6 +908,10 @@ Dock Panel Usidri panel Dock to Top Usidri na vrh Dock to Bottom Usidri na dno + Dock to Left Usidri lijevo + Dock to Right Usidri desno + Dock Toolbar Usidri traku s alatima + Float Toolbar Odvoji traku s alatima Control Bar Options Opcije kontrolne trake Tool Options Opcije alata Object Options Opcije objekta diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..af4e171e47 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1247,6 +1247,10 @@ antiAlias Sharp Nítid Dock Panel Acobla el panell Dock to Top Acobla a dalt Dock to Bottom Acobla a baix + Dock to Left Acobla a l’esquerra + Dock to Right Acobla a la dreta + Dock Toolbar Acobla la barra d’eines + Float Toolbar Flota la barra d’eines Control Bar Options Opcions de la barra de control Tool Options Opcions de l’eina Object Options Opcions de l’objecte diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..4431c720e5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -923,6 +923,10 @@ Dock Panel Ukotvit panel Dock to Top Ukotvit nahoru Dock to Bottom Ukotvit dolů + Dock to Left Ukotvit vlevo + Dock to Right Ukotvit vpravo + Dock Toolbar Ukotvit panel nástrojů + Float Toolbar Plovoucí panel nástrojů Control Bar Options Možnosti ovládacího panelu Tool Options Možnosti nástroje Object Options Možnosti objektu diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..06b554631e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -853,6 +853,10 @@ Dock Panel Dok panel Dock to Top Dok øverst Dock to Bottom Dok nederst + Dock to Left Dok til venstre + Dock to Right Dok til højre + Dock Toolbar Dok værktøjslinjen + Float Toolbar Flydende værktøjslinje Control Bar Options Indstillinger for kontrollinjen Tool Options Værktøjsindstillinger Object Options Objektindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..0b5f1887b4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4074,6 +4074,10 @@ theme Appearance Erscheinungsbild Dock Panel Bedienfeld andocken Dock to Top Oben andocken Dock to Bottom Unten andocken + Dock to Left Links andocken + Dock to Right Rechts andocken + Dock Toolbar Werkzeugleiste andocken + Float Toolbar Werkzeugleiste ablösen Control Bar Options Optionen der Steuerungsleiste Tool Options Werkzeugoptionen Object Options Objektoptionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..ee7e173604 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2257,6 +2257,10 @@ axis Both Και τα δύο Dock Panel Αγκύρωση πάνελ Dock to Top Αγκύρωση επάνω Dock to Bottom Αγκύρωση κάτω + Dock to Left Αγκύρωση αριστερά + Dock to Right Αγκύρωση δεξιά + Dock Toolbar Αγκύρωση γραμμής εργαλείων + Float Toolbar Αιωρούμενη γραμμή εργαλείων Control Bar Options Επιλογές γραμμής ελέγχου Tool Options Επιλογές εργαλείου Object Options Επιλογές αντικειμένου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..08c8ab13fb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4150,6 +4150,10 @@ axis Both Ambos Dock Panel Anclar panel Dock to Top Anclar arriba Dock to Bottom Anclar abajo + Dock to Left Anclar a la izquierda + Dock to Right Anclar a la derecha + Dock Toolbar Anclar barra de herramientas + Float Toolbar Hacer flotar la barra de herramientas Control Bar Options Opciones de la barra de control Tool Options Opciones de herramienta Object Options Opciones de objeto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..bafb620fc0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -911,6 +911,10 @@ Dock Panel Telakoi paneeli Dock to Top Telakoi ylös Dock to Bottom Telakoi alas + Dock to Left Telakoi vasemmalle + Dock to Right Telakoi oikealle + Dock Toolbar Telakoi työkalurivi + Float Toolbar Kelluta työkalurivi Control Bar Options Ohjauspalkin asetukset Tool Options Työkalun asetukset Object Options Objektin asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..3cd787bd97 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4139,6 +4139,10 @@ axis Both Les deux Dock Panel Ancrer le panneau Dock to Top Ancrer en haut Dock to Bottom Ancrer en bas + Dock to Left Ancrer à gauche + Dock to Right Ancrer à droite + Dock Toolbar Ancrer la barre d’outils + Float Toolbar Rendre la barre d’outils flottante Control Bar Options Options de la barre de contrôle Tool Options Options de l’outil Object Options Options de l’objet diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..cdc1de5b8a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1386,6 +1386,10 @@ antiAlias Sharp शार्प Dock Panel पैनल डॉक करें Dock to Top ऊपर डॉक करें Dock to Bottom नीचे डॉक करें + Dock to Left बाईं ओर डॉक करें + Dock to Right दाईं ओर डॉक करें + Dock Toolbar टूलबार डॉक करें + Float Toolbar टूलबार फ़्लोट करें Control Bar Options नियंत्रण बार ऑप्शन्स Tool Options टूल ऑप्शन्स Object Options ऑब्जेक्ट ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..5bb08d52fc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1114,6 +1114,10 @@ font-technology Variable Változó Dock Panel Panel dokkolása Dock to Top Dokkolás felülre Dock to Bottom Dokkolás alulra + Dock to Left Dokkolás balra + Dock to Right Dokkolás jobbra + Dock Toolbar Eszköztár dokkolása + Float Toolbar Eszköztár lebegtetése Control Bar Options Vezérlősáv beállításai Tool Options Eszközbeállítások Object Options Objektumbeállítások diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..e1499a5821 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -853,6 +853,10 @@ Dock Panel Tambatkan panel Dock to Top Tambatkan ke atas Dock to Bottom Tambatkan ke bawah + Dock to Left Tambatkan ke kiri + Dock to Right Tambatkan ke kanan + Dock Toolbar Tambatkan bilah alat + Float Toolbar Mengambangkan bilah alat Control Bar Options Opsi bilah kontrol Tool Options Opsi alat Object Options Opsi objek diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..8f01c8358a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4201,6 +4201,10 @@ axis Both Entrambi Dock Panel Ancora pannello Dock to Top Ancora in alto Dock to Bottom Ancora in basso + Dock to Left Ancora a sinistra + Dock to Right Ancora a destra + Dock Toolbar Ancora barra degli strumenti + Float Toolbar Rendi mobile barra degli strumenti Control Bar Options Opzioni barra di controllo Tool Options Opzioni strumento Object Options Opzioni oggetto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..817d515334 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4143,6 +4143,10 @@ axis Both 両方 Dock Panel パネルをドッキング Dock to Top 上部にドッキング Dock to Bottom 下部にドッキング + Dock to Left 左側にドッキング + Dock to Right 右側にドッキング + Dock Toolbar ツールバーをドッキング + Float Toolbar ツールバーをフローティング Control Bar Options コントロールバーオプション Tool Options ツールオプション Object Options オブジェクトオプション diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..93ff9f8e96 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -816,6 +816,10 @@ Dock Panel პანელის მიმაგრება Dock to Top ზემოთ მიმაგრება Dock to Bottom ქვემოთ მიმაგრება + Dock to Left მარცხნივ მიმაგრება + Dock to Right მარჯვნივ მიმაგრება + Dock Toolbar ხელსაწყოთა ზოლის მიმაგრება + Float Toolbar მცურავი ხელსაწყოთა ზოლი Control Bar Options მართვის ზოლის პარამეტრები Tool Options ხელსაწყოს პარამეტრები Object Options ობიექტის პარამეტრები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..5132cf1484 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4127,6 +4127,10 @@ axis Both 양쪽 Dock Panel 패널 고정 Dock to Top 위쪽에 고정 Dock to Bottom 아래쪽에 고정 + Dock to Left 왼쪽에 고정 + Dock to Right 오른쪽에 고정 + Dock Toolbar 도구 막대 고정 + Float Toolbar 도구 막대 분리 Control Bar Options 컨트롤 막대 옵션 Tool Options 도구 옵션 Object Options 개체 옵션 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..8f3c2da6b9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -867,6 +867,10 @@ Dock Panel Paneel dokken Dock to Top Bovenaan dokken Dock to Bottom Onderaan dokken + Dock to Left Links dokken + Dock to Right Rechts dokken + Dock Toolbar Werkbalk dokken + Float Toolbar Werkbalk laten zweven Control Bar Options Opties voor bedieningsbalk Tool Options Gereedschapsopties Object Options Objectopties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..049de20359 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -868,6 +868,10 @@ Dock Panel Zadokuj panel Dock to Top Zadokuj u góry Dock to Bottom Zadokuj u dołu + Dock to Left Zadokuj po lewej + Dock to Right Zadokuj po prawej + Dock Toolbar Zadokuj pasek narzędzi + Float Toolbar Odepnij pasek narzędzi Control Bar Options Opcje paska sterowania Tool Options Opcje narzędzia Object Options Opcje obiektu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..83ffa09c26 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3379,6 +3379,10 @@ imageTrace Outline Contorno Dock Panel Encaixar Painel Dock to Top Encaixar na Parte Superior Dock to Bottom Encaixar na Parte Inferior + Dock to Left Encaixar à Esquerda + Dock to Right Encaixar à Direita + Dock Toolbar Encaixar Barra de Ferramentas + Float Toolbar Flutuar Barra de Ferramentas Control Bar Options Opções da Barra de Controle Tool Options Opções de Ferramenta Object Options Opções de Objeto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..76d1ce271b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4187,6 +4187,10 @@ axis Both Оба Dock Panel Прикрепить панель Dock to Top Прикрепить сверху Dock to Bottom Прикрепить снизу + Dock to Left Прикрепить слева + Dock to Right Прикрепить справа + Dock Toolbar Прикрепить панель инструментов + Float Toolbar Сделать панель инструментов плавающей Control Bar Options Параметры панели управления Tool Options Параметры инструмента Object Options Параметры объекта diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8a8f8453b6..0eec252189 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4066,6 +4066,10 @@ axis Both Båda Dock Panel Docka panel Dock to Top Docka överst Dock to Bottom Docka nederst + Dock to Left Docka till vänster + Dock to Right Docka till höger + Dock Toolbar Docka verktygsfältet + Float Toolbar Gör verktygsfältet flytande Control Bar Options Alternativ för kontrollfältet Tool Options Verktygsalternativ Object Options Objektalternativ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..a30a5547af 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3950,6 +3950,10 @@ axis Both Her İkisi Dock Panel Bağlantı İstasyonu Paneli Dock to Top Üste yerleştir Dock to Bottom Alta yerleştir + Dock to Left Sola yerleştir + Dock to Right Sağa yerleştir + Dock Toolbar Araç çubuğunu yerleştir + Float Toolbar Araç Çubuğunu Yüzer Yap Control Bar Options Kontrol Çubuğu Seçenekleri Tool Options Araç Seçenekleri Object Options Nesne Seçenekleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..31e42b581e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4190,6 +4190,10 @@ axis Both Обидві Dock Panel Прикріпити панель Dock to Top Прикріпити вгорі Dock to Bottom Прикріпити внизу + Dock to Left Прикріпити ліворуч + Dock to Right Прикріпити праворуч + Dock Toolbar Прикріпити панель інструментів + Float Toolbar Від’єднати панель інструментів Control Bar Options Параметри панелі керування Tool Options Параметри інструменту Object Options Параметри об’єкта diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..70e11365c5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1012,6 +1012,10 @@ font-technology Variable Biến thiên Dock Panel Neo bảng Dock to Top Neo lên trên Dock to Bottom Neo xuống dưới + Dock to Left Neo sang trái + Dock to Right Neo sang phải + Dock Toolbar Neo thanh công cụ + Float Toolbar Thả nổi thanh công cụ Control Bar Options Tùy chọn thanh điều khiển Tool Options Tùy chọn công cụ Object Options Tùy chọn đối tượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..ed292747a2 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5437,6 +5437,10 @@ axis Both 两者 Dock Panel 停放面板 Dock to Top 停放到顶部 Dock to Bottom 停放到底部 + Dock to Left 停放到左侧 + Dock to Right 停放到右侧 + Dock Toolbar 停放工具栏 + Float Toolbar 浮动工具栏 Control Bar Options 控制栏选项 Tool Options 工具选项 Object Options 对象选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..24abfce36f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5266,6 +5266,10 @@ axis Both 兩者 Dock Panel 停駐面板 Dock to Top 停駐到頂部 Dock to Bottom 停駐到底部 + Dock to Left 停駐到左側 + Dock to Right 停駐到右側 + Dock Toolbar 停駐工具列 + Float Toolbar 浮動工具列 Control Bar Options 控制列選項 Tool Options 工具選項 Object Options 物件選項 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 20a2c58314..cd3a391793 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -564,7 +564,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name: string} delete the toolbar made with New Toolbar called `name`, and the groups torn off it, from the current workspace, as Manage Toolbars' trash can does", ), - ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"), + ( + "window.toolbar.dock", + "Dock Toolbar", + "", + "{name: string, side?: left|right, order?: int} dock the toolbar made with New Toolbar called `name` in the lane along the window's left or right edge (omitted: the edge it docked at last, the left at first), as Dock to Left and Dock to Right in its title bar's context menu and dropping its title bar on that edge do; `order` is its place in that edge's stack, 0 at the top (omitted: the end, or where it is when it is docked at that edge already); a bad `side` or `order` is an error and changes nothing; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.float", + "Float Toolbar", + "", + "{name: string, x?: number, y?: number} float the toolbar made with New Toolbar called `name` with its top-left corner at x, y in screen points (both or neither; omitted: where it floated last), as Float Toolbar in its title bar's context menu and dragging its title bar out of its lane do; returns {toolbar, floating, side, order, pos}", + ), + ( + "window.toolbar.list", + "List Toolbars", + "", + "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots, floating, side, order}], `side` the edge it docks at (while floating, the edge it docked at last) and `order` its place in that edge's stack while docked (else null)", + ), ("window.taskBar", "Contextual Task Bar", "", "{}"), ( "window.pathfinder.compoundShapes", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index cc72f74b77..e22dcd9cac 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1435,8 +1435,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { - let min = - if id == CANVAS { egui::vec2(200.0 + crate::toolbar::docked_width(&self.app.ui), 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; + // The canvas keeps room for Tools and the lane of toolbars docked beside it. + let beside = crate::toolbar::docked_width(&self.app.ui) + crate::custom_toolbars::beside_canvas_reserve(&self.app.ui); + let min = if id == CANVAS { egui::vec2(200.0 + beside, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; craft_ui::docking::PanelLimits { min, ..Default::default() } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f84b8f53b0..91fb21ac05 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -864,7 +864,8 @@ impl FloatingFlyout { } /// A toolbar made with Window › Toolbars › New Toolbar (`window.toolbar.new`): it floats on its -/// own, filled with tools from the All Tools drawer, and is kept with the workspace it was made in. +/// own or docks along the window's left or right edge, filled with tools from the All Tools drawer, +/// and is kept with the workspace it was made in. /// The groups torn off it are kept with it (`floating`), so the Tools panel's strips /// (`UiState::floating_flyouts`) hold none of them. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -877,8 +878,16 @@ pub struct CustomToolbar { /// It shows (Window › Toolbars › its name toggles it). #[serde(default = "yes")] pub shown: bool, - /// Top-left corner in screen points. + /// Top-left corner in screen points while it floats; while it is docked, where it floated last. pub pos: [f32; 2], + /// Its place in the stack of toolbars docked along one of the window's edges (0 at the top), + /// while it is docked there; none while it floats at `pos`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dock: Option, + /// It docks along the window's right edge, not the left: the edge it is docked at, or while it + /// floats the edge it docked at last. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dock_right: bool, /// The tool each slot of several tools shows, chosen last (keyed by the slot's first tool). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub slot_tool: std::collections::BTreeMap, diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b1788e1e8a..90f5f74646 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -145,6 +145,8 @@ pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.toolbar_pos.is_none() && (app.ui.tools_beside_canvas || app.ui.toolbar_right) { show(app, ui); } + // The lane where toolbars made with New Toolbar dock on the left, right of Tools. + crate::custom_toolbars::lane_beside_canvas(app, ui); } /// Is the Tools panel docked on the right, between the canvas and the panels? diff --git a/crates/ui-egui/src/tools_float.rs b/crates/ui-egui/src/tools_float.rs index 0cc6433429..b5fb04d384 100644 --- a/crates/ui-egui/src/tools_float.rs +++ b/crates/ui-egui/src/tools_float.rs @@ -46,6 +46,13 @@ pub(crate) fn escape_cancelled_move(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(escape_cancel_id())) == Some(ctx.cumulative_frame_nr_for(ctx.viewport_id())) } +/// This frame's Escape put a moved bar back ([`escape_cancelled_move`]): a toolbar made with New +/// Toolbar ([`crate::custom_toolbars::cancel_move_on_escape`]) as well as the Tools panel. +pub(crate) fn mark_escape_cancelled(ctx: &egui::Context) { + let frame = ctx.cumulative_frame_nr_for(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(escape_cancel_id(), frame)); +} + /// The strip along the canvas's left edge where the Tools panel docks while Keep Tools Beside the /// Canvas is on, as last laid out. pub(crate) fn tools_zone_id() -> Id { @@ -194,7 +201,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } /// The `x`, `y` params: a top-left corner in screen points, if given. -fn position(p: &Value) -> Result, String> { +pub(crate) fn position(p: &Value) -> Result, String> { let coord = |k: &str| match p.get(k) { None | Some(Value::Null) => Some(None), Some(v) => v.as_f64().filter(|f| f.abs() < 1e6).map(|f| Some(f as f32)), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5694d9582d..1a0d5c7d7d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -417,7 +417,10 @@ button (Add Tool…) opens a menu of every tool under the drawer's headings: a c {tool, toolbar}`; the tools already in the toolbar are dimmed under their headings. The drawer has no Show section there. `window.floatTools {tool, toolbar}` floats one of its groups, and a group torn off it shows and hides with it. `window.toolbar.list` returns the toolbars (`ui.custom_toolbars` in `ui.inspect`: name, slots, shown flag, -two-column flag, top-left corner, the groups torn off it). They are kept with the workspace they were made in: +two-column flag, top-left corner, the groups torn off it, and `dock` and `dock_right` while docked or once docked). +`window.toolbar.dock {name, side?, order?}` docks a toolbar in the lane along the window's left or right edge, and +`window.toolbar.float {name, x?, y?}` floats it again; dropping its title bar on an edge or dragging it out of its +lane does the same (see the docking section in `docs/mcp.md`). They are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them. diff --git a/docs/gaps.md b/docs/gaps.md index d34956d4e0..cdb15df0bd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -684,8 +684,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged. -- **Partial:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they - were made in, and Window › Toolbars › Manage Toolbars renames, copies and deletes them; docking them is missing. +- **Done:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they + were made in, and Window › Toolbars › Manage Toolbars renames, copies and deletes them; they dock in lanes along + the window's left and right edges (`window.toolbar.dock`, `window.toolbar.float`). Inside the estimate below. - **Done (#1753):** a saved workspace keeps the width of the dock column. - **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window. diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..13ad15ab4c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2672,6 +2672,43 @@ kept with the workspace, and built-in workspaces and workspaces saved without it {"name":"invoke_menu","arguments":{"command":"window.toolsBesideCanvas","params":{"on":true}}} ``` +## Docking toolbars made with New Toolbar + +A toolbar made with New Toolbar floats when it is made. `window.toolbar.dock {name, side?, order?}` (Dock to Left and +Dock to Right in the context menu of the toolbar's title bar, remote mode, `invoke_menu`) docks it in a lane along the +window's left or right edge. On the left, the lane is right of the Tools panel when the Tools panel is docked at the +window's edge. With Keep Tools Beside the Canvas on and the Tools panel docked on the left, the lane is right of the +Tools panel beside the canvas, and dropping a title bar on the Tools panel or on the lane docks the toolbar there. `side` is `left` or `right` (`start` and `end` also work); without it, the toolbar docks at the edge it +docked at last, or at the left the first time. Toolbars docked at one edge stack from the top. `order` is the +toolbar's place in that stack, 0 at the top, from 0 to the number of other toolbars docked there; without it, the +toolbar goes at the end, or stays where it is when it is already docked at that edge. Each toolbar takes an equal +share of the lane's height and its tools scroll in it, and the lane is as wide as its widest toolbar. When the lane +is too short to give each toolbar 88 points of height, the stack wraps into more columns, left to right, and the lane +widens, so no toolbar covers another. A lane takes at most half the width the canvas and panels would have (beside +the canvas, it also leaves the canvas at least 200 points, and the canvas keeps room for one column); when that +many columns still give a toolbar less than 88 points, the toolbars in those columns show only their title bars, which +still float, dock and show the context menu. A column too short for all of its title bars shows as many as fit, and +the mouse wheel over the column scrolls through the others, one toolbar per notch. +`window.toolbar.float {name, x?, y?}` (Float Toolbar in the same menu) floats it with its top-left corner at `x`, `y` +in screen points, or where it floated last when both are omitted. Both commands answer +`{toolbar, floating, side, order, pos}`: `side` is the edge the toolbar docks at (while it floats, the edge it docked +at last), `order` its place in that edge's stack (null while it floats) and `pos` where it floats (while it is docked, +where it floated last). A missing or unknown name, a `side` other than left or right, an `order` that is not a whole +number in range, or only one of `x` and `y` is an error and changes nothing. `window.toolbar.list` reports `floating`, +`side` and `order` for each toolbar. + +With the pointer, dropping a floating toolbar's title bar on the window's left or right edge, or on that edge's lane, +docks it at the end of that edge's stack; the target lights up while the title bar is dragged over it. Dragging a +docked toolbar's title bar out of its lane floats it under the pointer. Escape, or the window losing focus, during +the drag puts the toolbar back where it was. Hiding a docked toolbar keeps its place, renaming it keeps its place, a +copy made with Manage Toolbars' + floats, and deleting one closes up the stack. Placement is kept with the workspace. +Toolbars saved before docking existed load floating. The lanes, the edge targets and the stacking are VectorCraft's +own layout rules. + +```json +{"name":"invoke_menu","arguments":{"command":"window.toolbar.dock","params":{"name":"Toolbar 1","side":"left","order":0}}} +``` + ## Learn panel Window › Learn and the Explore tutorials button beside the search icon at the right end of the app bar show and hide diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..7ee5253df3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -840,6 +840,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). - A toolbar made with New Toolbar has a + button (Add Tool…) above its ••• button. A click on it, or Enter while it has the keyboard focus, opens a menu of every tool under the All Tools drawer's headings, without the drawer: a tool picked there goes at the end of that toolbar, and the tools already in it are dimmed. Under Remove Tool, the menu lists the toolbar's tools, and a click takes one out. This menu is VectorCraft's own way to fill a toolbar without a drag (`toolbar.addTool` and `toolbar.removeTool` with `toolbar`; `the_add_tool_menu_fills_a_new_toolbar_without_a_drag`). - Window › Toolbars › Manage Toolbars… and Manage Toolbars… in the All Tools drawer's menu open a dialog that lists the toolbars made with New Toolbar: a new name typed for the selected toolbar renames it, + copies it with its tools (with none selected, + makes a blank toolbar), and the trash can deletes it. Each change shows under Window › Toolbars and stays in the workspace (; `window.toolbar.manage`, `window.toolbar.rename`, `window.toolbar.duplicate`, `window.toolbar.delete`; `manage_toolbars_renames_duplicates_and_deletes`). + - A toolbar made with New Toolbar docks in a lane along the window's left or right edge when its title bar is dropped on that edge, or with Dock to Left and Dock to Right in the title bar's context menu. Toolbars docked at one edge stack from the top, each with its share of the lane's height, and the lane is as wide as its widest toolbar; a lane too short for its stack wraps it into more columns, up to half the window's width, and past that the toolbars show only their title bars, which the mouse wheel scrolls through when even they do not fit. Dragging the title bar out of the lane, or Float Toolbar, floats it again, and Escape during the drag puts it back. Hiding and renaming keep its place, and the workspace keeps it. New toolbars still start floating. The lanes and the stacking are VectorCraft's own rules (#1759; `window.toolbar.dock`, `window.toolbar.float`; `toolbars_dock_and_float_by_their_commands`, `docked_toolbars_stack_in_lanes_along_the_window_edges`, `a_title_bar_dropped_on_an_edge_docks_the_toolbar`). - Window › Toolbars › Keep Tools Beside the Canvas, a VectorCraft layout option that is off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge; the canvas's left edge then also docks a floating Tools panel, and workspaces keep the setting. Scores are unchanged (`window.toolsBesideCanvas`; `docked_tools_keep_the_window_edge_unless_kept_beside_the_canvas`, `tools_redock_at_the_targets_of_the_chosen_placement`). - Hindi (2026-11-06): Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged. [Terminology and limitations](localization-hi.md). - Window › Help Bar shows or hides the tool-help strip at the bottom of the window apart from the status bar (`window.helpBar`). Each setting is kept with the UI preferences and in saved workspaces, and Presentation Mode hides both bars. Layouts saved before the setting existed show the Help Bar where they show the status bar (`help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar`, `a_help_bar_setting_the_file_lacks_is_saved`). From ab75b67f852878f19c7cec2c94abeeb308565a17 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 04:33:23 -0800 Subject: [PATCH 03/20] =?UTF-8?q?Simplify:=20the=20join=20at=20the=20start?= =?UTF-8?q?=20of=20a=20smoothed=20loop=20turns=20by=20at=20most=200.5?= =?UTF-8?q?=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A smoothed loop with no corner is refitted along one direction across its start. The first and the last fitted curve were each compared with that direction, so when both became lines, the two lines could turn by up to 1° where they meet at the loop's start. `refit_part` now compares the last curve of such a loop with the direction the loop's first segment leaves along. When the line would turn by more than 0.5° from it, the last piece stays a flat curve. Test: `simplify_keeps_the_join_at_the_start_of_a_smoothed_loop_smooth` (`crates/pathops/tests/it/ops.rs`). --- crates/pathops/src/edit.rs | 10 ++++++++-- crates/pathops/tests/it/ops.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 19089dfd2e..bc1d54cba9 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -329,12 +329,18 @@ fn refit_part(part: &[(&Seg, usize)], opts: &SimplifyOptions, tol: f64, ends: Op // The fit may be `tol * 0.98` from the lines between the samples, those lines may be // `SAMPLE_GAP` of `tol` from the path, and the curve the test below turns into a line moves by up // to 1.5% of `tol`: 3/4 of how far its handles may be from the line. The fitted curves join - // smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. + // smoothly, so a curve becomes a line only when the line keeps both of its joins smooth. The last + // curve of a loop fitted across its start leaves along the loop's first segment. let (t0, t1) = ends.map_or((start_tangent(&first.c), end_tangent(&last.c)), |d| (d, d)); let fitted = fit_cubics(&pts, t0, t1, tol * 0.98); + let from = result.len(); let mut arrive = t0; for (k, &c) in fitted.iter().enumerate() { - let leave = fitted.get(k + 1).map_or(t1, start_tangent); + let leave = match fitted.get(k + 1) { + Some(next) => start_tangent(next), + None if ends.is_some() => result.get(from).map_or(t1, |s| start_tangent(&s.c)), + None => t1, + }; let line = is_straight(&c, tol * 0.02) && line_keeps_joins(&c, arrive, leave); arrive = if line { unit(c.p3 - c.p0).unwrap_or(arrive) } else { end_tangent(&c) }; result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index d48e1c5dd8..93f6d4c29c 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -462,6 +462,32 @@ fn simplify_turns_a_curve_into_a_line_only_where_the_joins_stay_smooth() { assert!(!has_curves(&s), "{s:?}"); } +/// A smoothed loop with no corner is fitted across its start, and the join there also turns by at +/// most 0.5°. Each loop is a flat side that bends by 0.6° at the loop's start, closed by an arc of +/// `n` lines `height` tall, with `inner` anchors along each half of the side. +#[test] +fn simplify_keeps_the_join_at_the_start_of_a_smoothed_loop_smooth() { + for (half, height, n, inner, tolerance, smooth) in + [(40.0, 20.0, 8, 3, 2.5, 2.0), (80.0, 60.0, 8, 3, 5.0, 0.5), (160.0, 200.0, 24, 3, 10.0, 2.0), (160.0, 20.0, 24, 8, 10.0, 5.0)] + { + let rise = half * 0.3f64.to_radians().tan(); + let along = |i: i32| Point::new(half * f64::from(i) / f64::from(inner), -rise * f64::from(i.abs()) / f64::from(inner)); + let mut pts: Vec = (0..=inner).map(along).collect(); + pts.extend((1..n).map(|i| { + let a = f64::from(i) / f64::from(n) * PI; + Point::new(half * a.cos(), -rise - height * a.sin()) + })); + pts.extend((-inner..0).map(along)); + let path = PathData::single(SubPath::polyline(&pts, true)); + let s = simplify_with(&path, &SimplifyOptions { tolerance, corner_angle_deg: 179.0, smooth, ..Default::default() }); + let sp = &s.subpaths[0]; + assert!(sp.closed && sp.anchors.len() < pts.len(), "{tolerance} pt: {s:?}"); + assert!(sp.segment_is_line(0), "{tolerance} pt: the first segment is a curve: {s:?}"); + let turn = largest_join_turn(sp); + assert!(turn <= 0.5 + 1e-9, "{tolerance} pt: an anchor turns {turn}°: {s:?}"); + } +} + #[test] fn simplify_straight_lines_option() { let c = dense_circle(50.0, 200); From 36c652d942b52b838bdccb7217f4cfd0cf51a33b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:28:38 -0800 Subject: [PATCH 04/20] Control bar: no separator is left at the end of the bar when the controls after it are hidden A separator that ends a group of the Control bar's controls is now painted only when a control follows it in the bar. When the groups after it are off in the panel menu, do not apply to the selection or the tool, or have no room in the window, the bar ends with its last control. Two separators with nothing between them (the artboard Transform link and the artboard Align group, or tool options and the envelope controls) show as one. Each separator keeps its room, so the groups keep their places and widths. - Fill and Stroke, Opacity and Graphic Style, Character, image and Image Trace options, the Mirror & Cut, Crop Image, Puppet Warp and shape tool options, the artboard Transform link and the envelope controls draw their outer separator through group_separator. Paragraph and area type vertical alignment end through group_end, which draws its separator through boundary. - Regression test: no_separator_is_left_at_the_end_of_the_control_bar sweeps window widths from 300 to 1500 points for each case. --- crates/ui-egui/src/addons/shapes.rs | 12 ++-- crates/ui-egui/src/chrome.rs | 1 + crates/ui-egui/src/chrome/control_bar.rs | 77 ++++++++++++++++++-- crates/ui-egui/src/chrome/tests.rs | 90 ++++++++++++++++++++++++ crates/ui-egui/src/dialogs/envelope.rs | 2 +- crates/ui-egui/src/panels/artboards.rs | 2 +- crates/ui-egui/src/panels/character.rs | 2 +- crates/ui-egui/src/panels/image_trace.rs | 2 +- crates/ui-egui/src/place.rs | 2 +- crates/ui-egui/src/toolbar.rs | 6 +- docs/target-app-parity.md | 1 + 11 files changed, 176 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/addons/shapes.rs b/crates/ui-egui/src/addons/shapes.rs index e7971bb888..fae45a190e 100644 --- a/crates/ui-egui/src/addons/shapes.rs +++ b/crates/ui-egui/src/addons/shapes.rs @@ -42,7 +42,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok(); } } - ui.separator(); + crate::chrome::group_separator(ui); } match active_shape.as_str() { @@ -106,7 +106,7 @@ fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, se if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true })); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -164,7 +164,7 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option) { @@ -225,7 +225,7 @@ fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, ac json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -255,7 +255,7 @@ fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sel if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true })); } - ui.separator(); + crate::chrome::group_separator(ui); } fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { @@ -298,7 +298,7 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele json!({ "reset": true }), ); } - ui.separator(); + crate::chrome::group_separator(ui); } fn mode_str(m: EllipseMode) -> &'static str { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 855d542d08..ddf0040a2c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -18,6 +18,7 @@ mod hints; mod status_bar; mod tabs; +pub(crate) use control_bar::group_separator; pub use control_bar::{ AnchorControls, CONTROL_BAR_EDGES, CONTROL_BAR_GROUPS, anchor_buttons, anchor_controls, control_bar, control_bar_moving, control_bar_placement, read_hidden_groups, diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 9b862cf636..7cb577c812 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -202,7 +202,7 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 { /// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it. fn group_end(ui: &mut Ui) { ui.add_space(GROUP_GAP); - separator(ui); + boundary(ui, separator); } fn group_end_width(ui: &Ui) -> f32 { @@ -218,6 +218,66 @@ fn separator_width(ui: &Ui) -> f32 { SEPARATOR + ui.spacing().item_spacing.x } +/// A separator between two groups of the Control bar's controls, drawn in the bar's row `ui`: the +/// pass that drew it, the range of the paint list that holds it (between two empty shapes), the +/// row's next widget id just after it, and whether it reaches the end of the row, leaving no room +/// for a control after it. +#[derive(Clone, Copy)] +struct Boundary { + pass: u64, + shapes: (usize, usize), + after: egui::Id, + at_end: bool, +} + +/// The key of the row `ui`'s last [`Boundary`]. +fn boundary_key(ui: &Ui) -> egui::Id { + ui.unique_id().with("cb-group-boundary") +} + +/// A separator between two groups of the Control bar's controls, drawn with `draw`. It stays only +/// while a control follows it in the row: [`erase_trailing`] erases it at the end of the row when no +/// control does or none has room, and the next separator erases it when no control comes between +/// them. The separator keeps its room either way. +fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) { + erase_trailing(ui); + let start = ui.painter().add(egui::Shape::Noop); + draw(ui); + let end = ui.painter().add(egui::Shape::Noop); + let at_end = ui.cursor().min.x >= ui.max_rect().right(); + let b = Boundary { pass: ui.ctx().cumulative_pass_nr(), shapes: (start.0, end.0), after: ui.next_auto_id(), at_end }; + let key = boundary_key(ui); + ui.data_mut(|d| d.insert_temp(key, b)); +} + +/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws +/// one, and left out when no control follows it ([`boundary`]). +pub(crate) fn group_separator(ui: &mut Ui) { + boundary(ui, |ui| { + ui.separator(); + }); +} + +/// Erases the row `ui`'s last group separator when no control was drawn after it in this pass, or +/// none had room in the row. The end of the Control bar's controls row calls it too. +fn erase_trailing(ui: &mut Ui) { + let key = boundary_key(ui); + let Some(b) = ui.data_mut(|d| { + let b = d.get_temp::(key); + d.remove::(key); + b + }) else { + return; + }; + if b.pass != ui.ctx().cumulative_pass_nr() || (b.after != ui.next_auto_id() && !b.at_end) { + return; + } + let painter = ui.painter(); + for i in b.shapes.0 + 1..b.shapes.1 { + painter.set(egui::layers::ShapeIdx(i), egui::Shape::Noop); + } +} + /// The room the controls at the end of the Control bar need: the Transform link, or Document Setup /// and Preferences with nothing selected, or none while the panel menu has that group off. The /// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align buttons, @@ -276,7 +336,7 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize } } if transform { - separator(ui); + boundary(ui, separator); } } transform @@ -292,7 +352,7 @@ fn artboard_align_group(app: &mut VectorcraftApp, ui: &mut Ui) { if !group_on(app, "alignDistribute") || app.session.active().is_none() || ui.available_width() < separator_width(ui) + buttons_width(ui, 7) { return; } - separator(ui); + boundary(ui, separator); crate::panels::align::align_buttons(app, ui, BUTTON); if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x { crate::panels::align::align_to_menu(app, ui, BUTTON); @@ -479,7 +539,7 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { } /// The Control bar's Fill and Stroke group: the fill and stroke chips, the Stroke link, the weight -/// and the width profile, and the separator after them. +/// and the width profile, and the separator after them while a control follows ([`group_separator`]). fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); @@ -494,12 +554,12 @@ fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("stroke.set", json!({"profile": id})).ok(); } ui.add_space(4.0); - ui.separator(); + group_separator(ui); } /// The Control bar's Opacity group (its link and field, showing `opacity`) and, with something /// `selected`, its Graphic Style group (Style, Recolor Artwork and Global Edit Options), each while -/// the panel menu has it on, and the separator after them. +/// the panel menu has it on, and the separator after them while a control follows. fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool) { let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected); if opacity_on { @@ -529,7 +589,7 @@ fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: global_edit_options(app, ui); } if opacity_on || style_on { - ui.separator(); + group_separator(ui); } } @@ -680,6 +740,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform")); if crate::panels::artboards::control_bar(app, ui, artboard_options, transform) { artboard_align_group(app, ui); + erase_trailing(ui); return; } if anchors != AnchorControls::None && group_on(app, "anchors") { @@ -713,6 +774,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, crate::panels::character::control_bar(app, ui); } if !groups_before_transform(app, ui, sel.len()) { + erase_trailing(ui); return; } // The Transform link opens the whole Transform panel (reference point, rotate, @@ -750,6 +812,7 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, crate::panels::transform::constrain_link_at(app, ui, ui.interact(room, id, Sense::click())); } } + erase_trailing(ui); }); // Controls that keep their size can still run into the button's room in a narrow window; // the button is left out then. A floating bar clips them, so its button always shows. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 626ff8c66e..346dcb9f05 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -2845,6 +2845,96 @@ fn the_artboard_tool_s_control_bar_follows_its_two_groups() { assert!(has(&bar, "Name:") && has(&bar, "Move Artwork with Artboard"), "{bar:?}"); } +/// The separators the docked Control bar draws in window `screen` (vertical lines most of the bar's +/// height, left of the panel menu button), in order, with whether any other shape is drawn between +/// each one and the next, or after the last one for the last. +fn control_bar_separators(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Vec<(f32, bool)> { + control_bar_frame_in(app, ctx, screen, vec![]); + let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); + let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + let menu_left = + nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds()).map_or(screen.right(), |b| b.x0 as f32); + let separator = |s: &egui::Shape| match s { + egui::Shape::LineSegment { points: [a, b], .. } if (a.x - b.x).abs() < 0.5 && (a.y - b.y).abs() >= 16.0 => Some(a.x), + _ => None, + }; + let (mut lines, mut others) = (vec![], vec![]); + for cs in &out.shapes { + let r = cs.shape.visual_bounding_rect(); + if !r.is_finite() || !cs.clip_rect.intersects(r) || r.min.x >= menu_left || r.width() > screen.width() / 2.0 { + continue; + } + match separator(&cs.shape) { + Some(x) => lines.push(x), + None => others.push(r.center().x), + } + } + lines.sort_by(f32::total_cmp); + (0..lines.len()) + .map(|i| { + let next = lines.get(i + 1).copied().unwrap_or(f32::INFINITY); + (lines[i], others.iter().any(|&x| x > lines[i] && x < next)) + }) + .collect() +} + +/// A Control bar separator stands only between two groups that draw controls: none is left at the +/// end of the bar when the groups after it are off, do not apply or have no room, and none follows +/// another with nothing between them, in every window width. With every group on, the separators +/// between the groups stay. +#[test] +fn no_separator_is_left_at_the_end_of_the_control_bar() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let set = |app: &mut crate::VectorcraftApp, groups: &[&str], visible: bool| { + for group in groups { + app.run("window.control.options", json!({ "group": group, "visible": visible })).unwrap(); + } + }; + // The first width in each case where a separator has nothing after it. + let mut stray = vec![]; + let mut check = |app: &mut crate::VectorcraftApp, case: &str| { + for w in (300..=1500).step_by(50) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + let lines = control_bar_separators(app, &ctx, screen); + if lines.iter().any(|(_, followed)| !followed) { + stray.push(format!("{case}, {w} wide: {lines:?}")); + return; + } + } + }; + app.run("select.set", json!({ "ids": [id] })).unwrap(); + let wide = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1500.0, 300.0)); + assert!(control_bar_separators(&mut app, &ctx, wide).len() >= 2, "the groups keep their separators"); + check(&mut app, "every group on"); + set(&mut app, &["transform", "alignDistribute"], false); + assert!(!control_bar_separators(&mut app, &ctx, wide).is_empty(), "Fill and Stroke and Opacity keep theirs"); + check(&mut app, "Transform and Align off"); + set(&mut app, &["opacity", "graphicStyle"], false); + check(&mut app, "Fill and Stroke last"); + app.run("select.set", json!({ "ids": [] })).unwrap(); + set(&mut app, &["opacity", "graphicStyle"], true); + set(&mut app, &["documentOptions"], false); + check(&mut app, "nothing selected, Document Options off"); + app.select_tool("puppetWarp"); + set(&mut app, &["fillStroke", "opacity"], false); + check(&mut app, "Puppet Warp options last"); + app.select_tool("ellipse"); + check(&mut app, "Ellipse options last"); + set(&mut app, &["fillStroke", "opacity", "documentOptions", "transform", "alignDistribute"], true); + app.select_tool("artboard"); + set(&mut app, &["artboardOptions"], false); + check(&mut app, "the artboard's Transform link and Align"); + set(&mut app, &["alignDistribute"], false); + check(&mut app, "the artboard's Transform link last"); + assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}"); +} + /// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps /// its Anchor Point label. #[test] diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 1513efc8f2..a73d68cd1a 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -255,7 +255,7 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let Some((editing, bar)) = bar_of(app) else { return }; let mut run: Option<(&str, Value)> = None; - ui.separator(); + crate::chrome::group_separator(ui); for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] { if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents { run = Some(("object.envelope.editContents", json!({ "editing": contents }))); diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 774002a72d..0f7223f7e8 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -100,7 +100,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool, } if transform { super::transform::link(app, ui); - ui.separator(); + crate::chrome::group_separator(ui); } if options { art_option_boxes(app, ui, ui.available_width() < ART_OPTIONS_WIDTH); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2ad678bf62..de42febef6 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1073,7 +1073,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0)); size_field(app, ui, "cb-font-size", 96.0); ui.add_space(4.0); - ui.separator(); + crate::chrome::group_separator(ui); } /// The panel state key of the panel menu's Show Snap to Glyph Options. diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index ce5a5955b8..7777380180 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -361,7 +361,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { if widgets::flat_button(ui, tl!("Expand"), 64.0).clicked() { app.run("imageTrace.expand", json!({})).ok(); } - ui.separator(); + crate::chrome::group_separator(ui); true } diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 1169422789..94ae695ca4 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -604,7 +604,7 @@ pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool crate::menus::invoke(app, cmd, json!({})); } } - ui.separator(); + crate::chrome::group_separator(ui); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b1788e1e8a..e20d3c734d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -528,7 +528,7 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("tool.setOption", json!({ "key": key, "value": value })).ok(); } } - ui.separator(); + crate::chrome::group_separator(ui); } /// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's @@ -564,7 +564,7 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) { crate::canvas::apply_requests(app, r); } } - ui.separator(); + crate::chrome::group_separator(ui); } /// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as @@ -1155,7 +1155,7 @@ fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::flat_button(ui, "Select All Pins", 104.0).clicked() { app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok(); } - ui.separator(); + crate::chrome::group_separator(ui); } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..9620fd452d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -626,6 +626,7 @@ since 2026-10-01. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Control bar docks to the top or the bottom, or floats:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked and neither while the bar floats. Dragging the bar's gripper and releasing it within 24 points of the top (under the menu bar) or the bottom of the window docks it at that edge, with a band along that edge while it moves; releasing it farther from both edges floats the bar there, with its outline under the pointer while it moves, and a floating bar's gripper moves it or docks it again (`window.control.dock {edge: "top"|"bottom"|"float"}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Escape cancels a gripper drag. The 24-point zones, the floating bar's 800-point width and first position, a release outside the window leaving the bar where it was, a window too short for a floating bar docking it at the nearer edge, the floating bar staying inside a window that shrinks, and keeping the edge and the floating position with the workspace, with older workspaces docking the bar at their edge, are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `dragging_the_control_bar_gripper_away_from_both_edges_floats_it`, `floating_control_bar_moves_and_redocks_from_each_edge`, `floating_control_bar_menu_docks_to_either_edge`, `control_bar_drag_escape_restores_placement`, `control_bar_drag_release_outside_window_keeps_previous_placement`, `floating_control_bar_stays_reachable_in_tiny_and_resized_windows`, `the_control_bar_dock_command_takes_top_bottom_or_float` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` and `saved_workspaces_restore_floating_control_bar_position` in `crates/ui-egui/src/workspaces.rs`. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. +- **Control bar separators stand only between groups:** the Control bar draws a separator between two groups of options only when a control follows it in the bar. When the groups after a separator are off in the panel menu, do not apply to the selection or the tool, or have no room in the window, the bar ends with the last control and no separator; two separators with nothing between them show as one. Each separator keeps its room, so the groups keep their places. This is VectorCraft's own rule. Regression test: `no_separator_is_left_at_the_end_of_the_control_bar` in `crates/ui-egui/src/chrome/tests.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. From 6b799457844e4e12b1f6cbbd2c4822fe627e6859 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:33:55 -0800 Subject: [PATCH 05/20] Toolbars: deleting or renaming a toolbar ends the drag of its title bar Escape during a drag puts the toolbar back by its name. A toolbar deleted while its title bar was dragged left the drag in place, so Escape docked and moved another toolbar that had taken the name since. Deleting the toolbar, or renaming it to a name that differs by more than case, now ends the drag. The drag of another toolbar goes on. The drag test also presses Escape in the frame of the first move after the press, where no drag starts. --- crates/ui-egui/src/custom_toolbars.rs | 57 ++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index 2b4618048a..31f83c8bba 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -241,7 +241,8 @@ fn index(ui: &UiState, name: &str) -> Result { /// The All Tools drawer beside the toolbar named `old`, and a group's flyout open on it (`ctx`), /// close when it is deleted (`new` is `None`) or renamed; a new name that changes only the case -/// leaves them open on it. +/// leaves them open on it. A drag of its title bar in progress ends with them, so Escape does not +/// put a toolbar that takes the name later back where this one was. fn close_popups(ui: &mut UiState, ctx: Option<&egui::Context>, old: &str, new: Option<&str>) { let same = new.is_some_and(|n| key(n) == key(old)); if drawer_target(ui).map(key) == Some(key(old)) { @@ -253,6 +254,9 @@ fn close_popups(ui: &mut UiState, ctx: Option<&egui::Context>, old: &str, new: O } } let Some(ctx) = ctx else { return }; + if !same && ctx.data(|d| d.get_temp::(move_id())).is_some_and(|m| key(&m.name) == key(old)) { + ctx.data_mut(|d| d.remove::(move_id())); + } let id = crate::toolbar::flyout_toolbar_id(); if ui.flyout.is_some() && ctx.data(|d| d.get_temp::>(id)).flatten().is_some_and(|t| key(&t) == key(old)) { if same { @@ -2099,6 +2103,52 @@ mod tests { assert_eq!(app.ui.flyout, None, "deleted, then its name taken"); } + /// Deleting or renaming a toolbar while its title bar is dragged ends the drag: Escape then + /// leaves a toolbar that took its name where it is. The drag of another toolbar goes on, and so + /// does the drag of a toolbar whose new name changes only the case. + #[test] + fn deleting_or_renaming_the_toolbar_being_dragged_ends_its_drag() { + let mut app = app(); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // A drag of the toolbar's title bar in progress, begun where the toolbar is now. + let hold = |app: &VectorcraftApp, name: &str| { + let bar = find(&app.ui, name).unwrap(); + let m = Move { + name: bar.name.clone(), + from: pos2(10.0, 10.0), + grab: egui::Vec2::ZERO, + dock: bar.dock, + right: bar.dock_right, + pos: bar.pos, + cancelled: false, + }; + ctx.data_mut(|d| d.insert_temp(move_id(), m)); + }; + let held = || ctx.data(|d| d.get_temp::(move_id())).is_some(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + app.run("window.toolbar.new", json!({"name": "Other"})).unwrap(); + app.run("window.toolbar.dock", json!({"name": "Mine", "side": "right"})).unwrap(); + hold(&app, "Mine"); + delete(&mut app.ui, Some(&ctx), "Other").unwrap(); + assert!(held(), "another toolbar's delete leaves the drag going"); + delete(&mut app.ui, Some(&ctx), "Mine").unwrap(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let at = find(&app.ui, "Mine").unwrap().pos; + assert!(!cancel(&mut app.ui, &ctx), "the deleted toolbar's drag ended"); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at), "deleted, then its name taken"); + app.run("window.toolbar.dock", json!({"name": "Mine", "side": "right"})).unwrap(); + hold(&app, "Mine"); + rename(&mut app.ui, Some(&ctx), "Mine", "mine").unwrap(); + assert!(held(), "a new name that changes only the case leaves the drag going"); + rename(&mut app.ui, Some(&ctx), "mine", "Pens").unwrap(); + app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap(); + let at = find(&app.ui, "Mine").unwrap().pos; + assert!(!cancel(&mut app.ui, &ctx), "the renamed toolbar's drag ended"); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at), "renamed, then its name taken"); + assert_eq!(place(&app, "Pens"), (Some(0), true), "the renamed toolbar stays docked"); + } + /// A toolbar deleted while Manage Toolbars has it selected leaves none selected, and a click /// on + or the trash can meant for it does nothing, so the dialog can go on. #[test] @@ -2677,6 +2727,9 @@ mod tests { // Escape while it is dragged out of the lane puts it back in its place. drag(&mut app, grip(&ctx), pos2(600.0, 300.0), Some(4)); assert_eq!(place(&app, "Mine"), (Some(0), false), "Escape keeps it docked"); + // Escape in the frame of the first move after the press: no drag starts. + drag(&mut app, grip(&ctx), pos2(600.0, 300.0), Some(1)); + assert_eq!(place(&app, "Mine"), (Some(0), false), "Escape with the first move keeps it docked"); // Dragged out of the lane, it floats under the pointer. drag(&mut app, grip(&ctx), pos2(600.0, 300.0), None); assert_eq!(place(&app, "Mine"), (None, false)); @@ -2685,6 +2738,8 @@ mod tests { let at = find(&app.ui, "Mine").unwrap().pos; drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), Some(5)); assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at)); + drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), Some(1)); + assert_eq!((place(&app, "Mine"), find(&app.ui, "Mine").unwrap().pos), ((None, false), at), "Escape with the first move"); // Dropped on the right edge, it docks there. drag(&mut app, grip(&ctx), pos2(1195.0, 400.0), None); assert_eq!(place(&app, "Mine"), (Some(0), true)); From d211e2f61302074991d2022add6dd6e587ba6b25 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:35:28 -0800 Subject: [PATCH 06/20] Artboards: a batch's Fit to Artwork Bounds step fits every selected artboard A Fit to Artwork Bounds step of `command.batch` that names neither `index` nor `indices` now fits the artboards the Artboard tool has selected when the step runs (two or more), as the command does when the app runs it on its own. Before, the step took the batch's `artboard` as its `index`, so an action played from the Actions panel fitted only the active artboard while several were selected. - A step's own `index` or `indices` is kept, and the batch no longer adds an unused `index` to a step that names `indices`. - The batch's journal entry records the `indices` the step took, so a replay fits the same artboards. An action leaves them out and fits the artboards selected when it plays. Docs: command.batch params text, docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/fileio/batch.rs | 25 +++++- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/command_sets.rs | 4 +- crates/engine/src/tests/journal.rs | 81 ++++++++++++++++++++ crates/ui-egui/src/panels/artboards/tests.rs | 22 ++++++ docs/mcp.md | 8 +- docs/target-app-parity.md | 3 +- 7 files changed, 136 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index 18bc78b5d9..8ca723b370 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -78,8 +78,11 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { /// `artboard`, as the Artboards panel shows it in the document the step runs in: past that /// document's last artboard, the last one; a Paste in Place, in Front or in Back step that names /// `artboards` keeps them, and one that names neither, while the Artboard tool is chosen with -/// several artboards selected, pastes on those. A step that resizes the active artboard -/// ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` gets that artboard as its `index`. +/// several artboards selected, pastes on those. A Fit to Artwork Bounds step that names neither +/// `index` nor `indices`, while the Artboard tool is chosen with several artboards selected when the +/// step runs, gets those as its `indices`, which its journal entry records. Otherwise a step that +/// resizes the active artboard ([`crate::FIT_ACTIVE_ARTBOARD`]) and names no `index` (nor +/// `indices`, for Fit to Artwork Bounds) gets that artboard as its `index`. /// `grouped` gets each step's undo groups, as [`Session::batch_steps`] keeps them. fn run_steps( s: &mut Session, @@ -110,9 +113,22 @@ fn run_steps( // Artboard tool is chosen with several artboards selected, pastes on those. let names_artboards = id.starts_with("edit.pasteIn") && (params.get("artboards").is_some() || (params.get("artboard").is_none() && !crate::cmd::edit::artboard_tool_picks(s).is_empty())); + // A Fit to Artwork Bounds step that names neither `index` nor `indices`, while the Artboard + // tool is chosen with several artboards selected, fits those, as the command does on its own. + let fit_picks = if id == "artboard.fitToArt" && params.get("index").is_none() && params.get("indices").is_none() { + crate::cmd::edit::artboard_tool_picks(s) + } else { + Vec::new() + }; + if !fit_picks.is_empty() + && let Some(o) = params.as_object_mut() + { + o.insert("indices".into(), json!(fit_picks)); + } if let Some(a) = artboard && let Some(key) = key && params.get(key).is_none() + && !(id == "artboard.fitToArt" && params.get("indices").is_some()) && !names_artboards && let Some(last) = s.active().and_then(|d| d.doc.artboards.len().checked_sub(1)) && let Some(o) = params.as_object_mut() @@ -129,8 +145,9 @@ fn run_steps( } grouped.push(step_groups(s, start, &held)); // The step's journal entry records what it took from the preferences or the clock (a - // save's date, a new document's), as a command of its own would. - if noted != params + // save's date, a new document's), as a command of its own would, and the artboards a Fit to + // Artwork Bounds step took from the Artboard tool. + if (noted != params || !fit_picks.is_empty()) && let Some(c) = c.as_object_mut() { c.insert("params".into(), noted); diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 26272d873d..ed6ff95808 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -108,7 +108,7 @@ pub fn specs() -> Vec { "Batch", [], None, - "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", + "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer; an artboard.fitToArt step that names neither index nor indices, while the Artboard tool is chosen with several artboards selected when the step runs, gets those as indices instead, and the journal records them)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch ), diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index a17fbaf3f8..89642a6e19 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -19,7 +19,9 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 8] = [ /// The commands that resize the artboards the app passes (Object › Artboards): the app passes the /// selected artboards to Fit to Artwork Bounds as its `indices`, and the active artboard to Fit to /// Selected Art as its `index`. A `command.batch` given `artboard` passes it on, as `index`, to -/// each of their steps that names none. +/// each of their steps that names none; a Fit to Artwork Bounds step that names neither `index` nor +/// `indices`, while the Artboard tool is chosen with several artboards selected, gets those as its +/// `indices` instead. pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitToSelection"]; /// `(command, param)` pairs the journal records only so a replay reproduces the original run diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index f6ee8015be..5eb66ec6c8 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -192,6 +192,87 @@ fn fit_steps_take_the_batchs_artboard_and_actions_leave_out_their_index() { assert_eq!(action, [fit("artboard.fitToArt"), fit("artboard.fitToArt"), fit("artboard.fitToSelection")]); } +/// A document of three artboards with art of its own on each → its artboards before any fit. +fn three_artboards_with_art(s: &mut Session) -> Vec { + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 3, "created": null})).unwrap(); + for (x, h) in [(10, 30), (130, 20), (250, 40)] { + s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap(); + } + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect() +} + +/// The artboards of [`three_artboards_with_art`], each fitted to its art. +fn three_fitted_artboards() -> [vectorcraft_geom::Rect; 3] { + let rect = vectorcraft_geom::Rect::new; + [rect(10.0, 10.0, 30.0, 40.0), rect(130.0, 10.0, 150.0, 30.0), rect(250.0, 10.0, 270.0, 50.0)] +} + +/// A Fit to Artwork Bounds step that names neither `index` nor `indices`, while the Artboard tool +/// is chosen with several artboards selected, fits each of those (not the batch's artboard), as the +/// command does on its own; a step's own `index` or `indices` wins, and with one artboard or none +/// selected the step takes the batch's artboard. +#[test] +fn a_batch_fit_step_uses_the_artboard_tool_selection() { + let fitted = three_fitted_artboards(); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let fit = |params: Value| json!({"artboard": 1, "commands": [{"command": "artboard.fitToArt", "params": params}]}); + let session = |picks: &Value| { + let mut s = Session::new(); + let was = three_artboards_with_art(&mut s); + s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + s.set_tool_option("selected", picks); + (s, was) + }; + let (mut s, was) = session(&json!([2, 0])); + s.execute("command.batch", &fit(json!({}))).unwrap(); + assert_eq!(boards(&s), [fitted[0], was[1], fitted[2]], "artboards 1 and 3, the selected ones, and not the batch's artboard 2"); + let (mut s, was) = session(&json!([2, 0])); + s.execute("command.batch", &fit(json!({"index": 1}))).unwrap(); + assert_eq!(boards(&s), [was[0], fitted[1], was[2]], "a step's own index"); + let (mut s, was) = session(&json!([2, 0])); + s.execute("command.batch", &fit(json!({"indices": [1, 2]}))).unwrap(); + assert_eq!(boards(&s), [was[0], fitted[1], fitted[2]], "a step's own indices"); + for picks in [json!([2]), json!([])] { + let (mut s, was) = session(&picks); + s.execute("command.batch", &fit(json!({}))).unwrap(); + assert_eq!(boards(&s), [was[0], fitted[1], was[2]], "the batch's artboard with {picks} selected"); + } +} + +/// The journal records the artboards a batch's Fit to Artwork Bounds step took from the Artboard +/// tool, so a replay in a new session fits the same ones; an action leaves them out and fits the +/// artboards selected when it plays. +#[test] +fn a_batch_fit_selection_replays_but_an_action_uses_the_current_selection() { + let fitted = three_fitted_artboards(); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let mut s = Session::new(); + let was = three_artboards_with_art(&mut s); + s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([0, 2])); + let start = s.journal.len(); + let steps = json!([{"command": "artboard.fitToArt", "params": {}}]); + s.execute("command.batch", &json!({"artboard": 1, "commands": steps})).unwrap(); + assert_eq!(boards(&s), [fitted[0], was[1], fitted[2]]); + let (id, p) = s.journal.last().unwrap().clone(); + assert_eq!((id.as_str(), &p["commands"][0]["params"]), ("command.batch", &json!({"indices": [0, 2]}))); + let r = replayed(&s); + assert_ne!(r.tool_id(), "artboard", "the new session's tool selects no artboards"); + assert_eq!(boards(&r), boards(&s), "the replay fits the recorded artboards"); + let action = s.journal_for_action(start); + assert_eq!(action, [("command.batch".to_string(), json!({"commands": steps}))]); + // Played on a fresh copy of the document with other artboards selected. + let mut a = Session::new(); + three_artboards_with_art(&mut a); + a.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + a.set_tool_option("selected", &json!([1, 2])); + a.execute("command.batch", &json!({"artboard": 0, "commands": action[0].1["commands"]})).unwrap(); + assert_eq!(boards(&a), [was[0], fitted[1], fitted[2]], "the action fits the artboards selected when it plays"); +} + #[test] fn a_save_records_its_date_and_replays_it() { let path = vectorcraft_testkit::temp_dir("journal-save").join("a.vectorcraft").to_string_lossy().into_owned(); diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index eae59fd7dc..b53faf87c0 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -95,6 +95,28 @@ fn fit_to_artwork_bounds_fits_each_selected_artboard_to_its_own_art() { assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]); } +/// A batch the app runs (as the Actions panel plays an action) with a Fit to Artwork Bounds step +/// fits artboards 1 and 3, selected with the Artboard tool, and leaves artboard 2, the active one, +/// as it was. +#[test] +fn a_batchs_fit_to_artwork_bounds_step_fits_each_selected_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 3})).unwrap(); + for (x, h) in [(10, 30), (130, 20), (250, 40)] { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": h})).unwrap(); + } + app.session.execute("select.all", &json!({})).unwrap(); + app.session.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let was = boards(&app); + app.select_tool("artboard"); + select(&mut app, 1); + app.session.set_tool_option("selected", &json!([0, 2])); + app.run("command.batch", json!({"commands": [{"command": "artboard.fitToArt", "params": {}}]})).unwrap(); + let rect = vectorcraft_geom::Rect::new; + assert_eq!(boards(&app), [rect(10.0, 10.0, 30.0, 40.0), was[1], rect(250.0, 10.0, 270.0, 50.0)]); +} + /// With the Artboard tool, Shift-click on a row selects the artboards from the last row clicked /// without Shift to it and makes it the active one, Ctrl/Cmd-click adds its artboard to the /// tool's selection or takes it out (#1139), a plain click selects it alone, and every selected diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..b07bbb32c8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5373,8 +5373,12 @@ the selected artboards (those the Artboard tool has selected, else the active on `artboard.fitToSelection` calls it runs without `index`. Both include MCP's `run_command` calls in remote mode. The Artboards panel menu's Fit to Artwork Bounds also passes the selected artboards. A batch passes its `artboard` on to each of their steps that names no `index`, as the step's `index`. When the document a step runs in has no artboard at -that index, the step gets that document's last artboard. An action recorded in the Actions panel leaves `index` and -`indices` out of these steps, and they resize the artboard that is active when the action plays. +that index, the step gets that document's last artboard. An `artboard.fitToArt` step that names neither `index` nor +`indices`, while the Artboard tool is chosen with several artboards selected when the step runs, gets those artboards +as its `indices` instead, and the batch's journal entry records them, so a replay of the journal fits the same +artboards. A step's own `index` or `indices` is kept. An action recorded in the Actions panel leaves `index` and +`indices` out of these steps: when the action plays, Fit to Artwork Bounds resizes the artboards the Artboard tool then +has selected (two or more), else the active artboard, and Fit to Selected Art resizes the active artboard. ```json {"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","to":"artboard","artboard":1}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..2cd35afba0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1146,8 +1146,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Align to Artboard chosen, the Align panel's Distribute Objects and Distribute Spacing buttons use the active artboard (`object.distribute` and `object.distributeSpacing` with `to: "artboard"` and `artboard`). The first object's left or top edge goes to the artboard's left or top edge, and the last object's right or bottom edge goes to the artboard's right or bottom edge. Distribute Objects spaces the other objects evenly by its button's line, and two objects are enough. Distribute Spacing makes the gaps between the objects equal. With anchor points selected, the buttons space the points among themselves and do not use the artboard. When an action plays, its Distribute Objects and Distribute Spacing steps with Align to Artboard use the artboard that is active then. With a key object, the Align panel's Distribute Objects buttons use the Distribute Spacing value (`object.distribute {spacing}`): the key object, or the compound path or selected group that contains it, stays in place, and the objects' left edges, centers or right edges (top edges, centers or bottom edges) end up that far apart, outward from the key object's, in their order. With the field's default value of 0, a click puts every object on the key object's line (behavior reference: [Ask Mordy: Distributing Objects](http://rwillustrator.blogspot.com/2008/01/ask-mordy-distribute.html)). Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it. - Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then. + Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Selected Art steps that name no artboard resize the artboard that is active then, and its Fit to Artwork Bounds steps resize the artboards selected then. Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document. + A Fit to Artwork Bounds step of a batch run from the app (`command.batch`), including an action played from the Actions panel, fits each artboard the Artboard tool has selected when the step runs, as the command does on its own and as the [help page](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) describes. A step's own `index` or `indices` is kept, and the journal records the artboards the step fitted, so a replay fits the same ones. The step used to fit only the active artboard. The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only. With the Artboard tool selecting several artboards, Paste in Place, Paste in Front and Paste in Back put a copy of the copied art on each, where it was on the artboard it was copied from, in one undo step, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes (`artboards` on the three commands). Art copied from no artboard keeps its place on the first selected artboard and its offset to it on the others, and an `artboard` or `artboards` index the document does not have is an error; these rules are VectorCraft's own. The three commands used to paste one copy, on the active artboard, and an `artboard` index the document did not have pasted the art where it was. A replay of the journal repeats a Cut or Copy made with the Artboard tool on the artboards it took (`edit.cut` and `edit.copy` record `selectedArtboards` and `art`), whichever tool is chosen in the new session and whatever is selected there. A recorded action follows the tool chosen when it plays. The replay used to act on the objects selected then, or stop with nothing selected. This rule is VectorCraft's own. From eb2f3ba71a97c98a25b4727f4ff7eb05bffad8ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:41:46 -0800 Subject: [PATCH 07/20] Tests: the font menu scroll bar test lists fonts of its own font_menu::tests::the_scroll_bar_leaves_the_stars_clear starred every family in the process-wide font database and listed that database, which tests running alongside add fonts and composite fonts to. In full parallel runs it sometimes failed with "8 families, no row stars drawn". With the four bundled families alone it never reached the scroll bar check, which ran only for seven or more families. - The menu's header takes the font database it lists and hands it to the rows and the name-width measurement. The app passes the process-wide database as before. - Each FontDb has an id of its own (FontDb::id), and the widest family name the menu keeps is stored with that id as well as the generation, so a width measured for one database is not used for another at the same generation. - The test lists a database of the bundled fonts, stars its four families, opens on Inter, and runs the frames the scroll bar takes to appear. It checks that the rows overflow the list's scroll area, that a row star is drawn inside that area, and that each star stays left of the scroll bar's column (#2277). With the list's scroll bar set to floating, the test fails with a star under the bar. Without the id in the widest name's key, each_font_database_has_its_own_widest_name fails. --- crates/text/src/fontdb.rs | 2 + crates/text/src/fontdb/db.rs | 10 ++++ crates/ui-egui/src/font_menu.rs | 66 ++++++++++++++---------- crates/ui-egui/src/font_menu/tests.rs | 74 ++++++++++++++++++--------- 4 files changed, 101 insertions(+), 51 deletions(-) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 9e18b2a86c..ace69a4148 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -105,6 +105,8 @@ pub struct FontDb { family_cache: Mutex>>, menu_cache: Mutex>, generation: AtomicU64, + /// This database's [`FontDb::id`]. + id: u64, /// System fallback state: characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 4ef17c40c6..0e28d57528 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -21,6 +21,9 @@ use super::{Arc, AtomicU64, FontDb, FontPaths, HashMap, Mutex, Ordering, Path, P #[cfg(not(target_arch = "wasm32"))] use super::{ScanStamp, modified}; +/// The next [`FontDb::id`]. +static NEXT_ID: AtomicU64 = AtomicU64::new(0); + impl FontDb { /// A database holding the bundled fonts, whose system font scan reads `font_dirs` (folders, /// read with their subfolders, and font files). @@ -64,6 +67,7 @@ impl FontDb { family_cache: Mutex::new(None), menu_cache: Mutex::new(None), generation: AtomicU64::new(0), + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), #[cfg(not(target_arch = "wasm32"))] @@ -128,6 +132,12 @@ impl FontDb { self.generation.load(Ordering::Relaxed) } + /// A number no other database made in this process has: with [`FontDb::generation`], it tells + /// what a list built from a database was built from. + pub fn id(&self) -> u64 { + self.id + } + /// Family names available (loaded plus installed system fonts), sorted and deduplicated. pub fn families(&self) -> Vec { self.family_list().to_vec() diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 4fbb7a7ffc..8e61f291e4 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -383,7 +383,7 @@ pub fn font_menu( |ui| { // Wider than a narrow field, so the filters keep their rows and long names show. ui.set_min_width(menu_width(ui, state, look)); - header(ui, state_id, state, current, look) + header(ui, vectorcraft_text::FontDb::global(), state_id, state, current, look) }, look.row, |h| h.rows.len(), @@ -970,6 +970,8 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { /// What the menu's header hands its rows. struct Listed<'s> { + /// The fonts listed: [`vectorcraft_text::FontDb::global`] outside the unit tests. + db: &'s vectorcraft_text::FontDb, st: &'s mut MenuState, /// The rows listed. rows: Arc<[Row]>, @@ -985,14 +987,28 @@ struct Listed<'s> { /// [`craft_ui::combo::Menu::show_rows_with_header`] lays them out. #[cfg(test)] fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, sample: Option<&str>, look: MenuLook) -> Option { - let h = header(ui, state_id, st, current, look); + list_of(ui, vectorcraft_text::FontDb::global(), state_id, st, current, sample, look).inner +} + +/// [`list`] of the families in `db`, with the rows' scroll area as laid out. +#[cfg(test)] +fn list_of( + ui: &mut Ui, + db: &vectorcraft_text::FontDb, + state_id: egui::Id, + st: &mut MenuState, + current: &str, + sample: Option<&str>, + look: MenuLook, +) -> egui::scroll_area::ScrollAreaOutput> { + let h = header(ui, db, state_id, st, current, look); let total = h.rows.len(); - egui::ScrollArea::vertical().max_height(ui.available_height()).show_rows(ui, look.row, total, |ui, range| rows(ui, range, h, sample, look)).inner + egui::ScrollArea::vertical().max_height(ui.available_height()).show_rows(ui, look.row, total, |ui, range| rows(ui, range, h, sample, look)) } /// The top of the open menu, which stays in place: the search field, the filters, and the rows -/// they list. -fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &str, look: MenuLook) -> Listed<'s> { +/// of `db` they list. +fn header<'s>(ui: &mut Ui, db: &'s vectorcraft_text::FontDb, state_id: egui::Id, st: &'s mut MenuState, current: &str, look: MenuLook) -> Listed<'s> { let t = Tokens::get(ui.ctx()); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); // Each time the menu opens: no query, the current family highlighted and in view (the @@ -1025,7 +1041,6 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase(); let refilter = filters(ui, st, &t); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - let db = vectorcraft_text::FontDb::global(); // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック), // as it was before any preview. let current = db.canonical(&st.current, "").0; @@ -1092,7 +1107,7 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & // The scroll bar beside the rows, not over them: a floating one covered the stars at their // right ends, so a favourite was hard to click (#2277). ui.spacing_mut().scroll = widgets::list_scroll_style(); - Listed { st, rows, current: current.to_string(), enter, keyed, opening } + Listed { db, st, rows, current: current.to_string(), enter, keyed, opening } } /// One of the filters' chips. @@ -1166,28 +1181,29 @@ fn filters(ui: &mut Ui, st: &mut MenuState, t: &Tokens) -> bool { /// [`WINDOW_SHARE`] of the window. The menu is never narrower than its field. fn menu_width(ui: &Ui, st: &MenuState, look: MenuLook) -> f32 { let sample = if look.samples { SAMPLE_W } else { 0.0 }; - let names = name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + let names = name_width(ui, vectorcraft_text::FontDb::global(), look.english_names, &st.rows).min(NAME_MAX); let row = DISCLOSURE + names + NAME_GAP + sample + STAR + 2.0 + widgets::list_scroll_style().allocated_width(); // Rounding in the chips' widths must not wrap the widest row. let filters = if st.filters > 0.0 { st.filters.ceil() + 1.0 } else { 0.0 }; filters.max(row).min(ui.ctx().content_rect().width() * WINDOW_SHARE) } -/// The width of the widest name the menu lists after the disclosure triangle: the family names as -/// Show Font Names in English shows them, and the style names among `rows` after their indent. -fn name_width(ui: &Ui, english: bool, rows: &[Row]) -> f32 { +/// The width of the widest name the menu lists after the disclosure triangle: the family names of +/// `db` as Show Font Names in English shows them, and the style names among `rows` after their +/// indent. +fn name_width(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool, rows: &[Row]) -> f32 { let font = egui::FontId::proportional(NAME_SIZE); let measure = |name: &str| ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x; let styles = rows.iter().filter_map(Row::style).map(|s| STYLE_INDENT + measure(s)).fold(0.0, f32::max); - family_name_width(ui, english).max(styles) + family_name_width(ui, db, english).max(styles) } -/// What the widest family name was measured for: the font list's generation, Show Font Names in -/// English, and the number of fonts the interface draws with, which grows as faces for other -/// scripts are added to it. -fn family_name_key(ui: &Ui, english: bool) -> (u64, bool, usize) { +/// What the widest family name was measured for: the font database and its generation, Show Font +/// Names in English, and the number of fonts the interface draws with, which grows as faces for +/// other scripts are added to it. +fn family_name_key(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool) -> (u64, u64, bool, usize) { let faces = ui.ctx().fonts(|f| f.definitions().font_data.len()); - (vectorcraft_text::FontDb::global().generation(), english, faces) + (db.id(), db.generation(), english, faces) } /// The id under which the widest family name's width is kept with its [`family_name_key`]. @@ -1195,16 +1211,15 @@ fn family_name_id() -> egui::Id { egui::Id::unique("font-menu-name-width") } -/// The width of the widest family name the menu lists, labeled as Show Font Names in English -/// (`english`) labels it; worked out again only when its [`family_name_key`] changes. -fn family_name_width(ui: &Ui, english: bool) -> f32 { - let key = family_name_key(ui, english); - if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, bool, usize), f32)>(family_name_id())) +/// The width of the widest family name the menu lists from `db`, labeled as Show Font Names in +/// English (`english`) labels it; worked out again only when its [`family_name_key`] changes. +fn family_name_width(ui: &Ui, db: &vectorcraft_text::FontDb, english: bool) -> f32 { + let key = family_name_key(ui, db, english); + if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, u64, bool, usize), f32)>(family_name_id())) && k == key { return w; } - let db = vectorcraft_text::FontDb::global(); let w = widest_label(ui, db.menu_family_list().iter().map(|f| db.family_display_name(f, english)).collect()); ui.data_mut(|d| d.insert_temp(family_name_id(), (key, w))); w @@ -1269,14 +1284,13 @@ fn heading_row(ui: &mut Ui, row_h: f32, label: &str, t: &Tokens) { /// The rows of the open menu in `range`, the ones in view: the menu's one scrolling part, under /// the search field and filters (#555). fn rows(ui: &mut Ui, range: std::ops::Range, h: Listed<'_>, sample: Option<&str>, look: MenuLook) -> Option { - let Listed { st, rows, current, enter, keyed, opening } = h; + let Listed { db, st, rows, current, enter, keyed, opening } = h; let current = current.as_str(); let row_h = look.row; let t = Tokens::get(ui.ctx()); let ink = sample_ink(look.on_white, t.text); - let db = vectorcraft_text::FontDb::global(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); - let widest = name_width(ui, look.english_names, &rows); + let widest = name_width(ui, db, look.english_names, &rows); if rows.is_empty() { // A filter or a font rescan can remove the row whose font was previewed. // Do not leave that font applied while the list has no candidate to highlight. diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 88a524e15b..11a66cab00 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -21,40 +21,44 @@ fn the_thai_filter_lists_fonts_with_thai() { /// stay clear of it. #[test] fn the_scroll_bar_leaves_the_stars_clear() { - // Every family starred, so each row draws its star. Tests running alongside add fonts and - // composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps - // the rows to the families starred here. - let all = vectorcraft_text::FontDb::global().families(); + // A database of the bundled fonts alone, each family starred, so each row draws its star: + // fonts that tests running alongside add to the process-wide database stay out of the rows. + let db = vectorcraft_text::FontDb::with_font_dirs(vec![]); + let favorites: Vec = ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"].map(str::to_string).into(); let ctx = egui::Context::default(); + // Under the search field and the filters, room for fewer rows than the four families. let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); let mut st = super::MenuState { favorites_only: true, ..Default::default() }; - let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; - let mut stars = vec![]; - for _ in 0..3 { + let look = super::MenuLook { samples: false, favorites: &favorites, ..Default::default() }; + let (mut list, mut stars) = (None, vec![]); + // Frames enough for the scroll bar to finish appearing. + for _ in 0..30 { let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); - super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look); + let shown = super::list_of(&mut child, &db, egui::Id::unique("fonts"), &mut st, "Inter", None, look); + list = Some((shown.inner_rect, shown.content_size)); }); out.textures_delta.clear(); stars = out .shapes .iter() .filter_map(|c| match &c.shape { - // The rows' stars, not the ★ filter button at the top left. - egui::Shape::Text(t) if t.galley.text() == "★" && t.pos.x > area.center().x => Some(t.visual_bounding_rect().right()), + egui::Shape::Text(t) if t.galley.text() == "★" => Some(t.visual_bounding_rect()), _ => None, }) .collect(); } - let families = st.rows.len(); - assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); - // The rows don't fill the list without enough families to scroll, and then there is no bar. - if families as f32 * look.row > area.height() { - let bar = super::widgets::list_scroll_style(); - let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin; - for right in stars { - assert!(right <= area.right() - column + 0.5, "a star at {right} runs under the scroll bar"); - } + let Some((inner, content)) = list else { panic!("the list was not drawn") }; + let families: Vec<&str> = st.rows.iter().filter_map(super::Row::family).collect(); + assert_eq!(families.len(), favorites.len(), "the starred families are listed: {families:?}"); + assert!(content.y > inner.height() + 0.5, "the rows, {} high, fill a list {} high", content.y, inner.height()); + // The rows' stars in the list, not the ★ filter above it. + let stars: Vec<_> = stars.into_iter().filter(|s| inner.contains(s.center())).collect(); + assert!(!stars.is_empty(), "no row star drawn in the list at {inner:?}"); + let bar = super::widgets::list_scroll_style(); + let column = bar.bar_width + bar.bar_inner_margin + bar.bar_outer_margin; + for star in stars { + assert!(star.right() <= area.right() - column + 0.5, "a star at {} runs under the scroll bar", star.right()); } } @@ -104,14 +108,33 @@ fn the_widest_name_is_measured_again_when_the_interface_fonts_change() { }; // A width no name has, kept as if measured for the fonts as they are. frame(&mut |ui| { - let key = super::family_name_key(ui, true); + let key = super::family_name_key(ui, vectorcraft_text::FontDb::global(), true); ui.data_mut(|d| d.insert_temp(super::family_name_id(), (key, -1.0_f32))); }); frame(&mut |ui| { let data = ui.ctx().fonts(|f| f.definitions().font_data.values().next().cloned()).expect("an interface font"); ui.ctx().add_font(egui::epaint::text::FontInsert { name: "font-menu-test-face".into(), data: (*data).clone(), families: vec![] }); }); - frame(&mut |ui| assert!(super::family_name_width(ui, true) >= 0.0, "measured again")); + frame(&mut |ui| assert!(super::family_name_width(ui, vectorcraft_text::FontDb::global(), true) >= 0.0, "measured again")); +} + +/// The widest family name measured for one font database is not taken for another one at the same +/// generation. +#[test] +fn each_font_database_has_its_own_widest_name() { + use vectorcraft_doc::CompositeFont; + let short = vectorcraft_text::FontDb::with_font_dirs(vec![]); + let long = vectorcraft_text::FontDb::with_font_dirs(vec![]); + assert!(short.set_composite_font(&CompositeFont::new("C", "Inter", "Regular"))); + assert!(long.set_composite_font(&CompositeFont::new("A composite font with a name longer than any family", "Inter", "Regular"))); + assert_eq!(short.generation(), long.generation()); + let ctx = egui::Context::default(); + let mut widths = (0.0, 0.0); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + widths = (super::family_name_width(ui, &short, true), super::family_name_width(ui, &long, true)); + }); + out.textures_delta.clear(); + assert!(widths.1 > widths.0 + 1.0, "the long name's database measured {} and the other {}", widths.1, widths.0); } /// Without a row of filters drawn yet, the menu still makes room for the widest name, its sample, @@ -125,7 +148,7 @@ fn the_menu_makes_room_for_the_widest_name_and_its_sample() { let mut out = ctx.run_ui(input, |ui| { let st = super::MenuState::default(); let look = super::MenuLook::default(); - let names = super::name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + let names = super::name_width(ui, vectorcraft_text::FontDb::global(), look.english_names, &st.rows).min(NAME_MAX); assert!(names > 0.0, "a family is listed"); let width = super::menu_width(ui, &st, look); assert!(width >= DISCLOSURE + names + NAME_GAP + SAMPLE_W + STAR, "the menu is {width} wide for a name {names} wide"); @@ -143,14 +166,15 @@ fn only_listed_style_names_widen_the_names() { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let long = format!("SemiCondensed ExtraLight Italic {}", "W".repeat(80)); + let db = vectorcraft_text::FontDb::global(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let families = super::name_width(ui, true, &[]); + let families = super::name_width(ui, db, true, &[]); let font = egui::FontId::proportional(super::NAME_SIZE); let style = STYLE_INDENT + ui.painter().layout_no_wrap(long.clone(), font, egui::Color32::WHITE).size().x; assert!(style > families, "the style name is wider than every family name"); let listed = [Row::Family("A".into()), Row::Style("A".into(), long.clone())]; - assert_eq!(super::name_width(ui, true, &listed), style); - assert!(super::name_width(ui, true, &listed[..1]) < style, "a style not listed is not measured"); + assert_eq!(super::name_width(ui, db, true, &listed), style); + assert!(super::name_width(ui, db, true, &listed[..1]) < style, "a style not listed is not measured"); }); out.textures_delta.clear(); } From 5700b0d8c111e985554d46dea28f96f793ccc6af Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:30:43 -0800 Subject: [PATCH 08/20] Live Paint: the bucket shows a filled bucket over a face and a brush over an edge The Live Paint Bucket drew the crosshair in place of its own cursor. It now shows what a click paints: a filled paint bucket over a face it fills (of a Live Paint group, or a region of the selected paths), a brush over an edge it paints, and an empty bucket where it paints nothing. The cursor follows Paint Fills, Paint Strokes and Shift, and shows with Highlight off too. Alt still shows the Eyedropper, and Use Precise Cursors shows the crosshair. With a Live Paint group and ordinary paths selected, a region of the paths outside the group is now highlighted, as a click there fills it. The help page: https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html --- crates/engine/src/tests/prefs.rs | 3 + crates/tools/src/builder/live_paint.rs | 132 +++++++++++++++++++++---- crates/tools/src/builder/tests.rs | 101 ++++++++++++++++++- crates/tools/src/cursor.rs | 10 +- crates/tools/src/cursors.toml | 33 ++++++- crates/tools/src/lib.rs | 5 +- crates/ui-egui/src/canvas/cursor.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 3 + crates/ui-egui/src/cursors.rs | 43 +++++++- crates/ui-egui/src/cursors/tests.rs | 39 ++++++++ docs/target-app-parity.md | 12 +++ docs/ui-parity.md | 3 +- 12 files changed, 354 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 14669fd357..3da5f95b10 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -753,6 +753,9 @@ fn precise_cursors_replace_every_pen_state() { Cursor::ShapeBuilderErase, Cursor::AnchorPoint, Cursor::Curvature, + Cursor::LivePaintBucket, + Cursor::LivePaintBucketFace, + Cursor::LivePaintBucketEdge, ] { assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); } diff --git a/crates/tools/src/builder/live_paint.rs b/crates/tools/src/builder/live_paint.rs index 010e423743..2b6cef4532 100644 --- a/crates/tools/src/builder/live_paint.rs +++ b/crates/tools/src/builder/live_paint.rs @@ -49,6 +49,42 @@ fn lp_part_at<'a>(doc: &'a Document, p: Point, tol: f64, edge: bool, takes: &dyn best.map(|(_, g, e)| (g, Some(e))) } +/// The face and edge of a Live Paint group at `p`, among the groups `takes` takes, by the rules of +/// [`lp_part_at`], in one walk of the document. Inside a visible face of a group, the face is the +/// front-most such group's visible face under `p`, and with `edge` the edge is that group's visible +/// edge nearest `p` within `tol`. Outside every visible face there is no face, and with `edge` the +/// edge is the visible edge nearest `p` within `tol` among the groups (the front-most group's on a +/// tie). +fn lp_face_and_edge<'a>( + doc: &'a Document, + p: Point, + tol: f64, + edge: bool, + takes: &dyn Fn(NodeId) -> bool, +) -> (Option<&'a Arc>, Option<&'a Arc>) { + let mut in_face: Option<(&Arc, Option<&Arc>)> = None; + let mut best: Option<(f64, &Arc)> = None; + doc.walk(|n| { + if !is_live_paint(n) || !takes(n.id) { + return; + } + let near = if edge { visible_edge_near(n, p, tol) } else { None }; + if let Some(f) = visible_face_at(n, p) { + in_face = Some((f, near)); + } + if let Some(e) = near + && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && best.is_none_or(|b| d <= b.0) + { + best = Some((d, e)); + } + }); + match in_face { + Some((f, e)) => (Some(f), e), + None => (None, best.map(|b| b.1)), + } +} + /// Whether the Live Paint Bucket takes Live Paint group `id`: it is visible, with every layer and /// group around it. fn lp_visible(cx: &ToolContext, id: NodeId) -> bool { @@ -62,17 +98,25 @@ fn lp_selectable(cx: &ToolContext, id: NodeId) -> bool { cx.doc.is_editable(id) && crate::direct::in_scope(cx.doc, cx.isolation, id) } +/// Whether every selected object is a Live Paint group (true with nothing selected). A selection +/// that also holds other paths has regions outside its groups' faces that a click fills, making +/// one Live Paint group of the selection. +fn lp_sel_all_live(cx: &ToolContext) -> bool { + cx.selection.objects.iter().all(|id| cx.doc.node(*id).is_some_and(is_live_paint)) +} + /// What the Live Paint tools highlight at `p`: the part of a Live Paint group ([`lp_part_at`], among -/// the groups `takes` takes), else with `cache` the region of the selection under `p`. A large -/// selection's regions are found on another thread ([`RegionCache::ready`]), and until they are, no -/// region is highlighted. +/// the groups `takes` takes), else with `cache` the region of the selection under `p` when the +/// selection is not only Live Paint groups ([`lp_sel_all_live`]). A large selection's regions are +/// found on another thread ([`RegionCache::ready`]), and until they are, no region is highlighted. fn lp_hover(cx: &ToolContext, cache: Option<&mut RegionCache>, p: Point, edge: bool, takes: &dyn Fn(NodeId) -> bool) -> LpHover { if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge, takes) { let path = part.and_then(|n| n.path_data()).map(|pd| pd.to_bezpath()); return path.map(|bp| if edge { LpHover::Edge(bp) } else { LpHover::Part(bp) }).unwrap_or_default(); } - let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); - if !sel_is_live && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) { + if !lp_sel_all_live(cx) + && let Some(i) = cache.and_then(|c| c.ready(cx, true)).and_then(|m| m.region_at(p)) + { return LpHover::Region(i); } LpHover::None @@ -125,7 +169,8 @@ enum LpPaint { /// Shift-click fills the face. With both on, a click paints the edge nearest the pointer when one /// is within 4 pixels, else it fills the face, and Shift-click fills the face. With both off, a /// click paints nothing. With Highlight off, the face or edge under the pointer is not -/// highlighted. +/// highlighted. The cursor is a filled bucket where a click fills a face, a brush where it paints +/// an edge and an empty bucket where it paints nothing; with Alt it is the Eyedropper. pub struct LivePaintBucketTool { pub(super) cache: RegionCache, pub(super) hover: LpHover, @@ -202,21 +247,68 @@ impl LivePaintBucketTool { } /// The highlight at `p` for a pointer with Shift and Alt held as given: none with Alt or with - /// Highlight off, else the face or edge a click there paints. + /// Highlight off, else the face or edge a click there paints. With Highlight off the selection's + /// map is still made, for the cursor ([`Self::paints_at`]). fn hover_at(&mut self, cx: &ToolContext, p: Point, shift: bool, alt: bool) -> LpHover { - if alt || !self.highlight { + if alt { return LpHover::None; } - let takes = |id| lp_visible(cx, id); - match self.paints(shift) { - LpPaint::Nothing => LpHover::None, - LpPaint::Fills => lp_hover(cx, Some(&mut self.cache), p, false, &takes), - // A click paints no region of the selection, so none is highlighted. - LpPaint::Strokes => lp_hover(cx, None, p, true, &takes), - LpPaint::Both => match lp_part_at(cx.doc, p, cx.tol(4.0), true, &takes) { - Some((_, Some(e))) => e.path_data().map(|pd| LpHover::Edge(pd.to_bezpath())).unwrap_or_default(), - _ => lp_hover(cx, Some(&mut self.cache), p, false, &takes), - }, + let target = self.target_at(cx, p, shift); + if self.highlight { target } else { LpHover::None } + } + + /// What a click at `p` reaches, Shift held or not: whether it fills a face and paints an edge, + /// with the face and the edge of a Live Paint group there ([`lp_face_and_edge`]). The document + /// is walked once: the highlight and the cursor are asked for on every pointer update. + fn parts_at<'a>(&self, cx: &ToolContext<'a>, p: Point, shift: bool) -> (bool, Option<&'a Arc>, Option<&'a Arc>) { + let (wants_face, wants_edge) = match self.paints(shift) { + LpPaint::Nothing => return (false, None, None), + LpPaint::Fills => (true, false), + LpPaint::Strokes => (false, true), + LpPaint::Both => (true, true), + }; + let (face, edge) = lp_face_and_edge(cx.doc, p, cx.tol(4.0), wants_edge, &|id| lp_visible(cx, id)); + (wants_face, face, edge) + } + + /// The face or edge a click at `p` paints, Shift held or not. With Paint Strokes alone, a click + /// paints no region of the selection, so none is highlighted. + fn target_at(&mut self, cx: &ToolContext, p: Point, shift: bool) -> LpHover { + let (wants_face, face, edge) = self.parts_at(cx, p, shift); + let path = |n: &Arc| n.path_data().map(|pd| pd.to_bezpath()); + if let Some(e) = edge { + return path(e).map(LpHover::Edge).unwrap_or_default(); + } + if !wants_face { + return LpHover::None; + } + if let Some(f) = face { + return path(f).map(LpHover::Part).unwrap_or_default(); + } + if !lp_sel_all_live(cx) + && let Some(i) = self.cache.ready(cx, true).and_then(|m| m.region_at(p)) + { + return LpHover::Region(i); + } + LpHover::None + } + + /// The cursor for a click at `p`, Shift held or not: [`Cursor::LivePaintBucketEdge`] where it + /// paints an edge of a Live Paint group, [`Cursor::LivePaintBucketFace`] where it fills a face + /// of one or a region of the selected paths, else [`Cursor::LivePaintBucket`]. A region counts + /// once the selection's map is made (the pointer moving over it makes it), as it is highlighted. + fn paints_at(&self, cx: &ToolContext, p: Point, shift: bool) -> Cursor { + let (wants_face, face, edge) = self.parts_at(cx, p, shift); + let edge = edge.is_some(); + let region = + || !lp_sel_all_live(cx) && self.cache.key.as_ref() == Some(&RegionCache::key_for(cx, true)) && self.cache.map.region_at(p).is_some(); + let face = wants_face && !edge && (face.is_some() || region()); + if edge { + Cursor::LivePaintBucketEdge + } else if face { + Cursor::LivePaintBucketFace + } else { + Cursor::LivePaintBucket } } } @@ -315,8 +407,8 @@ impl Tool for LivePaintBucketTool { lp_overlays(&self.hover, &self.cache, self.color, self.width as f32, (self.width * LP_BUCKET_EDGE_FRACTION) as f32) } - fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { - if m.alt { Cursor::Eyedropper } else { Cursor::LivePaintBucket } + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + if m.alt { Cursor::Eyedropper } else { self.paints_at(cx, p, m.shift) } } fn options(&self) -> Value { diff --git a/crates/tools/src/builder/tests.rs b/crates/tools/src/builder/tests.rs index eeaf8f0ea7..89ce04eb3d 100644 --- a/crates/tools/src/builder/tests.rs +++ b/crates/tools/src/builder/tests.rs @@ -1140,6 +1140,103 @@ fn bucket_options_choose_what_a_click_paints() { assert_eq!(t.options()["paintFills"], false); } +/// The cursor says what a click paints: a filled bucket over a face it fills, a brush over an +/// edge it paints, an empty bucket where it paints nothing, under each Paint Fills / Paint Strokes +/// setting and Shift; Alt keeps the Eyedropper, and Use Precise Cursors turns each into the +/// crosshair. +#[test] +fn bucket_cursor_shows_what_a_click_paints() { + let (d, ..) = live_group_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let at = |t: &LivePaintBucketTool, x: f64, m: Mods| t.cursor(&cx, Point::new(x, 25.0), m); + let (face, edge, none) = (Cursor::LivePaintBucketFace, Cursor::LivePaintBucketEdge, Cursor::LivePaintBucket); + let mut t = LivePaintBucketTool::default(); + // Paint Fills alone: the face, and with Shift the edge in reach. + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default()), at(&t, 400.0, Mods::default())], [face, face, none]); + assert_eq!([at(&t, 51.0, shift), at(&t, 75.0, shift)], [edge, none]); + assert_eq!([at(&t, 51.0, alt), at(&t, 400.0, alt)], [Cursor::Eyedropper, Cursor::Eyedropper]); + // Paint Strokes alone: the edge in reach, nothing farther away; Shift fills the face. + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(true)); + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default())], [edge, none]); + assert_eq!([at(&t, 51.0, shift), at(&t, 75.0, shift)], [face, face]); + // Both: the edge in reach, else the face; Shift fills the face. + t.set_option("paintFills", &json!(true)); + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, Mods::default()), at(&t, 400.0, Mods::default())], [edge, face, none]); + assert_eq!(at(&t, 51.0, shift), face); + // Both off: a click paints nothing. + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(false)); + assert_eq!([at(&t, 51.0, Mods::default()), at(&t, 75.0, shift)], [none, none]); + // Highlight off: the cursor still says what a click paints. + let mut t = LivePaintBucketTool::default(); + t.set_option("highlight", &json!(false)); + assert_eq!([at(&t, 75.0, Mods::default()), at(&t, 51.0, shift)], [face, edge]); + for c in [face, edge, none] { + assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}"); + assert_eq!(c.stand_in(), None, "{c:?} is drawn"); + } +} + +/// Over a region of selected ordinary paths the cursor is the filled bucket once the pointer has +/// moved there (the move makes the selection's map), with Highlight on or off; with Paint Strokes +/// alone, a click there paints nothing and the bucket stays empty. +#[test] +fn bucket_cursor_fills_a_region_of_the_selected_paths() { + let (d, a, b) = two_rects(); + let mut s = Selection::default(); + s.set([a, b]); + let p = paint(); + let cx = cx(&d, &s, &p); + let pos = Point::new(75.0, 50.0); + for highlight in [true, false] { + let mut t = LivePaintBucketTool::default(); + t.set_option("highlight", &json!(highlight)); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket, "no map yet, highlight {highlight}"); + t.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + t.poll(&cx, true); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace, "highlight {highlight}"); + assert_eq!(t.cursor(&cx, Point::new(400.0, 400.0), Mods::default()), Cursor::LivePaintBucket, "highlight {highlight}"); + t.set_option("paintFills", &json!(false)); + t.set_option("paintStrokes", &json!(true)); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket, "highlight {highlight}"); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)).is_empty()); + } +} + +/// With a Live Paint group and ordinary paths selected, a region of the paths outside the group +/// is highlighted and shows the filled bucket, as a click there fills it; with only the group +/// selected, the same point shows the empty bucket. +#[test] +fn bucket_fills_a_region_of_a_selection_that_mixes_a_group_and_paths() { + let (mut d, a, b) = two_rects_at(200.0); + let (g, _) = live_square(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0)); + let p = paint(); + let pos = Point::new(275.0, 50.0); + let mut s = Selection::default(); + s.set([g, a, b]); + let mixed = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&mixed, &ev(PointerKind::Move, pos.x, pos.y)); + t.poll(&mixed, true); + assert!(!t.overlays(&mixed).is_empty(), "the region is highlighted"); + assert_eq!(t.cursor(&mixed, pos, Mods::default()), Cursor::LivePaintBucketFace); + let acts = t.pointer(&mixed, &ev(PointerKind::Down, pos.x, pos.y)); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(id, v)] if id == "livePaint.fill" && v.get("ids").is_some()), "{acts:?}"); + let mut s = Selection::default(); + s.set([g]); + let live = cx(&d, &s, &p); + let mut t = LivePaintBucketTool::default(); + t.pointer(&live, &ev(PointerKind::Move, pos.x, pos.y)); + t.poll(&live, true); + assert!(t.overlays(&live).is_empty()); + assert_eq!(t.cursor(&live, pos, Mods::default()), Cursor::LivePaintBucket); +} + #[test] fn bucket_strokes_alone_paints_and_highlights_no_region_of_the_selection() { let (d, a, b) = two_rects(); @@ -1269,7 +1366,7 @@ fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() { let alt = Mods { alt: true, ..Default::default() }; let both = Mods { alt: true, shift: true, ..Default::default() }; let pos = Point::new(75.0, 25.0); - assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); assert!(!t.overlays(&cx).is_empty()); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0).with_mods(alt)); @@ -1282,7 +1379,7 @@ fn bucket_alt_shift_samples_only_solid_color_and_restores_hover() { ); assert!(t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0).with_mods(both)).is_empty()); t.pointer(&cx, &ev(PointerKind::Move, 75.0, 25.0)); - assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucket); + assert_eq!(t.cursor(&cx, pos, Mods::default()), Cursor::LivePaintBucketFace); assert!(!t.overlays(&cx).is_empty()); } diff --git a/crates/tools/src/cursor.rs b/crates/tools/src/cursor.rs index a59bc28af0..d5cdd05bc5 100644 --- a/crates/tools/src/cursor.rs +++ b/crates/tools/src/cursor.rs @@ -141,8 +141,13 @@ pub enum Cursor { Knife, /// The Shaper tool. Shaper, - /// The Live Paint Bucket. + /// The Live Paint Bucket where a click paints nothing: an empty paint bucket. LivePaintBucket, + /// The Live Paint Bucket where a click fills a face (of a Live Paint group, or a region of + /// the selected paths): a filled paint bucket. + LivePaintBucketFace, + /// The Live Paint Bucket where a click paints an edge of a Live Paint group: a brush. + LivePaintBucketEdge, /// The Measure tool. Measure, /// The Print Tiling tool. @@ -280,6 +285,8 @@ impl Cursor { | Cursor::Knife | Cursor::Shaper | Cursor::LivePaintBucket + | Cursor::LivePaintBucketFace + | Cursor::LivePaintBucketEdge | Cursor::Measure | Cursor::PrintTiling | Cursor::SymbolSprayer @@ -320,7 +327,6 @@ impl Cursor { | Cursor::Join | Cursor::Knife | Cursor::Shaper - | Cursor::LivePaintBucket | Cursor::Measure | Cursor::ObjectsOnPath | Cursor::PrintTiling diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 362262b488..381eb5ed61 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -439,7 +439,10 @@ designer = false [[cursor]] id = "Eyedropper" group = "Colour and gradient" -shown_when = ["The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead."] +shown_when = [ + "The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead.", + "The Live Paint Bucket with Alt held (a click samples paint).", +] hotspot = "The pipette's tip." look = "A pipette." @@ -475,8 +478,32 @@ hotspot = "Where the mesh point goes." [[cursor]] id = "LivePaintBucket" group = "Colour and gradient" -shown_when = ["The Live Paint Bucket: a click fills a face (or an edge) of a Live Paint group."] -hotspot = "Where the paint pours out." +shown_when = [ + "The Live Paint Bucket where a click paints nothing: away from Live Paint groups and the selected paths' regions, or over a part the Paint Fills and Paint Strokes options (and Shift) leave unpainted.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The drop the paint pours onto." +look = "An empty paint bucket tipped toward the hotspot, a small drop on it. LivePaintBucketFace is the same bucket filled." + +[[cursor]] +id = "LivePaintBucketFace" +group = "Colour and gradient" +shown_when = [ + "The Live Paint Bucket over a face of a Live Paint group, or a region of the selected paths, that a click fills.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The drop the paint pours onto." +look = "LivePaintBucket filled, with a larger drop on the hotspot." + +[[cursor]] +id = "LivePaintBucketEdge" +group = "Colour and gradient" +shown_when = [ + "The Live Paint Bucket over an edge of a Live Paint group that a click paints with the stroke.", + "With Use Precise Cursors on, the crosshair shows instead.", +] +hotspot = "The brush's tip." +look = "A brush pointing down-left: bristles at the hotspot, then a white ferrule and a dark handle." # ── Width, blend, slice and shape builder ───────────────────────────────────────────────────────── diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index d73cd2297f..baf461c6c7 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -641,10 +641,11 @@ mod cursor_tests { ] { assert_eq!(cursor_at(id, &d, away), c, "{id}"); assert_eq!(c.precise(), Cursor::Crosshair, "{id}"); - // The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. + // The Blob Brush's and the Eraser's dot is drawn: their tip outline goes round it. The + // Live Paint Bucket's bucket is drawn. let stand_in = match id { "mesh" => Some(Cursor::PenAdd), - "blobBrush" | "eraser" => None, + "blobBrush" | "eraser" | "livePaintBucket" => None, _ => Some(Cursor::Crosshair), }; assert_eq!(c.stand_in(), stand_in, "{id}"); diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 71f70e455b..8b368fa496 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -123,6 +123,7 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::TypeArea | Cursor::TypeOnPath => C::Text, // The system has no tip outline to go round: its crosshair aims as well. Cursor::BlobBrush | Cursor::Eraser => C::Crosshair, + Cursor::LivePaintBucket | Cursor::LivePaintBucketFace | Cursor::LivePaintBucketEdge => C::Crosshair, // Not drawn yet: the cursor each borrows. Cursor::Lasso | Cursor::MagicWand @@ -133,7 +134,6 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon { | Cursor::Join | Cursor::Knife | Cursor::Shaper - | Cursor::LivePaintBucket | Cursor::Measure | Cursor::PrintTiling | Cursor::SymbolSprayer diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 8597607717..6bf8a6fa97 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -184,6 +184,9 @@ fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() { Cursor::PencilNew, Cursor::PencilStraight, Cursor::PencilContinue, + Cursor::LivePaintBucket, + Cursor::LivePaintBucketFace, + Cursor::LivePaintBucketEdge, ] { assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}"); assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}"); diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index c26cae36ac..34156f2c41 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -432,6 +432,42 @@ fn shape_builder(p: &mut Ink, o: Pos2, erase: bool) { } } +/// The Live Paint Bucket: a paint bucket tipped toward the hotspot at its lower left, its mouth +/// down-left with a rim across it, and a drop on the hotspot. Empty (white) with a small drop where +/// a click paints nothing; filled, with a larger drop, where a click fills a face. +fn paint_bucket(p: &mut Ink, o: Pos2, filled: bool) { + // `u` runs from the hotspot up-right into the bucket, `v` across it. + let (d, n) = (vec2(1.0, -1.0).normalized(), vec2(1.0, 1.0).normalized()); + let at = |u: f32, v: f32| o + u * d + v * n; + poly(p, vec![at(5.5, -6.0), at(16.5, -4.5), at(16.5, 4.5), at(5.5, 6.0)], if filled { INK } else { HALO }, INK); + poly(p, vec![at(4.5, -7.0), at(6.5, -7.0), at(6.5, 7.0), at(4.5, 7.0)], INK, INK); + if filled { + p.circle_filled(o, 3.5, HALO); + p.circle_filled(o, 2.2, INK); + } else { + p.circle_filled(o, 2.5, HALO); + p.circle_filled(o, 1.25, INK); + } +} + +/// The Live Paint Bucket over an edge it paints: a brush, its bristles' tip on the hotspot, the +/// white ferrule and the black handle running up-right. +fn paint_brush(p: &mut Ink, o: Pos2) { + let (d, n) = (vec2(1.0, -1.0).normalized(), vec2(1.0, 1.0).normalized()); + let at = |u: f32, v: f32| o + u * d + v * n; + let bristles = vec![at(0.0, 0.0), at(4.0, -2.2), at(6.0, -2.2), at(6.0, 2.2), at(4.0, 2.2)]; + let ferrule = vec![at(6.0, -2.0), at(9.0, -2.0), at(9.0, 2.0), at(6.0, 2.0)]; + let handle = vec![at(9.0, -1.5), at(19.0, -1.2), at(19.0, 1.2), at(9.0, 1.5)]; + // Every halo first, so no part's halo covers another's outline. + for part in [&bristles, &ferrule, &handle] { + p.add(Shape::closed_line(part.clone(), Stroke::new(3.0, HALO))); + } + for (part, fill) in [(bristles, INK), (ferrule, HALO), (handle, INK)] { + p.add(Shape::convex_polygon(part.clone(), fill, Stroke::NONE)); + p.add(Shape::closed_line(part, Stroke::new(1.0, INK))); + } +} + /// The Hand tool and Space: an open hand (palm and four fingers, thumb to the left), or the same /// hand closed while it drags the view. Windows has no system hand that isn't a pointing finger /// (winit maps egui's Grab and Grabbing to the four-headed move cursor), so it is drawn like the @@ -480,7 +516,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { /// Every cursor with a glyph: its own, drawn here, or the one it borrows. #[cfg(test)] -const GLYPHS: [Cursor; 93] = [ +const GLYPHS: [Cursor; 95] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -548,6 +584,8 @@ const GLYPHS: [Cursor; 93] = [ Cursor::Knife, Cursor::Shaper, Cursor::LivePaintBucket, + Cursor::LivePaintBucketFace, + Cursor::LivePaintBucketEdge, Cursor::Measure, Cursor::PrintTiling, Cursor::SymbolSprayer, @@ -669,6 +707,9 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { Cursor::LoadedText => loaded_text(ink, p), Cursor::ShapeBuilder => shape_builder(ink, p, false), Cursor::ShapeBuilderErase => shape_builder(ink, p, true), + Cursor::LivePaintBucket => paint_bucket(ink, p, false), + Cursor::LivePaintBucketFace => paint_bucket(ink, p, true), + Cursor::LivePaintBucketEdge => paint_brush(ink, p), Cursor::Hand => hand(ink, p, false), Cursor::HandGrab => hand(ink, p, true), Cursor::ZoomIn => magnifier(ink, p, false), diff --git a/crates/ui-egui/src/cursors/tests.rs b/crates/ui-egui/src/cursors/tests.rs index 63ab6c23a1..4a12ff91b6 100644 --- a/crates/ui-egui/src/cursors/tests.rs +++ b/crates/ui-egui/src/cursors/tests.rs @@ -292,6 +292,45 @@ fn the_pencil_continuation_cursor_is_the_straight_line_cursor_with_an_end_point( } } +/// The Live Paint Bucket's cursors are drawn, not the crosshair: an empty bucket where a click +/// paints nothing, a filled bucket with a larger drop on the hotspot where it fills a face, and a +/// brush with its tip on the hotspot where it paints an edge. +#[test] +fn the_live_paint_bucket_shows_a_bucket_or_a_brush() { + for ppp in [1.0f32, 2.0] { + let img = |c| rasterize(c, ppp).unwrap_or_else(|| panic!("{c:?} at {ppp}")); + let (cross, empty, filled, brush) = + (img(Cursor::Crosshair), img(Cursor::LivePaintBucket), img(Cursor::LivePaintBucketFace), img(Cursor::LivePaintBucketEdge)); + // The pixel nearest the point `u` points up-right of the hotspot and `v` down-right across. + let at = |img: &CustomCursorImage, u: f32, v: f32| { + let s = std::f32::consts::FRAC_1_SQRT_2; + let [hx, hy] = img.hotspot.map(f32::from); + let (x, y) = ((hx + (u + v) * s * ppp - 0.5).round(), (hy + (v - u) * s * ppp - 0.5).round()); + let inside = x >= 0.0 && y >= 0.0 && x < f32::from(img.size[0]) && y < f32::from(img.size[1]); + if inside { pixel(img, x as usize, y as usize) } else { [0; 4] } + }; + let inked = |p: [u8; 4]| p[3] > 200 && p[0] < 64; + let white = |p: [u8; 4]| p[3] > 200 && p[0] > 192; + assert!(white(at(&empty, 11.0, 2.0)), "the empty bucket's body is white at {ppp}"); + assert!(inked(at(&filled, 11.0, 2.0)), "the filled bucket's body is ink at {ppp}"); + assert!(at(&empty, 0.0, 0.0)[3] > 0 && inked(at(&filled, 0.0, 0.0)), "a drop on the hotspot at {ppp}"); + if ppp == 2.0 { + assert!(inked(at(&filled, 1.4, 0.0)) && !inked(at(&empty, 2.0, 0.0)), "the filled bucket's drop is larger"); + } + assert!(inked(at(&brush, 3.0, 0.0)), "the bristles by the hotspot at {ppp}"); + assert!(white(at(&brush, 7.5, 0.0)), "the white ferrule at {ppp}"); + assert!(inked(at(&brush, 14.0, 0.0)), "the handle at {ppp}"); + assert!(!inked(at(&brush, 11.0, 4.0)), "the brush is thin where the bucket is wide at {ppp}"); + assert!(white(at(&empty, 14.0, 0.0)), "the empty bucket has no handle there at {ppp}"); + for (c, i) in [(Cursor::LivePaintBucket, &empty), (Cursor::LivePaintBucketFace, &filled), (Cursor::LivePaintBucketEdge, &brush)] { + assert!(c.stand_in().is_none(), "{c:?} is drawn"); + assert_ne!(i, &cross, "{c:?} is not the crosshair at {ppp}"); + assert_eq!(rasterize(c.precise(), ppp).as_ref(), Some(&cross), "{c:?} with Use Precise Cursors at {ppp}"); + } + assert!(empty != filled && filled != brush && empty != brush, "three looks at {ppp}"); + } +} + #[test] fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() { let mut images = Images::default(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..f325fcad14 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2165,6 +2165,18 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s `live_paint_bucket_drag_rejects_invalid_points_atomically` covers invalid command input. Behaviour source: [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). Scores and estimates unchanged. +- **Live Paint Bucket cursors:** over a face a click fills (of a Live Paint group, or a region of + the selected paths) the cursor is a filled paint bucket, over an edge it paints it is a brush, + and where a click paints nothing it is an empty bucket. It follows Paint Fills, Paint Strokes + and Shift, and shows with Highlight off too; Alt still shows the Eyedropper, and Use Precise + Cursors shows the crosshair. The drawings are VectorCraft's own. With a Live Paint group and + ordinary paths selected, a region of the paths outside the group is highlighted and shows the + filled bucket, as a click there fills it. Tests: + `bucket_cursor_shows_what_a_click_paints`, `bucket_cursor_fills_a_region_of_the_selected_paths`, + `bucket_fills_a_region_of_a_selection_that_mixes_a_group_and_paths`, + `the_live_paint_bucket_shows_a_bucket_or_a_brush`. Behavior source: + [the help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html). + The cursor swatch preview is not built. Scores and estimates unchanged. ## Revision history diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5391e3dc5b..20c8721c72 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -161,7 +161,8 @@ tools, never checked side by side) isn't captured row by row. | Node-editing cursors: Direct Selection's white arrow with a hollow or filled square over an anchor (path unselected or selected); the Reshape Segment cursor over a curved segment (Direct Selection, Anchor Point, the Pen with Alt); the Anchor Point caret for the Pen with Alt; Group Selection's plus (also Direct Selection with Alt); a filled arrow while dragging that turns hollow on a snap; a double arrowhead for an Alt-drag copy | partial | as the public manual describes them, drawn in code; no cue over handles (none documented) | | Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge | | Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it | -| Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed | +| Live Paint Bucket cursors | partial | a filled bucket over a face a click fills, a brush over an edge it paints, an empty bucket where it paints nothing, following Paint Fills, Paint Strokes and Shift; the Eyedropper with Alt; the crosshair with Use Precise Cursors. No cursor swatch preview | +| Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed | | Blob Brush and Eraser: the tip's outline at the pointer | done | before a press and while painting, the outline of the tip (its size, angle and roundness, at the zoom) follows the pointer, black on a white halo, round a small centre dot; `[` / `]` and Tool Options change it at once; precise cursors (Caps Lock) show only the crosshair; it goes when the pointer leaves the canvas, while Cmd/Ctrl lends a selection tool and while Alt lends the Blob Brush the Smooth tool (#4977); `crates/tools/src/draw2/gesture.rs` and its tests While the button is down a round Blob Brush shows the outline of the area it will add, rounded at both ends, not a line as wide as the brush that ends square (#4343). | | Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) | | Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` | From 6d0556e60f5f1eafc5ebf6d54f89c05777609433 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:31:37 -0800 Subject: [PATCH 09/20] Type: a threaded story measures each frame with the paragraphs it needs Distributing a story through threaded frames measured every frame but the last with the whole rest of the story, and copied that rest several times per frame, so a long story through many frames cost frames times story length. - The rest of the story is a borrowed view of the given runs (run index, byte offset, length). Each frame's share is copied once; the slices, styles of empty shares and the run left once the story is used up are the same as before. - Area type without Shrink Text to Fit whose lines align to the top is first measured with the text through the end of the paragraph that reaches about twice the previous frame's share (at least 512 bytes), doubling while the story goes on. The share is taken when its text stops fitting before the last paragraph break measured; layout places lines in order, stops at the first that does not fit and shapes each paragraph on its own text. Type on a path, Shrink Text to Fit, other vertical alignments and a share ending in the story's last paragraph are measured with the whole rest. Tests compare every slice with the earlier algorithm, kept in the tests, for many kinds of frames, styles and paragraphs and 12 seeded stories. A story of 1,500 paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes while measuring and is now laid out over 21,035. --- crates/text/src/thread.rs | 243 +++++++++++++++++++++++++--- crates/text/src/thread/tests.rs | 272 +++++++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 491 insertions(+), 25 deletions(-) diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index cfdd41cc78..0abd172bff 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -5,9 +5,9 @@ //! with [`distribute`]. use vectorcraft_doc::text::ListCounts; -use vectorcraft_doc::{ParaStyle, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, ParaStyle, TextKind, TextObject, TextRun}; -use crate::edit::{normalize, runs_len, slice_runs, style_at}; +use crate::edit::{floor_char, normalize, runs_len}; use crate::{FontDb, layout}; /// The story: every frame's runs joined in thread order. @@ -57,21 +57,23 @@ fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec usize { - let mut probe = frame.clone(); - probe.runs = runs.to_vec(); - let lay = layout(db, &probe); - let text: String = runs.iter().map(|r| r.text.as_str()).collect(); +/// overflows as any character does, it starts the next frame), and for area type that overflows, +/// where its first line that does not fit starts. +fn fit(db: &FontDb, probe: &TextObject) -> (usize, Option) { + #[cfg(test)] + FIT_BYTES.set(FIT_BYTES.get() + runs_len(&probe.runs)); + let lay = layout(db, probe); + let text: String = probe.runs.iter().map(|r| r.text.as_str()).collect(); if !lay.overflow { - return text.len(); + return (text.len(), None); } let mut end = lay.lines.last().map_or(0, |l| l.end); if lay.on_path { - return end; + return (end, None); } + let unplaced = end; let bytes = text.as_bytes(); while end < bytes.len() && bytes[end] == b' ' { end += 1; @@ -79,7 +81,179 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { if end < bytes.len() && bytes[end] == b'\n' { end += 1; } - end + (end, Some(unplaced)) +} + +#[cfg(test)] +thread_local! { + /// The bytes of text [`fit`] has laid out on this thread ([`fit_bytes`]). + static FIT_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The bytes of text [`fit`] has laid out on this thread. +#[cfg(test)] +fn fit_bytes() -> usize { + FIT_BYTES.get() +} + +/// The fewest bytes of story a frame is measured with before the measured text grows. +const MIN_MEASURE: usize = 512; + +/// What is left of a story while [`distribute_continuing_levels`] hands it out, `len` bytes +/// borrowed from the story's runs: the runs from `run` on, the first of them without its first +/// `skip` bytes. While `raw` they are all the runs given (`run` and `skip` are 0), empty runs +/// included. Once a frame has taken a share they are the story after it as +/// [`crate::edit::slice_runs`] cuts it: no empty runs, and a run cut after its first character +/// without its glyph choice. +#[derive(Clone, Copy)] +struct Rest<'a> { + runs: &'a [TextRun], + run: usize, + skip: usize, + raw: bool, + len: usize, +} + +impl<'a> Rest<'a> { + /// The runs' pieces in order: each piece's text, its run and whether the run was cut before it. + fn pieces(self) -> impl Iterator { + self.runs.get(self.run..).unwrap_or_default().iter().enumerate().filter_map(move |(k, r)| { + let at = if k == 0 { self.skip } else { 0 }; + let text = r.text.get(at..)?; + (self.raw || !text.is_empty()).then_some((text, r, at > 0)) + }) + } + + /// A piece's style: its run's, without the glyph choice when the run was cut before it. + fn style(r: &TextRun, cut: bool) -> CharStyle { + let mut style = r.style.clone(); + if cut { + style.glyph_alternate = None; + } + style + } + + /// [`crate::edit::slice_runs`] of the rest's runs. + fn slice(self, start: usize, end: usize) -> Vec { + let mut out = vec![]; + let mut off = 0; + for (text, r, cut) in self.pieces() { + let (rs, re) = (off, off + text.len()); + if rs >= end { + break; + } + let (a, b) = (start.max(rs), end.min(re)); + if a < b { + let from = floor_char(text, a - rs); + let t = &text[from..floor_char(text, b - rs)]; + if !t.is_empty() { + out.push(TextRun { text: t.to_string(), style: Self::style(r, cut || from > 0), inline: r.inline.clone() }); + } + } + off = re; + } + out + } + + /// The rest's runs before byte `end`, which follows a paragraph break, as a frame is measured + /// with them: every run that starts before it (an empty one too, while the runs are the ones + /// given), the last one cut there. + fn before(self, end: usize) -> Vec { + let mut out = vec![]; + let mut off = 0; + for (text, r, cut) in self.pieces() { + if off >= end { + break; + } + let t = text.get(..end - off).unwrap_or(text); + out.push(TextRun { text: t.to_string(), style: Self::style(r, cut), inline: r.inline.clone() }); + off += text.len(); + } + out + } + + /// [`crate::edit::style_at`] of the rest's runs. + fn style_at(self, byte: usize) -> CharStyle { + let mut off = 0; + let mut last = None; + let mut first = None; + for (text, r, cut) in self.pieces() { + first = first.or(Some((r, cut))); + let end = off + text.len(); + if byte < end || (byte == end && byte == off) { + return Self::style(r, cut); + } + if !text.is_empty() { + last = Some((r, cut)); + if byte == end { + return Self::style(r, cut); + } + } + off = end; + } + last.or(first).map(|(r, cut)| Self::style(r, cut)).unwrap_or_default() + } + + /// The end of the first paragraph break at or after byte `from`, or None when the rest has no + /// break there. + fn break_after(self, from: usize) -> Option { + let mut off = 0; + for (text, _, _) in self.pieces() { + let at = from.saturating_sub(off); + if let Some(i) = text.as_bytes().get(at..).and_then(|b| b.iter().position(|&b| b == b'\n')) { + return Some(off + at + i + 1); + } + off += text.len(); + } + None + } + + /// All of the rest's runs. + fn all(self) -> Vec { + if self.raw { self.runs.to_vec() } else { self.slice(0, self.len) } + } + + /// The rest after its first `n` bytes, as a slice of the story: the run that byte falls in + /// cut there (back to a character boundary, as [`crate::edit::slice_runs`] cuts it). + fn after(self, n: usize) -> Self { + let mut run = self.run; + let mut at = self.skip + n; + while let Some(r) = self.runs.get(run) + && at >= r.text.len() + && run + 1 < self.runs.len() + { + at -= r.text.len(); + run += 1; + } + let skip = self.runs.get(run).map_or(0, |r| floor_char(&r.text, at)); + let cut_back = at - skip; + Self { runs: self.runs, run, skip, raw: false, len: self.len + cut_back - n } + } +} + +/// How many bytes of `rest` fit in `frame`, measured as `probe` makes the frame for some runs (with +/// their paragraph styles, the list counts and the continuation mark). +/// +/// Area type without Shrink Text to Fit whose lines align to the top is first measured with the +/// whole paragraphs of the rest that end at or after byte `want`, and then with twice as many bytes +/// each time, while the rest goes on past them. The layout places lines in order and stops at the +/// first line that does not fit, and each paragraph is shaped and broken on its own text. So when +/// that line starts before the last paragraph break measured, the frame holds the same bytes with +/// the whole rest. Other type, and a rest that ends first, is measured with the whole rest. +fn fit_rest(db: &FontDb, probe: impl Fn(Vec) -> TextObject, frame: &TextObject, rest: Rest<'_>, mut want: usize) -> usize { + let by_paragraph = matches!(frame.kind, TextKind::Area { .. }) + && frame.area.fit.min_scale().is_none() + && frame.area.vertical_align == vectorcraft_doc::VerticalAlign::Top; + if by_paragraph { + while let Some(end) = rest.break_after(want.saturating_sub(1)).filter(|&end| end < rest.len) { + let (n, unplaced) = fit(db, &probe(rest.before(end))); + if unplaced.is_some_and(|u| u + 1 < end) { + return n; + } + want = end.saturating_mul(2); + } + } + fit(db, &probe(rest.all())).0 } /// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the @@ -175,31 +349,52 @@ pub fn distribute_continuing_levels( continued: bool, mut counts: ListCounts, ) -> Vec { - let mut rest = runs.to_vec(); + let mut story_rest = Rest { runs, run: 0, skip: 0, raw: true, len: runs_len(runs) }; + // Once the story is used up, what is left is one empty run with the style there. + let mut used_up: Option = None; let mut out = Vec::with_capacity(frames.len()); let mut next_para = 0; let mut continues = continued; + let mut want = MIN_MEASURE; for (i, f) in frames.iter().enumerate() { let first_para = next_para; - let len = runs_len(&rest); + let empty_story: [TextRun; 1]; + let rest = match used_up.clone() { + Some(empty) => { + empty_story = [empty]; + Rest { runs: &empty_story, run: 0, skip: 0, raw: true, len: 0 } + } + None => story_rest, + }; + let len = rest.len; let own = [f.para.clone()]; let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) }; let n = if i + 1 == frames.len() { len } else { - let mut probe = (*f).clone(); - probe.runs = rest.clone(); - probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); - probe.set_list_counts(counts); - probe.first_para_continues = continues; - fit(db, &probe, &rest) + let probe = |runs: Vec| { + let mut probe = (*f).clone(); + let styles = slice_paras(paras, first_para, &runs); + probe.runs = runs; + probe.set_paragraph_styles(styles); + probe.set_list_counts(counts); + probe.first_para_continues = continues; + probe + }; + fit_rest(db, probe, f, rest, want) }; - let mut head = slice_runs(&rest, 0, n); + want = n.saturating_mul(2).max(MIN_MEASURE); + let mut head = rest.slice(0, n); if head.is_empty() { - head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None }); + head.push(TextRun { text: String::new(), style: rest.style_at(n), inline: None }); + } + if used_up.is_none() { + if n >= len { + used_up = Some(TextRun { text: String::new(), style: rest.style_at(len), inline: None }); + } else { + story_rest = story_rest.after(n); + } } - let tail = slice_runs(&rest, n, len); - rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; let head_paras = slice_paras(paras, first_para, &head); next_para += head_paras.len().saturating_sub(1); let next_counts = count_list_paragraphs(&head, &head_paras, counts); diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index c3f05cef42..5ae49442fb 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -1,5 +1,6 @@ use super::*; -use vectorcraft_doc::{CharStyle, Justify, TextKind}; +use crate::edit::{slice_runs, style_at}; +use vectorcraft_doc::{Justify, TextKind}; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; fn frame(x: f64) -> TextObject { @@ -412,6 +413,275 @@ fn list_levels_count_on_through_the_frames() { assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1])); } +/// The shares the frames would take if each one were measured with the whole rest of the story: +/// what [`distribute_continuing_levels`] gives, measuring each frame with less of it. +fn whole_rest_shares( + db: &FontDb, + frames: &[&TextObject], + runs: &[TextRun], + paras: &[ParaStyle], + continued: bool, + mut counts: ListCounts, +) -> Vec { + let fit_whole = |frame: &TextObject, runs: &[TextRun]| { + let lay = layout(db, frame); + let text: String = runs.iter().map(|r| r.text.as_str()).collect(); + if !lay.overflow { + return text.len(); + } + let mut end = lay.lines.last().map_or(0, |l| l.end); + if lay.on_path { + return end; + } + let bytes = text.as_bytes(); + while end < bytes.len() && bytes[end] == b' ' { + end += 1; + } + if end < bytes.len() && bytes[end] == b'\n' { + end += 1; + } + end + }; + let mut rest = runs.to_vec(); + let mut out = Vec::with_capacity(frames.len()); + let mut next_para = 0; + let mut continues = continued; + for (i, f) in frames.iter().enumerate() { + let first_para = next_para; + let len = runs_len(&rest); + let own = [f.para.clone()]; + let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) }; + let n = if i + 1 == frames.len() { + len + } else { + let mut probe = (*f).clone(); + probe.runs = rest.clone(); + probe.set_paragraph_styles(slice_paras(paras, first_para, &rest)); + probe.set_list_counts(counts); + probe.first_para_continues = continues; + fit_whole(&probe, &rest) + }; + let mut head = slice_runs(&rest, 0, n); + if head.is_empty() { + head.push(TextRun { text: String::new(), style: style_at(&rest, n), inline: None }); + } + let tail = slice_runs(&rest, n, len); + rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail }; + let head_paras = slice_paras(paras, first_para, &head); + next_para += head_paras.len().saturating_sub(1); + let next_counts = count_list_paragraphs(&head, &head_paras, counts); + let starts_continued = continues; + continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues); + let (list_count, list_level_counts) = counts.parts(); + out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued }); + counts = next_counts; + } + out +} + +/// `frames` share `runs` as they would if each frame were measured with the whole rest, from the +/// story's start and continuing from text before it; returns how many frames took text. +fn same_as_whole_rest(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle], case: &str) -> usize { + let got = distribute(db, frames, runs, paras); + assert_eq!(got, whole_rest_shares(db, frames, runs, paras, false, ListCounts::default()), "{case}"); + let counts = ListCounts::from_parts(3, &[2, 1]); + let continuing = distribute_continuing_levels(db, frames, runs, paras, true, counts); + assert_eq!(continuing, whole_rest_shares(db, frames, runs, paras, true, counts), "{case}, continuing"); + got.iter().filter(|s| runs_len(&s.runs) > 0).count() +} + +/// An area frame `w` by `h` at `x`. +fn sized(x: f64, w: f64, h: f64) -> TextObject { + let mut t = frame(x); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(x, 0.0, x + w, h)) }; + t +} + +/// Numbered `n` short paragraphs. +fn paragraphs(n: usize) -> String { + (1..=n).map(|i| format!("Item {i}: the quick brown fox jumps.")).collect::>().join("\n") +} + +/// Each frame takes the same share of a long story when it is measured with only the paragraphs +/// it needs as when it is measured with the whole rest of the story, for every kind of frame, +/// text and paragraph style that changes how much a frame holds. +#[test] +fn frames_take_the_shares_the_whole_rest_gives_them() { + use vectorcraft_doc::text::{NumberStyle, ParaList}; + let db = FontDb::global(); + let plain = CharStyle::default(); + let big = CharStyle { size: 20.0, ..Default::default() }; + let small = CharStyle { size: 7.0, ..Default::default() }; + let story = |text: &str| vec![TextRun::new(text, plain.clone())]; + let frames: Vec = (0..6).map(|k| frame(200.0 * k as f64)).collect(); + fn refs(v: &[TextObject]) -> Vec<&TextObject> { + v.iter().collect() + } + let mut took = 0; + + // Many short paragraphs, every one of its own style. + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &[], "short paragraphs"); + let spaced: Vec = (0..120).map(|i| ParaStyle { space_before: (i % 4) as f64 * 3.0, ..Default::default() }).collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &spaced, "spaced paragraphs"); + + // Frames of different sizes, one with no height and one too narrow for a word. + let uneven = vec![ + sized(0.0, 120.0, 30.0), + sized(200.0, 120.0, 0.0), + sized(400.0, 200.0, 140.0), + sized(700.0, 4.0, 200.0), + sized(800.0, 60.0, 90.0), + frame(1000.0), + ]; + took += same_as_whole_rest(db, &refs(&uneven), &story(¶graphs(120)), &[], "uneven frames"); + + // A word longer than a line, and runs of spaces and empty paragraphs where frames end. + let long_word = format!("{}\n{}\n{}", "x".repeat(700), paragraphs(30), "y".repeat(900)); + took += same_as_whole_rest(db, &refs(&frames), &story(&long_word), &[], "long words"); + let breaks = (0..200).map(|i| if i % 5 == 0 { String::new() } else { format!("Line {i} ends here ") }).collect::>().join("\n"); + took += same_as_whole_rest(db, &refs(&frames), &story(&breaks), &[], "spaces and empty paragraphs"); + for k in [0usize, 1, 2, 3] { + let tall: Vec = (0..4).map(|i| sized(200.0 * i as f64, 120.0, 14.0 * (k + i + 1) as f64)).collect(); + took += same_as_whole_rest(db, &refs(&tall), &story(&breaks), &[], &format!("frame heights {k}")); + } + + // Style runs and inline graphics cut where frames end, and empty runs as given. + // Some runs start with a glyph choice, which a run cut after its first character loses. + let mut styled = vec![]; + let text = paragraphs(90); + let words: Vec<&str> = text.split_inclusive(' ').collect(); + for (i, three) in words.chunks(3).enumerate() { + let text = three.concat(); + let mut style = [&plain, &big, &small][i % 3].clone(); + if i % 4 == 0 { + let ch = text.chars().next().unwrap_or('I'); + style.glyph_alternate = Some(vectorcraft_doc::GlyphChoice { ch, gid: 1, font: "Test".into(), version: String::new() }); + } + styled.push(TextRun::new(text, style)); + if i % 7 == 0 { + styled.push(TextRun::inline(vectorcraft_doc::InlineArt::new("sym"), plain.clone())); + } + if i % 11 == 0 { + styled.push(TextRun::new("", big.clone())); + } + } + took += same_as_whole_rest(db, &refs(&frames), &styled, &[], "style runs"); + + // Numbered lists at two levels, counted on from text before. + let at = |level| ParaStyle { + list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)), + left_indent: 18.0, + first_line_indent: -18.0, + ..Default::default() + }; + let listed: Vec = (0..120).map(|i| at(1 + i % 3 / 2)).collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(¶graphs(120)), &listed, "lists"); + + // Justified and hyphenated paragraphs, long enough to run from frame to frame. + let text = "The quick brown fox jumps over the lazy dog, extraordinarily unbelievably. ".repeat(6); + let long_paras = (0..14).map(|_| text.trim_end()).collect::>().join("\n"); + let justified: Vec = (0..14) + .map(|i| ParaStyle { justify: [Justify::JustifyAll, Justify::JustifyLeft][i % 2], hyphenate: i % 3 != 0, ..Default::default() }) + .collect(); + took += same_as_whole_rest(db, &refs(&frames), &story(&long_paras), &justified, "justified and hyphenated"); + // Paragraphs longer than a frame holds, each composed as a whole. + let tall: Vec = (0..5).map(|k| sized(200.0 * k as f64, 160.0, 160.0 + 40.0 * k as f64)).collect(); + let very_long = (0..6).map(|_| text.repeat(4).trim_end().to_string()).collect::>().join("\n"); + for justify in [Justify::Left, Justify::JustifyLeft] { + let composed = [ParaStyle { justify, ..Default::default() }]; + took += same_as_whole_rest(db, &refs(&tall), &story(&very_long), &composed, &format!("long paragraphs, {justify:?}")); + } + + // Columns, vertical type, Shrink Text to Fit, lines aligned to the center, type on a path, + // and a last frame the story overflows. + let mut columns = frames.clone(); + for f in &mut columns { + f.area.columns = 2; + f.area.gutter = 6.0; + } + took += same_as_whole_rest(db, &refs(&columns), &story(¶graphs(120)), &[], "columns"); + let mut vertical = frames.clone(); + for f in &mut vertical { + f.vertical = true; + } + took += same_as_whole_rest(db, &refs(&vertical), &story(¶graphs(80)), &[], "vertical type"); + let mut mixed = frames.clone(); + mixed[1].area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 60.0 }; + mixed[2].area.vertical_align = vectorcraft_doc::VerticalAlign::Center; + mixed[3] = path_frame(300.0); + took += same_as_whole_rest(db, &refs(&mixed), &story(¶graphs(120)), &[], "other kinds of frame"); + let two = [&frames[0], &frames[1]]; + took += same_as_whole_rest(db, &two, &story(¶graphs(200)), &[], "overflowing last frame"); + + // Short stories, an empty one and one of empty runs. + took += same_as_whole_rest(db, &refs(&frames), &story("Hi"), &[], "short story"); + took += same_as_whole_rest(db, &refs(&frames), &story(""), &[], "empty story"); + took += same_as_whole_rest(db, &refs(&frames), &[TextRun::new("", big.clone()), TextRun::new("", small.clone())], &[], "empty runs"); + assert!(took > 40, "the frames took text: {took}"); +} + +/// A seeded mix of run splits, sizes, paragraph styles and frame sizes: each frame takes the share +/// the whole rest gives it. +#[test] +fn seeded_stories_take_the_shares_the_whole_rest_gives_them() { + let db = FontDb::global(); + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + let words = ["fox", "a", "jumps", "extraordinary", "dog,", "the", "lazy", "over", " ", "quick"]; + for case in 0..12 { + let mut runs = vec![]; + let mut paras = 1; + for _ in 0..(150 + next(250)) { + let mut text = words[next(words.len() as u64) as usize].to_string(); + text.push(if next(6) == 0 { + paras += 1; + '\n' + } else { + ' ' + }); + let style = CharStyle { size: [12.0, 9.0, 16.0][next(3) as usize], tracking: [0.0, 50.0][next(2) as usize], ..Default::default() }; + runs.push(TextRun::new(text, style)); + } + normalize(&mut runs); + let styles: Vec = (0..paras) + .map(|_| ParaStyle { + justify: [Justify::Left, Justify::Center, Justify::JustifyLeft][next(3) as usize], + first_line_indent: [0.0, 12.0][next(2) as usize], + space_after: [0.0, 4.0][next(2) as usize], + ..Default::default() + }) + .collect(); + let frames: Vec = (0..2 + next(6)).map(|k| sized(200.0 * k as f64, 40.0 + next(120) as f64, 10.0 + next(120) as f64)).collect(); + same_as_whole_rest(db, &frames.iter().collect::>(), &runs, &styles, &format!("case {case}")); + } +} + +/// A long story of short paragraphs through many frames: each frame is measured with about the +/// text it takes and a little more, not with the whole rest of the story. +#[test] +fn many_frames_measure_about_the_text_they_take() { + let db = FontDb::global(); + let text = paragraphs(1500); + let story = vec![TextRun::new(text.clone(), CharStyle::default())]; + let frames: Vec = (0..40).map(|k| frame(200.0 * k as f64)).collect(); + let refs: Vec<&TextObject> = frames.iter().collect(); + let before = fit_bytes(); + let parts = distribute(db, &refs, &story, &[]); + let measured = fit_bytes() - before; + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let placed: usize = parts[..parts.len() - 1].iter().map(|p| runs_len(&p.runs)).sum(); + let longest = text.split('\n').map(str::len).max().unwrap_or(0); + assert!(placed > 0 && placed < text.len() / 4, "the frames hold part of the story: {placed} of {}", text.len()); + let bound = 4 * placed + frames.len() * 2 * (MIN_MEASURE + longest); + assert!(measured <= bound, "measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len()); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..71a62a7798 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1239,6 +1239,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). + When a story flows through threaded area type, each frame is measured with the whole paragraphs of the story that it needs and a few more, not with the whole rest of the story, and it keeps the same text as before. A story of 1,500 one-line paragraphs (55,892 bytes) through 40 frames was laid out over 2,126,762 bytes of text while the frames were measured, and is now laid out over 21,035 (`thread::tests::many_frames_measure_about_the_text_they_take`). Type on a path, Shrink Text to Fit, lines aligned other than to the top, and a frame whose share ends in the story's last paragraph are still measured with the whole rest. A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again. Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once. Object › Rasterize on a frame of threaded text takes it out of the thread with the text it shows, which the image draws, and the other frames keep the text they hold. When the last frame is rasterized, the text it has no room for goes after the text of the last frame that stays. From 4282f4fa99f5e9f4a3f956fb863942f94afb5eea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:32:35 -0800 Subject: [PATCH 10/20] i18n: Document Info shows every category title and count label in the interface language Font Details, Linked Images and Embedded Images, and the Objects counts Compound Paths, Text Objects, Placed Documents, Symbol Instances, Gradient Meshes, Blends, Envelopes, Intertwine Groups, Opacity Masks and Objects with Effects had no catalog rows, so the panel and its category menu showed them in English in every other language. Each complete catalog now has them, and the Document rows Pattern Swatches (de, it, ja, ko, pl, uk, vi, zh-hant) and Path Length (bg, el) are added where missing. The catalog test now checks every category title, every Objects count label and Document's fixed row labels. document.info and the saved report keep the English labels. --- crates/engine/src/cmd.rs | 2 ++ crates/engine/src/cmd/docinfo.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/bs.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/ca.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/cs.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/da.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/de.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/el.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/es.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/fi.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/fr.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/hi.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/hu.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/id.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/it.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/ja.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/ka.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/ko.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/nl.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/pl.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/ru.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/sv.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/tests.rs | 35 ++++++++++++++++++----------- crates/ui-egui/src/i18n/tr.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/uk.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/vi.tsv | 14 ++++++++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 13 +++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 14 ++++++++++++ docs/target-app-parity.md | 1 + 31 files changed, 387 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 80ed19d145..af768284b0 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -778,6 +778,8 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; pub(crate) use addons::{DecodedImages, refresh_color_masks}; +/// Document Info's category titles and the Objects category's count labels. +pub use docinfo::{CATEGORIES, OBJECT_LABELS}; pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 833c7d22bf..a43325becd 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -52,7 +52,7 @@ pub const CATEGORIES: [(&str, &str); 13] = [ ]; /// The object counts of `document.info` and their labels. -const OBJECT_LABELS: [(&str, &str); 19] = [ +pub const OBJECT_LABELS: [(&str, &str); 19] = [ ("paths", "Paths"), ("compoundPaths", "Compound Paths"), ("groups", "Groups"), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..e41e163c62 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3369,6 +3369,20 @@ digits Malayalam Малаялам Spot Color Objects Обекти със спот цветове Pattern Objects Обекти с мостри Gradient Objects Обекти с градиенти + Font Details Подробности за шрифтовете + Linked Images Свързани изображения + Embedded Images Вградени изображения + Compound Paths Съставни пътища + Text Objects Текстови обекти + Placed Documents Поставени документи + Symbol Instances Екземпляри на символи + Gradient Meshes Мрежи с градиент + Blends Преливания + Envelopes Пликове + Intertwine Groups Групи с преплитане + Opacity Masks Маски за непрозрачност + Objects with Effects Обекти с ефекти + Path Length Дължина на пътя Spot Colors Спот цветове Spot Colors… Спот цветове… Spread: Разпространение: diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..56c3ee508d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3954,6 +3954,19 @@ theme Appearance Izgled Spot Color Objects Objekti sa spot bojama Pattern Objects Objekti s uzorcima Gradient Objects Objekti s gradijentima + Font Details Detalji fontova + Linked Images Povezane slike + Embedded Images Ugrađene slike + Compound Paths Složene putanje + Text Objects Tekstualni objekti + Placed Documents Postavljeni dokumenti + Symbol Instances Instance simbola + Gradient Meshes Gradijentne mreže + Blends Stapanja + Envelopes Omotnice + Intertwine Groups Grupe isprepletanja + Opacity Masks Maske neprozirnosti + Objects with Effects Objekti s efektima Spot Colors Spot boje Spot Colors (0) Spot boje (0) Swatch Options Opcije uzorka boje diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..50fc544253 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4379,6 +4379,19 @@ digits Malayalam Malaiàlam Spot Color Objects Objectes amb colors directes Pattern Objects Objectes amb motius Gradient Objects Objectes amb degradats + Font Details Detalls dels tipus de lletra + Linked Images Imatges enllaçades + Embedded Images Imatges incrustades + Compound Paths Traçats compostos + Text Objects Objectes de text + Placed Documents Documents col·locats + Symbol Instances Instàncies de símbol + Gradient Meshes Malles de degradat + Blends Fusions + Envelopes Embolcalls + Intertwine Groups Grups entrellaçats + Opacity Masks Màscares d’opacitat + Objects with Effects Objectes amb efectes Spot Colors Colors directes Spot Colors (0) Colors directes (0) Spot Colors… Colors directes… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..72e7a4bb0b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4010,6 +4010,19 @@ theme Appearance Motiv Spot Color Objects Objekty s přímými barvami Pattern Objects Objekty se vzorky Gradient Objects Objekty s přechody + Font Details Podrobnosti písem + Linked Images Propojené obrazy + Embedded Images Vložené obrazy + Compound Paths Složené cesty + Text Objects Textové objekty + Placed Documents Umístěné dokumenty + Symbol Instances Instance symbolů + Gradient Meshes Přechodové sítě + Blends Prolnutí + Envelopes Obálky + Intertwine Groups Skupiny proplétání + Opacity Masks Masky krytí + Objects with Effects Objekty s efekty Spot Colors Přímé barvy Spot Colors (0) Přímé barvy (0) Swatch Options Možnosti položky vzorníku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..92a7f74842 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3729,6 +3729,19 @@ theme Appearance Udseende Spot Color Objects Objekter med spotfarver Pattern Objects Objekter med mønstre Gradient Objects Objekter med gradienter + Font Details Skrifttypedetaljer + Linked Images Linkede billeder + Embedded Images Indlejrede billeder + Compound Paths Sammensatte kurver + Text Objects Tekstobjekter + Placed Documents Placerede dokumenter + Symbol Instances Symbolforekomster + Gradient Meshes Gradientnet + Blends Blandinger + Envelopes Konvolutter + Intertwine Groups Sammenfletningsgrupper + Opacity Masks Opacitetsmasker + Objects with Effects Objekter med effekter Spot Colors Spotfarver Spot Colors (0) Spotfarver (0) Swatch Options Indstillinger for farveprøve diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..e60fccea7b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3432,6 +3432,20 @@ effect Points Punkte Spot Color Objects Objekte mit Volltonfarben Pattern Objects Objekte mit Mustern Gradient Objects Objekte mit Verläufen + Font Details Schriftdetails + Linked Images Verknüpfte Bilder + Embedded Images Eingebettete Bilder + Compound Paths Zusammengesetzte Pfade + Text Objects Textobjekte + Placed Documents Platzierte Dokumente + Symbol Instances Symbolinstanzen + Gradient Meshes Verlaufsgitter + Blends Angleichungen + Envelopes Hüllen + Intertwine Groups Verflechtungsgruppen + Opacity Masks Deckkraftmasken + Objects with Effects Objekte mit Effekten + Pattern Swatches Musterfarbfelder Spot Colors Volltonfarben Spot Colors… Volltonfarben… Spread: Ausbreitung: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..a01fa440b2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3965,6 +3965,20 @@ digits Malayalam Μαλαγιάλαμ Spot Color Objects Αντικείμενα με χρώματα spot Pattern Objects Αντικείμενα με μοτίβα Gradient Objects Αντικείμενα με ντεγκραντέ + Font Details Λεπτομέρειες γραμματοσειρών + Linked Images Συνδεδεμένες εικόνες + Embedded Images Ενσωματωμένες εικόνες + Compound Paths Σύνθετες διαδρομές + Text Objects Αντικείμενα κειμένου + Placed Documents Τοποθετημένα έγγραφα + Symbol Instances Στιγμιότυπα συμβόλων + Gradient Meshes Πλέγματα ντεγκραντέ + Blends Αναμείξεις + Envelopes Περιβλήματα + Intertwine Groups Ομάδες διαπλοκής + Opacity Masks Μάσκες αδιαφάνειας + Objects with Effects Αντικείμενα με εφέ + Path Length Μήκος διαδρομής Spot Colors Πλακάτα χρώματα Spot Colors… Πλακάτα χρώματα… Stacked Bar Graph Tool Στοιβαγμένο ραβδόγραμμα (Stacked Bar Graph Tool) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..e1033c6d00 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3462,6 +3462,19 @@ digits Malayalam Malayálam Spot Color Objects Objetos con tintas planas Pattern Objects Objetos con motivos Gradient Objects Objetos con degradados + Font Details Detalles de fuentes + Linked Images Imágenes vinculadas + Embedded Images Imágenes incrustadas + Compound Paths Trazados compuestos + Text Objects Objetos de texto + Placed Documents Documentos colocados + Symbol Instances Instancias de símbolo + Gradient Meshes Mallas de degradado + Blends Fusiones + Envelopes Envolventes + Intertwine Groups Grupos entrelazados + Opacity Masks Máscaras de opacidad + Objects with Effects Objetos con efectos Spot Colors Tintas planas Spot Colors… Tintas planas… Spread: Extensión: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..273c789792 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3934,6 +3934,19 @@ theme Appearance Ulkoasu Spot Color Objects Objektit, joissa on spottivärejä Pattern Objects Objektit, joissa on kuvioita Gradient Objects Objektit, joissa on liukuvärejä + Font Details Fonttien tiedot + Linked Images Linkitetyt kuvat + Embedded Images Upotetut kuvat + Compound Paths Yhdistelmäpolut + Text Objects Tekstiobjektit + Placed Documents Sijoitetut asiakirjat + Symbol Instances Symbolien ilmentymät + Gradient Meshes Liukuväriverkot + Blends Sulautukset + Envelopes Vääristyskuoret + Intertwine Groups Lomitusryhmät + Opacity Masks Peittävyysmaskit + Objects with Effects Objektit, joissa on tehosteita Spot Colors Spottivärit Spot Colors (0) Spottivärit (0) Swatch Options Värimallin asetukset diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..38847af152 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3454,6 +3454,19 @@ digits Malayalam Malayalam Spot Color Objects Objets avec tons directs Pattern Objects Objets avec motifs Gradient Objects Objets avec dégradés + Font Details Détails des polices + Linked Images Images liées + Embedded Images Images incorporées + Compound Paths Tracés transparents + Text Objects Objets texte + Placed Documents Documents importés + Symbol Instances Instances de symbole + Gradient Meshes Filets de dégradé + Blends Dégradés de formes + Envelopes Enveloppes + Intertwine Groups Groupes entrelacés + Opacity Masks Masques d’opacité + Objects with Effects Objets avec effets Spot Colors Tons directs Spot Colors… Tons directs… Spread: Étendue : diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..c6c5c095e2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4741,6 +4741,19 @@ antiAlias Sharp शार्प Spot Color Objects स्पॉट कलर्स के साथ ऑब्जेक्ट्स Pattern Objects पैटर्न्स के साथ ऑब्जेक्ट्स Gradient Objects ग्रेडिएंट्स के साथ ऑब्जेक्ट्स + Font Details फ़ॉन्ट विवरण + Linked Images लिंक्ड चित्र + Embedded Images एम्बेडेड चित्र + Compound Paths कंपाउंड पाथ्स + Text Objects टेक्स्ट ऑब्जेक्ट्स + Placed Documents प्लेस्ड डॉक्यूमेंट्स + Symbol Instances सिंबल इंस्टेंस + Gradient Meshes ग्रेडिएंट मेश + Blends ब्लेंड्स + Envelopes एन्वेलप्स + Intertwine Groups इंटरट्वाइन समूह + Opacity Masks ओपेसिटी मास्क + Objects with Effects इफ़ेक्ट्स के साथ ऑब्जेक्ट्स Spot Colors स्पॉट रंग Spot Colors (0) स्पॉट कलर्स (0) Spot Colors… स्पॉट कलर्स… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..b215dc4a59 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4114,6 +4114,19 @@ mark-style Japanese Japán Spot Color Objects Direkt színeket használó objektumok Pattern Objects Mintázatokat használó objektumok Gradient Objects Színátmeneteket használó objektumok + Font Details Betűtípus-részletek + Linked Images Csatolt képek + Embedded Images Beágyazott képek + Compound Paths Összetett útvonalak + Text Objects Szövegobjektumok + Placed Documents Elhelyezett dokumentumok + Symbol Instances Szimbólumpéldányok + Gradient Meshes Színátmenetes hálók + Blends Összemosások + Envelopes Burkolók + Intertwine Groups Összefonási csoportok + Opacity Masks Átlátszósági maszkok + Objects with Effects Effektusokat használó objektumok Spot Colors Direktszínek Spot Colors (0) Direktszínek (0) Spot Colors… Direktszínek… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..6a04d2315b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3729,6 +3729,19 @@ theme Appearance Tampilan Spot Color Objects Objek dengan Warna Spot Pattern Objects Objek dengan Pola Gradient Objects Objek dengan Gradien + Font Details Detail Font + Linked Images Gambar Tertaut + Embedded Images Gambar Tersemat + Compound Paths Jalur Majemuk + Text Objects Objek Teks + Placed Documents Dokumen yang Ditempatkan + Symbol Instances Instans Simbol + Gradient Meshes Mesh Gradien + Blends Blend + Envelopes Amplop + Intertwine Groups Grup Jalinan + Opacity Masks Mask Opasitas + Objects with Effects Objek dengan Efek Spot Colors Warna spot Spot Colors (0) Warna spot (0) Swatch Options Opsi sampel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..1ab5eb90f6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3507,6 +3507,20 @@ digits Malayalam Malayalam Spot Color Objects Oggetti con colori spot Pattern Objects Oggetti con motivi Gradient Objects Oggetti con sfumature + Font Details Dettagli font + Linked Images Immagini collegate + Embedded Images Immagini incorporate + Compound Paths Tracciati composti + Text Objects Oggetti di testo + Placed Documents Documenti inseriti + Symbol Instances Istanze di simbolo + Gradient Meshes Trame sfumatura + Blends Fusioni + Envelopes Involucri + Intertwine Groups Gruppi intrecciati + Opacity Masks Maschere di opacità + Objects with Effects Oggetti con effetti + Pattern Swatches Campioni motivo Spot Colors Colori spot Spot Colors… Colori spot… Spread: Estensione: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..91dbde618a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3284,6 +3284,20 @@ mark-style Japanese 日本式 Spot Color Objects 特色を使用したオブジェクト Pattern Objects パターンを使用したオブジェクト Gradient Objects グラデーションを使用したオブジェクト + Font Details フォントの詳細 + Linked Images リンク画像 + Embedded Images 埋め込み画像 + Compound Paths 複合パス + Text Objects テキストオブジェクト + Placed Documents 配置ドキュメント + Symbol Instances シンボルインスタンス + Gradient Meshes グラデーションメッシュ + Blends ブレンド + Envelopes エンベロープ + Intertwine Groups 織り交ぜグループ + Opacity Masks 不透明マスク + Objects with Effects 効果を使用したオブジェクト + Pattern Swatches パターンスウォッチ Spot Colors 特色 Spot Colors… 特色… Spread: 拡散: diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..bf844b0007 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3568,6 +3568,19 @@ theme Appearance იერსახე Spot Color Objects ობიექტები სპოტ-ფერებით Pattern Objects ობიექტები პატერნებით Gradient Objects ობიექტები გრადიენტებით + Font Details შრიფტების დეტალები + Linked Images დაკავშირებული გამოსახულებები + Embedded Images ჩაშენებული გამოსახულებები + Compound Paths შედგენილი ტრაექტორიები + Text Objects ტექსტური ობიექტები + Placed Documents განთავსებული დოკუმენტები + Symbol Instances სიმბოლოს ეგზემპლარები + Gradient Meshes გრადიენტული ბადეები + Blends გადასვლები + Envelopes გარსები + Intertwine Groups გადახლართვის ჯგუფები + Opacity Masks გაუმჭვირვალობის ნიღბები + Objects with Effects ობიექტები ეფექტებით Spot Colors სპოტ-ფერები Swatch Options ფერის ნიმუშის პარამეტრები Ungroup Color Group ფერების ჯგუფის დაშლა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..767dc3382a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3273,6 +3273,20 @@ mark-style Japanese 일본식 Spot Color Objects 별색을 사용한 개체 Pattern Objects 패턴을 사용한 개체 Gradient Objects 그레이디언트를 사용한 개체 + Font Details 글꼴 세부 정보 + Linked Images 연결된 이미지 + Embedded Images 포함된 이미지 + Compound Paths 컴파운드 패스 + Text Objects 텍스트 개체 + Placed Documents 가져온 문서 + Symbol Instances 심볼 인스턴스 + Gradient Meshes 그레이디언트 메시 + Blends 블렌드 + Envelopes 엔벨로프 + Intertwine Groups 교차 그룹 + Opacity Masks 불투명 마스크 + Objects with Effects 효과를 사용한 개체 + Pattern Swatches 패턴 견본 Spot Colors 별색 Spot Colors… 별색… Spread: 확산: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..ec4c063db7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3743,6 +3743,19 @@ theme Appearance Vormgeving Spot Color Objects Objecten met spotkleuren Pattern Objects Objecten met patronen Gradient Objects Objecten met verlopen + Font Details Lettertypedetails + Linked Images Gekoppelde afbeeldingen + Embedded Images Ingesloten afbeeldingen + Compound Paths Samengestelde paden + Text Objects Tekstobjecten + Placed Documents Geplaatste documenten + Symbol Instances Symboolinstanties + Gradient Meshes Verlooprasters + Blends Vermengingen + Envelopes Omvormingen + Intertwine Groups Verstrengelingsgroepen + Opacity Masks Dekkingmaskers + Objects with Effects Objecten met effecten Spot Colors Spotkleuren Spot Colors (0) Spotkleuren (0) Swatch Options Kleurstaalopties diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..b0f6b4c37d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3869,6 +3869,20 @@ theme Appearance Wygląd Spot Color Objects Obiekty z kolorami dodatkowymi Pattern Objects Obiekty ze wzorkami Gradient Objects Obiekty z gradientami + Font Details Szczegóły czcionek + Linked Images Połączone obrazy + Embedded Images Osadzone obrazy + Compound Paths Ścieżki złożone + Text Objects Obiekty tekstowe + Placed Documents Umieszczone dokumenty + Symbol Instances Wystąpienia symboli + Gradient Meshes Siatki gradientowe + Blends Przejścia + Envelopes Koperty + Intertwine Groups Grupy przeplatania + Opacity Masks Maski krycia + Objects with Effects Obiekty z efektami + Pattern Swatches Próbki wzorków Spot Colors Kolory dodatkowe Swatch Options Opcje próbki Ungroup Color Group Rozgrupuj grupę kolorów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..513616bf90 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2910,6 +2910,19 @@ digits Malayalam Malaiala Spot Color Objects Objetos com cores spot Pattern Objects Objetos com padrões Gradient Objects Objetos com gradientes + Font Details Detalhes das fontes + Linked Images Imagens vinculadas + Embedded Images Imagens incorporadas + Compound Paths Caminhos compostos + Text Objects Objetos de texto + Placed Documents Documentos colocados + Symbol Instances Instâncias de símbolo + Gradient Meshes Malhas de gradiente + Blends Mesclagens + Envelopes Envelopes + Intertwine Groups Grupos entrelaçados + Opacity Masks Máscaras de opacidade + Objects with Effects Objetos com efeitos Spot Colors Cores Spot Spot Colors… Cores Spot… Spread: Propagação: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..91c1580abf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3494,6 +3494,19 @@ digits Malayalam Малаялам Spot Color Objects Объекты с плашечными цветами Pattern Objects Объекты с узорами Gradient Objects Объекты с градиентами + Font Details Сведения о шрифтах + Linked Images Связанные изображения + Embedded Images Внедрённые изображения + Compound Paths Составные контуры + Text Objects Текстовые объекты + Placed Documents Размещённые документы + Symbol Instances Экземпляры символов + Gradient Meshes Градиентные сетки + Blends Переходы + Envelopes Оболочки + Intertwine Groups Группы переплетения + Opacity Masks Непрозрачные маски + Objects with Effects Объекты с эффектами Spot Colors Плашечные цвета Spot Colors… Плашечные цвета… Spread: Разворот: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8a8f8453b6..a2e0b53a13 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3384,6 +3384,19 @@ digits Malayalam Malayalam Spot Color Objects Objekt med dekorfärger Pattern Objects Objekt med mönster Gradient Objects Objekt med gradienter + Font Details Typsnittsdetaljer + Linked Images Länkade bilder + Embedded Images Inbäddade bilder + Compound Paths Sammansatta banor + Text Objects Textobjekt + Placed Documents Placerade dokument + Symbol Instances Symbolinstanser + Gradient Meshes Gradientnät + Blends Blandningar + Envelopes Kuvert + Intertwine Groups Sammanflätningsgrupper + Opacity Masks Opacitetsmasker + Objects with Effects Objekt med effekter Spot Colors Dekorfärger Spot Colors… Dekorfärger… Spread: Spridning: diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 66caa1c3a4..364981e5b8 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -239,24 +239,33 @@ fn control_bar_panel_menu_labels_are_in_complete_catalogs() { } } -/// The titles of Document Info's Spot Color Objects, Pattern Objects and Gradient Objects -/// categories, which the panel, its menu and the Objects category's counts show. +/// Document Info's labels: every category title, which the panel and its menu show, every count +/// label of the Objects category, and the Document category's fixed row labels (the artboard rows +/// show artboard names and are left out). #[test] -fn document_info_object_category_titles_are_in_complete_catalogs() { +fn document_info_labels_are_in_complete_catalogs() { let mut session = vectorcraft_engine::Session::new(); session.execute("file.new", &serde_json::json!({"width": 100, "height": 100})).unwrap(); + session.execute("shape.rectangle", &serde_json::json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); let info = session.execute("document.info", &serde_json::json!({})).unwrap(); - let titles: Vec<&str> = info["sections"] - .as_array() - .unwrap() - .iter() - .filter(|s| matches!(s["id"].as_str(), Some("spotColors" | "patterns" | "gradients"))) - .filter_map(|s| s["title"].as_str()) - .collect(); - assert_eq!(titles, ["Spot Color Objects", "Pattern Objects", "Gradient Objects"]); + let sections = info["sections"].as_array().unwrap(); + let titles: Vec<&str> = sections.iter().filter_map(|s| s["title"].as_str()).collect(); + let categories: Vec<&str> = vectorcraft_engine::cmd::CATEGORIES.iter().map(|&(_, title)| title).collect(); + assert_eq!(titles, categories); + let mut labels = categories; + labels.extend(vectorcraft_engine::cmd::OBJECT_LABELS.iter().map(|&(_, label)| label)); + let objects = sections.iter().find(|s| s["id"] == "objects").unwrap()["rows"].as_array().unwrap(); + assert!(objects.iter().any(|r| r[0] == "Path Length"), "{objects:?}"); + labels.push("Path Length"); + let document = sections.iter().find(|s| s["id"] == "document").unwrap()["rows"].as_array().unwrap(); + let artboards = document.iter().position(|r| r[0] == "Artboards").unwrap(); + let names = artboards + 1..=artboards + document[artboards][1].as_str().unwrap().parse::().unwrap(); + let fixed: Vec<&str> = document.iter().enumerate().filter(|(k, _)| !names.contains(k)).filter_map(|(_, r)| r[0].as_str()).collect(); + assert_eq!(fixed.len(), 9, "{fixed:?}"); + labels.extend(fixed); for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { - for title in &titles { - assert!(lang.catalog().plain(title).is_some(), "{} is missing {title}", lang.code); + for label in &labels { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); } } } diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..afd3b904a9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3283,6 +3283,19 @@ digits Malayalam Malayalam Spot Color Objects Spot Renkli Nesneler Pattern Objects Desenli Nesneler Gradient Objects Degradeli Nesneler + Font Details Yazı Tipi Ayrıntıları + Linked Images Bağlantılı Görseller + Embedded Images Gömülü Görseller + Compound Paths Bileşik Yollar + Text Objects Metin Nesneleri + Placed Documents Yerleştirilen Belgeler + Symbol Instances Sembol Örnekleri + Gradient Meshes Degrade Örgüler + Blends Karışımlar + Envelopes Zarflar + Intertwine Groups İç İçe Geçme Grupları + Opacity Masks Opaklık Maskeleri + Objects with Effects Efektli Nesneler Spot Colors Nokta Renkleri Spot Colors… Nokta Renkleri… Spread: Yayılma: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..24556a72e2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3500,6 +3500,20 @@ effect Points Точки Spot Color Objects Об’єкти з плашковими кольорами Pattern Objects Об’єкти з візерунками Gradient Objects Об’єкти з градієнтами + Font Details Відомості про шрифти + Linked Images Пов’язані зображення + Embedded Images Впроваджені зображення + Compound Paths Складені контури + Text Objects Текстові об’єкти + Placed Documents Розміщені документи + Symbol Instances Екземпляри символів + Gradient Meshes Градієнтні сітки + Blends Переходи + Envelopes Оболонки + Intertwine Groups Групи переплетення + Opacity Masks Маски непрозорості + Objects with Effects Об’єкти з ефектами + Pattern Swatches Зразки візерунків Spot Colors Плашкові кольори Spot Colors… Плашкові кольори… Spread: Розворот: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..f53e50f021 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3846,6 +3846,20 @@ mark-style Japanese Kiểu Nhật Spot Color Objects Đối tượng có màu đặc biệt Pattern Objects Đối tượng có họa tiết Gradient Objects Đối tượng có chuyển sắc + Font Details Chi tiết phông chữ + Linked Images Hình ảnh đã liên kết + Embedded Images Hình ảnh đã nhúng + Compound Paths Đường dẫn phức + Text Objects Đối tượng văn bản + Placed Documents Tài liệu đã đặt + Symbol Instances Thực thể ký hiệu + Gradient Meshes Lưới chuyển sắc + Blends Hòa trộn + Envelopes Envelope + Intertwine Groups Nhóm đan xen + Opacity Masks Mặt nạ độ mờ + Objects with Effects Đối tượng có hiệu ứng + Pattern Swatches Mẫu màu họa tiết Spot Colors Màu đặc biệt Spot Colors… Màu đặc biệt… Spray Radius Bán kính phun diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..5141eaa16d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3323,6 +3323,19 @@ digits Malayalam 马拉雅拉姆数字 Spot Color Objects 使用专色的对象 Pattern Objects 使用图案的对象 Gradient Objects 使用渐变的对象 + Font Details 字体详细信息 + Linked Images 链接的图像 + Embedded Images 嵌入的图像 + Compound Paths 复合路径 + Text Objects 文本对象 + Placed Documents 置入的文档 + Symbol Instances 符号实例 + Gradient Meshes 渐变网格 + Blends 混合 + Envelopes 封套 + Intertwine Groups 交织编组 + Opacity Masks 不透明度蒙版 + Objects with Effects 使用效果的对象 Spot Colors 专色 Spot Colors… 专色… Spread: 扩散: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..f01e340aba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4559,6 +4559,20 @@ digits Malayalam 馬來亞拉姆數字 Spot Color Objects 使用特別色的物件 Pattern Objects 使用圖樣的物件 Gradient Objects 使用漸層的物件 + Font Details 字體詳細資訊 + Linked Images 已連結的影像 + Embedded Images 嵌入的影像 + Compound Paths 複合路徑 + Text Objects 文字物件 + Placed Documents 已置入的文件 + Symbol Instances 符號範例 + Gradient Meshes 漸層網格 + Blends 漸變 + Envelopes 封套 + Intertwine Groups 交織群組 + Opacity Masks 不透明度遮色片 + Objects with Effects 使用效果的物件 + Pattern Swatches 圖樣色票 Spot Colors 特別色 Spot Colors… 特別色… Spread: 擴散: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..08234ac269 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1437,6 +1437,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info's Gradient Objects category lists the objects whose fill or stroke is a gradient, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as the count of objects with gradient fills or strokes. Each object is listed by name with the gradients it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "gradients"}`, `gradientObjects`). Naming a linked gradient by its swatch and any other gradient by its kind, and leaving out paint that draws nothing by the rules Pattern Objects follows, gradient swatches no art uses, gradient meshes and gradients drawn by effects, are VectorCraft's own rules. Objects counts the listed objects as Gradient Objects. Test: `cmd::docinfo::tests::info_lists_the_gradient_objects` (vectorcraft-engine). Document Info's Spot Color Objects category lists the objects that use spot colors, the category the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes as objects using spot color separations. Each object is listed by name with the spot colors it uses. With Selection Only it lists those in the selection, and the Document Info report and Package's report list them too (`document.info {category: "spotColors"}`, `spotObjects`). Taking a solid color or a gradient stop linked to a spot swatch at a tint above 0 and a grayscale image colorized with one, leaving out process colors that look the same, Registration, gradients whose stops are all at an opacity of 0, fills and strokes at an opacity of 0, paint that draws nothing by the rules Pattern Objects follows, spot swatches no art uses and spot colors inside patterns, symbols, opacity masks, effects and placed files, are VectorCraft's own rules. Objects counts the listed objects as Spot Color Objects. Tests: `cmd::docinfo::tests::info_lists_the_spot_color_objects`, `spot_color_objects_take_colorized_images_and_skip_clear_gradients` and `spot_color_objects_skip_clear_fills_and_strokes` (vectorcraft-engine). Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. + Document Info's category menu and headings show every category title in the interface language, Font Details, Linked Images and Embedded Images included, and the Objects category shows every count label in it too: Compound Paths, Text Objects, Placed Documents, Symbol Instances, Gradient Meshes, Blends, Envelopes, Intertwine Groups, Opacity Masks and Objects with Effects. The Document category's Pattern Swatches row and the Objects category's Path Length row are translated in every catalog that has every menu label. `document.info` and the saved report keep the English labels. Showing these labels in the interface language is VectorCraft's own rule. Test: `i18n::tests::document_info_labels_are_in_complete_catalogs` (vectorcraft-ui-egui). The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). From df975e63002255c5ebd15cc1f56282c1f0d10729 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:41:22 -0800 Subject: [PATCH 11/20] Justify: a line with a tab spreads the text after its last tab to the edge A justified area type line counted a tab as a word space, so it gave the tab a share of its room that no glyph drew, and the line ended short of its measure. A word-tab-word line ended short too. - The text up to a line's last tab keeps its spacing, each tab keeps its stop and its leader stays in its gap. The text after the last tab takes the line's room: word spacing, letter spacing and glyph scaling within their ranges, then its word spaces, or the gaps between its letters when it has none. Kashidas go in that text when it is one run. - A tab separates two words, so Single Word Justification does not align a word-tab-word line. - Right-to-left lines take the text drawn after the last tab drawn, measured as the line is drawn, and vertical lines work down the column. Point type keeps its natural width. Docs: docs/target-app-parity.md, docs/type-parity.md. --- crates/text/src/layout/flow.rs | 117 +++++++++++++++++++------ crates/text/src/tests/justify.rs | 142 ++++++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + docs/type-parity.md | 2 +- 4 files changed, 232 insertions(+), 30 deletions(-) diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index aae91be1ee..a77e05e2cb 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -494,6 +494,9 @@ struct LineJustification { /// The tracking after the line's last character that its alignment leaves out of its width: /// [`LineGeometry::trailing`] for a line that isn't justified, 0 for one that is. trailing: f64, + /// The glyphs justification leaves at their natural advance, by offset from the line's first + /// glyph: on a line with a tab, those drawn up to its last tab ([`tab_fixed_glyphs`]). + fixed: Vec, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -796,10 +799,48 @@ fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option], index: u } } -// A space that takes a share of a justified line's room: not a zero-width space, which is a line -// break opportunity but not a word separator. -fn is_justified_space(g: &SGlyph) -> bool { - g.is_space() && !is_format_control(g.ch) +// The glyphs of a justified line that keep their natural advance, by offset from the line's first +// glyph: on a line with a tab of its own (not a list marker's), every glyph drawn up to and +// including the last such tab, so the tabs keep their stops and leaders. Justification spreads the +// line's room over the glyphs drawn after that tab. Empty on a line without such a tab. +fn tab_fixed_glyphs(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> Vec { + let sg = ¶graph.sg; + let Some(last) = line.drawn.iter().rposition(|&j| j >= paragraph.marker && sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0)) else { + return Vec::new(); + }; + let mut fixed = vec![false; line.body.saturating_sub(line.index)]; + for &j in line.drawn.iter().take(last + 1) { + if let Some(slot) = j.checked_sub(line.index).and_then(|k| fixed.get_mut(k)) { + *slot = true; + } + } + fixed +} + +// Does justification leave glyph `glyph` of the line starting at `index` at its natural advance +// (see [`tab_fixed_glyphs`])? +fn is_tab_fixed(fixed: &[bool], index: usize, glyph: usize) -> bool { + glyph.checked_sub(index).and_then(|k| fixed.get(k)).copied().unwrap_or(false) +} + +// The natural width of justified area type `line` with a tab, as [`emit_flow_line`] draws it from +// the line's start: its glyphs in drawing order, each tab up to its stop from where the glyphs +// drawn before it end, then the line's hyphen. +fn tabbed_natural_width(paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, fixed: &[bool]) -> f64 { + let sg = ¶graph.sg; + let start = line.ax0 - line.opt_l; + let mut x = start; + for &j in &line.drawn { + let Some(g) = sg.get(j) else { continue }; + x += if g.ch == '\t' && g.len > 0 && is_tab_fixed(fixed, line.index, j) { + tab_advance(¶graph.para.tabs, line.position.x0, x + line.opt_l, sg.get(j + 1..line.trimmed).unwrap_or_default()).0 + } else if j + 1 == line.trimmed { + g.adv - line.end_trim + } else { + g.adv + }; + } + x - start + line.hyphen.as_ref().map_or(0.0, |h| h.adv) } // May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic, @@ -845,7 +886,11 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin Justify::JustifyAll => (0, true), }; justify &= paragraph.regions.is_some(); - let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).count(); + // A tab keeps its stop: the glyphs drawn up to a line's last tab keep their advances, and the + // ones after it take the line's room. + let fixed = tab_fixed_glyphs(paragraph, line); + let free = |j: usize| !is_tab_fixed(&fixed, line.index, j); + let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j])).count(); // A gap that justification adds between two glyphs goes into the advance of the one // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a // right-to-left line comes before it in the text. @@ -855,10 +900,10 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin *slot = Some(b); } } - let cjk_gaps = (line.index..line.body).filter(|&j| is_cjk_gap(j, held, &after, line.index, sg)).count(); + let cjk_gaps = (line.index..line.body).filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg)).count(); // Single Word Justification: a justified line holding one word (no word space) is - // spread between its letters (Full Justify) or aligned. - let single_word = justify && spaces == 0 && cjk_gaps == 0; + // spread between its letters (Full Justify) or aligned. A tab separates two words. + let single_word = justify && fixed.is_empty() && spaces == 0 && cjk_gaps == 0; (align, justify) = match (single_word, para.justification.single_word) { (false, _) | (true, SingleWordJustify::FullJustify) => (align, justify), (true, SingleWordJustify::AlignLeft) => (0, false), @@ -867,12 +912,24 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin }; // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, // and the word spaces and gaps take what is left. - let kashidas = if justify && !cx.vertical && room > line.render_width + EPS { - kashida::plan(cx.text, sg, line.index..line.body, para.kashida, room - line.render_width, |byte| cx.style_at(byte).kashidas) + // On a line with a tab, the kashidas go in the text after its last tab, when that text is one + // run of characters. + let kashida_range = if fixed.is_empty() { + Some(line.index..line.body) + } else { + let start = (line.index..line.body).find(|&j| free(j)).unwrap_or(line.body); + let end = (line.index..line.body).rev().find(|&j| free(j)).map_or(start, |j| j + 1); + (start..end).all(free).then_some(start..end) + }; + // A justified line with a tab is as wide as it is drawn: in a right-to-left line its tabs are + // drawn in another order than the one the line was measured in. + let natural = if justify && !fixed.is_empty() { tabbed_natural_width(paragraph, line, &fixed) } else { line.render_width }; + let kashidas = if let Some(range) = kashida_range.filter(|r| justify && !cx.vertical && !r.is_empty() && room > natural + EPS) { + kashida::plan(cx.text, sg, range, para.kashida, room - natural, |byte| cx.style_at(byte).kashidas) } else { Vec::new() }; - let width = line.render_width + kashidas.iter().map(|k| k.1).sum::(); + let width = natural + kashidas.iter().map(|k| k.1).sum::(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); let mut squeeze = 0.0; @@ -882,13 +939,17 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin let pushed_in = line.position.pushed_in && paragraph.regions.is_some() && cjk_gaps > 0 && width > room + EPS; if pushed_in { let ems: f64 = (line.index..line.body) - .filter(|&j| is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) + .filter(|&j| free(j) && is_cjk_gap(j, held, &after, line.index, sg) && may_squeeze(&sg[j])) .map(|j| squeeze_em(&sg[j], cx.vertical)) .sum(); if ems > EPS { squeeze = ((room - width) / ems).max(-PUSH_IN_SQUEEZE); } } else if justify && (room - width).abs() > EPS { + // What justification may change of each glyph: nothing of one drawn up to the last tab. + let line_flex = || -> Vec { + (line.index..line.body).map(|j| if free(j) { flow_line_flex(j, ¶graph.flex, held, hung) } else { Flex::default() }).collect() + }; if cjk_gaps > 0 && room > width { // Spread over the CJK gaps and the word spaces alike. per_space = (room - width) / (cjk_gaps + spaces) as f64; @@ -896,18 +957,20 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin } else if spaces > 0 { // Word spacing, letter spacing and glyph scaling within their ranges, then the // word spaces alone; composed lines may shrink word spaces (never below zero). - let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); - let floor = - (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).map(|j| sg[j].adv).fold(f64::MAX, f64::min); + let flex = line_flex(); + let floor = (line.index..line.body) + .filter(|&j| j >= paragraph.marker && free(j) && is_word_space(&sg[j])) + .map(|j| sg[j].adv) + .fold(f64::MAX, f64::min); fit = fit_line(&flex, room - width, spaces, floor); } else if room > width { // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. - let flex: Vec = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect(); + let flex = line_flex(); fit = fit_line(&flex, room - width, 0, 0.0); let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); let rest = room - width - used; let hyphen = line.hyphen.is_some(); - let gaps = (line.index..line.body).filter(|&j| is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); + let gaps = (line.index..line.body).filter(|&j| free(j) && is_letter_gap(j, held, &after, line.index, sg, hyphen)).count(); if gaps > 0 && rest > EPS { per_gap = rest / gaps as f64; } @@ -939,7 +1002,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab, trailing, fixed } } /// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop @@ -1017,22 +1080,20 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut leader = ""; if glyph.ch == '\t' { (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); - } else if glyph_index < body { + } else if glyph_index < body && !is_tab_fixed(&justification.fixed, index, glyph_index) { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { - advance += justification.fit.space(&flex); + advance += justification.fit.space(&flex) + justification.per_space; } else { advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex); scale = justification.fit.glyph_scale(&flex); - } - if is_justified_space(glyph) { - advance += justification.per_space; - } else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { - if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { - advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); + if justification.per_cjk != 0.0 || justification.squeeze != 0.0 { + if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) { + advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical); + } + } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { + advance += justification.per_gap; } - } else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) { - advance += justification.per_gap; } } // The kashida before a cluster in the text is drawn at the cluster's right, between diff --git a/crates/text/src/tests/justify.rs b/crates/text/src/tests/justify.rs index dd80eed25b..8212a652e3 100644 --- a/crates/text/src/tests/justify.rs +++ b/crates/text/src/tests/justify.rs @@ -4,7 +4,7 @@ use crate::*; use kurbo::{Affine, Shape}; use vectorcraft_doc::text::Kashida; -use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind, TextRun}; +use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TabAlign, TabStop, TextKind, TextRun}; use vectorcraft_geom::PathData; fn db() -> &'static FontDb { @@ -412,6 +412,146 @@ fn single_word_lines_follow_single_word_justification() { assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}"); } +/// Word spacing at 100% with no range: the line's room goes on its word spaces alone. +fn no_ranges() -> JustificationOptions { + JustificationOptions { word_spacing: [100.0, 100.0, 100.0], ..JustificationOptions::default() } +} + +/// `text` in a 300 pt wide frame with left tab stops at `stops` (a leader of `leader` on each). +fn tabbed(text: &str, stops: &[f64], leader: &str, justify: Justify, j: JustificationOptions) -> TextLayout { + let mut t = area(text, 300.0, justify, j); + t.para.tabs = stops.iter().map(|&position| TabStop { position, align: TabAlign::Left, leader: leader.to_string(), align_on: '.' }).collect(); + layout(db(), &t) +} + +/// Where the glyph of the character at byte `b` ends. +fn end(l: &TextLayout, b: usize) -> f64 { + at(l, b).origin.x + at(l, b).advance +} + +/// A justified line with a tab: the text before its last tab keeps its spacing and the tab its +/// stop, and the word spaces after the tab take the line's room, so the line ends on the far edge. +#[test] +fn a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge() { + // A (0), A (1), space (2), B (3), B (4), tab (5), C (6), C (7), space (8), D (9), D (10); a + // soft return ends the line, so Justify Left justifies it. + let text = "AA BB\tCC DD\u{2028}x"; + for j in [no_ranges(), JustificationOptions::default()] { + let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone()); + let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j); + for b in 0..5 { + assert!((at(&l, b).origin.x - at(&natural, b).origin.x).abs() < 1e-6, "byte {b} stays where it was"); + assert!((at(&l, b).advance - at(&natural, b).advance).abs() < 1e-6, "byte {b} keeps its advance"); + } + assert!((end(&l, 5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 5)); + assert!((end(&l, 10) - 300.0).abs() < 1e-6, "the last word ends on the edge: {}", end(&l, 10)); + assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "the line's record ends there too: {}", l.lines[0].x1); + assert!(at(&l, 8).advance > at(&natural, 8).advance + 1.0, "the word space after the tab takes the room"); + } +} + +/// A leader stays inside its tab's gap on a justified line. +#[test] +fn a_justified_tab_keeps_its_leader_inside_its_gap() { + let l = tabbed("AA BB\tCC DD", &[150.0], ".", Justify::JustifyAll, no_ranges()); + let tab = at(&l, 5); + let dots = tab.outline.bounding_box(); + assert!(!tab.outline.elements().is_empty(), "the leader is drawn"); + assert!(dots.x0 >= tab.origin.x - 1e-6 && dots.x1 <= 150.0 + 1e-6, "{dots:?} inside {}..150", tab.origin.x); + assert!((end(&l, 10) - 300.0).abs() < 1e-6, "{}", end(&l, 10)); +} + +/// Two words with a tab between them and no word space: the word after the tab is spread +/// between its letters to the edge, whatever Single Word Justification says, since a tab +/// separates two words. +#[test] +fn a_justified_word_tab_word_line_reaches_the_edge() { + // A (0), A (1), tab (2), B (3), B (4). + let text = "AA\tBB\u{2028}x"; + for single_word in [SingleWordJustify::FullJustify, SingleWordJustify::AlignLeft, SingleWordJustify::AlignRight] { + let j = JustificationOptions { single_word, ..JustificationOptions::default() }; + let natural = tabbed(text, &[150.0], "", Justify::Left, j.clone()); + let l = tabbed(text, &[150.0], "", Justify::JustifyLeft, j); + assert!(at(&l, 0).origin.x.abs() < 1e-6, "{single_word:?}: starts at the left edge: {}", at(&l, 0).origin.x); + assert!((at(&l, 0).advance - at(&natural, 0).advance).abs() < 1e-6, "{single_word:?}: the word before the tab keeps its width"); + assert!((end(&l, 2) - 150.0).abs() < 1e-6, "{single_word:?}: the tab keeps its stop: {}", end(&l, 2)); + assert!((end(&l, 4) - 300.0).abs() < 1e-6, "{single_word:?}: ends on the edge: {}", end(&l, 4)); + } +} + +/// The last line of a paragraph: Justify All spreads the text after its tab to the edge, and +/// Justify Left leaves it aligned left. +#[test] +fn the_last_line_with_a_tab_fills_the_measure_only_with_justify_all() { + let text = "AA BB\tCC DD"; + let all = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges()); + assert!((end(&all, 5) - 150.0).abs() < 1e-6 && (end(&all, 10) - 300.0).abs() < 1e-6, "{} {}", end(&all, 5), end(&all, 10)); + let left = tabbed(text, &[150.0], "", Justify::JustifyLeft, no_ranges()); + let natural = tabbed(text, &[150.0], "", Justify::Left, no_ranges()); + assert!((end(&left, 10) - end(&natural, 10)).abs() < 1e-6 && end(&left, 10) < 250.0, "{}", end(&left, 10)); +} + +/// A line with two tabs: both keep their stops, the word space between them keeps its width, and +/// the text after the last tab takes the room. +#[test] +fn a_justified_line_takes_its_room_after_its_last_tab() { + // A (0), tab (1), B (2), space (3), C (4), tab (5), D (6), space (7), E (8). + let text = "A\tB C\tD E"; + let natural = tabbed(text, &[80.0, 160.0], "", Justify::Left, no_ranges()); + let l = tabbed(text, &[80.0, 160.0], "", Justify::JustifyAll, no_ranges()); + assert!((end(&l, 1) - 80.0).abs() < 1e-6 && (end(&l, 5) - 160.0).abs() < 1e-6, "{} {}", end(&l, 1), end(&l, 5)); + assert!((at(&l, 3).advance - at(&natural, 3).advance).abs() < 1e-6, "the space between the tabs keeps its width"); + assert!((end(&l, 8) - 300.0).abs() < 1e-6, "{}", end(&l, 8)); +} + +/// Vertical area type: the tab keeps its stop down the column, and the text after it reaches the +/// column's end. +#[test] +fn a_justified_vertical_line_with_a_tab_reaches_the_end_of_its_column() { + let mut t = area("AA BB\tCC DD", 100.0, Justify::JustifyAll, no_ranges()); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 300.0).to_path(0.1)) }; + t.vertical = true; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let l = layout(db(), &t); + let down = |b: usize| at(&l, b).origin.y + at(&l, b).advance; + assert!(at(&l, 0).origin.y.abs() < 1e-6, "{}", at(&l, 0).origin.y); + assert!((down(5) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", down(5)); + assert!((down(10) - 300.0).abs() < 1e-6, "the last word ends at the column's end: {}", down(10)); +} + +/// Right-to-left area type: the glyphs drawn up to the last tab drawn keep their advances, the tab +/// its stop, and the text drawn after it reaches the right edge. +#[test] +fn a_justified_right_to_left_line_with_a_tab_spans_its_measure() { + // א (0), ב (2), space (4), ג (5), ד (7), tab (9), ה (10), ו (12), space (14), ז (15), ח (17). + let text = "אב גד\tהו זח"; + let natural = tabbed(text, &[150.0], "", Justify::Right, no_ranges()); + let l = tabbed(text, &[150.0], "", Justify::JustifyAll, no_ranges()); + let mut drawn: Vec<&PositionedGlyph> = l.glyphs.iter().collect(); + drawn.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + assert!(drawn[0].origin.x.abs() < 1e-6, "starts at the left edge: {:?}", drawn[0].origin); + let tab = drawn.iter().position(|g| g.byte == 9).unwrap(); + for g in &drawn[..tab] { + assert!((g.advance - at(&natural, g.byte).advance).abs() < 1e-6, "byte {} keeps its advance", g.byte); + } + assert!((end(&l, 9) - 150.0).abs() < 1e-6, "the tab keeps its stop: {}", end(&l, 9)); + let last = drawn.last().unwrap(); + assert!((last.origin.x + last.advance - 300.0).abs() < 1e-6, "ends on the right edge: {}", last.origin.x + last.advance); + assert!((l.lines[0].x1 - 300.0).abs() < 1e-6, "{}", l.lines[0].x1); +} + +/// Point type has no measure to fill: a justified line with a tab keeps its natural width. +#[test] +fn justified_point_type_with_a_tab_keeps_its_width() { + let mut t = point("AA BB\tCC DD", no_ranges()); + t.para.justify = Justify::JustifyAll; + t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let l = layout(db(), &t); + t.para.justify = Justify::Left; + let natural = layout(db(), &t); + assert!((end(&l, 10) - end(&natural, 10)).abs() < 1e-6, "{} {}", end(&l, 10), end(&natural, 10)); +} + /// An OpenType (GSUB) Arabic face with a tatweel: craft-fonts' Noto Sans Arabic when the build has /// craft-fonts (`CRAFT_FONTS_DIR`), else an installed one. None skips the test. fn arabic_face(test: &str) -> Option> { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..206365d7f1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -667,6 +667,7 @@ since 2026-10-01. - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy ` copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. - **Letter spacing and Full Justify between shaped clusters:** Desired Letter Spacing on any line, the letter spacing range of a justified line, and Single Word Justification's Full Justify add space only between shaped clusters, in horizontal, right-to-left and vertical type, so a combining mark stays on its letter and a Devanagari syllable's glyphs stay together. They add no space next to a zero-width format control (a soft hyphen, a bidi mark, embedding, override or isolate, a zero-width space, a joiner or non-joiner, a word joiner, a variation selector, a tag, the byte order mark), so a word wrapped in an override still reaches both edges of a justified line, and a Kinsoku push-in counts no gap at one either. A zero-width space is a line break opportunity but not a word separator ([CSS Text Level 3](https://www.w3.org/TR/css-text-3/#word-separator) does not list it), so a justified line gives it no share of its room. Desired Letter Spacing between the letters on either side of a zero-width space goes after the zero-width space, so a line that breaks there ends at its last letter, and the line breaker does not measure a gap after it. A line that Full Justify spreads and that ends in a drawn hyphen puts the same gap between its last letter and the hyphen. The cluster boundary is VectorCraft's own rule, based on the typographic character unit of [CSS Text Level 3](https://www.w3.org/TR/css-text-3/#typographic-character-unit). Tests `tests::justify::single_word_full_justify_keeps_combining_mark_with_base`, `desired_and_fitted_letter_spacing_skip_shaped_cluster`, `rtl_single_word_cluster_gap_follows_drawn_order`, `vertical_single_word_cluster_gap_follows_drawn_order`, `single_word_full_justify_keeps_conjunct_cluster`, `desired_letter_spacing_skips_bidi_format_controls`, `letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens`, `format_controls_are_the_zero_width_default_ignorables`, `zero_width_space_takes_no_word_space_room`, `desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap`, `single_word_full_justify_spreads_up_to_the_drawn_hyphen` and `layout::wrapping_tests::kinsoku_shori_type_counts_no_gap_at_a_format_control`. Public reference: the help page (). +- **Justified lines with tabs:** a tab is not a word space, so a justified area type line no longer gives it a share of its room that no glyph draws. On a justified line with a tab, the text up to the last tab keeps its spacing, each tab keeps its stop and its leader stays in its gap, and the text after the last tab takes the room (word spaces first, then letter spacing, glyph scaling and the gaps between its letters), so the line ends on the far edge. A tab separates two words, so Single Word Justification does not align a word-tab-word line. Right-to-left lines take the text drawn after the last tab drawn, and vertical lines work down the column. Spreading the room after the last tab is VectorCraft's own rule. Tests `tests::justify::a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge`, `a_justified_tab_keeps_its_leader_inside_its_gap`, `a_justified_word_tab_word_line_reaches_the_edge`, `the_last_line_with_a_tab_fills_the_measure_only_with_justify_all`, `a_justified_line_takes_its_room_after_its_last_tab`, `a_justified_vertical_line_with_a_tab_reaches_the_end_of_its_column`, `a_justified_right_to_left_line_with_a_tab_spans_its_measure` and `justified_point_type_with_a_tab_keeps_its_width`. Public reference: the help pages (, ). - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. diff --git a/docs/type-parity.md b/docs/type-parity.md index 102a454c3b..b8397e8404 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -48,7 +48,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests` | | Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` | | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | -| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred lines are still aligned as a whole); in justified area type the text up to a line's last tab keeps its spacing and its tabs their stops, and the text after the last tab takes the line's room to the far edge, VectorCraft's own rule (evidence: `tests::justify::a_justified_line_with_a_tab_spreads_the_text_after_it_to_the_edge` and the other tab tests in `crates/text/src/tests/justify.rs`) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | partial | the engine (#4936): `text.spelling.check` lists the unknown words of the document's type, each run in its Language, at the bytes `text.editRange` changes, and `text.spelling.suggest` gives suggestions, both from craft-proof, each paragraph on its own (English from the first dictionary it finds: a Hunspell dictionary in the user's dictionaries folder, the SCOWL dictionary built in from craft-i18n, a system Hunspell dictionary, and last the public-domain Moby word list built into it; other languages from the dictionaries craft-proof finds for them). The custom dictionary commands are done: `text.spelling.addWord`, `removeWord` and `changeWord` (Add, and Edit Custom Dictionary's Add, Delete and Change), and `text.spelling.dictionary` lists the words, saved to `Custom Dictionary.txt` beside the preferences; Ignore All: `text.spelling.ignoreAll` skips a word in the active document while it is open. Auto Spell Check is done: Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`, off at first, saved with the view settings) draws a red wavy line under the unknown words of the visible type, leaves out type with effects, and follows edits, the custom dictionary and Ignore All. A right-click on an underlined word lists the words its dictionary suggests (each replaces it in one undo step, `text.editRange`), or No Suggestions, then Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check, checked while it is on. Edit › Edit Custom Dictionary… lists the custom dictionary's words, with an Entry field and Add, Delete and Change, applied on Done (Cancel leaves the dictionary as it was). The Check Spelling dialog remains. Evidence: `crates/engine/src/tests/spelling.rs`, `crates/ui-egui/src/canvas/tests.rs` (`auto_spell_check_*`), `crates/ui-egui/src/tests/contextmenu.rs` (the right-click), `crates/ui-egui/src/dialogs/custom_dictionary.rs` (the dialog). | | Smart Punctuation, Change Case, Find & Replace | done | | From 57d37a6dae48fdc0231dbd8f7ed14df0575a1558 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:23:42 -0800 Subject: [PATCH 12/20] 3D: fill walls and gapped strokes draw in depth order where they overlap Extrude & Bevel, Extrude & Bevel (Classic) and Inflate sorted the fill walls and the faces of a stroke that leaves gaps (dashed, profiled, brushed) by one depth each, so a far stroke face could draw over a near fill wall and the reverse. Two such strokes on one shape drew over each other the same way, and Inflate drew a plain stroke as if it covered the fill's edge, which an inflated fill can rise in front of. - Each flat facet now carries its plane, so its depth can be measured at any point under it, with orthographic and perspective views. - Where faces of one shape's fill and strokes overlap in the wrong order, the overlapping faces are ordered by their depths where they overlap. A fill wall and its covering stroke keep their order. - A face of a stroke that leaves gaps that passes through a fill wall, a covering stroke's face or an earlier such stroke's face is cut along the line where their depths meet, keeping its curved outline, within the face limit and the paint allowance. - Under Inflate, no stroke counts as covering the fill's edge. - The faces go in one order when the fill and strokes are at full opacity with Normal blending. A member of a 3D group with an opacity mask or a blending mode of its own orders its faces inside it when its own paints qualify. A gradient with transparent stops keeps the one order with graphics on its cap, and so does Inflate in gradients under a colored light. A solid color under a colored light, drawn as one composite per surface, still draws each surface whole. --- crates/effects/src/revolve.rs | 2 +- crates/effects/src/solids.rs | 625 ++++++++++++++++++-- crates/effects/src/solids/tests.rs | 436 ++++++++++++++ crates/effects/src/solids/tests_graphics.rs | 104 ++++ crates/three-d/src/lib.rs | 4 +- crates/three-d/src/solid.rs | 42 ++ crates/three-d/src/solid/build.rs | 10 +- crates/three-d/src/solid/tests.rs | 23 + docs/target-app-parity.md | 1 + docs/three-d.md | 19 +- 10 files changed, 1202 insertions(+), 64 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index e9d64864d0..1677937f17 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -27,7 +27,7 @@ pub fn is_revolve(id: &str) -> bool { /// Most bytes the copies of the object's paint in the faces may take together. Each face and the /// seam stroke around it gets a copy of the paint, so a gradient of many stops times tens of /// thousands of faces took gigabytes; past this the source path is drawn. -const MAX_PAINT_COPIES: usize = 256 << 20; +pub(crate) const MAX_PAINT_COPIES: usize = 256 << 20; /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 52739983a5..bd1e6e34ae 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; -use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2}; +use vectorcraft_pathops::BoolOp; +use vectorcraft_three_d::{Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -678,7 +679,8 @@ fn built_solids<'a>( /// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's /// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of /// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its -/// strokes that covers the fill's whole edge. +/// strokes that covers the fill's whole edge. A face that [`convex_face`] takes also keeps its +/// polygon and plane, for [`local_order`]; every other face has an empty `outline` and no `plane`. struct Placed { depth: f64, side: bool, @@ -686,6 +688,8 @@ struct Placed { bounds: Rect, member: usize, paints: Paints, + outline: Vec, + plane: Option, } /// The part of its shape a face's solid is built from ([`Placed`]). @@ -694,13 +698,22 @@ enum Paints { Fill, /// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without /// dashes, a width profile or a brush, painted with a solid color or a gradient without - /// transparent stops, on a shape whose subpaths are all closed. + /// transparent stops, on a shape whose subpaths are all closed, extruded (an inflated fill can + /// rise in front of its stroke). Stroke, Other, } +/// Can a stroke's solid on a shape with outline `path` hold the fill's edge ([`Paints::Stroke`]) +/// under the effect `effect`? Only when the subpaths are all closed and the effect extrudes: an +/// inflated fill can rise in front of its stroke. +fn holds_edge(effect: &str, path: &PathData) -> bool { + effect != INFLATE && path.subpaths.iter().all(|p| p.closed) +} + impl Paints { - /// What `item` of a shape is, the shape's subpaths all closed when `closed`. + /// What `item` of a shape is, its stroke's solid able to hold the fill's edge when `closed` + /// ([`holds_edge`]). fn of(item: &AppearanceItem, closed: bool) -> Self { match item { AppearanceItem::Fill(_) => Paints::Fill, @@ -729,10 +742,434 @@ impl Placed { fn of(f: &Facet, member: usize, paints: Paints) -> Self { let mut points = f.contours.iter().flatten(); let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q))); - Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints } + let local = convex_face(f).is_some(); + let outline = f.contours.first().filter(|_| local).cloned().unwrap_or_default(); + let plane = f.plane.filter(|_| local); + Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints, outline, plane } } } +/// The most pairs of faces [`local_order`] compares, and the most [`cut_stroke_faces`] compares; +/// the pairs they have not reached by then keep the order of their depths. +const MAX_LOCAL_CHECKS: usize = 4_000_000; + +/// The polygon of `f` and the sign of its turns, when `f` is a face turned toward the viewer other +/// than a planar end, with a plane and one convex contour: a face [`local_order`] and +/// [`cut_stroke_faces`] work on. +fn convex_face(f: &Facet) -> Option<(&[Point], f64)> { + if f.away || f.end.is_some() || f.plane.is_none() || f.contours.len() != 1 { + return None; + } + let c = f.contours.first()?; + Some((c, convex(c)?)) +} + +/// The bounds of polygon `p` (`Rect::ZERO` when it has no points). +fn polygon_bounds(p: &[Point]) -> Rect { + let mut points = p.iter(); + points.next().map_or(Rect::ZERO, |q| points.fold(Rect::from_points(*q, *q), |r, q| r.union_pt(*q))) +} + +/// Do rectangles `a` and `b` overlap (more than along an edge)? +fn rects_overlap(a: Rect, b: Rect) -> bool { + a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 +} + +/// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces +/// and whose paint copies take `paint_bytes` so far: as many as keep the faces within +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies +/// of the costliest stroke's paint. +fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { + let per_face = items + .filter(|item| matches!(item, AppearanceItem::Stroke(_))) + .map(|item| { + let mut copies = 0usize; + crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + copies + }) + .max() + .unwrap_or(0) + .max(1); + let affordable = crate::revolve::MAX_PAINT_COPIES.saturating_sub(paint_bytes) / per_face; + MAX_OBJECT_FACES.saturating_sub(faces).min(affordable) +} + +/// In `solids`, each solid's part ([`Paints`]), its shape's index and its faces, cut each face of a +/// stroke that does not cover the fill's edge ([`Paints::Other`]) that passes through the plane of a +/// face of the same shape's fill, or of a face of another of the shape's strokes, where the two +/// overlap on the drawing ([`cut_through`]). Of two such strokes, the later one's faces are cut +/// against the earlier one's; a covering stroke's faces ([`Paints::Stroke`]) are never cut, and an +/// other stroke's faces are cut against them. At most `room` pieces are added, the object's solids +/// together. +fn cut_stroke_faces(solids: &mut [(Paints, usize, &mut Vec)], mut room: usize) { + let mut checks = 0usize; + for k in 0..solids.len() { + let Some((Paints::Other, shape)) = solids.get(k).map(|(p, s, _)| (*p, *s)) else { continue }; + let walls: Vec = (0..solids.len()) + .filter(|&j| { + solids.get(j).is_some_and(|(p, s, _)| { + *s == shape + && match p { + Paints::Fill | Paints::Stroke => true, + Paints::Other => j < k, + } + }) + }) + .collect(); + for j in walls { + let (wall, stroke) = if j < k { + let (before, from) = solids.split_at_mut(k); + (&**before[j].2, &mut *from[0].2) + } else { + let (before, from) = solids.split_at_mut(j); + (&**from[0].2, &mut *before[k].2) + }; + cut_through(wall, stroke, &mut room, &mut checks); + } + } +} + +/// Cut each face of `stroke` that passes through the plane of a face of `solid` (the fill's or +/// another stroke's) where their polygons overlap on the drawing, along the line where their depths +/// meet, so that each piece is wholly in front of or behind that face there ([`local_order`] can +/// then order them). A dash's end, which crosses the edge of the fill it is drawn on, is cut into its +/// part outside the fill, in front of the fill's wall, and its part inside, behind it. A piece keeps +/// the part of the face's curved outline on its side of the cut. The first piece of a face takes its +/// place, the others go at the end. Each cut adds a face out of `room`, and each pair compared counts +/// in `checks` ([`MAX_LOCAL_CHECKS`]); cutting stops when either runs out. +fn cut_through(solid: &[Facet], stroke: &mut Vec, room: &mut usize, checks: &mut usize) { + // The faces from left to right: a face is compared with those that start left of its end. + let mut walls: Vec<(&Facet, &[Point], f64, Rect)> = + solid.iter().filter_map(|f| convex_face(f).map(|(c, turn)| (f, c, turn, polygon_bounds(c)))).collect(); + if walls.is_empty() { + return; + } + walls.sort_by(|a, b| a.3.x0.total_cmp(&b.3.x0)); + let mut k = 0; + while k < stroke.len() { + let mut extent = stroke.get(k).and_then(convex_face).map(|(c, _)| polygon_bounds(c)); + for &(wall, outline, turn, bounds) in &walls { + let Some(at) = extent.filter(|e| bounds.x0 < e.x1) else { break }; + if *checks >= MAX_LOCAL_CHECKS || *room == 0 { + return; + } + *checks += 1; + if !rects_overlap(at, bounds) { + continue; + } + let Some(s) = stroke.get(k) else { break }; + let Some((c, _)) = convex_face(s) else { break }; + if let Some((near, far)) = cut(s, c, wall, outline, turn) { + *room -= 1; + extent = convex_face(&near).map(|(c, _)| polygon_bounds(c)); + stroke[k] = near; + stroke.push(far); + } + } + k += 1; + } +} + +/// Face `s` (polygon `c`) cut where its depth meets that of face `wall` (convex polygon `outline`, +/// turning with sign `turn`): its piece nearer the viewer than the wall's plane and its piece +/// farther. `None` when the two do not overlap on the drawing or `s` is on one side of the wall's +/// plane wherever they do. +fn cut(s: &Facet, c: &[Point], wall: &Facet, outline: &[Point], turn: f64) -> Option<(Facet, Facet)> { + let ahead = |p: Point| Some(s.depth_at(p)? - wall.depth_at(p)?); + let overlap = clip_convex(c, outline, turn); + centroid(&overlap)?; + let tolerance = 1e-6; + let (mut nearer, mut farther) = (false, false); + for p in &overlap { + let d = ahead(*p)?; + nearer |= d > tolerance; + farther |= d < -tolerance; + } + if !(nearer && farther) { + return None; + } + let (a, b) = (s.plane?, wall.plane?); + let values: Vec = match (a.eye, b.eye) { + (Some(e), Some(f)) if e == f && a.center == b.center => { + // With perspective the depths themselves are not linear on the drawing, but where they + // meet is a line: (dA - aA e)(nzB - aB) = (dB - aB e)(nzA - aA), with aX = (nX · p) / e, + // whose difference is linear. Its sign is that of the depths' difference over the + // product of the two denominators, taken at the middle of the face. + let at = |p: Point| { + let (x, y) = (p.x - a.center.x, p.y - a.center.y); + let (aa, ab) = ((a.normal[0] * x + a.normal[1] * y) / e, (b.normal[0] * x + b.normal[1] * y) / e); + let (da, db) = (a.normal[2] - aa, b.normal[2] - ab); + ((a.offset - aa * e) * db - (b.offset - ab * e) * da, da * db) + }; + let sign = at(centroid(c)?).1.signum(); + c.iter().map(|p| at(*p).0 * sign).collect() + } + _ => c.iter().map(|p| ahead(*p)).collect::>()?, + }; + let piece = |sign: f64| { + let points = half(c, &values, sign); + let mid = centroid(&points)?; + let mut f = s.clone(); + f.depth = s.depth_at(mid).unwrap_or(s.depth); + f.contours = vec![points]; + f.outline = s.outline.as_ref().and_then(|o| clip_to_side(o, c, &values, sign)); + Some(f) + }; + Some((piece(1.0)?, piece(-1.0)?)) +} + +/// Curved outline `outline` of a face with polygon `c`, cut along the line where `values` (one per +/// point of `c`, varying linearly along each edge) is 0, keeping the side where `sign` times them is +/// at least 0. `None` when the line does not cross `c` at two points or nothing is left. +fn clip_to_side(outline: &PathData, c: &[Point], values: &[f64], sign: f64) -> Option { + let mut crossings = vec![]; + for (j, (&a, &va)) in c.iter().zip(values).enumerate() { + let k = (j + 1) % c.len(); + let (Some(&b), Some(&vb)) = (c.get(k), values.get(k)) else { continue }; + if (va >= 0.0) != (vb >= 0.0) { + crossings.push(a.lerp(b, va / (va - vb))); + } + } + let &[q0, q1] = crossings.as_slice() else { return None }; + let along = q1 - q0; + let length = along.hypot(); + if length.is_nan() || length <= 1e-9 { + return None; + } + let along = along / length; + let (inside, _) = c.iter().zip(values).find(|(_, v)| **v * sign > 0.0)?; + let mut across = Vec2::new(-along.y, along.x); + if (*inside - q0).dot(across) < 0.0 { + across = -across; + } + let bounds = outline.bounds()?.union(polygon_bounds(c)); + let reach = 2.0 * (bounds.width() + bounds.height() + (q0 - bounds.center()).hypot()) + 1.0; + let mut side = BezPath::new(); + side.move_to(q0 - along * reach); + side.line_to(q0 + along * reach); + side.line_to(q0 + along * reach + across * reach); + side.line_to(q0 - along * reach + across * reach); + side.close_path(); + let kept = vectorcraft_pathops::boolean(outline, FillRule::NonZero, &PathData::from_bezpath(&side), FillRule::NonZero, BoolOp::Intersect); + kept.bounds().is_some().then_some(kept) +} + +/// The part of polygon `p` where `sign` times `values` (one per point, varying linearly along each +/// edge) is at least 0. +fn half(p: &[Point], values: &[f64], sign: f64) -> Vec { + let mut out = vec![]; + for (j, (&a, &va)) in p.iter().zip(values).enumerate() { + let k = (j + 1) % p.len(); + let (Some(&b), Some(&vb)) = (p.get(k), values.get(k)) else { continue }; + let (sa, sb) = (va * sign, vb * sign); + if sa >= 0.0 { + out.push(a); + } + if (sa >= 0.0) != (sb >= 0.0) { + out.push(a.lerp(b, sa / (sa - sb))); + } + } + out +} + +/// `order` (back to front), when it draws a face of one of a shape's strokes and an overlapping face +/// of the same shape's fill or strokes the wrong way round for their depths where they overlap on +/// the drawing, with every pair of overlapping faces that keep a polygon and a plane +/// ([`convex_face`]: turned toward the viewer, not a planar end), of any shape, put in the order of +/// their depths where they overlap: the face nearer the viewer there goes after the other. A fill +/// wall and a face of the same shape's covering stroke ([`Paints::Stroke`]) keep their order. Each +/// face otherwise keeps its place in `order`, so a fill wall in the gap between two dashes stays +/// where its depth puts it. A cycle of such pairs is broken at the face that comes first in `order`. +/// Otherwise `order` as it is. +fn local_order(faces: &[Placed], order: Vec) -> Vec { + use std::cmp::Reverse; + // The faces of each shape that take part, from left to right for a sweep along x. + let mut shapes: Vec> = vec![]; + for (k, f) in faces.iter().enumerate() { + if f.outline.len() < 3 || f.plane.is_none() { + continue; + } + if shapes.len() <= f.member { + shapes.resize_with(f.member + 1, Vec::new); + } + shapes[f.member].push(k); + } + if !shapes.iter().any(|list| list.iter().any(|&k| faces[k].paints != Paints::Fill)) { + return order; + } + for list in &mut shapes { + list.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0)); + } + // A fill wall and a face of the same shape's covering stroke keep the order [`draw_order`] gave + // them: the stroke's solid holds the wall, which never shows. + let covered = |f: &Placed, s: &Placed| f.paints == Paints::Fill && f.side && s.paints == Paints::Stroke && f.member == s.member; + // Is there a face of a shape's strokes and a face of the same shape's fill or strokes that the + // order of their depths draws the wrong way round where they overlap? + let mut position = vec![0usize; faces.len()]; + for (at, &k) in order.iter().enumerate() { + position[k] = at; + } + let mut checks = 0usize; + let mut misordered = false; + 'shapes: for list in &shapes { + for (i, &f) in list.iter().enumerate() { + let a = &faces[f]; + for &s in list.iter().skip(i + 1).take_while(|&&s| faces[s].bounds.x0 < a.bounds.x1) { + if checks >= MAX_LOCAL_CHECKS { + break 'shapes; + } + checks += 1; + let b = &faces[s]; + if (a.paints == Paints::Fill && b.paints == Paints::Fill) || covered(a, b) || covered(b, a) || !rects_overlap(a.bounds, b.bounds) { + continue; + } + if nearer_where_overlapping(a, b).is_some_and(|a_nearer| a_nearer != (position[f] > position[s])) { + misordered = true; + break 'shapes; + } + } + } + } + if !misordered { + return order; + } + // Moving those faces must not carry them past any other face they overlap, so every pair of + // faces with a polygon and a plane that overlap goes in the order of their depths there. + let mut sweep: Vec = (0..faces.len()).filter(|&k| faces[k].outline.len() >= 3 && faces[k].plane.is_some()).collect(); + sweep.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0)); + // For each face, the faces drawn after it, and for each, how many go before it. + let mut after: Vec> = vec![vec![]; faces.len()]; + let mut waits = vec![0usize; faces.len()]; + 'pairs: for (i, &k) in sweep.iter().enumerate() { + let a = &faces[k]; + for &j in sweep.iter().skip(i + 1).take_while(|&&j| faces[j].bounds.x0 < a.bounds.x1) { + if checks >= MAX_LOCAL_CHECKS { + break 'pairs; + } + checks += 1; + let b = &faces[j]; + if !rects_overlap(a.bounds, b.bounds) { + continue; + } + let a_later = if covered(a, b) || covered(b, a) { + position[k] > position[j] + } else { + let Some(a_nearer) = nearer_where_overlapping(a, b) else { continue }; + a_nearer + }; + let (back, front) = if a_later { (j, k) } else { (k, j) }; + after[back].push(front); + waits[front] += 1; + } + } + // Each step draws the face that comes first in `order` of those whose faces before are drawn. + let mut ready: std::collections::BinaryHeap> = + (0..faces.len()).filter(|&k| waits[k] == 0).map(|k| Reverse((position[k], k))).collect(); + let mut drawn = vec![false; faces.len()]; + let mut out = Vec::with_capacity(order.len()); + let mut first = 0usize; + while out.len() < order.len() { + let k = match ready.pop() { + Some(Reverse((_, k))) => k, + None => { + // A cycle: the first face in `order` not drawn yet goes next. + while order.get(first).is_some_and(|&k| drawn[k]) { + first += 1; + } + let Some(&k) = order.get(first) else { break }; + k + } + }; + if drawn[k] { + continue; + } + drawn[k] = true; + out.push(k); + for &j in &after[k] { + waits[j] = waits[j].saturating_sub(1); + if waits[j] == 0 && !drawn[j] { + ready.push(Reverse((position[j], j))); + } + } + } + out +} + +/// Whether face `a` is nearer the viewer than face `b` where their polygons overlap on the drawing, +/// measured at the middle of the overlap. `None` when they do not overlap, when either polygon is +/// not convex, or when they are at the same depth there. +fn nearer_where_overlapping(a: &Placed, b: &Placed) -> Option { + let (pa, pb) = (a.plane?, b.plane?); + let turn = convex(&b.outline)?; + convex(&a.outline)?; + let overlap = clip_convex(&a.outline, &b.outline, turn); + let mid = centroid(&overlap)?; + let (da, db) = (pa.depth_at(mid)?, pb.depth_at(mid)?); + let tolerance = 1e-6 * (1.0 + da.abs().max(db.abs())); + ((da - db).abs() > tolerance).then_some(da > db) +} + +/// The sign of the turns of convex polygon `p` (`None` when it is not convex or has no area). +fn convex(p: &[Point]) -> Option { + let n = p.len(); + let mut sign = 0.0; + for i in 0..n { + let (a, b, c) = (p[i], p[(i + 1) % n], p[(i + 2) % n]); + let cross = (b - a).cross(c - b); + if cross.abs() <= 1e-12 { + continue; + } + if sign == 0.0 { + sign = cross.signum(); + } else if cross.signum() != sign { + return None; + } + } + (sign != 0.0).then_some(sign) +} + +/// The part of polygon `subject` inside convex polygon `clip`, whose turns have sign `turn` +/// (Sutherland–Hodgman). +fn clip_convex(subject: &[Point], clip: &[Point], turn: f64) -> Vec { + let mut out = subject.to_vec(); + for i in 0..clip.len() { + if out.is_empty() { + break; + } + let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); + let side = |p: Point| (e1 - e0).cross(p - e0) * turn; + let input = std::mem::take(&mut out); + for j in 0..input.len() { + let (p, q) = (input[j], input[(j + 1) % input.len()]); + let (sp, sq) = (side(p), side(q)); + if sp >= 0.0 { + out.push(p); + } + if (sp >= 0.0) != (sq >= 0.0) { + let t = sp / (sp - sq); + out.push(p.lerp(q, t)); + } + } + } + out +} + +/// The centroid of polygon `p`, `None` when it has (almost) no area. It is measured from the +/// polygon's first point, so a small polygon far from the origin keeps its area. +fn centroid(p: &[Point]) -> Option { + let origin = *p.first()?; + let n = p.len(); + let (mut area, mut cx, mut cy) = (0.0, 0.0, 0.0); + for i in 0..n { + let (a, b) = (p[i] - origin, p[(i + 1) % n] - origin); + let cross = a.x * b.y - b.x * a.y; + area += cross; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + } + (area.abs() > 1e-9).then(|| origin + Vec2::new(cx / (3.0 * area), cy / (3.0 * area))) +} + /// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep /// their depths. const MAX_OVERLAP_CHECKS: usize = 4_000_000; @@ -742,7 +1179,8 @@ const MAX_OVERLAP_CHECKS: usize = 4_000_000; /// of a shape's fill turned toward the viewer is drawn before each face of the same shape's /// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own. /// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under -/// any other face. +/// any other face. A shape's fill faces and the faces of its other strokes then go in the order of +/// their depths where they overlap ([`local_order`]). fn draw_order(faces: &[Placed], sealed: bool) -> Vec { // Each face's depth to sort by, and whether it was moved before a stroke face at that depth. let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect(); @@ -781,7 +1219,7 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec { // A stable sort: faces at the same depth keep the order of the appearance, and a fill's side // face moved to a stroke face's depth goes before it. order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1))); - order + local_order(faces, order) } /// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` @@ -806,7 +1244,9 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// each in its own paint. When every one is opaque with normal blending and no colored light makes /// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as /// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's -/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the +/// fill walls before the faces of its covering strokes that overlap them, and its fill's faces and +/// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the +/// order of their depths where they overlap: [`draw_order`]), the /// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask /// ([`in_runs`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and @@ -995,18 +1435,22 @@ type BuiltSurface<'s> = (Vec, Node, &'s [Scope], Vec> /// [`with_graphics`] of built surfaces, each drawn whole. fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { - with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), arts) + with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), 0, arts) } -/// `surfaces` drawn whole, each with its scopes, and the graphics `arts` ([`placed_graphics`]) each -/// right after its surface, with that surface's scopes and its opacity mask (a gradient's -/// transparency), so a graphic is hidden where its cap is. -fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { +/// `surfaces` (those from index `first`) drawn whole, each with its scopes, and the graphics `arts` +/// ([`placed_graphics`]) of those surfaces each right after its surface, with that surface's scopes +/// and its opacity mask (a gradient's transparency), so a graphic is hidden where its cap is. +fn with_graphics<'s>( + surfaces: Vec<(&'s [Scope], Arc)>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], +) -> Vec<(&'s [Scope], Arc)> { let mut out = Vec::with_capacity(surfaces.len() + arts.len()); for (k, (scopes, s)) in surfaces.into_iter().enumerate() { let mask = s.extras().mask.clone(); out.push((scopes, s)); - out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| { + out.extend(arts.iter().filter(|(at, ..)| *at == first + k).map(|(.., art)| { let mut art = art.clone(); if let Some(mask) = &mask { Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); @@ -1058,7 +1502,7 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile } } let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks); - let out = with_graphics(out, &arts); + let out = with_graphics(out, 0, &arts); return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); @@ -1085,6 +1529,43 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); + let closed = |shape: usize| shapes.get(shape).is_some_and(|s| holds_edge(&fx.id, &s.path)); + // A solid whose surface is one composite of all its facets ([`surface`]: a solid color under + // colored lights with smooth shading) has no faces to order. + let composite = |item: &AppearanceItem, f: &[Facet]| colored && paint_ref(item).color().is_some() && f.iter().any(|f| f.rgb_gradient.is_some()); + // The faces are drawn in one order (below) unless a composite draws the surfaces whole. Under + // a member's opacity mask or blending, each member's faces go in one order of their own when its + // fills and strokes are opaque, blend normally, carry no opacity mask and are not composites. + let one_order = opaque && !solids.iter().any(|(item, _, _, f, _)| composite(item, f)); + let shape_order: Vec = (0..shapes.len()) + .map(|shape| { + solids.iter().filter(|s| s.4 == shape).all(|(item, member, _, f, _)| { + let (paint, opacity, blend) = paint_of(item, *member); + opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && !composite(item, f) + }) + }) + .collect(); + let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order }; + if solids.iter().any(|s| ordered(s.4)) { + // A stroke face that passes through a fill wall or another stroke's face is cut where it + // does. The pieces count against the limits as the faces did: cutting stops before the + // faces pass MAX_OBJECT_FACES or their paint copies pass the allowance, so an effect that + // draws uncut still draws. + let counts: Vec = solids.iter().map(|(.., f, _)| f.len()).collect(); + let room = cut_room(solids.iter().map(|(item, ..)| *item), counts.iter().sum(), paint_bytes); + let mut cuts: Vec<(Paints, usize, &mut Vec)> = solids + .iter_mut() + .filter(|(.., shape)| ordered(*shape)) + .map(|(item, _, _, f, shape)| (Paints::of(item, closed(*shape)), *shape, f)) + .collect(); + cut_stroke_faces(&mut cuts, room); + for ((item, _, _, f, _), before) in solids.iter().zip(counts) { + let added = f.get(before..).unwrap_or_default(); + if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(added, paint_ref(item), colored), paint_ref(item)) { + return None; + } + } + } let arts = if !graphics_on { vec![] } else { @@ -1100,11 +1581,11 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile .collect(); placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) }; + let members: Vec = solids.iter().map(|s| s.4).collect(); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { - let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed)); - let paints = Paints::of(item, closed); + let paints = Paints::of(item, closed(shape)); let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect(); let (paint, opacity, blend) = paint_of(item, member); @@ -1112,50 +1593,86 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile (depths, art, scopes, ends) }) .collect(); - if masks { - return Some(masked(n.id, whole_surfaces(surfaces, &arts))); - } - // One order takes a plain group of faces from each solid, one face for each facet. With - // colored lights a surface can instead be one clipped composite of all its facets. A composite - // has no faces to order, so when any surface is one, every surface of the solid, masked ones - // included, draws whole in member order. - let plain = - surfaces.iter().all(|(depths, s, _, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - // A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs - // ([`in_runs`]), and each run carries the mask. With graphics as well, the surfaces draw whole, - // each graphic right after its surface. - let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); - let masked_faces = masks.iter().any(Option::is_some); - if !opaque || !plain || (masked_faces && !arts.is_empty()) { - return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); - } - let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); - for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { - if let NodeKind::Group { children, .. } = s.kind { - placed.extend(depths); - faces.extend(children.into_iter().map(|face| Some((surface, face)))); - ends.extend(e); - } - } let sealed = match fx.id.as_str() { INFLATE => inflate_options(&p).cap, CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, _ => extrude_options(&p).cap, }; + // One order takes a plain group of faces from each solid, one face for each facet. With + // colored lights a surface can instead be one clipped composite of all its facets. A composite + // has no faces to order, so it draws whole. + let plain = |(depths, s, _, _): &BuiltSurface| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len()); + if masks { + // Each member's surfaces, in the scopes of its opacity mask or blending: its faces in one + // order when it is opaque and plain, otherwise each surface whole. + let mut out = vec![]; + let mut surfaces = surfaces.into_iter().zip(members).enumerate().peekable(); + while let Some((first, (s, member))) = surfaces.next() { + let mut run = vec![s]; + while let Some((_, (s, _))) = surfaces.next_if(|(_, (_, m))| *m == member) { + run.push(s); + } + let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + if ordered(member) && flat { + let scopes = run.first().map_or(&[][..], |s| s.2); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + } else { + out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + } + } + return Some(masked(n.id, out)); + } + if !opaque || !surfaces.iter().all(plain) { + return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); + } + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) +} + +/// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in +/// one order ([`draw_order`], `sealed` for solids with caps), each of the graphics `arts` of those +/// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops +/// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of +/// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. +fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { + let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); + let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); + let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); + for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { + if let NodeKind::Group { children, .. } = s.kind { + placed.extend(depths); + faces.extend(children.into_iter().map(|face| Some((surface, face)))); + ends.extend(e.into_iter().map(|end| end.map(|end| (surface, end)))); + } + } let order = draw_order(&placed, sealed); - if masked_faces { - let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); - return Some(vec![Arc::new(Node::group(n.id, in_runs(n.id, faces, &masks)))]); - } - // Each face's graphics go right after the last of the faces they are drawn on. - let last: Vec> = - arts.iter().map(|(_, face, _)| order.iter().rposition(|k| ends.get(*k).copied().flatten() == Some(*face))).collect(); - let mut children = Vec::with_capacity(order.len() + arts.len()); + // Each graphic goes right after the last of the faces it is drawn on, among the surfaces that + // share it ([`placed_graphics`]): a masked surface's own, or else the run of surfaces without a + // mask that ends at its surface. + let last: Vec> = arts + .iter() + .map(|(at, face, _)| { + let at = at - first; + let from = if masks.get(at).is_some_and(Option::is_some) { + at + } else { + masks.get(..at).and_then(|before| before.iter().rposition(Option::is_some)).map_or(0, |k| k + 1) + }; + order.iter().rposition(|k| ends.get(*k).copied().flatten().is_some_and(|(surface, end)| end == *face && (from..=at).contains(&surface))) + }) + .collect(); + let mut drawn: Vec<(usize, Arc)> = Vec::with_capacity(order.len() + arts.len()); for (at, k) in order.into_iter().enumerate() { - children.extend(faces.get_mut(k).and_then(Option::take).map(|(_, face)| face)); - children.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (.., art))| art.clone())); + drawn.extend(faces.get_mut(k).and_then(Option::take)); + drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (surface, .., art))| { + let mut art = art.clone(); + if let Some(mask) = masks.get(surface - first).cloned().flatten() { + Arc::make_mut(&mut art).extras_mut().mask = Some(mask); + } + // An index past the surfaces: the graphic goes in a run of its own, with its own mask. + (masks.len(), art) + })); } - Some(vec![Arc::new(Node::group(n.id, children))]) + if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index b4e012f977..3c2795dd1b 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -253,6 +253,437 @@ fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() { } } +/// The faces of the solids effect `id` with `params` builds for `n`, in the order they are drawn, +/// as [`surfaces_for`] orders them, each with the index of the part ([`solid_parts`]) it was built +/// from and whether it is a stroke's. +fn drawn_parts(n: &Node, id: &str, params: &Value) -> Vec<(Facet, usize, bool)> { + let (path, rule) = profile(n).unwrap(); + let shapes = [Profile { path, rule, items: solid_paints(n), opacity: 1.0, scopes: vec![] }]; + let p = crate::merged_params(id, params); + let (parts, bounds) = solid_parts(&shapes, union_bounds(&shapes).unwrap()); + let mut solids = vec![]; + for (part, ((item, _, _, _, _, member), f)) in parts.iter().zip(built_solids(id, &p, &parts, bounds, MAX_OBJECT_FACES)).enumerate() { + let Some(f) = f.unwrap() else { continue }; + let closed = holds_edge(id, &shapes[*member].path); + solids.push((Paints::of(item, closed), *member, f, part, matches!(item, AppearanceItem::Stroke(_)))); + } + let mut cuts: Vec<(Paints, usize, &mut Vec)> = solids.iter_mut().map(|(paints, member, f, _, _)| (*paints, *member, f)).collect(); + cut_stroke_faces(&mut cuts, MAX_OBJECT_FACES); + let (mut placed, mut all) = (vec![], vec![]); + for (paints, member, f, part, stroke) in solids { + for facet in f { + placed.push(Placed::of(&facet, member, paints)); + all.push((facet, part, stroke)); + } + } + let sealed = match id { + INFLATE => inflate_options(&p).cap, + CLASSIC_EXTRUDE => classic_extrude_options(&p).cap, + _ => extrude_options(&p).cap, + }; + draw_order(&placed, sealed).into_iter().map(|k| all[k].clone()).collect() +} + +/// Is `p` inside the polygon of `f` (nonzero winding)? +fn facet_contains(f: &Facet, p: Point) -> bool { + let mut winding = 0i32; + for c in &f.contours { + for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { + let cross = (b.x - a.x) * (p.y - a.y) - (p.x - a.x) * (b.y - a.y); + if a.y <= p.y && b.y > p.y && cross > 0.0 { + winding += 1; + } else if a.y > p.y && b.y <= p.y && cross < 0.0 { + winding -= 1; + } + } + } + winding != 0 +} + +/// The points of a 1 pt grid over `faces` (drawn in that order, each with whether it is a stroke's) +/// where the face drawn on top is the fill's while a face of the stroke turned toward the viewer is +/// nearer the viewer there by more than 0.5 pt, or the other way round. Points within 0.3 pt of an +/// edge are left out. +fn wrong_on_top(faces: &[(Facet, bool)]) -> Vec { + let polygons: Vec<&Facet> = faces.iter().map(|(f, _)| f).collect(); + let extent = + polygons.iter().flat_map(|f| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); + let mut wrong = vec![]; + let mut y = extent.y0.floor() + 0.5; + while y < extent.y1 { + let mut x = extent.x0.floor() + 0.5; + while x < extent.x1 { + let p = Point::new(x, y); + let well_inside = |f: &Facet| { + [(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy))) + }; + let top = faces.iter().rposition(|(f, _)| facet_contains(f, p)); + if let Some((f, stroke)) = top.and_then(|k| faces.get(k)).filter(|(f, _)| !f.away && well_inside(f)) { + let nearest = |of_stroke: bool| { + faces.iter().filter(|(g, s)| *s == of_stroke && !g.away && well_inside(g)).filter_map(|(g, _)| g.depth_at(p)).reduce(f64::max) + }; + if let (Some(ds), Some(df)) = (nearest(true), nearest(false)) + && (ds - df).abs() > 0.5 + && (ds > df) != *stroke + && f.depth_at(p).is_some() + { + wrong.push(p); + } + } + x += 1.0; + } + y += 1.0; + } + wrong +} + +/// The points of a 1 pt grid over `faces` (drawn in that order) where the face drawn on top is +/// farther from the viewer than another face turned toward the viewer, of any part, by more than +/// 0.5 pt. Points within 0.3 pt of an edge, and faces without a plane, are left out. +fn misdrawn(faces: &[(Facet, usize, bool)]) -> Vec { + let extent = + faces.iter().flat_map(|(f, ..)| f.contours.iter().flatten()).fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); + let mut wrong = vec![]; + let mut y = extent.y0.floor() + 0.5; + while y < extent.y1 { + let mut x = extent.x0.floor() + 0.5; + while x < extent.x1 { + let p = Point::new(x, y); + let well_inside = |f: &Facet| { + [(-0.3, -0.3), (0.3, -0.3), (-0.3, 0.3), (0.3, 0.3), (0.0, 0.0)].iter().all(|(dx, dy)| facet_contains(f, Point::new(x + dx, y + dy))) + }; + let top = faces.iter().rposition(|(f, ..)| facet_contains(f, p)).and_then(|k| faces.get(k)).filter(|(f, ..)| !f.away && well_inside(f)); + if let Some(on_top) = top.and_then(|(f, ..)| f.depth_at(p)) { + let nearest = faces.iter().filter(|(g, ..)| !g.away && well_inside(g)).filter_map(|(g, ..)| g.depth_at(p)).reduce(f64::max); + if nearest.is_some_and(|d| d - on_top > 0.5) { + wrong.push(p); + } + } + x += 1.0; + } + y += 1.0; + } + wrong +} + +/// The faces of a shape's fill and strokes are drawn in the order of their depths where they +/// overlap: on a turned C, with or without perspective, under a stroke that does not cover the +/// fill's whole edge (dashed, with a width profile, or with a brush), under a plain stroke and +/// under two strokes whose faces pass through each other, no point shows a face farther from the +/// viewer than another, in Extrude & Bevel and Extrude & Bevel (Classic), and only a few in +/// Inflate, where curved puffs cross. +#[test] +fn fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap() { + use vectorcraft_doc::{Dash, WidthProfile}; + let views = [ + json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), + json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10}), + json!({"rotationX": -30, "rotationY": 50, "rotationZ": 5, "perspectiveAngle": 40}), + json!({"rotationX": -30, "rotationY": 20, "rotationZ": 5, "perspectiveAngle": 40}), + ]; + type Style<'a> = (&'a str, &'a dyn Fn(&mut Node)); + let first = |n: &mut Node, set: &dyn Fn(&mut StrokeLayer)| { + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + set(s); + } + }; + let dash = |pattern: Vec, offset: f64| Some(Dash { pattern, offset, align_corners: false }); + let styles: [Style; 6] = [ + ("plain", &|_| {}), + ("dashed", &|n| first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0))), + ("profiled", &|n| first(n, &|s| s.profile = Some(WidthProfile::lens()))), + ("brushed", &|n| first(n, &|s| s.brush = Some("brush".into()))), + ("dashed with perspective", &|n| first(n, &|s| s.dash = dash(vec![20.0, 6.0], 3.0))), + ("two strokes", &|n| { + first(n, &|s| s.dash = dash(vec![12.0, 12.0], 0.0)); + let mut wide = StrokeLayer::new(Paint::solid(RED), 14.0); + wide.profile = Some(WidthProfile::lens()); + n.appearance.items.push(AppearanceItem::Stroke(wide)); + }), + ]; + for id in [EXTRUDE, CLASSIC_EXTRUDE, INFLATE] { + for (v, view) in views.iter().enumerate() { + for (style, set) in &styles { + // The last two views, for the plain and the two strokes only. + if v >= 2 && !["plain", "two strokes"].contains(style) { + continue; + } + let mut params = view.clone(); + params["depth"] = json!(60); + if style.contains("perspective") { + params["perspectiveAngle"] = json!(40); + } + let mut n = stroked(&letter_c()); + set(&mut n); + let parts = drawn_parts(&n, id, ¶ms); + let faces: Vec<(Facet, bool)> = parts.iter().map(|(f, _, s)| (f.clone(), *s)).collect(); + assert!(faces.iter().any(|(_, s)| *s) && faces.iter().any(|(_, s)| !*s), "{id} {view} {style}: faces of both"); + // Inflate's curved puffs cross each other along curves its flat facets only follow + // roughly: a few points next to those crossings may still show the farther face. + let allowed = if id == INFLATE { 5 } else { 0 }; + let wrong = wrong_on_top(&faces); + assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points drawn in the wrong paint, first {:?}", wrong.len(), wrong.first()); + let wrong = misdrawn(&parts); + assert!(wrong.len() <= allowed, "{id} {view} {style}: {} points show a farther face, first {:?}", wrong.len(), wrong.first()); + } + } + } +} + +/// The paths of `art`'s faces in drawing order, leaving out the graphics (clip groups). +fn face_paths(art: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Group { clip: true, .. } => {} + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)), + _ => out.extend(n.path_data().cloned()), + } + } + let mut out = vec![]; + walk(art, &mut out); + out +} + +/// A member of a 3D group with an opacity mask or a blending mode of its own draws its fill's and +/// its strokes' faces in one order inside that mask, the order the same member draws them in +/// without it, its dashes cut where they pass through the fill's walls. A member whose stroke is +/// translucent still draws each surface whole. +#[test] +fn a_masked_members_fill_and_stroke_faces_go_in_one_order() { + let mut member = stroked(&letter_c()); + if let Some(AppearanceItem::Stroke(s)) = member.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + let solid = |member: Node| { + let mut g = Node::group(NodeId(2), vec![Arc::new(member)]); + g.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}))); + solid_art(&g).unwrap() + }; + let cover = Node::path( + NodeId(900), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 400.0, 400.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0), + ); + let mut masked = member.clone(); + masked.extras_mut().mask = Some(Box::new(OpacityMask::new(cover, true))); + let mut multiplied = member.clone(); + multiplied.blend = BlendMode::Multiply; + let unmasked = face_paths(&solid(member.clone())); + for (what, scoped) in [("masked", masked.clone()), ("multiplied", multiplied)] { + let art = solid(scoped); + let mut scopes = 0; + art.walk(&mut |n| scopes += usize::from(n.extras().mask.is_some() || n.blend == BlendMode::Multiply)); + assert!(scopes > 0, "{what}: drawn in its scope"); + assert_eq!(face_paths(&art), unmasked, "{what}: the faces in the member's one order"); + } + // A translucent stroke keeps its surface whole: the fill's faces, then the stroke's. + if let Some(AppearanceItem::Stroke(s)) = masked.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) { + s.opacity = 0.5; + } + let art = solid(masked); + let mut groups = vec![]; + art.walk(&mut |n| { + if n.opacity == 0.5 { + groups.push(n.children().map_or(0, Vec::len)); + } + }); + assert_eq!(groups.len(), 1, "the stroke's surface is one translucent group"); +} + +/// Under a light that is not white, Inflate in gradients, whose faces are drawn one by one, draws the +/// fill's and the dashed stroke's faces in one order, as it does under a white light. A solid color, +/// drawn as one composite of each surface's facets, still draws each surface whole. +#[test] +fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + // How many surfaces the art draws, and how many faces the first holds. + let drawn = |n: &Node, lights: Option| { + let mut n = n.clone(); + let mut p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + if let Some(lights) = lights { + p["lights"] = lights; + } + n.appearance.effects.push(fx(INFLATE, p)); + let art = solid_art(&n).unwrap(); + let surfaces = art.children().unwrap(); + (surfaces.len(), surfaces[0].children().map_or(0, Vec::len)) + }; + let colored = json!([{"color": "#ff8080"}]); + let white = drawn(&n, None); + assert_eq!(white.0, 1, "one order"); + let cut = drawn_parts(&n, INFLATE, &json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60})).len(); + assert_eq!(white.1, cut, "the white light's faces, cut"); + assert_eq!(drawn(&n, Some(colored.clone())).0, 1, "one order under the colored light"); + // A solid color under the colored light: a composite for each of the fill and the stroke. + let solid = stroked(&letter_c()); + assert_eq!(drawn(&solid, Some(colored)).0, 2); +} + +/// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they +/// overlap is cut along the line where their depths meet, into the piece in front of the wall and +/// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke +/// and a cut past the room left are left whole. +#[test] +fn a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet() { + let facet = |x: f64, normal: [f64; 3], offset: f64| Facet { + contours: vec![vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]], + depth: 0.0, + brightness: 1.0, + illumination: [1.0; 3], + normal, + rgb_gradient: None, + outline: None, + side: true, + away: false, + end: None, + plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }), + }; + let wall = facet(0.0, [0.0, 0.0, 1.0], 0.0); + // z = x - 8: behind the wall left of x = 8, in front of it right of it. + let r = std::f64::consts::FRAC_1_SQRT_2; + let mut through = facet(5.0, [-r, 0.0, r], -8.0 * r); + // Its curved outline: the top edge bows up to y = -2. + let mut curved = BezPath::new(); + curved.move_to((5.0, 0.0)); + curved.curve_to((8.0, -8.0 / 3.0), (12.0, -8.0 / 3.0), (15.0, 0.0)); + curved.line_to((15.0, 10.0)); + curved.line_to((5.0, 10.0)); + curved.close_path(); + through.outline = Some(PathData::from_bezpath(&curved)); + let extent = |f: &Facet| polygon_bounds(&f.contours[0]); + let cut_with = |paints: Paints, member: usize, room: usize| { + let (mut fill, mut stroke) = (vec![wall.clone()], vec![through.clone()]); + let mut solids: Vec<(Paints, usize, &mut Vec)> = vec![(Paints::Fill, 0, &mut fill), (paints, member, &mut stroke)]; + cut_stroke_faces(&mut solids, room); + stroke + }; + let pieces = cut_with(Paints::Other, 0, 10); + assert_eq!(pieces.len(), 2); + let (near, far) = (extent(&pieces[0]), extent(&pieces[1])); + assert!((near.x0 - 8.0).abs() < 1e-9 && near.x1 == 15.0 && far.x0 == 5.0 && (far.x1 - 8.0).abs() < 1e-9, "{near:?} {far:?}"); + assert!(pieces[0].depth > 0.0 && pieces[1].depth < 0.0); + // Each piece keeps its side of the curved outline, the bowed edge included. + let curve = |f: &Facet| f.outline.as_ref().and_then(PathData::bounds).unwrap(); + let (near, far) = (curve(&pieces[0]), curve(&pieces[1])); + assert!((near.x0 - 8.0).abs() < 1e-6 && (near.x1 - 15.0).abs() < 1e-6 && near.y0 < -1.5, "{near:?}"); + assert!((far.x0 - 5.0).abs() < 1e-6 && (far.x1 - 8.0).abs() < 1e-6 && far.y0 < -1.5, "{far:?}"); + let whole = std::slice::from_ref(&through); + assert_eq!(cut_with(Paints::Other, 1, 10), whole, "another shape's stroke"); + assert_eq!(cut_with(Paints::Stroke, 0, 10), whole, "a covering stroke"); + assert_eq!(cut_with(Paints::Other, 0, 0), whole, "no room"); + let (mut fill, mut stroke) = (vec![wall.clone()], vec![facet(5.0, [0.0, 0.0, 1.0], 1.0)]); + cut_stroke_faces(&mut [(Paints::Fill, 0, &mut fill), (Paints::Other, 0, &mut stroke)], 10); + assert_eq!(stroke.len(), 1, "in front of the wall wherever they overlap"); + // Of two of a shape's strokes whose faces pass through each other, the later stroke's face is + // cut against the earlier one's; a covering stroke's face is never cut, and the other stroke's + // face is cut against it. Two shapes' strokes are left whole. + for (case, (first, second, cut_first)) in + [(Paints::Other, Paints::Other, false), (Paints::Stroke, Paints::Other, false), (Paints::Other, Paints::Stroke, true)].into_iter().enumerate() + { + let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]); + cut_stroke_faces(&mut [(first, 0, &mut a), (second, 0, &mut b)], 10); + assert_eq!((a.len(), b.len()), if cut_first { (2, 1) } else { (1, 2) }, "case {case}"); + } + let (mut a, mut b) = (vec![wall.clone()], vec![through.clone()]); + cut_stroke_faces(&mut [(Paints::Other, 0, &mut a), (Paints::Other, 1, &mut b)], 10); + assert_eq!((a.len(), b.len()), (1, 1), "two shapes"); +} + +/// A small polygon far from the origin keeps its area and centroid. +#[test] +fn a_small_polygon_far_away_has_a_centroid() { + let far = 100_000_000.0; + let square = [Point::new(far, far), Point::new(far + 1.0, far), Point::new(far + 1.0, far + 1.0), Point::new(far, far + 1.0)]; + let c = centroid(&square).unwrap(); + assert!((c.x - far - 0.5).abs() < 1e-6 && (c.y - far - 0.5).abs() < 1e-6, "{c:?}"); + assert!(centroid(&[]).is_none() && centroid(&square[..2]).is_none()); +} + +/// Cutting adds no more faces than the object's face limit and the paint-copy allowance leave room +/// for, so an effect that draws with its faces uncut still draws. +#[test] +fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { + let items = stroked(&letter_c()).appearance.items.clone(); + let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); + let mut one = 0usize; + crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + let max = crate::revolve::MAX_PAINT_COPIES; + assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); + assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); + assert_eq!(cut_room(items.iter(), 10, max - 3 * one), 3); + assert_eq!(cut_room(items.iter(), 10, max + 1), 0); +} + +/// Where a shape's fill wall and a face of its stroke that leaves gaps overlap, the one nearer the +/// viewer there is drawn after the other, whichever their depths put first; a stroke face that +/// overlaps no fill wall keeps its place. +#[test] +fn local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + let at = |depth: f64, x: f64, z: f64, paints: Paints| Placed { + depth, + side: true, + away: false, + bounds: Rect::new(x, 0.0, x + 10.0, 10.0), + member: 0, + paints, + outline: square(x), + plane: Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None }), + }; + // 0 a fill wall at z 0; 1 a stroke face over half of it, nearer, sorted before it; 2 a stroke + // face elsewhere; 3 a stroke face over it, farther, sorted after it. + let faces = + [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 1.0, Paints::Other), at(1.0, 20.0, 1.0, Paints::Other), at(6.0, 2.0, -1.0, Paints::Other)]; + assert_eq!(draw_order(&faces, true), [2, 3, 0, 1]); + assert_eq!(draw_order(&faces, false), [2, 3, 0, 1]); + // A covering stroke's faces keep the wall's own rule. + let covering: Vec = faces + .iter() + .map(|f| Placed { paints: if f.paints == Paints::Other { Paints::Stroke } else { f.paints }, outline: vec![], plane: None, ..*f }) + .collect(); + assert_eq!(draw_order(&covering, false), [2, 1, 0, 3]); + // Another shape's stroke, and a stroke face at the same depth where they overlap, move nothing. + let other_shape: Vec = + faces[..3].iter().map(|f| Placed { member: usize::from(f.paints == Paints::Other), outline: f.outline.clone(), ..*f }).collect(); + assert_eq!(draw_order(&other_shape, true), [2, 1, 0]); + let level = [at(5.0, 0.0, 0.0, Paints::Fill), at(2.0, 5.0, 0.0, Paints::Other)]; + assert_eq!(draw_order(&level, true), [1, 0]); + // A stroke face moved after its fill's wall does not pass a nearer face of another shape that + // it overlaps: 0 a stroke face, 1 another shape's face in front of both, 2 the fill's wall. + let in_front = Placed { member: 1, ..at(2.0, 5.0, 2.0, Paints::Fill) }; + let shapes = [at(1.0, 5.0, 1.0, Paints::Other), in_front, at(3.0, 0.0, 0.0, Paints::Fill)]; + assert_eq!(draw_order(&shapes, true), [2, 0, 1]); + // A shape with a covering stroke as well keeps its fill wall before the covering stroke's face, + // even where that face is the farther: 0 the fill's wall, 1 the covering stroke's face behind + // it, 2 the other stroke's face in front. + let both = [at(3.0, 0.0, 0.0, Paints::Fill), at(3.0, 0.0, -1.0, Paints::Stroke), at(2.0, 5.0, 1.0, Paints::Other)]; + assert_eq!(draw_order(&both, true), [0, 1, 2]); + // Faces of a shape's strokes go in the order of their depths where they overlap too, with no + // fill face among them: 1 is nearer than 0 there, though sorted before it. + for (case, (back, front)) in + [(Paints::Other, Paints::Other), (Paints::Stroke, Paints::Other), (Paints::Stroke, Paints::Stroke)].into_iter().enumerate() + { + let strokes = [at(5.0, 0.0, 0.0, back), at(2.0, 5.0, 1.0, front)]; + assert_eq!(draw_order(&strokes, true), [0, 1], "case {case}"); + } +} + /// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a /// face of the same shape's stroke drawn before it: the wall then goes just before that face. A /// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an @@ -266,6 +697,8 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { bounds: Rect::new(x, 0.0, x + 10.0, 10.0), member, paints, + outline: vec![], + plane: None, }; // 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke // wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a @@ -292,6 +725,9 @@ fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() { }; assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke); assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape"); + let closed = profile(&stroked(&letter_c())).unwrap().0; + assert!(holds_edge(EXTRUDE, &closed) && holds_edge(CLASSIC_EXTRUDE, &closed)); + assert!(!holds_edge(INFLATE, &closed), "an inflated fill can rise in front of its stroke"); assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width"); let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false }; assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes"); diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index aa73535fc5..bf219353cf 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -620,6 +620,110 @@ fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); } +/// The paths of `art`'s faces in drawing order (a face lit by a colored light is a clip group: its +/// clipping path), leaving out the graphics (clip groups of solid-color paths). +fn face_paths(art: &Node) -> Vec { + fn walk(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Group { children, clip: true } => { + let graphic = children.get(1).is_some_and(|inner| match &inner.kind { + NodeKind::Group { children: paths, clip: false } => paths.iter().all(|p| { + p.path_data().is_some() + && p.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint.color().is_some())) + }), + _ => false, + }); + if !graphic { + out.extend(children.first().and_then(|c| c.path_data()).cloned()); + } + } + NodeKind::Group { children, .. } => children.iter().for_each(|c| walk(c, out)), + _ => out.extend(n.path_data().cloned()), + } + } + let mut out = vec![]; + walk(art, &mut out); + out +} + +/// A turned solid in a gradient with transparent stops under a colored light, with a dashed stroke +/// and a graphic, draws its fill's and its stroke's faces in the one order it draws them in without +/// the graphic, and the graphic keeps the cap's mask and goes after the fill's cap, not the stroke's. +#[test] +fn a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); + let solid = |graphics: Value| { + let mut n = object( + EXTRUDE, + json!({"rotationX": -20.0, "rotationY": -40.0, "depth": 60.0, "shade": true, "lights": [{"color": "#ff8080"}], "graphics": graphics}), + ); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient.clone())); + } + // A gradient stroke: a solid color under the colored light would be one composite. + let blues = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.5))], + ); + let mut stroke = StrokeLayer::new(Paint::Gradient(Box::new(GradientPaint::new(blues))), 6.0); + stroke.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + n.appearance.items.push(AppearanceItem::Stroke(stroke)); + solid_art_in(Some(&d), &n).unwrap() + }; + let with = solid(json!([{"symbol": "Mark"}])); + let without = solid(json!([])); + assert!(face_paths(&without).len() > 20, "the faces are found"); + assert_eq!(face_paths(&with), face_paths(&without), "the faces in one order"); + // The graphic is drawn on the fill's cap and on the stroke's (the clip groups holding the + // symbol's red square); the fill's, whose surface carries the mask, carries it too. + let mut masked = vec![]; + with.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let [_, inner] = children.as_slice() + && let NodeKind::Group { children: paths, clip: false } = &inner.kind + && paths.iter().any(|p| { + p.appearance.items.iter().any(|i| match i { + AppearanceItem::Fill(f) => f.paint.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3), + AppearanceItem::Stroke(_) => false, + }) + }) + { + masked.push(c.extras().mask.is_some()); + } + }); + masked.sort_unstable(); + assert_eq!(masked, [false, true], "the fill's graphic carries the cap's mask"); + // The fill's graphic goes right after the fill's cap, so the dashes on the cap's edge, drawn + // after it, cover it. Drawn in order: the fill's graphic (the masked clip group), and each face, + // the fill's or the stroke's by its gradient's first stop (red or blue), with its bounds. + let mut drawn: Vec<(&str, Rect)> = vec![]; + with.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && c.extras().mask.is_some() + && let Some(bounds) = children.first().and_then(|clip| clip.path_data()).and_then(PathData::bounds) + { + drawn.push(("graphic", bounds)); + } + let first_stop = c.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_rgb()), + _ => None, + }, + AppearanceItem::Stroke(_) => None, + }); + if let (Some(rgb), Some(bounds)) = (first_stop, c.path_data().and_then(PathData::bounds)) { + drawn.push((if rgb[0] > rgb[2] { "fill" } else { "stroke" }, bounds)); + } + }); + let graphic = drawn.iter().position(|(kind, _)| *kind == "graphic").unwrap(); + let covering = |(kind, b): &(&str, Rect)| *kind == "stroke" && b.intersect(drawn[graphic].1).area() > 0.0; + assert!(drawn[graphic..].iter().any(covering), "a dash on the cap's edge is drawn over the fill's graphic"); +} + #[test] fn a_cap_with_a_hole_keeps_the_hole_out_of_the_graphic() { let d = document(); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 7b778498e6..bab5849c1a 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -9,8 +9,8 @@ mod solid; mod visibility; pub use solid::{ - Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, inflate_each, - inflate_together, plane, plane_face_map, + Bevel, BevelShape, DepthPlane, Extrude, FaceMap, Facet, Inflate, PlanarFace, TOO_COMPLEX_SOLID, View, extrude, extrude_face_map, inflate, + inflate_each, inflate_together, plane, plane_face_map, }; pub use visibility::{VisibleFace, visible_faces}; mod shading; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index f3da6f866f..49b0738d9e 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -292,12 +292,53 @@ pub struct Facet { /// The planar end of the solid the facet is: Extrude & Bevel's front or back cap, or a plane /// (its front). `None` for every other facet. pub end: Option, + /// The face's plane in view space and the projection it was drawn with, for its depth at a + /// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose + /// corners lie more than 0.001 pt off one plane (a piece of a twisted wall). + pub plane: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, } +/// A facet's plane in view space (x right, y down, z toward the viewer): the points `v` with +/// `normal · v = offset`, drawn by a projection about `center` with the eye `eye` in front of the +/// object's center plane (`None` without perspective). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DepthPlane { + pub normal: [f64; 3], + pub offset: f64, + pub center: Point, + pub eye: Option, +} + +impl DepthPlane { + /// The view-space depth (larger is nearer the viewer) of the point of the plane that projects + /// to `p`, a point of the drawing. `None` when the plane is seen edge-on there or the point is + /// not in front of the eye. + pub fn depth_at(&self, p: Point) -> Option { + let [nx, ny, nz] = self.normal; + let (x, y) = (p.x - self.center.x, p.y - self.center.y); + let z = match self.eye { + None => (nz.abs() > 1e-9).then(|| (self.offset - nx * x - ny * y) / nz)?, + Some(e) => { + // On the ray through `p`, the point at depth z is at (x, y) × (e - z) / e. + let a = (nx * x + ny * y) / e; + let den = nz - a; + let z = (den.abs() > 1e-9).then(|| (self.offset - a * e) / den)?; + (z < e).then_some(z)? + } + }; + z.is_finite().then_some(z) + } +} + impl Facet { + /// The view-space depth of the facet at `p`, a point of the drawing ([`DepthPlane::depth_at`]). + pub fn depth_at(&self, p: Point) -> Option { + self.plane.as_ref()?.depth_at(p) + } + /// About how thick the facet is across: twice its area over its perimeter (a w × l rectangle /// long against its width gives about w). pub fn thickness(&self) -> f64 { @@ -339,6 +380,7 @@ impl Facet { // A revolved cap is not one of the planar faces graphics map onto. end: None, away: false, + plane: None, #[cfg(test)] view: vec![], } diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index bd4030dfc4..1263582df9 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -6,8 +6,8 @@ use super::rings::{ solid_rings_traced, }; use super::{ - Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, eye_distance, - perspective_scale, + Bevel, DepthPlane, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, + eye_distance, perspective_scale, }; use crate::shadow::Projection; @@ -129,6 +129,11 @@ impl Builder<'_> { } else { (V3(-n.0, -n.1, -n.2), false) }; + // A face whose corners lie off one plane (a piece of a twisted wall bent into a saddle) has + // no plane to measure its depth on. + let offset = n.dot(centre); + let flat = contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); + let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] let view = contours.clone(); let contours: Vec> = contours.iter().map(|c| c.iter().map(|v| self.camera.project(*v)).collect()).collect(); @@ -156,6 +161,7 @@ impl Builder<'_> { side: false, away: behind, end: None, + plane, #[cfg(test)] view, }); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index be887180b2..b0c26ccc0a 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -154,6 +154,29 @@ fn taper_shrinks_the_back_and_twist_turns_it() { assert!(open.len() > 2 * 8, "{}", open.len()); } +/// A flat facet carries its plane: the depth it gives at each corner of its drawing is the corner's +/// view-space depth, with and without perspective. A piece of a twisted wall whose corners lie off +/// one plane has none. +#[test] +fn a_flat_facet_measures_its_depth_on_its_plane() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let o = Extrude { depth: 60.0, cap: true, ..Extrude::default() }; + for perspective in [0.0, 50.0] { + let view = View { rotation_x: -20.0, rotation_y: 30.0, perspective, ..front() }; + let flat = extrude(&p, FillRule::EvenOdd, b, o, view).unwrap(); + assert!(!flat.is_empty()); + for f in &flat { + assert!(f.plane.is_some(), "{perspective}"); + for (q, v) in f.contours.iter().flatten().zip(f.view.iter().flatten()) { + let d = f.depth_at(*q); + assert!(d.is_some_and(|d| close(d, v.2, 1e-6)), "{perspective}: {d:?} against {}", v.2); + } + } + let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 90.0, ..o }, view).unwrap(); + assert!(twisted.iter().any(|f| f.side && f.plane.is_none()), "{perspective}"); + } +} + #[test] fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..f447b00c84 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1176,6 +1176,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. + - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_cuts_its_dashes_as_a_white_one_does`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index c701585b88..e9d5a696ff 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -117,11 +117,20 @@ The rest is our reading: each side face of the fill's solid that is turned toward the viewer is drawn before every face of the same shape's strokes, turned toward the viewer, whose bounds overlap it, so the stroke's walls cover the fill's walls at concave corners. This applies to strokes on closed paths without dashes, a width profile or a brush, painted - with a solid color or a gradient without transparent stops; with other strokes, and without caps, the faces keep - their depth order. Otherwise - each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not - white, each solid is also drawn on its own when one of them is a puffed, shaded Inflate surface in a solid color - (drawn as one composite) or is painted with a gradient that has transparent stops (drawn with an opacity mask). + with a solid color or a gradient without transparent stops, under Extrude & Bevel (both kinds). Under Inflate + the fill can rise in front of such a stroke, so it is drawn like the other strokes. Other strokes (dashed, with a + width profile or a brush, on an open subpath, or in a paint that may show through) leave parts of the fill's edge + uncovered, such as the gaps between dashes. Each face of such a stroke that passes through a face of the fill's + solid, or through a face of another of the shape's strokes, where the two overlap in the drawing is cut where + their depths meet, and the faces of the shape's fill and strokes that overlap are drawn in the order of their + depths where they overlap, with caps on or off. A fill wall in a gap stays in its own depth order. Inflate's + curved surfaces follow this only as closely as their flat facets do. A solid painted with a gradient that has + transparent stops under a light that is not white is drawn with an opacity mask: its faces keep their places in + this order, in runs that each carry the mask, and so do its graphics. A member of a 3D group with its own opacity + mask or blending mode draws its fill's and strokes' faces in this order inside that mask or blending mode when + they are opaque with Normal blending. Otherwise each solid is drawn on its own with its opacity and blending, the + lower item's first. With a light that is not white, each solid is also drawn on its own when one of them is a + puffed, shaded Inflate surface in a solid color (drawn as one composite). - The solids turn, twist and taper about the center of the bounds of all their shapes, Inflate's grid covers those bounds, and with Perspective one eye sees every solid. A stroke too narrow to hold a point of Inflate's grid stays flat. From 1ce6431a99fb188e92d3992c45204a6f59125d2e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:53:42 -0800 Subject: [PATCH 13/20] SVG import: raster images follow their EXIF or TIFF orientation An whose EXIF orientation or TIFF Orientation tag turns or flips its pixels, linked or as a data URL, is turned before usvg reads it: the pixels are stored as they are shown, the width and height swap, and the box fits the turned image under preserveAspectRatio, as CSS image-orientation: from-image describes. A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. - Data URLs of type image/tiff, and untyped TIFF bytes, are read. - TIFFs with extra channels, CCITT Group 3 TIFFs and planar CMYK TIFFs are read as Open and Place read them. - Converted images decode within the decode allowance, and a TIFF that claims more strips or tiles than the decoder can count is refused before any decoder opens it. - exif_orientation moves to vectorcraft-doc so Open/Place and SVG import share it. Tests: crates/svg/tests/it/image_orientation.rs. --- crates/doc/src/cmyk.rs | 15 ++ crates/engine/src/cmd/fileio/load.rs | 16 +- crates/svg/src/import/files.rs | 149 ++++++++++-- crates/svg/src/import/importer.rs | 6 + crates/svg/tests/it/image_orientation.rs | 278 +++++++++++++++++++++++ crates/svg/tests/it/main.rs | 1 + docs/target-app-parity.md | 1 + 7 files changed, 431 insertions(+), 35 deletions(-) create mode 100644 crates/svg/tests/it/image_orientation.rs diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index ea2c44215e..391fc39633 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -140,6 +140,21 @@ pub fn tiff_orientation(bytes: &[u8]) -> Option { d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() } +/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when +/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with +/// extra samples) has its Orientation tag read from its first image ([`tiff_orientation`]). Reads +/// only the image's header and metadata; `None` for an [`oversized_tiff`], which no decoder opens. +pub fn exif_orientation(bytes: &[u8]) -> Option { + use image::ImageDecoder as _; + use image::metadata::Orientation; + if oversized_tiff(bytes).is_some() { + return None; + } + let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); + let tagged = || tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); + decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index e9f6ecf3d4..4df490af13 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -868,7 +868,7 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. -/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is +/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`vectorcraft_doc::cmyk::exif_orientation`]) turns or flips it is /// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG /// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). pub fn raster_image(bytes: &[u8]) -> Result { @@ -909,7 +909,7 @@ pub fn raster_image(bytes: &[u8]) -> Result { let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is // seen, so it places the way it was taken (#4284); the others keep their own bytes. - let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); + let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| vectorcraft_doc::cmyk::exif_orientation(bytes)).flatten(); let turned = orientation.is_some(); // The resolution describes the stored pixels; a quarter turn swaps its axes. let quarter = orientation.is_some_and(|o| o.to_exif() >= 5); @@ -957,18 +957,6 @@ pub fn raster_image(bytes: &[u8]) -> Result { as_png(img, None, shown_ppi) } -/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when -/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with -/// extra samples) has its Orientation tag read from its first image -/// ([`vectorcraft_doc::cmyk::tiff_orientation`]). -pub(crate) fn exif_orientation(bytes: &[u8]) -> Option { - use image::ImageDecoder as _; - use image::metadata::Orientation; - let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); - let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); - decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) -} - /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its /// colors are in. pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index 1a4c06f0e6..0472bc83e9 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -3,11 +3,15 @@ //! stays a linked image ([`LinkInfo`]); an SVG file becomes vector art. One that can't be read //! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it //! can be relinked, and a warning. +//! +//! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag) +//! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as +//! it is shown. use std::collections::HashMap; use std::path::Path; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use usvg::{ImageHrefResolver, ImageKind, roxmltree}; use vectorcraft_doc::LinkInfo; @@ -44,6 +48,20 @@ pub(in crate::import) struct Files { svg_images: AtomicUsize, /// SVG images were left out for exceeding [`MAX_NESTED_SVG_IMAGES`]. too_many_svg_images: AtomicBool, + /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got + /// (kept here too, so the address stays its own). + cmyk: Mutex>, +} + +/// Encoded image bytes, shared. +type Bytes = Arc>; + +/// Raster bytes as usvg gets them. +struct Shown { + kind: RasterKind, + bytes: Arc>, + /// The CMYK TIFF stored in their place: a turned CMYK image's inks, turned. + cmyk: Option>, } /// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…). @@ -174,27 +192,37 @@ impl Files { files } - /// What usvg gets for a file read (`bytes`, linked by `link`): a raster usvg reads (others are - /// stored as PNG) or an SVG; `None` for anything else. + /// What usvg gets for a file read (`bytes`, linked by `link`): a raster as [`shown`] gives it + /// (formats usvg doesn't read are stored as PNG) or an SVG; `None` for anything else. fn source(&mut self, bytes: Vec, link: LinkInfo) -> Option { let Ok(format) = image::guess_format(&bytes) else { // Unpacked once, here, within what the SVGZ images may unpack to. let text = crate::text_within(&bytes, &self.unpacked).ok()?; return text.contains(" (RasterKind, Arc>) { + if let Some(cmyk) = shown.cmyk + && let Ok(mut stored) = self.cmyk.lock() + { + stored.insert(key(&shown.bytes), (shown.bytes.clone(), Arc::new(cmyk))); + } + (shown.kind, shown.bytes) + } + + /// The CMYK TIFF stored in place of the image whose data usvg got as `kind`: a CMYK JPEG or + /// TIFF its orientation turns, which usvg got as PNG. + pub(in crate::import) fn stored(&self, kind: &ImageKind) -> Option>> { + let ImageKind::PNG(b) = kind else { return None }; + self.cmyk.lock().ok()?.get(&key(b)).map(|(_, tiff)| tiff.clone()) + } + /// Does href `h` show a placeholder? fn placeholder(&self, h: &str) -> bool { matches!(self.sources.get(h), Some(Source::Raster(_, b)) if self.links.get(&key(b)).is_some_and(|l| l.missing)) @@ -241,21 +269,24 @@ impl Files { pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> { self.depth.store(height, Ordering::Relaxed); ImageHrefResolver { - // By type as usvg reads them; one without a type (`text/plain`) by its bytes: a - // raster usvg reads, else SVG. + // By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its + // bytes: one of those rasters, else SVG. A raster as [`shown`] gives it. resolve_data: Box::new(move |mime, bytes, opts| { - let kind = match mime { - "image/jpg" | "image/jpeg" => ImageKind::JPEG, - "image/png" => ImageKind::PNG, - "image/gif" => ImageKind::GIF, - "image/webp" => ImageKind::WEBP, + use image::ImageFormat as F; + let format = match mime { + "image/jpg" | "image/jpeg" => F::Jpeg, + "image/png" => F::Png, + "image/gif" => F::Gif, + "image/webp" => F::WebP, + "image/tiff" => F::Tiff, "image/svg+xml" => return self.svg_data(&bytes, opts), - "text/plain" => match image::guess_format(&bytes).ok().and_then(raster) { - Some(kind) => kind, + "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { + Some(format) => format, None => return self.svg_data(&bytes, opts), }, _ => return None, }; + let (kind, bytes) = self.keep(shown(bytes, format)?); Some(kind(bytes)) }), resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? { @@ -312,6 +343,82 @@ fn raster(format: image::ImageFormat) -> Option { } } +/// Encoded raster `bytes`, `format` by their type (or by their bytes when they have none), as usvg +/// gets them, as Open and Place store them: unchanged when usvg reads the format and the image's +/// orientation ([`vectorcraft_doc::cmyk::exif_orientation`], read for PNG, JPEG, GIF, WebP and +/// TIFF) leaves the pixels as stored. Otherwise the pixels are read ([`decoded`], +/// [`tiff_pixels`]), turned or flipped as the orientation says, and written as PNG, so usvg sizes +/// and fits the image as it is shown. A CMYK TIFF also keeps its inks as a CMYK TIFF +/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]), and a CMYK JPEG or TIFF that is turned keeps them +/// turned ([`vectorcraft_doc::cmyk::turned_cmyk`]). `None` when the pixels can't be read, and for +/// a TIFF with more strips or tiles than the decoder can count +/// ([`vectorcraft_doc::cmyk::oversized_tiff`]), before any decoder opens it. +fn shown(bytes: Arc>, format: image::ImageFormat) -> Option { + use image::ImageFormat as F; + if vectorcraft_doc::cmyk::oversized_tiff(&bytes).is_some() { + return None; + } + let orientation = + matches!(format, F::Png | F::Jpeg | F::Gif | F::WebP | F::Tiff).then(|| vectorcraft_doc::cmyk::exif_orientation(&bytes)).flatten(); + if let Some(kind) = raster(format) + && orientation.is_none() + { + return Some(Shown { kind, bytes, cmyk: None }); + } + let tiff = image::guess_format(&bytes).ok() == Some(F::Tiff); + let mut img = if tiff { tiff_pixels(&bytes)? } else { decoded(&bytes, format)? }; + let cmyk = match orientation { + Some(o) if matches!(format, F::Jpeg | F::Tiff) => vectorcraft_doc::cmyk::turned_cmyk(&bytes, o, None).map(|(tiff, _)| tiff), + None if tiff => vectorcraft_doc::cmyk::stored_cmyk_tiff(&bytes, None), + _ => None, + }; + if let Some(orientation) = orientation { + img.apply_orientation(orientation); + } + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?; + Some(Shown { kind: ImageKind::PNG, bytes: Arc::new(png), cmyk }) +} + +/// The pixels of encoded image `bytes` (`format` when their bytes don't say), as stored, decoded +/// within [`vectorcraft_doc::MAX_RASTER_ALLOC`], counted as decoded and as RGBA, as Open and Place +/// decode them; `None` when they can't be. +fn decoded(bytes: &[u8], format: image::ImageFormat) -> Option { + use image::ImageDecoder as _; + let max = vectorcraft_doc::MAX_RASTER_ALLOC; + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + if reader.format().is_none() { + reader.set_format(format); + } + let mut limits = image::Limits::default(); + limits.max_alloc = Some(max); + reader.limits(limits); + let decoder = reader.into_decoder().ok()?; + let (w, h) = decoder.dimensions(); + if decoder.total_bytes() > max || u64::from(w) * u64::from(h) * 4 > max { + return None; + } + image::DynamicImage::from_decoder(decoder).ok() +} + +/// The pixels of TIFF `bytes`, as stored, read as Open and Place read them: a TIFF with extra +/// samples (CMYK or gray with alpha) as RGBA, CMYK converted with the active color settings +/// ([`vectorcraft_doc::cmyk::extra_samples_tiff_rgba`]); a 1-bit TIFF compressed with CCITT Group +/// 3 as RGBA ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]); others by [`decoded`], a CMYK TIFF +/// that stores one plane per ink with its inks written together first +/// ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]). `None` when they can't be read. +fn tiff_pixels(bytes: &[u8]) -> Option { + use image::DynamicImage::ImageRgba8; + let cms = vectorcraft_color::cms::active(); + if let Some(img) = vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) { + return Some(ImageRgba8(img)); + } + if let Some(read) = vectorcraft_doc::cmyk::group3_tiff_rgba(bytes) { + return read.ok().map(ImageRgba8); + } + decoded(bytes, image::ImageFormat::Tiff).or_else(|| decoded(&vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, None)?, image::ImageFormat::Tiff)) +} + /// No image: left out, noted in `why` when `noted`. fn leave_out(why: &AtomicBool, noted: bool) -> Option { if noted { diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 306a0c17d2..2eede1f7be 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -771,6 +771,12 @@ impl Importer { .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); + // A turned CMYK image is stored as the CMYK TIFF of its inks, the size of the PNG usvg got. + let cmyk = self.files.stored(i.kind()); + let (bytes, mime) = match &cmyk { + Some(tiff) => (tiff, "image/tiff"), + None => (bytes, mime), + }; // A linked file keeps a preview; a placeholder is all a missing one has (its preview). let linked = self.files.linked(i.kind()); let missing = linked.is_some_and(|l| l.missing); diff --git a/crates/svg/tests/it/image_orientation.rs b/crates/svg/tests/it/image_orientation.rs new file mode 100644 index 0000000000..fbf412a0bb --- /dev/null +++ b/crates/svg/tests/it/image_orientation.rs @@ -0,0 +1,278 @@ +//! Raster images whose orientation (EXIF, or a TIFF's Orientation tag) turns their pixels import +//! as they are shown, linked or as data URLs: the pixels turned, the width and height swapped, and +//! the turned image fitted to the `` box. + +use std::path::PathBuf; + +use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind}; +use vectorcraft_geom::Rect; +use vectorcraft_svg::{ImportOptions, base64_encode, import_with, import_with_report}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +fn images(d: &Document) -> Vec<(&Node, &ImageObject)> { + let mut v = Vec::new(); + d.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + v.push((n, &**im)); + } + }); + v +} + +/// The one image `body` imports to, with its warnings, its link read from `dir` when given. +fn import_one(body: &str, dir: Option<&PathBuf>) -> (Document, ImageObject) { + let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path))); + let folder = dir.map(|d| d.to_string_lossy().into_owned()); + let (d, w) = match &folder { + Some(f) => import_with(&svg(body), &ImportOptions { folder: Some(f), read: Some(&read) }).unwrap(), + None => import_with_report(&svg(body)).unwrap(), + }; + assert!(w.is_empty(), "{w:?}"); + let im = match &images(&d)[..] { + [(_, im)] => (*im).clone(), + other => panic!("{} images", other.len()), + }; + (d, im) +} + +/// A fresh folder for one test's files. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-svg-orientation-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A 16 × 8 JPEG, red on the left half, with an EXIF segment giving Orientation `orientation` +/// when there is one. +fn jpeg(orientation: Option) -> Vec { + let rgb = image::RgbImage::from_fn(16, 8, |x, _| if x < 8 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) }); + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut std::io::Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + let Some(orientation) = orientation else { return jpg }; + let o = orientation.to_le_bytes(); + let exif: Vec = + [b"Exif\0\0".as_slice(), b"II", &[0x2a, 0, 8, 0, 0, 0], &[1, 0], &[0x12, 0x01, 3, 0, 1, 0, 0, 0, o[0], o[1], 0, 0], &[0, 0, 0, 0]].concat(); + let mut tagged = jpg[..2].to_vec(); + tagged.extend([0xff, 0xe1]); + tagged.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + tagged.extend(&exif); + tagged.extend(&jpg[2..]); + tagged +} + +/// A little-endian, uncompressed TIFF of `w` × `h` pixels of `samples` 8-bit samples each (1 for +/// gray, 2 for gray with an unassociated alpha channel, 4 for CMYK inks), `data` row by row, with +/// an Orientation tag (274) of `orientation`. Its RowsPerStrip (278) value is at byte [`ROWS_AT`] +/// and its PlanarConfiguration (284, 1) value at [`PLANAR_AT`]. +fn tiff(w: u32, h: u32, samples: u16, data: &[u8], orientation: u16) -> Vec { + let entries: u32 = if samples == 2 { 12 } else { 11 }; + let ifd_end = 8 + 2 + entries * 12 + 4; + let bits_at = ifd_end; + let data_at = bits_at + if samples > 2 { 2 * u32::from(samples) } else { 0 }; + let entry = |tag: u16, kind: u16, count: u32, value: u32| { + [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &count.to_le_bytes(), &value.to_le_bytes()].concat() + }; + // Two SHORTs fit in the entry; more are written after the directory. + let bits = match samples { + 1 => 8, + 2 => 8 | 8 << 16, + _ => bits_at, + }; + let photometric = if samples == 4 { 5 } else { 1 }; + let mut out = [b"II".as_slice(), &[0x2a, 0], &8u32.to_le_bytes(), &u16::try_from(entries).unwrap().to_le_bytes()].concat(); + // ExtraSamples (338): unassociated alpha. + let extra = (samples == 2).then(|| entry(338, 3, 1, 2)); + for e in [ + entry(256, 4, 1, w), + entry(257, 4, 1, h), + entry(258, 3, u32::from(samples), bits), + entry(259, 3, 1, 1), + entry(262, 3, 1, photometric), + entry(273, 4, 1, data_at), + entry(274, 3, 1, u32::from(orientation)), + entry(277, 3, 1, u32::from(samples)), + entry(278, 4, 1, h), + entry(279, 4, 1, u32::try_from(data.len()).unwrap()), + entry(284, 3, 1, 1), + ] + .into_iter() + .chain(extra) + { + out.extend(e); + } + out.extend(0u32.to_le_bytes()); + if samples > 2 { + for _ in 0..samples { + out.extend(8u16.to_le_bytes()); + } + } + assert_eq!(out.len(), data_at as usize); + out.extend(data); + out +} + +/// Where [`tiff`] writes the RowsPerStrip value (the ninth entry) and the PlanarConfiguration value +/// (the eleventh). +const ROWS_AT: usize = 8 + 2 + 8 * 12 + 8; +const PLANAR_AT: usize = 8 + 2 + 10 * 12 + 8; + +/// A 4 × 2 gray TIFF, black on the left half, turned a quarter clockwise by Orientation 6. +fn gray_tiff() -> Vec { + tiff(4, 2, 1, &[0, 0, 255, 255, 0, 0, 255, 255], 6) +} + +/// Where the image's own box goes on the page. +fn placed(im: &ImageObject) -> Rect { + im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))) +} + +fn near(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6) +} + +/// The stored pixels of `im`, as RGB. +fn pixels(d: &Document, im: &ImageObject) -> image::RgbImage { + image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgb8() +} + +fn red(p: &image::Rgb) -> bool { + p[0] > 150 && p[1] < 80 +} + +/// A JPEG whose EXIF Orientation 6 turns it a quarter clockwise shows upright, linked and as a +/// data URL alike: 8 × 16, the left half on top, fitted (centered) in a square box as the turned +/// image is, and stored without an orientation that would turn it again. +#[test] +fn a_turned_jpeg_imports_upright() { + let jpg = jpeg(Some(6)); + let dir = folder("jpeg"); + std::fs::write(dir.join("photo.jpg"), &jpg).unwrap(); + let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg)); + let untyped = format!("data:;base64,{}", base64_encode(&jpg)); + for href in ["photo.jpg", data.as_str(), untyped.as_str()] { + let (d, im) = import_one(&format!(r#""#), Some(&dir)); + let what = &href[..href.len().min(20)]; + assert_eq!((im.width, im.height), (8, 16), "{what}: turned a quarter"); + assert!(near(placed(&im), Rect::new(30.0, 20.0, 70.0, 100.0)), "{what}: fitted as turned: {:?}", placed(&im)); + let px = pixels(&d, &im); + assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "{what}: the left half is on top"); + assert_eq!(vectorcraft_doc::cmyk::exif_orientation(&d.images[&im.key].bytes), None, "{what}: not turned again"); + assert_eq!(im.link.is_some(), href == "photo.jpg", "{what}"); + } +} + +/// With `preserveAspectRatio="none"` the turned image fills the box. +#[test] +fn a_turned_jpeg_stretches_to_the_box_as_turned() { + let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6)))); + let (_, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), (8, 16)); + assert!(near(placed(&im), Rect::new(10.0, 20.0, 90.0, 60.0)), "{:?}", placed(&im)); +} + +/// A JPEG without an orientation, or with Orientation 1, keeps its own bytes. +#[test] +fn an_upright_jpeg_keeps_its_bytes() { + for jpg in [jpeg(None), jpeg(Some(1))] { + let data = format!("data:image/jpeg;base64,{}", base64_encode(&jpg)); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), (16, 8)); + assert_eq!(*d.images[&im.key].bytes, jpg); + assert_eq!(d.images[&im.key].mime, "image/jpeg"); + } +} + +/// A TIFF whose Orientation tag is 6 shows upright, linked, as an `image/tiff` data URL and as a +/// data URL without a type. +#[test] +fn a_turned_tiff_imports_upright() { + let tif = gray_tiff(); + let dir = folder("tiff"); + std::fs::write(dir.join("scan.tif"), &tif).unwrap(); + let data = format!("data:image/tiff;base64,{}", base64_encode(&tif)); + let untyped = format!("data:;base64,{}", base64_encode(&tif)); + for href in ["scan.tif", data.as_str(), untyped.as_str()] { + let (d, im) = import_one(&format!(r#""#), Some(&dir)); + let what = &href[..href.len().min(20)]; + assert_eq!((im.width, im.height), (2, 4), "{what}: turned a quarter"); + assert!(near(placed(&im), Rect::new(0.0, 0.0, 20.0, 40.0)), "{what}: {:?}", placed(&im)); + let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8(); + assert_eq!((px.get_pixel(0, 0)[0], px.get_pixel(1, 1)[0], px.get_pixel(0, 3)[0]), (0, 0, 255), "{what}: the left half is on top"); + } +} + +/// A CMYK TIFF keeps its inks as a CMYK TIFF, turned when its orientation turns it. +#[test] +fn a_cmyk_tiff_keeps_its_inks_turned_or_not() { + let (c, none) = ([255u8, 0, 0, 0], [0u8; 4]); + let data: Vec = [c, c, none, none, c, c, none, none].concat(); + for (orientation, (w, h), cyan_at, none_at) in [(1, (4usize, 2usize), (1, 1), (2, 0)), (6, (2, 4), (1, 1), (0, 2))] { + let tif = tiff(4, 2, 4, &data, orientation); + let href = format!("data:image/tiff;base64,{}", base64_encode(&tif)); + let (d, im) = import_one(&format!(r#""#), None); + let what = format!("orientation {orientation}"); + assert_eq!((im.width as usize, im.height as usize), (w, h), "{what}"); + let blob = &d.images[&im.key]; + assert_eq!(blob.mime, "image/tiff", "{what}"); + let inks = blob.cmyk().expect("CMYK inks"); + assert_eq!((inks.width as usize, inks.height as usize), (w, h), "{what}"); + let ink = |(x, y): (usize, usize)| &inks.data[(y * w + x) * 4..][..4]; + assert_eq!((ink((0, 0)), ink(cyan_at), ink(none_at)), (&c[..], &c[..], &none[..]), "{what}: cyan on the left, or on top"); + } +} + +/// A gray TIFF with an alpha channel, which the image decoder can't read, imports as Open and +/// Place read it, with its transparency: upright, and turned by Orientation 6. +#[test] +fn a_gray_tiff_with_alpha_imports_turned_or_not() { + let (black, clear) = ([0u8, 255], [255u8, 0]); + let data: Vec = [black, black, clear, clear, black, black, clear, clear].concat(); + for (orientation, size, black_at, clear_at) in [(1, (4, 2), (0, 1), (3, 0)), (6, (2, 4), (1, 1), (0, 3))] { + let href = format!("data:image/tiff;base64,{}", base64_encode(&tiff(4, 2, 2, &data, orientation))); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), size, "orientation {orientation}"); + let px = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(black_at.0, black_at.1).0, [0, 0, 0, 255], "orientation {orientation}: black"); + assert_eq!(px.get_pixel(clear_at.0, clear_at.1)[3], 0, "orientation {orientation}: clear"); + } +} + +/// A turned TIFF whose header claims more strips than the decoder can count (2^31 + 1 rows a +/// strip each, times 5 planes) is refused without a panic: linked, it is a placeholder with a +/// warning; as a data URL, it is left out. +#[test] +fn a_tiff_with_more_strips_than_can_be_counted_is_refused() { + let mut tif = tiff(3, 1 << 31 | 1, 5, &[0; 30], 6); + tif[ROWS_AT..ROWS_AT + 4].copy_from_slice(&1u32.to_le_bytes()); + tif[PLANAR_AT..PLANAR_AT + 2].copy_from_slice(&2u16.to_le_bytes()); + assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&tif), Some((3, 1 << 31 | 1))); + let dir = folder("oversized"); + std::fs::write(dir.join("huge.tif"), &tif).unwrap(); + let read = |path: &str| std::fs::read(path).ok().map(|b| (b, LinkInfo::new(path))); + let f = dir.to_string_lossy(); + let (d, w) = + import_with(&svg(r#""#), &ImportOptions { folder: Some(&f), read: Some(&read) }).unwrap(); + assert!(w.iter().any(|w| w.contains("huge.tif") && w.contains("is not an image VectorCraft reads")), "{w:?}"); + let [(_, im)] = images(&d)[..] else { panic!("{} images", images(&d).len()) }; + assert!(d.images[&im.key].is_proxy(), "a placeholder"); + let data = format!("data:image/tiff;base64,{}", base64_encode(&tif)); + let (d, _) = import_with_report(&svg(&format!(r#""#))).unwrap(); + assert!(images(&d).is_empty()); +} + +/// A turned JPEG inside an SVG shown as an image is turned too. +#[test] +fn a_turned_jpeg_in_an_svg_image_imports_upright() { + let jpg = format!("data:image/jpeg;base64,{}", base64_encode(&jpeg(Some(6)))); + let inner = format!(r#""#); + let nested = format!("data:image/svg+xml;base64,{}", base64_encode(inner.as_bytes())); + let (d, im) = import_one(&format!(r#""#), None); + assert_eq!((im.width, im.height), (8, 16)); + assert!(near(placed(&im), Rect::new(20.0, 0.0, 60.0, 80.0)), "{:?}", placed(&im)); + let px = pixels(&d, &im); + assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12))); +} diff --git a/crates/svg/tests/it/main.rs b/crates/svg/tests/it/main.rs index b241f2e75b..05546891f4 100644 --- a/crates/svg/tests/it/main.rs +++ b/crates/svg/tests/it/main.rs @@ -20,6 +20,7 @@ mod font_weights; mod foreign_attributes; mod gradients; mod ids; +mod image_orientation; mod import_details; mod import_live; mod import_text; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..67a9b05567 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1640,6 +1640,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. + - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. From 4fae51023ef36e90619f7fed9955559c7b554174 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:54:55 -0800 Subject: [PATCH 14/20] Paths: Remove Anchor Points removes many anchors of a subpath in one pass Object > Path > Remove Anchor Points removed each selected anchor with its own Vec::remove, shifting the rest of the subpath every time, so removing k anchors from an n-anchor subpath took O(k*n) moves. - pathops: remove_anchors removes a subpath's anchors the last first, reading each anchor's current neighbors, handles, length and closed state as removing them one at a time does, and compacts the anchor list once. remove_anchor and remove_anchors share one refit, so the result is bit for bit the same. - engine: the command groups the selected anchors by subpath, works out the next selected anchor with each subpath reading only its own selected anchors, and takes emptied paths out of the selection together (Selection::remove_all). - Tests compare remove_anchors bit for bit with remove_anchor over 600 generated subpaths and focused cases, count the anchors moved (2001 anchors, every other one removed: at most one move per anchor left, 500,500 one at a time), and compare the next-anchor selection and the command on many paths and subpaths with removing one at a time. --- crates/doc/src/selection.rs | 19 ++ crates/doc/src/selection/tests.rs | 29 +++ crates/engine/src/cmd/draw2/anchors.rs | 102 +++++++++-- crates/engine/src/tests/draw2.rs | 43 +++++ crates/pathops/src/edit.rs | 241 +++++++++++++++++++++++-- crates/pathops/src/lib.rs | 4 +- 6 files changed, 397 insertions(+), 41 deletions(-) diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 6f0db556e0..4ef6446247 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -144,6 +144,25 @@ impl Selection { self.key = None; } } + /// [`Self::remove`] for each of `ids`, going through the selected objects once. + pub fn remove_all(&mut self, ids: impl IntoIterator) { + let ids: HashSet = ids.into_iter().collect(); + if ids.is_empty() { + return; + } + self.target = None; + self.slices.clear(); + self.symbol_part = None; + self.on_path_member = None; + self.objects.retain(|x| !ids.contains(x)); + for id in &ids { + self.anchors.remove(id); + self.segments.remove(id); + } + if self.key.is_some_and(|k| ids.contains(&k)) { + self.key = None; + } + } /// Select slices `ids` (see [`Selection::slices`]) and nothing else. pub fn set_slices(&mut self, ids: impl IntoIterator) { self.clear(); diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d9df845129..b6090f8993 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -16,6 +16,35 @@ fn basic_ops() { assert!(s.is_empty()); } +#[test] +fn remove_all_does_what_removing_each_does() { + let mut s = Selection::default(); + for i in 1..=6 { + s.add(NodeId(i)); + } + for i in [2, 4, 5] { + s.anchors.insert(NodeId(i), BTreeSet::from([(0, 1)])); + s.segments.insert(NodeId(i), BTreeSet::from([(0, 0)])); + } + s.key = Some(NodeId(4)); + s.target = Some(NodeId(3)); + s.slices = vec![NodeId(9)]; + s.guides = vec![NodeId(10)]; + s.on_path_member = Some(NodeId(11)); + let mut one_by_one = s.clone(); + for id in [NodeId(4), NodeId(1), NodeId(4), NodeId(7)] { + one_by_one.remove(id); + } + let mut all = s.clone(); + all.remove_all([NodeId(4), NodeId(1), NodeId(4), NodeId(7)]); + assert_eq!(all, one_by_one); + assert_eq!(all.objects, [NodeId(2), NodeId(3), NodeId(5), NodeId(6)]); + // Removing nothing leaves the selection as it was. + let mut none = s.clone(); + none.remove_all([]); + assert_eq!(none, s); +} + #[test] fn guides_are_selected_on_their_own() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index 10254f3cd3..5b80fcc80f 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -116,8 +116,11 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no such anchor".into())); } let mut emptied = Beside::replacing(d, &[id]); - let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?; + let removed = remove_anchors_of(d, sel, &mut emptied, id, vec![(si, ai)], curvature)?; emptied.finish(d)?; + if removed { + sel.remove(id); + } Ok(removed) })?; Ok(json!({ "removedObject": removed })) @@ -136,14 +139,14 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); } let removed = s.edit("Remove Anchor Points", |d, sel| { - let mut removed = 0; + let mut gone = Vec::new(); let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); for (id, anchors) in jobs { let shape: Vec<(usize, bool)> = d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); let next = following_anchors(&shape, &anchors); if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? { - removed += 1; + gone.push(id); } else if !next.is_empty() { // The next anchor along is selected, so the next Remove Anchor Points goes on from // there without picking it first (#2241). @@ -151,7 +154,8 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { } } emptied.finish(d)?; - Ok(removed) + sel.remove_all(gone.iter().copied()); + Ok(gone.len()) })?; Ok(json!({ "removedObjects": removed })) } @@ -160,19 +164,32 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { /// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its /// last removed one, round the end of a closed subpath, or the one before it at an open subpath's /// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. +/// `removed` is in ascending order without repeats, as a selection holds it, and each subpath reads +/// only its own pairs. fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { let mut next = BTreeSet::new(); let mut kept = 0; + let mut rest = removed; for (si, &(n, closed)) in shape.iter().enumerate() { - let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count()); + rest = after; + let gone = &mine[..mine.partition_point(|&(_, a)| a < n)]; if n - gone.len() < 2 { continue; } - if let Some(&last) = gone.last() { - let wrap = if closed { 0..last } else { 0..0 }; - let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); - if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { - next.insert((kept, a - gone.range(..a).count())); + if let Some(&(_, last)) = gone.last() { + // Anchors past `last` are all left. Below it, the first one left (round a closed + // subpath's end) is past the removed run from 0, and the last one left is below the + // removed run down from `last`. + let a = if last + 1 < n { + Some(last + 1) + } else if closed { + Some(gone.iter().zip(0..).take_while(|&(&(_, g), k)| g == k).count()) + } else { + last.checked_sub(gone.iter().rev().zip((0..=last).rev()).take_while(|&(&(_, g), k)| g == k).count()) + }; + if let Some(a) = a { + next.insert((kept, a - gone.partition_point(|&(_, g)| g < a))); } } kept += 1; @@ -183,34 +200,37 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr /// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round /// each one, or with `curvature` re-curving it as the Curvature tool curves its points /// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed, -/// and a path left with no subpath is removed through `emptied`. True when the path went. +/// and a path left with no subpath is removed through `emptied`; the caller then takes it out of +/// the selection. True when the path went. fn remove_anchors_of( d: &mut Document, sel: &mut Selection, emptied: &mut Beside, id: NodeId, - anchors: impl IntoIterator, + mut anchors: Vec<(usize, usize)>, curvature: bool, ) -> Result { - let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); // The last first, so the indexes still to come stay valid. anchors.sort_unstable_by(|a, b| b.cmp(a)); anchors.dedup(); let path = path_mut(d, id)?; - for (si, ai) in anchors { - if let Some(sp) = path.subpaths.get_mut(si) { - if curvature { + let mut rest = &anchors[..]; + while let Some(&(si, _)) = rest.first() { + let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count()); + rest = after; + let Some(sp) = path.subpaths.get_mut(si) else { continue }; + if curvature { + for &(_, ai) in mine { vectorcraft_tools::draw2::curvature_remove(sp, ai); - } else { - po::remove_anchor(sp, ai); } + } else { + po::remove_anchors(sp, &mine.iter().map(|&(_, ai)| ai).collect::>()); } } path.subpaths.retain(|sp| sp.anchors.len() >= 2); let gone = path.is_empty(); if gone { emptied.put(d, id, vec![])?; - sel.remove(id); } else { sel.anchors.remove(&id); sel.segments.remove(&id); @@ -277,3 +297,47 @@ pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result { })?; ok() } + +#[cfg(test)] +mod tests { + use super::*; + + /// [`following_anchors`] by its definition: for each subpath, a search of the anchors after + /// its last removed one, round a closed end, then before it. + fn following_by_search(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { + let mut next = BTreeSet::new(); + let mut kept = 0; + for (si, &(n, closed)) in shape.iter().enumerate() { + let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + if n - gone.len() < 2 { + continue; + } + if let Some(&last) = gone.last() { + let wrap = if closed { 0..last } else { 0..0 }; + let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); + if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { + next.insert((kept, a - gone.range(..a).count())); + } + } + kept += 1; + } + next + } + + #[test] + fn following_anchors_matches_a_search_along_each_subpath() { + let mut seed = 0x2545_f491_4f6c_dd1d_u64; + let mut rnd = |m: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % m) as usize + }; + for case in 0..2000 { + let shape: Vec<(usize, bool)> = (0..rnd(5) + 1).map(|_| (rnd(9), rnd(2) == 0)).collect(); + let removed: BTreeSet<(usize, usize)> = (0..rnd(14)).map(|_| (rnd(shape.len() as u64 + 1), rnd(11))).collect(); + let removed: Vec<_> = removed.into_iter().collect(); + assert_eq!(following_anchors(&shape, &removed), following_by_search(&shape, &removed), "case {case}: {shape:?} removing {removed:?}"); + } + } +} diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 5986b618fd..104c2c144c 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2533,6 +2533,49 @@ fn remove_anchor_points_selects_one_next_anchor_per_subpath_and_per_path() { assert_eq!(selected_anchors(&s, other), [(0, 0)]); } +#[test] +fn remove_anchor_points_on_many_paths_and_subpaths_matches_removing_one_at_a_time() { + // Many anchors over many subpaths and paths, curved and straight, open and closed: each + // subpath ends as removing its anchors one at a time, the last first, leaves it. Paths left + // with nothing go, the rest stay selected in order, and one undo step brings all back. + let mut s = session(); + let curvy = "M0 0 C10 20 30 20 40 0 C50 -20 70 -20 80 0 C90 20 110 20 120 0 C130 -20 150 -20 160 0 Z \ + M0 100 L40 120 C60 140 80 80 100 100 L140 90 L180 110 L220 100"; + let ids: Vec = (0..4).map(|i| path_from(&mut s, if i == 2 { "M0 0 L10 0 L20 5" } else { curvy })).collect(); + let picks: [&[(usize, usize)]; 4] = + [&[(0, 0), (0, 2), (0, 3), (1, 1), (1, 2), (1, 5)], &[(0, 1), (1, 0), (1, 6)], &[(0, 0), (0, 1)], &[(0, 3), (1, 3), (1, 4)]]; + for (k, (&id, &anchors)) in ids.iter().zip(&picks).enumerate() { + let mode = if k == 0 { "set" } else { "add" }; + s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors, "mode": mode})).unwrap(); + } + let before: Vec = ids.iter().map(|&id| path(&s, id)).collect(); + assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 1); + for (k, &id) in ids.iter().enumerate() { + let mut want = before[k].clone(); + let mut order = picks[k].to_vec(); + order.sort_unstable_by(|a, b| b.cmp(a)); + for (si, ai) in order { + vectorcraft_pathops::remove_anchor(&mut want.subpaths[si], ai); + } + want.subpaths.retain(|sp| sp.anchors.len() >= 2); + if want.subpaths.is_empty() { + assert!(s.doc().unwrap().doc.node(id).is_none(), "path {k} goes"); + } else { + assert_eq!(path(&s, id).subpaths, want.subpaths, "path {k}"); + } + } + let kept = [ids[0], ids[1], ids[3]]; + assert_eq!(s.doc().unwrap().selection.objects, kept); + s.execute("edit.undo", &json!({})).unwrap(); + for (k, &id) in ids.iter().enumerate() { + assert_eq!(path(&s, id), before[k]); + assert_eq!(selected_anchors(&s, id), picks[k]); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(ids[2]).is_none()); + assert_eq!(s.doc().unwrap().selection.objects, kept); +} + #[test] fn remove_anchor_point_tool_and_delete_still_select_the_remainder_whole() { // Only Remove Anchor Points moves the selection on. The Delete Anchor Point tool leaves the diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index d8d121ecb9..87b9885f75 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -860,27 +860,10 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { return false; } if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 { - let (pi, ni) = ((index + n - 1) % n, (index + 1) % n); - if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) { - let (left, right) = (sp.segment(pi), sp.segment(index)); - const N: usize = 16; - let mut pts = Vec::with_capacity(2 * N + 1); - sample(&left, N, &mut pts, true); - sample(&right, N, &mut pts, false); - let (t0, t1) = (start_tangent(&left), end_tangent(&right)); - // The new curve passes the removed point at some parameter k: the left samples sit - // at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit. - let fit = |k: f64| { - let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect(); - fit_single_from(&pts, u, t0, t1) - }; - let (c, _, _) = golden_min(fit, 0.0, 1.0); - if c.p1.is_finite() && c.p2.is_finite() { - sp.anchors[pi].h_out = c.p1; - sp.anchors[ni].h_in = c.p2; - } - } + refit_across(&mut sp.anchors, (index + n - 1) % n, index, (index + 1) % n); } + #[cfg(test)] + tests::count_moves(n - index - 1); sp.anchors.remove(index); if !sp.closed { // A new end has nothing beyond it. @@ -897,6 +880,101 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { true } +/// Refit the facing handles of anchors `pi` and `ni` so one cubic follows the two segments that +/// meet at anchor `index` between them ([`remove_anchor`]). Two straight segments are left as +/// they are, and so are the handles when the fit is not finite. +fn refit_across(anchors: &mut [Anchor], pi: usize, index: usize, ni: usize) { + let (Some(&a), Some(&b), Some(&c)) = (anchors.get(pi), anchors.get(index), anchors.get(ni)) else { return }; + let line = |a: &Anchor, b: &Anchor| !a.has_out() && !b.has_in(); + if line(&a, &b) && line(&b, &c) { + return; + } + let (left, right) = (CubicBez::new(a.p, a.h_out, b.h_in, b.p), CubicBez::new(b.p, b.h_out, c.h_in, c.p)); + const N: usize = 16; + let mut pts = Vec::with_capacity(2 * N + 1); + sample(&left, N, &mut pts, true); + sample(&right, N, &mut pts, false); + let (t0, t1) = (start_tangent(&left), end_tangent(&right)); + // The new curve passes the removed point at some parameter k: the left samples sit at k·i/N, + // the right ones at k + (1 − k)·i/N. Search k for the closest fit. + let fit = |k: f64| { + let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect(); + fit_single_from(&pts, u, t0, t1) + }; + let (curve, _, _) = golden_min(fit, 0.0, 1.0); + if curve.p1.is_finite() && curve.p2.is_finite() { + anchors[pi].h_out = curve.p1; + anchors[ni].h_in = curve.p2; + } +} + +/// [`remove_anchor`] for each of `indexes`, the last first, with the same result, moving each +/// anchor that stays at most once: removing many anchors from a long subpath one at a time shifts +/// the rest of it each time. Indexes out of range and repeats are skipped. Returns how many +/// anchors were removed. +pub fn remove_anchors(sp: &mut SubPath, indexes: &[usize]) -> usize { + let total = sp.anchors.len(); + let mut targets: Vec = indexes.iter().copied().filter(|&i| i < total).collect(); + targets.sort_unstable_by(|a, b| b.cmp(a)); + targets.dedup(); + if targets.is_empty() { + return 0; + } + // Every anchor below the one being removed is still there, so it keeps its index, the one + // before it is `index - 1` and the first one left is anchor 0 until anchor 0 itself goes. + let mut gone = vec![false; total]; + let mut len = total; + let mut last = total - 1; + // The lowest anchor removed so far and the first one left after it. + let (mut lowest, mut after_lowest) = (total, None); + for &index in &targets { + let next = if index + 1 < lowest { Some(index + 1) } else { after_lowest }; + if (sp.closed || (index > 0 && next.is_some())) && len >= 3 { + let pi = if index > 0 { index - 1 } else { last }; + refit_across(&mut sp.anchors, pi, index, next.unwrap_or(0)); + } + gone[index] = true; + len -= 1; + (lowest, after_lowest) = (index, next); + if next.is_none() { + last = index.saturating_sub(1); + } + if len == 0 { + continue; + } + let first = if index > 0 { 0 } else { next.unwrap_or(0) }; + if !sp.closed { + // A new end has nothing beyond it. + if let Some(a) = sp.anchors.get_mut(first) { + a.h_in = a.p; + } + if let Some(a) = sp.anchors.get_mut(last) { + a.h_out = a.p; + } + } + if len < 3 { + let ends = [first, last].map(|i| sp.anchors.get(i).is_some_and(|a| a.has_in() || a.has_out())); + sp.closed = sp.closed && len == 2 && (ends[0] || ends[1]); + } + } + if len == 0 { + sp.closed = false; + } + let mut kept = 0; + for (i, &g) in gone.iter().enumerate() { + if !g { + if kept != i { + sp.anchors[kept] = sp.anchors[i]; + #[cfg(test)] + tests::count_moves(1); + } + kept += 1; + } + } + sp.anchors.truncate(kept); + targets.len() +} + /// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a /// golden-section search (a bounded number of steps). fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) { @@ -1107,6 +1185,129 @@ fn end_point(sp: &SubPath, last: bool) -> Option { mod tests { use super::*; + std::thread_local! { + /// Anchors moved within a subpath's anchor list by [`remove_anchor`] and [`remove_anchors`]. + static MOVES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + pub(super) fn count_moves(n: usize) { + MOVES.set(MOVES.get() + n); + } + + /// A subpath of `n` anchors from `seed`: corners, smooth points and points with one handle. + fn random_subpath(seed: &mut u64, n: usize, closed: bool) -> SubPath { + let mut rnd = || { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let anchors = (0..n) + .map(|i| { + let p = Point::new(i as f64 * 20.0 + (rnd() % 100) as f64 / 10.0, (rnd() % 400) as f64 / 4.0); + let off = Vec2::new((rnd() % 300) as f64 / 10.0 - 15.0, (rnd() % 300) as f64 / 10.0 - 15.0); + match rnd() % 4 { + 0 => Anchor::corner(p), + 1 => Anchor::smooth(p, p + off), + 2 => Anchor::with_handles(p, p, p + off), + _ => Anchor::with_handles(p, p - off, p), + } + }) + .collect(); + SubPath::new(anchors, closed) + } + + /// `sp` with [`remove_anchor`] run for each of `indexes`, the last first. + fn one_at_a_time(sp: &SubPath, indexes: &[usize]) -> SubPath { + let mut order = indexes.to_vec(); + order.sort_unstable_by(|a, b| b.cmp(a)); + order.dedup(); + let mut out = sp.clone(); + for i in order { + remove_anchor(&mut out, i); + } + out + } + + fn assert_same_bits(got: &SubPath, want: &SubPath, what: &str) { + let bits = |sp: &SubPath| -> Vec<[u64; 6]> { + sp.anchors.iter().map(|a| [a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits)).collect() + }; + assert_eq!(got.closed, want.closed, "closed: {what}"); + assert_eq!(bits(got), bits(want), "{what}"); + assert_eq!(got.anchors.iter().map(|a| a.kind).collect::>(), want.anchors.iter().map(|a| a.kind).collect::>(), "{what}"); + } + + #[test] + fn removing_anchors_together_gives_what_removing_them_one_at_a_time_gives() { + let mut seed = 0x9e37_79b9_7f4a_7c15_u64; + for case in 0..600 { + let n = case % 40 + 1; + let closed = case % 2 == 0; + let sp = random_subpath(&mut seed, n, closed); + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + // Up to n + 2 picks over 0..n + 2: runs of neighbors, both ends, everything, repeats + // and indexes past the end. + let picks = (seed % (n as u64 + 3)) as usize; + let mut indexes: Vec = (0..picks).map(|k| ((seed >> (k % 48)) as usize).wrapping_add(k * 7) % (n + 2)).collect(); + if case % 5 == 0 { + indexes = (0..n).collect(); + } + let mut got = sp.clone(); + let removed = remove_anchors(&mut got, &indexes); + let want = one_at_a_time(&sp, &indexes); + let what = format!("case {case}: {n} anchors, closed {closed}, removing {indexes:?}"); + assert_same_bits(&got, &want, &what); + assert_eq!(removed, sp.anchors.len() - want.anchors.len(), "{what}"); + } + } + + #[test] + fn removing_anchors_together_keeps_each_step_of_one_at_a_time() { + let curve = |p: Point, d: f64| Anchor::smooth(p, p + Vec2::new(d, 25.0)); + let pts = [Point::new(0.0, 0.0), Point::new(50.0, 40.0), Point::new(100.0, 0.0), Point::new(150.0, 40.0), Point::new(200.0, 0.0)]; + let cases: Vec<(SubPath, Vec)> = vec![ + // A refit at one removal makes the next segment curved, which the next removal refits. + ( + SubPath::new( + vec![Anchor::corner(pts[0]), Anchor::corner(pts[1]), curve(pts[2], 20.0), Anchor::corner(pts[3]), Anchor::corner(pts[4])], + false, + ), + vec![1, 2, 3], + ), + // The last anchor of an open path goes, then the one before it becomes the end. + (SubPath::new(pts.iter().map(|&p| curve(p, 15.0)).collect(), false), vec![2, 3, 4]), + // The first anchor of a closed path refits across the wrap. + (SubPath::new(pts.iter().map(|&p| curve(p, -10.0)).collect(), true), vec![0, 4]), + // A closed path down to two anchors stays closed with a handle, and opens without one. + (SubPath::new(pts.iter().map(|&p| curve(p, 10.0)).collect(), true), vec![1, 2, 3]), + (SubPath::polyline(&pts, true), vec![1, 2, 3]), + (SubPath::polyline(&pts, true), vec![0, 1, 2, 3]), + (SubPath::polyline(&pts[..1], false), vec![0]), + (SubPath::new(vec![], true), vec![0]), + ]; + for (sp, indexes) in cases { + let mut got = sp.clone(); + remove_anchors(&mut got, &indexes); + assert_same_bits(&got, &one_at_a_time(&sp, &indexes), &format!("{sp:?} removing {indexes:?}")); + } + } + + #[test] + fn removing_anchors_together_moves_each_anchor_left_at_most_once() { + let n = 2001; + let pts: Vec = (0..n).map(|i| Point::new(i as f64, (i % 2) as f64)).collect(); + let mut sp = SubPath::polyline(&pts, false); + let every_other: Vec = (1..n).step_by(2).collect(); + MOVES.set(0); + assert_eq!(remove_anchors(&mut sp, &every_other), n / 2); + assert_eq!(sp.anchors.len(), n - n / 2); + let moves = MOVES.get(); + assert!(moves < n, "{moves} anchors moved to remove {} of {n}", n / 2); + } + /// The samples Object › Path › Smooth at `level` smooths for `sp`, a subpath with no corner, /// and the spread it smooths them with. fn smooth_samples(sp: &SubPath, level: f64) -> (Vec, f64) { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 291c956786..49b7c051f1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -23,8 +23,8 @@ pub use boolean::{ }; pub use edit::{ AverageAxis, SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, - remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, smooth_options, - split_into_grid, + remove_anchor, remove_anchors, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, + smooth_options, split_into_grid, }; pub use fit::{fit_cubics, fit_cubics_through}; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; From fb9160be7aaf92f39900c3db303be3241fa6dd00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:58:01 -0800 Subject: [PATCH 15/20] Paths: Delete, Cut and Cut Path read each subpath's own selected anchors Clear, path.deleteAnchors, Cut, Copy and Cut Path scanned every selected anchor or segment of a path once for each of its subpaths, so a path with thousands of subpaths took time that grew with the square of their count. The selection sets are ordered by subpath, so a new helper, subpath_refs, reads one subpath's entries with a range lookup instead. The results are unchanged. With one selected interior anchor on each of 8,000 open subpaths of one path, Clear took a median of 74 ms of thread CPU time before and 3 ms after (release build, five runs each; the timings are noisy because the CPU was shared and loaded). Tests in delete_path_pieces.rs check exact surviving subpaths and their order for anchor and segment selections across open and closed subpaths, an untouched subpath, references to missing subpaths and indexes, one Undo/Redo step and a second Clear, and Cut Path on several subpaths. --- crates/engine/src/cmd/clipboard.rs | 8 +- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/path.rs | 12 +- crates/engine/tests/it/delete_path_pieces.rs | 147 ++++++++++++++++++- docs/target-app-parity.md | 1 + 5 files changed, 159 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index d28bd0e6c9..446634d21c 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -143,17 +143,13 @@ pub(crate) fn subpath_selected_segments( let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty()); if has_explicit_segs { if let Some(segments) = seg_refs { - for &(s, seg) in segments { - if s == si && seg < seg_count { - selected_segs.insert(seg); - } - } + selected_segs.extend(super::path::subpath_refs(segments, si).filter(|&seg| seg < seg_count)); } return selected_segs; } if let Some(anchors) = anchor_refs { - let sub_anchors: BTreeSet = anchors.iter().filter_map(|&(s, a)| (s == si && a < n).then_some(a)).collect(); + let sub_anchors: BTreeSet = super::path::subpath_refs(anchors, si).filter(|&a| a < n).collect(); if !sub_anchors.is_empty() { for i in 0..seg_count { let next = (i + 1) % n; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f3befb3462..474a87e457 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1086,7 +1086,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { subpaths.extend(super::path::without_segments(sp, &cuts)); } else { // Picked anchors leave the path with their connections, as Clear removes them. - let gone: BTreeSet = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect(); + let gone: BTreeSet = anchor_refs.into_iter().flat_map(|refs| super::path::subpath_refs(refs, si)).collect(); subpaths.extend(super::path::without_anchors(sp, &gone)); } } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index b4639f0de5..ed5aaac79c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1276,7 +1276,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .into_iter() .enumerate() .flat_map(|(si, sp)| { - let cuts = refs.iter().filter_map(|&(s, seg)| (s == si).then_some(seg)).collect(); + let cuts = subpath_refs(refs, si).collect(); without_segments(sp, &cuts) }) .filter(|s| s.anchors.len() >= 2) @@ -1288,7 +1288,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { .into_iter() .enumerate() .flat_map(|(si, sp)| { - let gone = set.iter().filter_map(|&(s, ai)| (s == si).then_some(ai)).collect(); + let gone = subpath_refs(set, si).collect(); without_anchors(sp, &gone) }) .filter(|s| s.anchors.len() >= 2) @@ -1314,6 +1314,12 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result { ok() } +/// The anchor or segment indexes in `refs` that belong to subpath `si`, in index order. +/// `refs` is ordered by subpath first, so this reads only that subpath's entries. +pub(super) fn subpath_refs(refs: &BTreeSet<(usize, usize)>, si: usize) -> impl Iterator + '_ { + refs.range((si, 0)..=(si, usize::MAX)).map(|&(_, i)| i) +} + /// Retain path `id` from `d` without copying its geometry before deletion takes its subpaths. /// The shared snapshot supplies the original path classification and, only if needed, gradient /// bounds after the working node's geometry has moved. Returns `NoNode` for a missing/non-path ID. @@ -1722,7 +1728,7 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result { let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue }; let (mut subs, mut starts) = (vec![], vec![]); for (si, sp) in pd.subpaths.iter().enumerate() { - let at: BTreeSet = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect(); + let at: BTreeSet = subpath_refs(set, si).collect(); let pieces = sp.cut_at(&at); // The pieces that start at a cut: every piece of an opened closed subpath, all but the // first of an open one. diff --git a/crates/engine/tests/it/delete_path_pieces.rs b/crates/engine/tests/it/delete_path_pieces.rs index 0d8b7171f7..afc304e99f 100644 --- a/crates/engine/tests/it/delete_path_pieces.rs +++ b/crates/engine/tests/it/delete_path_pieces.rs @@ -2,7 +2,7 @@ use serde_json::json; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, NodeId, NodeKind, selection::Selection}; use vectorcraft_engine::Session; use vectorcraft_geom::{Affine, Anchor, PathData, Rect, SubPath}; use vectorcraft_testkit::{invariants::check_session, raster::render_artboard}; @@ -226,6 +226,151 @@ fn deletion_split_preserves_gradient_placement() { } } +/// Subpath `i` of a multi-subpath test path: four cubic anchors on their own row, so every piece +/// that survives a deletion can be matched exactly. +fn row(i: usize, closed: bool) -> SubPath { + let y = 10.0 + 3.0 * i as f64; + let anchors = (0..4) + .map(|k| { + let mut a = Anchor::corner((10.0 + 20.0 * f64::from(k), y).into()); + a.h_in = a.p + (-3.0, -1.0); + a.h_out = a.p + (3.0, 1.0); + a + }) + .collect(); + SubPath::new(anchors, closed) +} + +/// An open piece made of `indexes` of `sp`, its unused end handles retracted as deletion leaves them. +fn piece(sp: &SubPath, indexes: &[usize]) -> SubPath { + let mut run: Vec = indexes.iter().map(|&i| sp.anchors[i]).collect(); + if let Some(first) = run.first_mut() { + first.h_in = first.p; + } + if let Some(last) = run.last_mut() { + last.h_out = last.p; + } + SubPath::new(run, false) +} + +/// Put `subpaths` into a new path object and return its ID. +fn multi_subpath_path(s: &mut Session, subpaths: Vec) -> NodeId { + let id = rectangle(s); + *std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().path_data_mut().unwrap() = PathData::new(subpaths); + id +} + +/// Run `command` on the current selection, check the path's surviving subpaths and that one Undo +/// restores the document and `before_selection` and one Redo repeats the edit, then check that a +/// second Clear removes the survivors. +fn delete_and_check(s: &mut Session, command: &str, id: NodeId, expected: &[SubPath], before_selection: &Selection) { + let before_doc = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let history = s.doc().unwrap().history.undo.len(); + s.execute(command, &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.node(id).unwrap().path_data().unwrap().subpaths, expected, "{command}"); + assert_eq!(st.selection.objects, [id], "{command}: a multi-subpath path keeps its object"); + assert!(st.selection.anchors.is_empty() && st.selection.segments.is_empty()); + assert_eq!(st.history.undo.len(), history + 1); + let after_doc = serde_json::to_value(&st.doc).unwrap(); + let after_selection = st.selection.clone(); + check_session(s).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before_doc); + assert_eq!(&s.doc().unwrap().selection, before_selection); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), after_doc); + assert_eq!(s.doc().unwrap().selection, after_selection); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none(), "{command}: the second Clear removes the survivors"); +} + +/// Selected anchors on many subpaths each remove only their own subpath's anchors; anchors named on +/// a subpath or index the path does not have remove nothing. +#[test] +fn deleting_anchors_on_many_subpaths_removes_each_subpaths_own_anchors() { + for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [1, 2], [2, 0], [2, 9], [4, 3], [5, 1], [7, 1]]})).unwrap(); + // Subpath 3 has no selected anchor and stays closed and unchanged. + let expected = [ + piece(&subpaths[0], &[2, 3]), + piece(&subpaths[1], &[3, 0, 1]), + piece(&subpaths[2], &[1, 2, 3]), + subpaths[3].clone(), + piece(&subpaths[4], &[0, 1, 2]), + piece(&subpaths[5], &[2, 3, 0]), + ]; + let before = s.doc().unwrap().selection.clone(); + delete_and_check(&mut s, command, id, &expected, &before); + } +} + +/// Selected segments on many subpaths each cut only their own subpath; a segment reference to a +/// subpath or segment the path does not have cuts nothing. +#[test] +fn deleting_segments_on_many_subpaths_cuts_each_subpaths_own_segments() { + for command in ["edit.clear", "path.deleteAnchors", "edit.cut"] { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 1], [1, 3], [4, 0], [5, 1]]})).unwrap(); + // Undo prunes the restored selection (`Selection::prune`), which drops references to + // segments the path does not have, so it restores the selection as it is here. + let before = s.doc().unwrap().selection.clone(); + // `select.segments` refuses references to missing segments, so these are added directly. + let refs = s.doc_mut().unwrap().selection.segments.get_mut(&id).unwrap(); + refs.extend([(2, 9), (7, 0)]); + let expected = [ + piece(&subpaths[0], &[0, 1]), + piece(&subpaths[0], &[2, 3]), + piece(&subpaths[1], &[0, 1, 2, 3]), + subpaths[2].clone(), + subpaths[3].clone(), + piece(&subpaths[4], &[1, 2, 3]), + piece(&subpaths[5], &[2, 3, 0, 1]), + ]; + delete_and_check(&mut s, command, id, &expected, &before); + } +} + +/// One selected interior anchor on each of many subpaths removes exactly that anchor from each, +/// keeping the subpaths in their original order. +#[test] +fn deleting_one_anchor_on_each_of_many_subpaths_keeps_subpath_order() { + let mut s = session(); + let subpaths: Vec = (0..400).map(|i| row(i, false)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + let refs: Vec<[usize; 2]> = (0..400).filter(|i| i % 7 != 3).map(|i| [i, 1]).collect(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": refs})).unwrap(); + let expected: Vec = subpaths.iter().enumerate().map(|(i, sp)| if i % 7 == 3 { sp.clone() } else { piece(sp, &[2, 3]) }).collect(); + let before = s.doc().unwrap().selection.clone(); + delete_and_check(&mut s, "edit.clear", id, &expected, &before); +} + +/// Cut Path cuts each subpath of a multi-subpath path only at that subpath's selected anchors. +#[test] +fn cutting_at_anchors_on_many_subpaths_cuts_each_subpath_at_its_own_anchors() { + let mut s = session(); + let subpaths: Vec = (0..6).map(|i| row(i, i % 2 == 1)).collect(); + let id = multi_subpath_path(&mut s, subpaths.clone()); + let refs = [(0, 1), (1, 2), (2, 9), (4, 2), (5, 0), (5, 2), (7, 1)]; + s.execute("select.anchors", &json!({"id": id.0, "anchors": refs.map(|(a, b)| [a, b])})).unwrap(); + s.execute("path.cutAtAnchors", &json!({})).unwrap(); + let expected: Vec = subpaths + .iter() + .enumerate() + .flat_map(|(si, sp)| sp.cut_at(&refs.iter().filter(|r| r.0 == si).map(|r| r.1).collect::>())) + .collect(); + // Subpaths 0 and 4 become two pieces each, closed subpath 1 opens into one, closed subpath 5 + // becomes two, and subpaths 2 and 3 stay as they were. + assert_eq!(expected.len(), 9); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths, expected); + check_session(&s).unwrap(); +} + /// A connected remainder keeps automatic fitting; pinning is needed only for separate objects. #[test] fn deleting_one_segment_keeps_unplaced_gradients() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..094697b277 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -569,6 +569,7 @@ since 2026-10-01. - **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged. +- **Selection: deletion on paths with many subpaths:** Clear/Delete, `path.deleteAnchors`, Copy, Cut and Cut Path read each subpath's selected anchors or segments directly from the path's ordered selection, instead of scanning every selected anchor or segment of the path once per subpath. The result is unchanged: `crates/engine/tests/it/delete_path_pieces.rs` checks exact surviving subpaths and their order, open and closed contours, an untouched subpath, references to missing subpaths and indexes, one Undo/Redo step and a second Delete, for anchor and segment selections on one path. Scores and hours unchanged. - **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged. - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. From 7f18643ada19a3701187c69a4af33d2673d6287f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:12:59 -0800 Subject: [PATCH 16/20] Type: measuring a threaded frame copies its own text once Each measurement of a frame's share started from a copy of the whole frame, its own runs included, and then replaced the runs. The frame is now copied once without its own text, and each measurement copies that. A test counts the frame text copied to measure frames that already hold text. --- crates/text/src/thread.rs | 25 ++++++++++++++++++++++++- crates/text/src/thread/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 0abd172bff..7850fcc717 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -96,6 +96,25 @@ fn fit_bytes() -> usize { FIT_BYTES.get() } +#[cfg(test)] +thread_local! { + /// The bytes of frames' own text copied on this thread to measure them ([`probe_copied`]). + static PROBE_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The bytes of frames' own text copied on this thread to measure them. +#[cfg(test)] +fn probe_copied() -> usize { + PROBE_COPIED.get() +} + +/// A copy of `frame` to measure text in, its own text counted as copied ([`probe_copied`]). +fn copy_to_measure(frame: &TextObject) -> TextObject { + #[cfg(test)] + PROBE_COPIED.set(PROBE_COPIED.get() + runs_len(&frame.runs)); + frame.clone() +} + /// The fewest bytes of story a frame is measured with before the measured text grows. const MIN_MEASURE: usize = 512; @@ -372,8 +391,12 @@ pub fn distribute_continuing_levels( let n = if i + 1 == frames.len() { len } else { + // The frame without its own text, copied once and then for each measurement. + let mut base = copy_to_measure(f); + base.runs = Vec::new(); + base.paras = Vec::new(); let probe = |runs: Vec| { - let mut probe = (*f).clone(); + let mut probe = copy_to_measure(&base); let styles = slice_paras(paras, first_para, &runs); probe.runs = runs; probe.set_paragraph_styles(styles); diff --git a/crates/text/src/thread/tests.rs b/crates/text/src/thread/tests.rs index 5ae49442fb..eac74d635f 100644 --- a/crates/text/src/thread/tests.rs +++ b/crates/text/src/thread/tests.rs @@ -682,6 +682,29 @@ fn many_frames_measure_about_the_text_they_take() { assert!(measured <= bound, "measured {measured} bytes for {placed} placed in {} frames (at most {bound})", frames.len()); } +/// Frames that already hold text and take a story over several measurements each: each frame's own +/// text is copied once to measure it, not once for each measurement. +#[test] +fn frames_with_text_copy_it_once_to_measure_it() { + let db = FontDb::global(); + let text = paragraphs(600); + let story = vec![TextRun::new(text.clone(), CharStyle::default())]; + let mut frames: Vec = (0..4).map(|k| sized(400.0 * k as f64, 300.0, 600.0)).collect(); + for f in &mut frames { + f.runs = vec![TextRun::new("Old text. ".repeat(500), CharStyle::default())]; + } + let refs: Vec<&TextObject> = frames.iter().collect(); + let (fit_before, copied_before) = (fit_bytes(), probe_copied()); + let parts = distribute(db, &refs, &story, &[]); + let (measured, copied) = (fit_bytes() - fit_before, probe_copied() - copied_before); + let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let first = runs_len(&parts[0].runs); + assert!(measured > 2 * first, "the first frame is measured more than once: {measured} bytes for {first}"); + let own: usize = frames[..frames.len() - 1].iter().map(|f| runs_len(&f.runs)).sum(); + assert_eq!(copied, own, "each frame before the last copies its own text once to measure it"); +} + #[test] fn short_story_leaves_later_frames_empty_with_a_style() { let db = FontDb::global(); From 07522fb4e6b92f0d470ebd0d94ac6f2831ae8c37 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:01:50 -0800 Subject: [PATCH 17/20] Tests: ui-egui tests remove the temp folders they make Place, taskbar, opening, Save Options, Missing Fonts and one control test left their folders in the system temp folder after every run. Other opening and control tests removed theirs only at the end of a passing test, so a failing one left its folder. - A test-only Folder guard (crate::tests_folder) removes its folder with everything in it when dropped, also when the test panics, as the find-files tests' Tree guard does (#6948). - The opening and control tests that made their own folders use it. - Each Place test gets its own folder instead of one shared per-process folder, so one test's cleanup cannot remove another's files. - The Missing Fonts helper no longer removes and remakes the folder vectorcraft_testkit::temp_dir made, which keeps it owner-only on Unix. - vectorcraft_testkit::temp_dir is unchanged. --- crates/ui-egui/src/control/tests.rs | 35 +++---- .../src/dialogs/missing_fonts/tests.rs | 16 ++-- .../ui-egui/src/dialogs/save_options/tests.rs | 11 ++- crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/opening/tests.rs | 33 ++----- crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/folder.rs | 76 +++++++++++++++ crates/ui-egui/src/tests/place.rs | 93 +++++++++++-------- crates/ui-egui/src/tests/taskbar.rs | 8 +- 9 files changed, 171 insertions(+), 106 deletions(-) create mode 100644 crates/ui-egui/src/tests/folder.rs diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 19751bd3cd..ac8fbfa3eb 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -33,11 +33,9 @@ fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { /// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { - let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new(&format!("{tag}-root")); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden)); - let _ = std::fs::remove_dir_all(root); } /// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64`, `thumbnailBase64` @@ -73,7 +71,7 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { /// space do not keep it. #[test] fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { - let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id())); + let base = crate::tests_folder::Folder::new("continuation"); let root = base.join("in"); std::fs::create_dir_all(&root).unwrap(); let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned(); @@ -84,7 +82,6 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")}); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); - let _ = std::fs::remove_dir_all(base); assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); // A readable library's id with a space after the hidden part stays whole. // A readable id with a `..` part does not keep the hidden part. @@ -250,7 +247,7 @@ fn a_reply_hides_a_hidden_relative_path_written_as_absolute() { /// by their file names; plain text and a path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { - let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); + let base = crate::tests_folder::Folder::new("sent-paths"); let root = base.join("in"); std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); @@ -259,7 +256,6 @@ fn a_request_sends_paths_that_an_error_hides() { let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); - let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); let want = [ pair("outside/h.svg", "h.svg"), @@ -276,7 +272,7 @@ fn a_request_sends_paths_that_an_error_hides() { /// relative name and a path inside the root are not. #[test] fn an_absolute_path_outside_the_read_root_is_unreadable() { - let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id())); + let base = crate::tests_folder::Folder::new("unreadable-absolute"); std::fs::create_dir_all(base.join("in")).unwrap(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); let inside = base.join("in").join("a.svg").to_string_lossy().into_owned(); @@ -295,7 +291,6 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() { assert!(!super::unreadable_absolute("keep")); assert!(!super::unreadable_absolute(&inside)); }); - let _ = std::fs::remove_dir_all(base); } /// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps @@ -395,12 +390,10 @@ fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { #[cfg(not(windows))] #[test] fn a_sent_path_keeps_a_backslash_in_its_file_name() { - let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new("sent-backslash"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); let mut sent = Vec::new(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent)); - let _ = std::fs::remove_dir_all(root); assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]); } @@ -412,13 +405,11 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { let panel = "library:swatches:loaded//b.svg".to_string(); app.ui.color_guide_limit = "loaded//b.svg".to_string(); app.ui.open_panel = Some(panel); - let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new("top-level-library"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || { (input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({}))) }); - let _ = std::fs::remove_dir_all(root); assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}"); assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}"); assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); @@ -429,9 +420,8 @@ fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { #[test] fn a_sent_path_reads_the_same_whatever_the_outcome() { let app = VectorcraftApp::new(Session::new(), Services::default()); - let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let root = crate::tests_folder::Folder::new("sent-outcome"); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[]).unwrap(); let sent = json!({"path": "/outside/b.svg"}); let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}}); let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"}); @@ -439,7 +429,6 @@ fn a_sent_path_reads_the_same_whatever_the_outcome() { super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default()); super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default()); }); - let _ = std::fs::remove_dir_all(root); assert_eq!(done["result"]["path"], "b.svg", "{done}"); assert_eq!(failed["error"], "can't read b.svg", "{failed}"); } @@ -1157,7 +1146,7 @@ fn panels_that_redraw_apply_a_merge_on_another_thread_first() { /// applies the merge first: the file holds it, and closing asks to save it. #[test] fn save_and_close_apply_a_merge_on_another_thread_first() { - let dir = vectorcraft_testkit::temp_dir("control-merge-save"); + let dir = crate::tests_folder::Folder::adopt(vectorcraft_testkit::temp_dir("control-merge-save")); for then in ["file.saveAs", "file.exportAs", "file.close"] { let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); diff --git a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs index 0f57b99f46..b173deb49c 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts/tests.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts/tests.rs @@ -1,21 +1,20 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use std::time::Instant; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::findfiles::Rules; use super::*; +use crate::tests_folder::Folder; fn frame(app: &mut VectorcraftApp) -> String { crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) } -/// A fresh temporary folder (canonical, as a search reports it). -fn folder(tag: &str) -> PathBuf { +/// A fresh temporary folder (canonical, as a search reports it), removed when dropped. +fn folder(tag: &str) -> Folder { let dir = vectorcraft_testkit::temp_dir(&format!("missing-fonts-{tag}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap()) + Folder::adopt(vectorcraft_engine::cmd::findfiles::plain(std::fs::canonicalize(&dir).unwrap())) } /// An app that reads files and whose searches may go anywhere in `root`, on two threads. @@ -191,7 +190,7 @@ fn closing_the_dialog_stops_its_search() { app.session.search_threads = Some(1); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.run("text.create", json!({"x": 10, "y": 40, "text": "Gone", "font": "No Such Font Family"})).unwrap(); - app.run("ui.missingFontsDialog", json!({"folder": root})).unwrap(); + app.run("ui.missingFontsDialog", json!({"folder": root.as_path()})).unwrap(); let id = app.ui.dialog_search.expect("the dialog's search"); super::super::cancel(&mut app); assert!(app.ui.dialog.is_none() && app.ui.dialog_search.is_none()); @@ -275,7 +274,8 @@ fn the_question_comes_back_after_a_dialog_that_took_its_place() { /// opens without the question. #[test] fn generic_families_are_not_asked_about() { - let mut app = app_for(&folder("generic")); + let root = folder("generic"); + let mut app = app_for(&root); let svg = r#"A"#; crate::io::open_bytes(&mut app, "generic.svg", svg.as_bytes(), None).unwrap(); let listed = app.session.execute("text.fonts", &json!({})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/save_options/tests.rs b/crates/ui-egui/src/dialogs/save_options/tests.rs index 968d79568c..7a1aeb3849 100644 --- a/crates/ui-egui/src/dialogs/save_options/tests.rs +++ b/crates/ui-egui/src/dialogs/save_options/tests.rs @@ -4,6 +4,7 @@ use std::rc::Rc; use vectorcraft_engine::Session; use super::*; +use crate::tests_folder::Folder; use crate::{FilePick, Services, theme}; type Written = Rc)>>>; @@ -197,7 +198,7 @@ fn board_files_of(app: &VectorcraftApp, boards: [bool; 4]) -> Vec #[test] fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { let (mut app, written, _) = desktop("again.ai"); - let dir = vectorcraft_testkit::temp_dir("ai-options-enter"); + let dir = Folder::adopt(vectorcraft_testkit::temp_dir("ai-options-enter")); let orig = dir.join("orig.ai"); std::fs::write(&orig, vectorcraft_testkit::ai::ai(&vectorcraft_testkit::ai::sample_data(), vectorcraft_testkit::ai::page_pdf())).unwrap(); app.run("document.open", json!({"path": orig.to_string_lossy()})).unwrap(); @@ -215,7 +216,7 @@ fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { #[test] fn save_a_copy_and_template_suggest_their_names() { let (mut app, written, picks) = desktop("copy.vectorcraft"); - let folder = vectorcraft_testkit::temp_dir("save-template-dialog"); + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-dialog")); app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); app.run("file.saveCopy", json!({})).unwrap(); assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft"); @@ -237,7 +238,8 @@ fn save_a_copy_and_template_suggest_their_names() { #[test] fn save_as_template_writes_ait_when_it_is_picked() { let (mut app, written, _) = desktop("flyer.ait"); - app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ait").to_string_lossy().into_owned(); + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-ait")); + app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); app.run("file.saveAsTemplate", json!({})).unwrap(); assert!(app.ui.dialog.is_none(), "a template asks no options"); let (path, bytes) = written.borrow()[0].clone(); @@ -285,9 +287,10 @@ fn the_native_options_have_no_precision_row() { /// replaced with the template's, as Save As replaces the extension of a format it doesn't write. #[test] fn save_as_template_replaces_a_save_formats_extension() { + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("save-template-ext")); for (picked, saved) in [("card.pdf", "card.vctemplate"), ("card.vectorcraft", "card.vctemplate"), ("card.svg", "card.vctemplate")] { let (mut app, written, _) = desktop(picked); - app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ext").to_string_lossy().into_owned(); + app.session.prefs.templates_folder = folder.to_string_lossy().into_owned(); app.run("file.saveAsTemplate", json!({})).unwrap(); let (path, bytes) = written.borrow()[0].clone(); assert_eq!(path, saved, "typed {picked}"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4c6ded189..d768854cb4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -91,8 +91,8 @@ pub mod workspaces; mod document_drag; #[cfg(test)] use tests::{ - background as tests_background, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, svg as tests_svg, - synthetic as tests_synthetic, sysclip as tests_sysclip, + background as tests_background, folder as tests_folder, labels as tests_labels, picks as tests_picks, removeanchors as tests_removeanchors, + svg as tests_svg, synthetic as tests_synthetic, sysclip as tests_sysclip, }; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/opening/tests.rs b/crates/ui-egui/src/opening/tests.rs index ef62509bf3..5840df50cc 100644 --- a/crates/ui-egui/src/opening/tests.rs +++ b/crates/ui-egui/src/opening/tests.rs @@ -56,13 +56,9 @@ fn install_importer(app: &mut VectorcraftApp, src: &str) { } /// A file for the importers below, in a folder of its own. -fn importer_file(tag: &str) -> (std::path::PathBuf, String) { - let dir = std::env::temp_dir().join(format!("vc-ui-opening-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let file = dir.join("a.tob"); - std::fs::write(&file, "not json").unwrap(); - let file = file.to_string_lossy().into_owned(); +fn importer_file(tag: &str) -> (crate::tests_folder::Folder, String) { + let dir = crate::tests_folder::Folder::new(&format!("ui-opening-{tag}")); + let file = dir.file("a.tob", b"not json"); (dir, file) } @@ -73,7 +69,7 @@ fn importer_file(tag: &str) -> (std::path::PathBuf, String) { fn open_with_runs_the_importer_off_the_ui_thread() { use vectorcraft_plugins::wat::{self as tpl, Given}; let rect = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":true,"anchors":[{"p":[10,20]},{"p":[110,20]},{"p":[110,70]},{"p":[10,70]}]}]}}]}"#; - let (dir, file) = importer_file("with"); + let (_dir, file) = importer_file("with"); let mut app = VectorcraftApp::new(Session::new(), Services::default()); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#; @@ -97,14 +93,13 @@ fn open_with_runs_the_importer_off_the_ui_thread() { assert!(app.ui.dialog.is_some(), "the failure is reported in a dialog"); assert!(app.ui.recent_files.is_empty()); app.run("plugin.remove", json!({"id": "test.opening.with"})).unwrap(); - let _ = std::fs::remove_dir_all(&dir); } /// Cancel stops an importer that never returns, on the thread reading the file. #[test] fn cancel_stops_a_running_importer() { use vectorcraft_plugins::wat as tpl; - let (dir, file) = importer_file("cancel"); + let (_dir, file) = importer_file("cancel"); let mut app = VectorcraftApp::new(Session::new(), Services::default()); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); install_importer(&mut app, &tpl::module(&tpl::importer("test.opening.spins", r#"["tob"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1)); @@ -115,7 +110,6 @@ fn cancel_stops_a_running_importer() { assert!(r.as_ref().err().is_some_and(|e| e.contains("cancelled")), "{:?}", r.err()); assert!(app.session.documents().is_empty()); app.run("plugin.remove", json!({"id": "test.opening.spins"})).unwrap(); - let _ = std::fs::remove_dir_all(&dir); } /// #1694: a file that finishes opening while a dialog is up waits for it to close, so the @@ -289,8 +283,7 @@ fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() { #[test] fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { use image::ImageFormat::{Bmp, Jpeg, Png, Tiff}; - let dir = std::env::temp_dir().join(format!("vc-opening-links-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let dir = crate::tests_folder::Folder::new("opening-links"); let (before, after) = (dir.join("before"), dir.join("after")); std::fs::create_dir_all(&before).unwrap(); let file = |folder: &std::path::Path, name: &str| folder.join(name).to_string_lossy().into_owned(); @@ -336,7 +329,6 @@ fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { assert!(st.history.undo.is_empty() && !st.is_dirty(), "{update}"); assert_eq!(asked(&app), question, "{update}"); } - let _ = std::fs::remove_dir_all(&dir); } /// A file opened under automation roots is installed under them too: the open keeps the scope it @@ -344,7 +336,7 @@ fn a_file_read_off_the_ui_thread_has_its_links_and_type_as_one_read_at_once() { #[test] fn a_file_opened_under_automation_roots_finishes_under_them() { use vectorcraft_engine::file_access; - let dir = scope_dir(); + let dir = crate::tests_folder::Folder::new("opening-scope"); let roots = file_access::AutomationRoots::new(&[dir.as_path()], &[]).unwrap(); let mut app = VectorcraftApp::new(Session::new(), Services::default()); file_access::confine(roots.as_ref(), || { @@ -355,12 +347,6 @@ fn a_file_opened_under_automation_roots_finishes_under_them() { assert!(scope.run(file_access::current).is_some(), "the roots go with the open"); } -fn scope_dir() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("vectorcraft-opening-scope-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - dir -} - /// A file asked for again while it is still being read is not read a second time (#3455). #[test] fn a_file_asked_for_again_while_it_is_read_opens_once() { @@ -378,9 +364,7 @@ fn a_file_asked_for_again_while_it_is_read_opens_once() { /// the dialog acts on its own document (#5369). #[test] fn a_queued_reopen_that_asks_holds_back_files_ready_to_open() { - let dir = std::env::temp_dir().join(format!("vectorcraft-opening-queued-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let dir = crate::tests_folder::Folder::new("opening-queued"); let mut app = VectorcraftApp::new(Session::new(), Services::default()); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); let (doc, other) = (dir.join("doc.vectorcraft"), dir.join("other.vectorcraft")); @@ -407,7 +391,6 @@ fn a_queued_reopen_that_asks_holds_back_files_ready_to_open() { assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(&*doc), "the dialog's document stays active"); assert_eq!(app.opening.activate.first().map(|(p, _)| p), Some(&other), "the later activation waits"); assert_eq!(app.opening.pending.len(), 1, "the other file waits"); - let _ = std::fs::remove_dir_all(dir); } /// Of two files read off the UI thread and ready in the same frame, the first that opens and asks diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 07d88595d7..b37ed0d10c 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -8,6 +8,7 @@ mod cut; mod distortkeys; mod docsetup; mod document_drag; +pub(super) mod folder; mod font_menu; mod fonts; mod guide_escape; diff --git a/crates/ui-egui/src/tests/folder.rs b/crates/ui-egui/src/tests/folder.rs new file mode 100644 index 0000000000..e8b4dfae8d --- /dev/null +++ b/crates/ui-egui/src/tests/folder.rs @@ -0,0 +1,76 @@ +//! A test's temporary folder, removed with everything in it when the test ends. + +use std::path::PathBuf; + +/// A test's folder, removed with everything in it when dropped, also when the test panics, as +/// the engine's find-files tests remove their trees. A removal that fails (a file still held open) +/// is ignored. +pub(crate) struct Folder(PathBuf); + +impl Folder { + /// A new empty folder `vectorcraft--` in the system temporary folder. One + /// left there by an earlier process with the same id is removed first. + pub(crate) fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("vectorcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + /// The folder at `dir`, made by the caller, removed when dropped. + pub(crate) fn adopt(dir: PathBuf) -> Self { + Self(dir) + } + + /// `bytes` written to a file `name` in the folder → its path. + pub(crate) fn file(&self, name: &str, bytes: &[u8]) -> String { + let path = self.0.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() + } +} + +impl std::ops::Deref for Folder { + type Target = PathBuf; + fn deref(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for Folder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn a_folder_is_removed_when_its_test_ends() { + let folder = Folder::new("ui-folder-ends"); + let path = folder.to_path_buf(); + std::fs::create_dir_all(path.join("nested")).unwrap(); + folder.file("nested/a.txt", b"a"); + assert!(path.join("nested").join("a.txt").is_file()); + drop(folder); + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn a_folder_is_removed_when_its_test_panics() { + let path = std::env::temp_dir().join(format!("vectorcraft-ui-folder-panics-{}", std::process::id())); + let r = vectorcraft_testkit::catch_quiet::<()>(|| { + let folder = Folder::new("ui-folder-panics"); + folder.file("a.txt", b"a"); + panic!("the test fails"); + }); + assert_eq!(r.err().as_deref(), Some("the test fails")); + assert!(!path.exists(), "{} is left", path.display()); +} + +#[test] +fn an_adopted_test_kit_folder_is_removed_when_its_test_ends() { + let folder = Folder::adopt(vectorcraft_testkit::temp_dir("ui-folder-adopted")); + let path = folder.to_path_buf(); + folder.file("a.txt", b"a"); + drop(folder); + assert!(!path.exists(), "{} is left", path.display()); +} diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index 60f80f4fa4..9c67861eeb 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -11,6 +11,7 @@ use vectorcraft_engine::Session; use crate::VectorcraftApp; use crate::canvas::Xf; use crate::place::{DropAt, DropTarget}; +use crate::tests_folder::Folder; /// A `w`×`h` red PNG declaring `ppi`. fn png(w: u32, h: u32, ppi: f64) -> Vec { @@ -19,15 +20,6 @@ fn png(w: u32, h: u32, ppi: f64) -> Vec { vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out, (ppi, ppi)) } -/// `bytes` written to a fresh temporary file named `name` → its path. -fn temp_file(name: &str, bytes: &[u8]) -> String { - let dir = std::env::temp_dir().join(format!("vectorcraft-ui-place-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(name); - std::fs::write(&path, bytes).unwrap(); - path.to_string_lossy().to_string() -} - /// An app reading files from disk, with a 400×300 document. fn app() -> VectorcraftApp { let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; @@ -166,7 +158,8 @@ fn a_dropped_swatch_library_opens_in_the_library_panel() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let gpl = temp_file("dropped.gpl", b"GIMP Palette\nName: Dropped\n#\n255 0 0\tRed\n 0 128 255\tSky\n"); + let dir = Folder::new("ui-place-swatches"); + let gpl = dir.file("dropped.gpl", b"GIMP Palette\nName: Dropped\n#\n255 0 0\tRed\n 0 128 255\tSky\n"); frame(&mut app, &ctx, vec![], &[&gpl], false); let id = format!("loaded/{gpl}"); assert_eq!(crate::panels::library_panel::last_open(&app.ui).map(|o| o.id.as_str()), Some(id.as_str()), "{}", app.ui.status); @@ -184,7 +177,8 @@ fn a_dropped_script_shows_its_check() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let path = temp_file("dropped.jsx", b"#target acme\napp.executeMenuCommand('acmecopyall');\n"); + let dir = Folder::new("ui-place-script"); + let path = dir.file("dropped.jsx", b"#target acme\napp.executeMenuCommand('acmecopyall');\n"); frame(&mut app, &ctx, vec![], &[&path], false); let d = app.ui.dialog.as_ref().expect("the check is shown"); assert_eq!(d.kind, crate::dialogs::script_check::KIND, "{}", app.ui.status); @@ -203,7 +197,8 @@ fn a_dropped_libraries_panel_file_is_added_as_a_new_library() { let ctx = egui::Context::default(); laid_out(&mut app, &ctx); let file = json!({"name": "Dropped", "colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}]}); - let path = temp_file("dropped.vclibrary", file.to_string().as_bytes()); + let dir = Folder::new("ui-place-library"); + let path = dir.file("dropped.vclibrary", file.to_string().as_bytes()); frame(&mut app, &ctx, vec![], &[&path], false); assert_eq!(app.ui.status, "Imported library Dropped"); let lib = app.session.execute("library.get", &json!({})).unwrap(); @@ -220,7 +215,8 @@ fn a_dropped_shortcut_set_opens_import_shortcut_set() { let ctx = egui::Context::default(); let rect = laid_out(&mut app, &ctx); let set = "/Menus {\n /Acme\\ Undo { /Modifiers 11 /Key 902 }\n}\n"; - let path = temp_file("dropped.kys", set.as_bytes()); + let dir = Folder::new("ui-place-shortcuts"); + let path = dir.file("dropped.kys", set.as_bytes()); frame(&mut app, &ctx, vec![], &[&path], false); let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone()); assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::shortcut_import::KIND), "{}", app.ui.status); @@ -237,7 +233,8 @@ fn a_dropped_png_is_placed_in_the_middle_of_the_view() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let path = temp_file("drop.png", &png(20, 10, 72.0)); + let dir = Folder::new("ui-place-png"); + let path = dir.file("drop.png", &png(20, 10, 72.0)); // Where egui last saw the pointer is stale during a desktop drag: it doesn't count. frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0))], &[&path], false); let n = selected_image(&app); @@ -258,8 +255,9 @@ fn a_dropped_document_opens_as_a_tab_of_its_own() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let svg = temp_file("dropped.svg", SVG.as_bytes()); - let pic = temp_file("with-it.png", &png(8, 8, 72.0)); + let dir = Folder::new("ui-place-tab"); + let svg = dir.file("dropped.svg", SVG.as_bytes()); + let pic = dir.file("with-it.png", &png(8, 8, 72.0)); frame(&mut app, &ctx, vec![], &[&svg, &pic], false); assert_eq!(app.session.documents().len(), 2, "opened, not placed"); assert_eq!(app.session.active_index(), Some(1), "the document opened is shown"); @@ -270,7 +268,8 @@ fn a_dropped_document_opens_as_a_tab_of_its_own() { #[test] fn a_native_svg_drop_uses_file_open_even_with_a_path_only_handle() { - let path = temp_file("path-only.svg", SVG.as_bytes()); + let dir = Folder::new("ui-place-path-only"); + let path = dir.file("path-only.svg", SVG.as_bytes()); let mut dropped = app(); let ctx = egui::Context::default(); laid_out(&mut dropped, &ctx); @@ -317,8 +316,9 @@ fn multiple_svg_drops_open_individually() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let a = temp_file("first.svg", SVG.as_bytes()); - let b = temp_file("second.svg", SVG.as_bytes()); + let dir = Folder::new("ui-place-several-svg"); + let a = dir.file("first.svg", SVG.as_bytes()); + let b = dir.file("second.svg", SVG.as_bytes()); frame(&mut app, &ctx, vec![], &[&a, &b], false); assert_eq!(app.session.documents().len(), 3); assert_eq!(app.session.active().unwrap().path.as_deref(), Some(b.as_str())); @@ -330,15 +330,16 @@ fn unsupported_and_unreadable_drops_leave_the_document_open() { let mut app = app(); let ctx = egui::Context::default(); laid_out(&mut app, &ctx); - let invalid = temp_file("not-art.xyz", b"not an image or document"); + let dir = Folder::new("ui-place-unreadable"); + let invalid = dir.file("not-art.xyz", b"not an image or document"); frame(&mut app, &ctx, vec![], &[&invalid], false); assert!(app.ui.status.contains("Couldn't place not-art.xyz"), "{}", app.ui.status); - let invalid_svg = temp_file("broken.svg", b" = ["held-a.png", "held-b.png", "held-c.png"].iter().map(|name| temp_file(name, &png(10, 10, 72.0))).collect(); + let dir = Folder::new("ui-place-held-escape"); + let paths: Vec = ["held-a.png", "held-b.png", "held-c.png"].iter().map(|name| dir.file(name, &png(10, 10, 72.0))).collect(); app.run("file.place", json!({"paths": paths})).unwrap(); crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(app.session.tool_options()["count"], 3); @@ -774,7 +785,8 @@ fn holding_a_chord_bound_to_a_command_repeats_it_with_the_place_cursor_loaded() let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); app.run("shortcuts.set", json!({"id": "view.zoomIn", "shortcut": "Shift+Escape", "force": true})).unwrap(); - let paths: Vec = ["chord-a.png", "chord-b.png"].iter().map(|name| temp_file(name, &png(10, 10, 72.0))).collect(); + let dir = Folder::new("ui-place-held-chord"); + let paths: Vec = ["chord-a.png", "chord-b.png"].iter().map(|name| dir.file(name, &png(10, 10, 72.0))).collect(); app.run("file.place", json!({"paths": paths})).unwrap(); crate::dialogs::confirm(&mut app).unwrap(); let modifiers = egui::Modifiers::SHIFT; @@ -793,7 +805,8 @@ fn holding_a_chord_bound_to_a_command_repeats_it_with_the_place_cursor_loaded() fn crop_image_shows_a_box_that_apply_crops_to() { use vectorcraft_tools::ToolKey; let mut app = app(); - place_picked(&mut app, &temp_file("crop.png", &png(100, 50, 72.0))); + let dir = Folder::new("ui-place-crop"); + place_picked(&mut app, &dir.file("crop.png", &png(100, 50, 72.0))); app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); crate::dialogs::confirm(&mut app).unwrap(); let whole = selected_image(&app).geometric_bounds().unwrap(); @@ -899,7 +912,8 @@ fn a_file_placed_while_idle_follows_its_changes() { // Half a second later, before the next look is due, a file is placed. The place itself is a // frame (as any command); then nothing happens for 3 s. time += 0.5; - let path = temp_file("idle-watch.png", &png(40, 30, 72.0)); + let dir = Folder::new("ui-place-idle-watch"); + let path = dir.file("idle-watch.png", &png(40, 30, 72.0)); app.run("file.place", json!({"path": path})).unwrap(); let id = app.session.active().unwrap().selection.objects[0]; idle(&mut app, &ctx, &mut time, 3.0, &|_| false); @@ -925,7 +939,8 @@ fn a_look_whose_worker_stopped_does_not_stop_later_looks() { // Every frame renders on this thread: a frame finished by the render worker asks for a repaint. app.canvas.worker_started = true; app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap(); - let path = temp_file("stopped-watch.png", &png(40, 30, 72.0)); + let dir = Folder::new("ui-place-stopped-watch"); + let path = dir.file("stopped-watch.png", &png(40, 30, 72.0)); app.run("file.place", json!({"path": path})).unwrap(); let id = app.session.active().unwrap().selection.objects[0]; let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 4cece18050..d5199e0220 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -121,12 +121,10 @@ fn an_image_with_vector_art_offers_make_clipping_mask() { image::RgbaImage::from_pixel(20, 20, image::Rgba([0, 128, 255, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); - let dir = std::env::temp_dir().join(format!("vectorcraft-ui-taskbar-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("blue.png"); - std::fs::write(&path, &png).unwrap(); + let dir = crate::tests_folder::Folder::new("ui-taskbar"); + let path = dir.file("blue.png", &png); app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); - app.run("file.place", json!({"path": path.to_string_lossy(), "link": false})).unwrap(); + app.run("file.place", json!({"path": path, "link": false})).unwrap(); let img = app.session.active().unwrap().selection.objects[0].0; assert!(matches!(kind(&app, img), NodeKind::Image(_))); let shape = id(app.run("shape.ellipse", json!({"x": 150, "y": 100, "width": 60, "height": 60})).unwrap()); From 08e1b7af8002cefc107673bc970ce833b5f42e0d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:18:53 -0800 Subject: [PATCH 18/20] Image Trace: a background trace applies only to the pixels it traced A trace made away from its command (the background trace, #3098) counted as current when the object, its image record and the settings were unchanged, without looking at the image data. When other pixels came to sit under the same image key (Rasterize after an undo reuses the key and the id, Update Link, a replaced blob), the finished trace applied its old paths to the new image. The trace's inputs now include the encoded bytes it decodes, compared by allocation first and then by content. TraceRun::current and the hand-off in imageTrace.make and imageTrace.makeAndExpand both use them, so a stale trace is dropped and the command traces the pixels there now. Equal bytes in another allocation still count as the same image. --- crates/engine/src/cmd/buildcmds/trace.rs | 30 ++++---- crates/engine/src/tests/build.rs | 97 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 9c6c28b900..11519b84be 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -527,8 +527,9 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result, + /// The encoded image the tracer decodes: the blob under the image's key, shared with the + /// document. Two blobs are equal when they are the same allocation or hold the same bytes. + bytes: Arc>, } /// What `imageTrace.make` and `imageTrace.makeAndExpand` take from the session before they trace. @@ -549,7 +553,6 @@ struct Prepared { params_json: Value, preset_label: String, library: Vec<([u8; 3], Paint)>, - bytes: Arc>, } /// What command `c` with params `p` traces, and how, from the session: everything but the trace. @@ -597,8 +600,8 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; let traced_with = serde_json::to_value(¶ms).map_err(|e| EngineError::Other(e.to_string()))?; - let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone() }; - Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library, bytes }) + let inputs = TraceInputs { target, image, params: traced_with, swatches: params.swatches.clone(), bytes }; + Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library }) } /// What a trace made of an image: the size in pixels it was decoded at, and the paths. @@ -616,11 +619,11 @@ impl Traced { } } -/// Decode the image `bytes` and trace it with `params`, telling `steps` how far it has got -/// ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`. -fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, bytes: &[u8], steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { +/// Decode the image bytes of `inputs` and trace them with `params`, telling `steps` how far it has +/// got ([`tr::trace_watched`]); [`EngineError::Cancelled`] once `steps` answers `false`. +fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { // An image over the tracer's size is traced scaled down (the paths are scaled back). - let raster = tr::Raster::decode_to_fit(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + let raster = tr::Raster::decode_to_fit(&inputs.bytes).map_err(|e| EngineError::Other(e.to_string()))?; if !steps(0, 0) { return Err(EngineError::Cancelled); } @@ -682,7 +685,8 @@ impl TraceRun { (self.prepared.inputs.image.width, self.prepared.inputs.image.height) } - /// Does it still apply: would the command trace the same object, image and settings now? + /// Does it still apply: would the command trace the same object, image, pixels and settings + /// now? pub fn current(&self, s: &Session) -> bool { prepare(s, self.command, &self.params).is_ok_and(|now| now.inputs == self.prepared.inputs) } @@ -691,13 +695,13 @@ impl TraceRun { /// failed or stopped ([`EngineError::Cancelled`] once `steps` answers `false`). It touches no /// document, so it can run on any thread. pub fn run(&self, steps: &(dyn Fn(u64, u64) -> bool + Sync)) -> Result { - trace_now(&self.prepared.inputs, &self.prepared.trace, &self.prepared.bytes, steps) + trace_now(&self.prepared.inputs, &self.prepared.trace, steps) } } pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result { let c = if expand { "imageTrace.makeAndExpand" } else { "imageTrace.make" }; - let Prepared { inputs, trace, view, params_json, preset_label, library, bytes } = prepare(s, c, p)?; + let Prepared { inputs, trace, view, params_json, preset_label, library } = prepare(s, c, p)?; let group_by_color = expand && by_color_param(c, p)?; // A saved preset's settings join the journal entry, so a replay traces as this run did after // the preset is changed, renamed or deleted. @@ -709,7 +713,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result traced, - _ => trace_now(&inputs, &trace, &bytes, &|_, _| true)?, + _ => trace_now(&inputs, &trace, &|_, _| true)?, }; let TraceInputs { target, image: img, .. } = inputs; let mut warnings = keep_warnings(&trace, &res); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 49a806b258..21cbf678eb 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -3788,6 +3788,103 @@ fn a_trace_run_needs_the_library_colours_it_traced_with() { assert!(!run.current(&s), "{name} changed"); } +/// `image_doc`'s image with its disc 20 px to the left: the same size, other pixels. +fn moved_disc_png() -> Vec { + vectorcraft_trace::Raster::from_fn(100, 100, |x, y| { + let (dx, dy) = (x as f64 + 0.5 - 30.0, y as f64 + 0.5 - 50.0); + if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] } + }) + .encode_png() +} + +/// Puts `bytes` under the image key `key`, or takes the key's image data away (`None`). +fn set_image_data(s: &mut Session, key: &str, bytes: Option>) { + s.edit("image data", |d, _| { + match bytes { + Some(b) => d.images.insert(key.into(), ImageBlob::new("image/png", b)), + None => d.images.remove(key), + }; + Ok(()) + }) + .unwrap(); +} + +/// The middle of the first traced path of the Image Trace object the reply `r` names, in x. +fn traced_center_x(s: &Session, r: &Value) -> f64 { + outline(&traced_paths(&node(s, NodeId(r["id"].as_u64().unwrap())))[0]).0.bounds().unwrap().center().x +} + +/// A trace made away from its command no longer applies once the pixels under its image's key +/// change, though the object, its image record and the settings stay as they were; both commands +/// then trace the pixels there now. The same bytes in another allocation still apply. +#[test] +fn a_trace_run_needs_the_pixels_it_traced() { + let p = json!({"params": {"ignoreWhite": true}}); + for command in ["imageTrace.make", "imageTrace.makeAndExpand"] { + let mut s = session(); + image_doc(&mut s); + let run = cmd::TraceRun::prepare(&s, command, &p).unwrap(); + let traced = run.run(&|_, _| true).unwrap(); + // The same bytes, allocated anew: still current. + let same = s.doc().unwrap().doc.images["img1"].bytes.to_vec(); + set_image_data(&mut s, "img1", Some(same)); + assert!(run.current(&s), "{command}: equal bytes"); + // Other pixels under the same key: no longer current, and the hand-off is dropped. + set_image_data(&mut s, "img1", Some(moved_disc_png())); + assert!(!run.current(&s), "{command}: other pixels"); + traced.hand_in(&mut s); + let r = s.execute(command, run.params()).unwrap(); + assert!(s.trace_hand_off.is_none(), "{command}: dropped"); + // The disc is now at image x 30, which the ×2 placement at 50 puts at 110. + let x = traced_center_x(&s, &r); + assert!((x - 110.0).abs() < 1.5, "{command}: traced the pixels there now, at {x}"); + } +} + +/// Image data that is missing or empty: a run made before it went no longer applies, the run's +/// command gives the error it gives without a run, and nothing panics. +#[test] +fn a_trace_run_of_missing_or_empty_image_data() { + let p = json!({"params": {"ignoreWhite": true}}); + let mut s = session(); + image_doc(&mut s); + let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap(); + let traced = run.run(&|_, _| true).unwrap(); + set_image_data(&mut s, "img1", None); + assert!(!run.current(&s), "missing"); + traced.hand_in(&mut s); + let e = s.execute(run.command(), run.params()).unwrap_err().to_string(); + assert!(e.contains("image data `img1` is missing"), "{e}"); + set_image_data(&mut s, "img1", Some(vec![])); + assert!(!run.current(&s), "empty"); + let empty = cmd::TraceRun::prepare(&s, "imageTrace.make", &p).unwrap(); + assert!(empty.current(&s), "two empty blobs are equal"); + assert!(matches!(empty.run(&|_, _| true), Err(EngineError::Other(_)))); + assert!(matches!(s.execute(run.command(), run.params()), Err(EngineError::Other(_)))); +} + +/// Tracing an Image Trace object again follows the same rule: once the pixels of the image it +/// holds change, a run made before no longer applies, and the command traces the pixels there now. +#[test] +fn a_trace_run_of_an_image_trace_object_needs_its_pixels() { + let p = json!({"params": {"ignoreWhite": true}}); + let mut s = session(); + image_doc(&mut s); + let g = s.execute("imageTrace.make", &p).unwrap()["id"].as_u64().unwrap(); + let again = json!({"id": g, "params": {"ignoreWhite": true}}); + let run = cmd::TraceRun::prepare(&s, "imageTrace.make", &again).unwrap(); + assert_eq!(run.target(), NodeId(g)); + let traced = run.run(&|_, _| true).unwrap(); + assert!(run.current(&s)); + set_image_data(&mut s, "img1", Some(moved_disc_png())); + assert!(!run.current(&s)); + traced.hand_in(&mut s); + let r = s.execute(run.command(), run.params()).unwrap(); + assert!(s.trace_hand_off.is_none(), "dropped"); + let x = traced_center_x(&s, &r); + assert!((x - 110.0).abs() < 1.5, "traced the pixels there now, at {x}"); +} + /// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same /// circle as its traced path; without the option it is a plain path. #[test] From 2361d2cb1b810bf32f60d5ff304fb6acc195e58b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:23:56 -0800 Subject: [PATCH 19/20] 3D: caps stay last when overlapping faces form a depth cycle - When the faces that overlap form a cycle of depth orders, the order broke the cycle only after drawing every face with nothing to wait for, so a cap could be drawn before the walls and they painted over it. A face drawn last now keeps its place: no face moves across it. - A face with a corner at or past the eye, which the perspective projection holds just in front of the eye, no longer gets a plane, so its depth is not measured on a plane its drawing does not match. - docs/three-d.md states the masked member rule in Groups as the Fills and strokes section does, and docs/target-app-parity.md cites the Inflate test by its name. --- crates/effects/src/solids.rs | 22 ++++++++++++++++----- crates/effects/src/solids/tests.rs | 31 ++++++++++++++++++++++++++++++ crates/three-d/src/solid/build.rs | 7 +++++-- crates/three-d/src/solid/tests.rs | 17 ++++++++++++++++ docs/target-app-parity.md | 2 +- docs/three-d.md | 7 +++++-- 6 files changed, 76 insertions(+), 10 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index bd1e6e34ae..aeec471bcd 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -980,7 +980,8 @@ fn half(p: &[Point], values: &[f64], sign: f64) -> Vec { /// wall and a face of the same shape's covering stroke ([`Paints::Stroke`]) keep their order. Each /// face otherwise keeps its place in `order`, so a fill wall in the gap between two dashes stays /// where its depth puts it. A cycle of such pairs is broken at the face that comes first in `order`. -/// Otherwise `order` as it is. +/// A face drawn last (a planar end turned toward the viewer, at depth `f64::MAX`) keeps its place: +/// no face moves across it. Otherwise `order` as it is. fn local_order(faces: &[Placed], order: Vec) -> Vec { use std::cmp::Reverse; // The faces of each shape that take part, from left to right for a sweep along x. @@ -1065,14 +1066,25 @@ fn local_order(faces: &[Placed], order: Vec) -> Vec { // Each step draws the face that comes first in `order` of those whose faces before are drawn. let mut ready: std::collections::BinaryHeap> = (0..faces.len()).filter(|&k| waits[k] == 0).map(|k| Reverse((position[k], k))).collect(); + // The faces drawn last, in `order`: each goes only after every face before it in `order`. + let last: Vec = order.iter().copied().filter(|&k| faces[k].depth >= f64::MAX).collect(); + let mut next_last = 0usize; let mut drawn = vec![false; faces.len()]; let mut out = Vec::with_capacity(order.len()); let mut first = 0usize; while out.len() < order.len() { - let k = match ready.pop() { - Some(Reverse((_, k))) => k, - None => { - // A cycle: the first face in `order` not drawn yet goes next. + while last.get(next_last).is_some_and(|&k| drawn[k]) { + next_last += 1; + } + let limit = last.get(next_last).map_or(usize::MAX, |&k| position[k]); + let k = match ready.peek() { + Some(&Reverse((at, k))) if at < limit => { + ready.pop(); + k + } + _ => { + // A cycle, or the faces left to draw before a face drawn last wait for faces after + // it: the first face in `order` not drawn yet goes next. while order.get(first).is_some_and(|&k| drawn[k]) { first += 1; } diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 3c2795dd1b..5126b76660 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -684,6 +684,37 @@ fn local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order() { } } +/// A cycle of overlapping stroke faces is broken at the face that comes first by depth, and a cap +/// drawn last stays after every wall: 0 at x 0 to 10, flat at z 0; 1 at x 4 to 14, z = x - 8; 2 at +/// x 8 to 18, flat at z 1. Where they overlap, 0 is nearer than 1, 1 nearer than 2 and 2 nearer +/// than 0. 3 is the cap. +#[test] +fn a_depth_cycle_keeps_the_cap_last() { + let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)]; + let at = |depth: f64, x: f64, normal: [f64; 3], offset: f64| Placed { + depth, + side: true, + away: false, + bounds: Rect::new(x, 0.0, x + 10.0, 10.0), + member: 0, + paints: Paints::Other, + outline: square(x), + plane: Some(DepthPlane { normal, offset, center: Point::ZERO, eye: None }), + }; + let cap = Placed { + depth: f64::MAX, + side: false, + away: false, + bounds: Rect::new(0.0, 0.0, 18.0, 10.0), + member: 0, + paints: Paints::Other, + outline: vec![], + plane: None, + }; + let faces = [at(1.0, 0.0, [0.0, 0.0, 1.0], 0.0), at(2.0, 4.0, [-1.0, 0.0, 1.0], -8.0), at(3.0, 8.0, [0.0, 0.0, 1.0], 1.0), cap]; + assert_eq!(draw_order(&faces, true), [0, 2, 1, 3]); +} + /// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a /// face of the same shape's stroke drawn before it: the wall then goes just before that face. A /// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 1263582df9..359192c7f8 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -130,9 +130,12 @@ impl Builder<'_> { (V3(-n.0, -n.1, -n.2), false) }; // A face whose corners lie off one plane (a piece of a twisted wall bent into a saddle) has - // no plane to measure its depth on. + // no plane to measure its depth on. Nor has a face with a corner at or past the eye: the + // projection holds that corner just in front of the eye, so the drawing no longer matches + // the plane's projection there. let offset = n.dot(centre); - let flat = contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); + let held = self.camera.eye.is_some_and(|e| contours.iter().flatten().any(|v| e - v.2 < e * 1e-3)); + let flat = !held && contours.iter().flatten().all(|v| (n.dot(*v) - offset).abs() <= 1e-3); let plane = flat.then_some(DepthPlane { normal: [n.0, n.1, n.2], offset, center: self.camera.origin, eye: self.camera.eye }); #[cfg(test)] let view = contours.clone(); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index b0c26ccc0a..9929a52860 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -177,6 +177,23 @@ fn a_flat_facet_measures_its_depth_on_its_plane() { } } +/// A flat face with a corner at or past the eye, which the projection holds just in front of the +/// eye, has no plane: its drawing no longer matches the plane's projection there. +#[test] +fn a_face_reaching_past_the_eye_has_no_plane() { + use super::build::{Builder, Order}; + let (_, b) = shape(&square(0.0, 0.0, 100.0)); + let camera = Camera::new(View { perspective: 50.0, ..front() }, b, 0.0); + let eye = camera.center_and_eye().1.unwrap(); + for (far, has_plane) in [(eye / 2.0, true), (2.0 * eye, false)] { + let mut builder = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: None }; + let corners = vec![V3(-10.0, -10.0, 0.0), V3(10.0, -10.0, 0.0), V3(0.0, 10.0, far)]; + builder.face(vec![corners], V3(0.0, -far, 20.0), Order::Depth).unwrap(); + assert_eq!(builder.facets.len(), 1, "{far}"); + assert_eq!(builder.facets[0].plane.is_some(), has_plane, "{far}"); + } +} + #[test] fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f447b00c84..895708e987 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1176,7 +1176,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`. - With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`. - - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_cuts_its_dashes_as_a_white_one_does`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. + - When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`. - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). - Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`). diff --git a/docs/three-d.md b/docs/three-d.md index e9d5a696ff..71cced06b3 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -159,8 +159,11 @@ them all. The members stay editable: moving or editing one reshapes the solids. inside the 3D group applies to the solids of that group's members together. An opacity mask on a member masks its solids, and one on a group inside the 3D group masks the solids of that group's members together. The mask stays where it is on the page; it does not turn with the solids. A disabled mask is ignored. -- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, each - solid is drawn on its own, the backmost member's first, as when a solid is transparent. +- When a member, or a group inside the 3D group, has an opacity mask or a blending mode other than Normal, the + members' faces are not drawn in one shared order. Each member whose fills and strokes are opaque with Normal + blending draws their faces in one order of its own inside its masks and blending modes, as described under + [Fills and strokes](#fills-and-strokes). Otherwise each solid is drawn on its own, the backmost member's first, as + when a solid is transparent. - When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn on its own, the backmost member's first. - Hidden members and guides are left out. A group holding a live object other than a blend (an envelope, a mesh, a From 8b990de0043b2e659886405a97cc429c3b9e52a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:25:49 -0800 Subject: [PATCH 20/20] Join: merge a run of coincident anchors in one pass over the path Join removed each merged anchor from the path's anchor list as it went, which moved every anchor after it, so a long selected run in front of many other anchors took time quadratic in the path's length. It also looked up each anchor's selection in the selected-anchor set. Join now marks the merged anchors and removes them in one pass at the end, reading the selection from one flag per anchor. Each corner or smooth conversion sees the same neighbors as before, so the resulting anchors are unchanged. Anchor::smooth_between holds the smoothing that SubPath::smooth_anchor applies. Tests compare the new merge bit for bit with the old one on 30,000 generated paths, and count the anchors moved when 300,000 selected anchors in front of 300,000 others merge: each of the others moves once. --- crates/engine/src/cmd/path.rs | 215 ++++++++++++++++++++++++++++++++-- crates/geom/src/path.rs | 36 +++--- docs/target-app-parity.md | 2 +- 3 files changed, 229 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index b4639f0de5..72484eda34 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -481,6 +481,12 @@ fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) { } } +#[cfg(test)] +thread_local! { + /// Anchors that [`merge_coincident`] moved to a lower index of their vector on this thread. + static ANCHOR_MOVES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which /// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many /// anchors went. @@ -501,23 +507,68 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: join_point(sp, 0, smooth); } } - // Back to front, so the indices `picked` knows stay true for the anchors still to visit. - for i in (1..sp.anchors.len()).rev() { + // Back to front, so the indices `picked` knows stay true for the anchors still to visit. A + // merged anchor is only marked here; the marked anchors are removed in one pass at the end. + let closed = sp.closed; + let anchors = &mut sp.anchors; + let len = anchors.len(); + let mut gone = vec![false; len]; + // The anchor after `i` that is still on the path, and the last one still on it. + let mut next = None; + let mut last = len.saturating_sub(1); + for i in (1..len).rev() { if picked(i - 1) && picked(i) - && let Some([a, b]) = sp.anchors.get_mut(i - 1..=i) + && let Some([a, b]) = anchors.get_mut(i - 1..=i) && same(a, b) { a.h_out = b.h_out; - sp.anchors.remove(i); - if let Some(smooth) = join_kind { - join_point(sp, i - 1, smooth); + if let Some(slot) = gone.get_mut(i) { + *slot = true; } + if next.is_none() { + last = i - 1; + } + if let Some(smooth) = join_kind { + // The neighbors the anchor has on the path as it now stands, as `join_point` sees them. + let prev = if i > 1 { Some(i - 2) } else { closed.then_some(last) }; + let after = next.or(closed.then_some(0)); + join_point_between(anchors, i - 1, prev, after, smooth); + } + } else { + next = Some(i); } } + #[cfg(test)] + if let Some(first) = gone.iter().position(|&g| g) { + // `retain` moves each anchor it keeps after the first one it drops. + let dropped = gone.iter().filter(|&&g| g).count(); + ANCHOR_MOVES.set(ANCHOR_MOVES.get() + (len - first - dropped)); + } + if gone.contains(&true) { + let mut k = 0; + anchors.retain(|_| { + let keep = !gone.get(k).copied().unwrap_or(false); + k += 1; + keep + }); + } before - sp.anchors.len() } +/// [`join_point`] on anchor `ai` of `anchors` with its neighbors given by index. +fn join_point_between(anchors: &mut [Anchor], ai: usize, prev: Option, next: Option, smooth: bool) { + let at = |j: Option| j.and_then(|j| anchors.get(j)).map(|a| a.p); + let (prev, next) = (at(prev), at(next)); + if let Some(a) = anchors.get_mut(ai) { + if smooth { + a.smooth_between(prev, next); + } else { + a.retract(); + } + } +} + /// Close subpath `si` of path `id`, merging coincident endpoints if present. fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option) -> Result<()> { let path = path_mut(d, id)?; @@ -751,8 +802,19 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result { let mut merged = vec![]; for (id, picked) in &st.selection.anchors { let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue }; - let n: usize = - path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)), join_kind)).sum(); + // One flag per anchor; references past the end of a subpath or to a missing one pick nothing. + let mut masks: Vec> = path.subpaths.iter().map(|sp| vec![false; sp.anchors.len()]).collect(); + for &(si, ai) in picked { + if let Some(flag) = masks.get_mut(si).and_then(|m| m.get_mut(ai)) { + *flag = true; + } + } + let n: usize = path + .subpaths + .iter_mut() + .zip(&masks) + .map(|(sp, mask)| merge_coincident(sp, |ai| mask.get(ai).copied().unwrap_or(false), join_kind)) + .sum(); if n > 0 { merged.push((*id, path)); } @@ -1763,3 +1825,140 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result { })?; Ok(super::draw2::ids_json(&ids)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `merge_coincident` removing each merged anchor from the vector straight away, kept as the + /// reference the one-pass version must match. + fn merge_coincident_by_removal(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: Option) -> usize { + let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6; + let before = sp.anchors.len(); + if sp.closed + && before > 2 + && picked(0) + && picked(before - 1) + && let [first, .., last] = sp.anchors.as_mut_slice() + && same(first, last) + { + first.h_in = last.h_in; + sp.anchors.pop(); + if let Some(smooth) = join_kind { + join_point(sp, 0, smooth); + } + } + for i in (1..sp.anchors.len()).rev() { + if picked(i - 1) + && picked(i) + && let Some([a, b]) = sp.anchors.get_mut(i - 1..=i) + && same(a, b) + { + a.h_out = b.h_out; + sp.anchors.remove(i); + if let Some(smooth) = join_kind { + join_point(sp, i - 1, smooth); + } + } + } + before - sp.anchors.len() + } + + /// Every field of every anchor, bit for bit. + fn bits(sp: &SubPath) -> (Vec<[u64; 6]>, Vec, bool) { + let b = |p: Point| [p.x.to_bits(), p.y.to_bits()]; + let fields = sp.anchors.iter().map(|a| [b(a.p), b(a.h_in), b(a.h_out)].concat().try_into().unwrap()).collect(); + (fields, sp.anchors.iter().map(|a| a.kind).collect(), sp.closed) + } + + /// A small linear congruential generator, so the cases are the same on every run. + struct Lcg(u64); + impl Lcg { + fn below(&mut self, n: u64) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (self.0 >> 33) % n + } + } + + fn same_as_removal(sp: &SubPath, picked: &dyn Fn(usize) -> bool, join_kind: Option) { + let (mut ours, mut theirs) = (sp.clone(), sp.clone()); + let n = merge_coincident(&mut ours, picked, join_kind); + let m = merge_coincident_by_removal(&mut theirs, picked, join_kind); + assert_eq!(n, m, "{sp:?} {join_kind:?}"); + assert_eq!(bits(&ours), bits(&theirs), "{sp:?} {join_kind:?}"); + } + + /// Short paths with points on, near and just past the merge distance, selections with stale + /// indices, every anchor kind and every join kind give the same anchors, bit for bit, as + /// removing each merged anchor straight away. + #[test] + fn merging_coincident_anchors_matches_removing_them_one_by_one() { + let spots = [(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (5e-7, 0.0), (0.0, 1.5e-6), (10.0, 0.0), (5.0, 5.0), (10.0, 0.0)]; + let mut rng = Lcg(7); + for _ in 0..30_000 { + let n = rng.below(9) as usize; + let anchors = (0..n) + .map(|_| { + let (x, y) = spots[rng.below(spots.len() as u64) as usize]; + let p = Point::new(x, y); + let handle = |rng: &mut Lcg| match rng.below(3) { + 0 => p, + _ => p + Vec2::new(rng.below(7) as f64 - 3.0, rng.below(7) as f64 - 3.0), + }; + let (h_in, h_out) = (handle(&mut rng), handle(&mut rng)); + let kind = if rng.below(2) == 0 { AnchorKind::Corner } else { AnchorKind::Smooth }; + Anchor { p, h_in, h_out, kind } + }) + .collect(); + let sp = SubPath::new(anchors, rng.below(2) == 0); + let picks: Vec = (0..n + 2).map(|_| rng.below(4) != 0).collect(); + let join_kind = [None, Some(false), Some(true)][rng.below(3) as usize]; + same_as_removal(&sp, &|i| picks.get(i).copied().unwrap_or(false), join_kind); + } + } + + /// A smooth join of three anchors on one another takes the outgoing handle the middle anchor + /// was given when it joined the right one: an already smooth left anchor keeps that handle. + #[test] + fn a_smooth_join_of_three_anchors_keeps_the_handle_the_middle_one_got() { + let o = Point::ZERO; + let left = Anchor { p: o, h_in: Point::new(-2.0, 0.0), h_out: o, kind: AnchorKind::Smooth }; + let right = Anchor { p: o, h_in: o, h_out: Point::new(0.0, 5.0), kind: AnchorKind::Corner }; + let mut sp = SubPath::new(vec![left, Anchor::corner(o), right, Anchor::corner(Point::new(10.0, 0.0))], false); + assert_eq!(merge_coincident(&mut sp, |i| i < 3, Some(true)), 2); + let a = sp.anchors[0]; + assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(-2.0, 0.0), Point::new(10.0 / 3.0, 0.0), AnchorKind::Smooth)); + assert_eq!(sp.anchors.len(), 2); + } + + /// One long run of selected anchors on one another, open or closed, becomes one anchor. + #[test] + fn a_long_run_of_anchors_on_one_another_becomes_one() { + for closed in [false, true] { + for join_kind in [None, Some(false), Some(true)] { + let anchors = (0..50_000).map(|i| Anchor::smooth(Point::ZERO, Point::new(1.0, i as f64))).collect(); + let sp = SubPath::new(anchors, closed); + same_as_removal(&sp, &|_| true, join_kind); + let mut ours = sp.clone(); + assert_eq!(merge_coincident(&mut ours, |_| true, join_kind), 49_999); + assert_eq!((ours.anchors.len(), ours.closed), (1, closed)); + } + } + } + + /// A long selected run at the start of a path followed by as many anchors that stay: each + /// anchor after the run moves once, not once per merged anchor. + #[test] + fn a_long_run_before_many_other_anchors_merges_in_one_pass() { + let run = 300_000; + let anchors = (0..run).map(|_| Anchor::corner(Point::ZERO)).chain((1..=run).map(|i| Anchor::corner(Point::new(i as f64, 1.0)))).collect(); + let mut sp = SubPath::new(anchors, false); + ANCHOR_MOVES.set(0); + assert_eq!(merge_coincident(&mut sp, |i| i < run, Some(false)), run - 1); + assert_eq!(ANCHOR_MOVES.get(), run); + assert_eq!(sp.anchors.len(), run + 1); + assert_eq!(sp.anchors[0].p, Point::ZERO); + assert_eq!(sp.anchors[1].p, Point::new(1.0, 1.0)); + assert_eq!(sp.anchors[run].p, Point::new(run as f64, 1.0)); + } +} diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 59a8f30660..af5a82c274 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -165,6 +165,26 @@ impl Anchor { pub fn reversed(&self) -> Self { Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind } } + /// Make the anchor smooth between the points `prev` and `next` (its own point for a missing + /// one): handles in line with them, each a third of the way to that side's point. A smooth + /// anchor with both handles keeps them. False, with no change, when the two points give no + /// direction. [`SubPath::smooth_anchor`] calls it with the anchor's neighbors. + pub fn smooth_between(&mut self, prev: Option, next: Option) -> bool { + if self.kind == AnchorKind::Smooth && self.has_in() && self.has_out() { + return true; + } + let (pp, nn) = (prev.unwrap_or(self.p), next.unwrap_or(self.p)); + let dir = nn - pp; + let l = dir.hypot(); + if l <= 1e-9 { + return false; + } + let u = dir / l; + self.h_in = self.p - u * (self.p.distance(pp) / 3.0); + self.h_out = self.p + u * (self.p.distance(nn) / 3.0); + self.kind = AnchorKind::Smooth; + true + } /// Remove both handles (convert to a sharp corner). pub fn retract(&mut self) { self.h_in = self.p; @@ -398,21 +418,7 @@ impl SubPath { }; let at = |j: Option| j.and_then(|j| self.anchors.get(j)).map(|a| a.p); let (prev, next) = (at(prev), at(next)); - let Some(a) = self.anchors.get_mut(i) else { return false }; - if a.kind == AnchorKind::Smooth && a.has_in() && a.has_out() { - return true; - } - let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p)); - let dir = nn - pp; - let l = dir.hypot(); - if l <= 1e-9 { - return false; - } - let u = dir / l; - a.h_in = a.p - u * (a.p.distance(pp) / 3.0); - a.h_out = a.p + u * (a.p.distance(nn) / 3.0); - a.kind = AnchorKind::Smooth; - true + self.anchors.get_mut(i).is_some_and(|a| a.smooth_between(prev, next)) } /// Cut at the anchors `at`: each becomes the end of one open piece and the start of the next, /// its handle off each piece retracted (the shape doesn't change). A closed subpath opens at diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..2103c2f9d4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1056,7 +1056,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. + - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **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.