From 2497713779c2499c073189b7edba348b6a004c17 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 05:31:43 +0700 Subject: [PATCH] Fix #598: the Free Transform widget While the Free Transform tool (E) is active, a small box at the canvas's top left holds Constrain over the tool's three modes: Free Transform, Perspective Distort and Free Distort. Before, the modes were reachable only by modifier keys or `tool.setOption`. - The buttons set the tool's `constrain` and `mode` options through `tool.setOption`, as agents do. Constrain acts as Shift held and is greyed in the distort modes, where it does nothing. The modifier keys keep working while dragging. - Theme tokens; Constrain shows the link icon, Free Transform the tool's own icon, the distort modes are drawn in code. Tooltips translated (cs, es, it, ja, pt-br, ru, zh-hans, zh-hant). - `ui.inspect` reports where it shows (`freeTransformWidget`). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/free_transform.rs | 202 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 1 + docs/control-protocol.md | 9 +- 14 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/free_transform.rs diff --git a/ROADMAP.md b/ROADMAP.md index a8bc032f..9aff4e90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), `.ai` files writing their layers and sublayers as PDF layers, hidden ones with their art, and PDF layers nested in layers importing as sublayers (#372), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592), the Free Transform widget with Constrain, Free Transform, Perspective Distort and Free Distort (#598); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 588924fa..34453577 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -384,6 +384,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 { task_bar(app, ui, &xf); } + if app.ui.screen_mode < 3 { + crate::free_transform::show(app, ui, xf.rect); + } // Cursor. if resp.hovered() { let m = ui.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 43977320..724f111e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -84,6 +84,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "pinned": app.ui.task_bar_place.pinned, "rect": crate::canvas::task_bar_rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), }, + // The Free Transform tool's widget, where it shows. + "freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), "window": [r.width(), r.height()], diff --git a/crates/ui-egui/src/free_transform.rs b/crates/ui-egui/src/free_transform.rs new file mode 100644 index 00000000..56b3eb24 --- /dev/null +++ b/crates/ui-egui/src/free_transform.rs @@ -0,0 +1,202 @@ +//! The Free Transform widget (#598): while the Free Transform tool is active, a small floating box +//! at the canvas's top left with Constrain (as holding Shift: proportional scaling, moves and +//! rotations by 45°) over the tool's three modes, Free Transform, Perspective Distort and Free +//! Distort. It sets the tool's options through `tool.setOption` (`constrain`, `mode`), as agents +//! do; the modifier keys keep working while dragging. + +use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; +use crate::theme::Tokens; + +/// The tool the widget belongs to. +const TOOL: &str = "freeTransform"; +/// A button's side. +const BUTTON: f32 = 28.0; + +/// What a button shows. +#[derive(Clone, Copy)] +enum Glyph { + /// An icon of the set. + Icon(&'static str), + /// A box narrower at the top than at the bottom, its corners marked. + Perspective, + /// A box with corners pulled every which way, its corners marked. + Distort, +} + +/// The tool's modes as its `mode` option names them, with their buttons. +fn modes() -> [(&'static str, Glyph, &'static str); 3] { + [ + ("free", Glyph::Icon("dc-free-transform"), tl!("Free Transform")), + ("perspective", Glyph::Perspective, tl!("Perspective Distort")), + ("distort", Glyph::Distort, tl!("Free Distort")), + ] +} + +/// Where the widget was last drawn (none while it isn't shown). +pub(crate) fn rect(ctx: &egui::Context) -> Option { + let id = egui::Id::new(TOOL); + ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) +} + +/// Show the widget in `canvas` (the canvas's area inside the rulers) while the Free Transform +/// tool is active. +pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) { + if app.session.tool_id() != TOOL { + return; + } + let t = Tokens::get(ui.ctx()); + let opts = app.session.tool_options(); + let mode = opts["mode"].as_str().unwrap_or("free").to_string(); + let constrain = opts["constrain"].as_bool().unwrap_or(false); + let mut set: Option<(&str, Value)> = None; + egui::Area::new(egui::Id::new(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.button_border)) + .corner_radius(CornerRadius::same(4)) + .inner_margin(egui::Margin::same(3)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(60) }) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + // Constrain shapes only what the Free Transform mode does. + if button(ui, Glyph::Icon("link"), tl!("Constrain"), constrain, mode == "free") { + set = Some(("constrain", json!(!constrain))); + } + let (line, _) = ui.allocate_exact_size(vec2(BUTTON, 3.0), Sense::hover()); + ui.painter().hline(line.x_range().shrink(4.0), line.center().y, Stroke::new(1.0, t.button_border)); + for (name, glyph, tip) in modes() { + if button(ui, glyph, tip, mode == name, true) && mode != name { + set = Some(("mode", json!(name))); + } + } + }); + }); + if let Some((key, value)) = set + && let Err(e) = app.run("tool.setOption", json!({ "key": key, "value": value })) + { + app.status(e); + } +} + +/// A square button showing `glyph`, highlighted when `on`, greyed and inert unless `enabled`. +/// Returns whether it was clicked. +fn button(ui: &mut Ui, glyph: Glyph, tip: &str, on: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, BUTTON), if enabled { Sense::click() } else { Sense::hover() }); + let bg = if on && enabled { + t.tool_active + } else if enabled && resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(3), bg); + let color = match (enabled, on) { + (false, _) => t.text_dim, + (true, true) => t.text, + (true, false) => t.icon, + }; + paint(ui, glyph, rect.shrink(6.0), color); + resp.on_hover_text(tip).clicked() && enabled +} + +/// Draw `glyph` in `r`. +fn paint(ui: &Ui, glyph: Glyph, r: Rect, color: Color32) { + let at = |x: f32, y: f32| pos2(r.left() + x * r.width(), r.top() + y * r.height()); + let quad: [Pos2; 4] = match glyph { + Glyph::Icon(name) => return crate::icons::paint(ui, name, r, color), + Glyph::Perspective => [at(0.3, 0.12), at(0.7, 0.12), at(0.95, 0.88), at(0.05, 0.88)], + Glyph::Distort => [at(0.08, 0.2), at(0.78, 0.06), at(0.94, 0.9), at(0.2, 0.76)], + }; + let (p, fill) = (ui.painter(), Tokens::get(ui.ctx()).panel); + p.add(egui::Shape::closed_line(quad.to_vec(), Stroke::new(1.3, color))); + for c in quad { + let mark = Rect::from_center_size(c, vec2(3.5, 3.5)); + p.rect_filled(mark, 0.0, fill); + p.rect_stroke(mark, 0.0, Stroke::new(1.0, color), StrokeKind::Middle); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + use vectorcraft_geom::Point; + + use super::*; + use crate::canvas::Xf; + + fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| crate::canvas::show(app, ui)).textures_delta.clear(); + } + + /// A press and release at `at`, then a frame to settle. + fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, ctx, vec![egui::Event::PointerMoved(at), button(true)]); + frame(app, ctx, vec![button(false)]); + frame(app, ctx, vec![]); + } + + fn options(app: &VectorcraftApp) -> (String, bool) { + let o = app.session.tool_options(); + (o["mode"].as_str().unwrap_or_default().to_string(), o["constrain"].as_bool().unwrap_or_default()) + } + + /// The widget shows with the Free Transform tool only; its buttons set the mode and Constrain + /// (inert in the distort modes); a corner dragged in Free Distort moves that corner alone. + #[test] + fn the_widget_sets_the_mode_and_a_corner_drag_then_distorts() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + app.select_tool("selection"); + frame(&mut app, &ctx, vec![]); + assert!(rect(&ctx).is_none(), "not with the Selection tool"); + app.select_tool(TOOL); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let w = rect(&ctx).expect("the widget shows"); + let canvas = app.canvas_rect.unwrap(); + assert!(canvas.contains(w.min) && w.left() - canvas.left() < 20.0 && w.top() - canvas.top() < 20.0, "{w:?} in {canvas:?}"); + // Constrain, a rule, then Free Transform, Perspective Distort and Free Distort. + let at = |i: usize| { + let rule = if i > 0 { 3.0 + 2.0 } else { 0.0 }; + pos2(w.center().x, w.top() + 4.0 + BUTTON / 2.0 + i as f32 * (BUTTON + 2.0) + rule) + }; + assert_eq!(options(&app), ("free".into(), false)); + click(&mut app, &ctx, at(0)); + assert_eq!(options(&app), ("free".into(), true), "Constrain on"); + click(&mut app, &ctx, at(3)); + assert_eq!(options(&app), ("distort".into(), true)); + click(&mut app, &ctx, at(0)); + assert_eq!(options(&app), ("distort".into(), true), "Constrain is inert in Free Distort"); + click(&mut app, &ctx, at(2)); + assert_eq!(options(&app).0, "perspective"); + click(&mut app, &ctx, at(3)); + // Free Distort: the top-left corner dragged alone. + let xf = Xf::new(canvas, app.view().unwrap()); + let (from, to) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(80.0, 90.0))); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) / 2.0)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]); + frame(&mut app, &ctx, vec![button(to, false)]); + frame(&mut app, &ctx, vec![]); + let st = app.session.active().unwrap(); + let node = st.doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let pts: Vec = node.path_data().unwrap().anchors().map(|(.., a)| a.p).collect(); + let near = |q: Point| pts.iter().any(|p| p.distance(q) < 0.5); + assert!( + near(Point::new(80.0, 90.0)) && near(Point::new(200.0, 100.0)) && near(Point::new(200.0, 160.0)) && near(Point::new(100.0, 160.0)), + "{pts:?}" + ); + assert_eq!(app.session.tool_id(), TOOL); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 82c42bc8..7b9207c3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -635,3 +635,6 @@ Appearance of Black Vzhled černé Devices Zařízení A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm + Constrain Omezit + Free Transform Volná transformace + Perspective Distort Perspektivní deformace diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 52049bd1..d0390099 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3599,3 +3599,6 @@ axis Both Ambos Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo. VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo. A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él + Constrain Restringir + Free Transform Transformación libre + Perspective Distort Distorsión de perspectiva diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c02a2915..007c7633 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3599,3 +3599,6 @@ axis Both Entrambi Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto. VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia. A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì + Constrain Vincola + Free Transform Trasformazione libera + Perspective Distort Distorsione prospettica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ffe95c7b..656c0eb5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3061,3 +3061,6 @@ theme Appearance 外観 Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。 VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。 A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください + Constrain 制限 + Free Transform 自由変形 + Perspective Distort 遠近変形 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e98ec47a..4ecd356e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2649,3 +2649,6 @@ Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo. VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda. A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro + Constrain Restringir + Free Transform Transformação Livre + Perspective Distort Distorção de Perspectiva diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fbeac868..402d69bc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3597,3 +3597,6 @@ axis Both Оба Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения. VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла. A file dialog is open: choose there first Открыто окно выбора файла: сначала сделайте выбор в нём + Constrain Ограничить + Free Transform Свободное трансформирование + Perspective Distort Искажение перспективы diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1f9dbec4..0bdc7380 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3316,3 +3316,5 @@ axis Both 两者 Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。 VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。 A file dialog is open: choose there first 文件对话框已打开:请先在其中选择 + Constrain 约束 + Perspective Distort 透视扭曲 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d1d78f38..34d5395b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3091,3 +3091,6 @@ axis Both 兩者 Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。 VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。 A file dialog is open: choose there first 檔案對話方塊已開啟:請先在其中選擇 + Constrain 強制 + Free Transform 任意變形 + Perspective Distort 透視扭曲 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5a99a00..4e301b85 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod dock; pub mod find_font; pub mod floating; pub mod font_menu; +mod free_transform; pub mod graphics; pub mod i18n; pub mod icon_data; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ebf984d0..a5414b52 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -18,7 +18,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | | `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does | -| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) | +| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | @@ -214,6 +214,13 @@ Bar Position (`window.taskBar.reset`), which unpins it and puts it back under th Panel. As in Illustrator, neither the position nor the pin is saved: each launch starts with the bar under the selection. +The Free Transform widget: while the Free Transform tool (E) is active, a small box at the canvas's top left holds +Constrain over the tool's three modes, Free Transform, Perspective Distort and Free Distort. The buttons set the tool's +options, as `tool.setOption {key: "mode", value: "free"|"perspective"|"distort"}` and `{key: "constrain", value}` do: +Constrain acts as Shift held (proportional scaling, moves and rotations by 45°) and does nothing in the distort modes, +where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in +perspective, Cmd on a side shears). + Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;