diff --git a/ROADMAP.md b/ROADMAP.md index 1279a1780..54921fb11 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a text style binds a number variable to its weight (#2174); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); a text style binds a number variable to its weight (#2174); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -365,6 +365,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result. Numbers unchanged | | 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. `board.mindMap` places a first node with three children on curved branches and `board.mindMapAdd` adds a child or a sibling; the normalize pass lays a touched map out again from its first node (88 between columns, 48 between siblings, stacks centred on their parents). No toolbar entry yet; numbers unchanged. | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it while an explicit Hans stays Simplified; the desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled; native review pending. Numbers unchanged | | 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 5b90b3bff..f9601f362 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -395,7 +395,8 @@ impl NodeKind { ) } - /// Kinds whose corners can be rounded from the panel. + /// Kinds whose corners can be rounded from the panel. A boolean group has one radius for every + /// sharp corner of its result. pub fn has_corner_radius(&self) -> bool { matches!( self, @@ -408,16 +409,19 @@ impl NodeKind { | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::Section { .. } + | NodeKind::BooleanOp { .. } ) } - /// Kinds with a radius per corner (the others round every corner alike). + /// Kinds with a radius per corner (the others, boolean groups included, round every corner alike). pub fn has_independent_corners(&self) -> bool { - self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. }) + self.has_corner_radius() + && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::BooleanOp { .. }) } /// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network - /// has a closed region (an open path's rounded points are drawn without it). + /// has a closed region (an open path's rounded points are drawn without it). A boolean group's + /// smoothing applies to its result's corners between straight edges. pub fn has_corner_smoothing(&self) -> bool { match self { NodeKind::Vector { network } => !network.regions.is_empty(), diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index e4892bcae..6fc5f2396 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -103,3 +103,24 @@ fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_s let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); } + +#[test] +fn the_invert_zoom_preference_toggles_and_is_saved() { + let mut s = Session::new(); + assert!(!s.prefs.invert_zoom, "off by default"); + assert_eq!(s.execute("pref.invertZoom", &json!({})).unwrap()["on"], true); + assert!(s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], true); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(loaded.invert_zoom); + assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false); + assert!(!s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], false); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(!loaded.invert_zoom, "off persists after being on"); + // Preferences saved before the setting existed read it as off. + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert!(!old.invert_zoom); +} diff --git a/crates/engine/src/boolean_radius_tests.rs b/crates/engine/src/boolean_radius_tests.rs new file mode 100644 index 000000000..f739b3eaf --- /dev/null +++ b/crates/engine/src/boolean_radius_tests.rs @@ -0,0 +1,134 @@ +//! A boolean group's corner radius through the engine: it rounds the result (and the SVG export +//! of it), children keep their own radius, Flatten bakes the arcs and clicks follow the result. + +use serde_json::json; + +use crate::Session; +use crate::booleans_tests::{area, click, ex}; +use crate::params::id_of; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_geom::{Point, Shape}; + +const L_AREA: f64 = 34656.6; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"]).unwrap() +} + +fn snapping_off(s: &mut Session) { + ex(s, "select.none", json!({})); + let ds = s.ds_mut().unwrap(); + ds.view.snap_objects = false; + ds.view.snap_pixel = false; +} + +fn set_radius(s: &mut Session, id: NodeId, r: f64) { + ex(s, "node.set", json!({"ids": [id], "props": {"cornerRadius": r}})); +} + +/// The L of two rectangles, united, nothing selected, snapping off. +fn l_group(s: &mut Session) -> NodeId { + let (a, b) = (rect(s, 0.0, 0.0, 200.0, 100.0), rect(s, 150.0, 0.0, 100.0, 200.0)); + let g = id_of(&ex(s, "bool.union", json!({"ids": [a, b]}))["id"]).unwrap(); + snapping_off(s); + g +} + +/// Whether the page point is inside the group's drawn result. +fn covers(s: &Session, g: NodeId, x: f64, y: f64) -> bool { + let doc = s.doc().unwrap(); + let (path, _) = figcraft_render::node_outline(doc, doc.node(g).unwrap()).unwrap(); + let p = doc.world_transform(g).inverse() * Point::new(x, y); + path.winding(p) % 2 != 0 +} + +#[test] +fn a_boolean_group_rounds_every_sharp_corner_of_its_result() { + let mut s = Session::new(); + let g = l_group(&mut s); + assert!(covers(&s, g, 1.0, 1.0)); + set_radius(&mut s, g, 20.0); + assert!(!covers(&s, g, 1.0, 1.0) && covers(&s, g, 148.0, 102.0)); + assert!((area(&s, g) - L_AREA).abs() < 1.0, "{}", area(&s, g)); + let doc = s.doc().unwrap(); + let svg = figcraft_svg::export_svg(doc, &[g], &figcraft_svg::SvgOptions::default()).unwrap(); + let d = svg.split("d=\"").nth(1).and_then(|r| r.split('"').next()).unwrap(); + let exported = figcraft_geom::svg_path(d).unwrap(); + assert!((exported.area().abs() - L_AREA).abs() < 1.0, "{}", exported.area()); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii, [0.0; 4], "one undo step"); + assert!((area(&s, g) - 35000.0).abs() < 1.0); +} + +/// The notch of the measurement: A (200 by 100 at the origin) minus a 100 square on its top edge, +/// whose own radius is `a_radius`, under a group radius of `radius`. +fn notch(s: &mut Session, a: NodeId, radius: f64) -> NodeId { + let b = rect(s, 50.0, -50.0, 100.0, 100.0); + let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap(); + snapping_off(s); + set_radius(s, g, radius); + g +} + +/// The asserts of the measurement on a notched group at radius 20 under A's own 10. +fn assert_notch_at_20(s: &Session, g: NodeId) { + assert!(covers(s, g, 4.0, 4.0), "A's 10 was not enlarged to 20"); + assert!(!covers(s, g, 48.0, 2.0), "the cut's top corner rounds"); + assert!(covers(s, g, 52.0, 48.0), "the cut's inner corner fills in"); +} + +#[test] +fn children_keep_their_radius_and_a_subtraction_rounds_its_cut() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 200.0, 100.0); + set_radius(&mut s, a, 10.0); + let g = notch(&mut s, a, 20.0); + assert_notch_at_20(&s, g); + assert_eq!(s.doc().unwrap().node(a).unwrap().corners.radii, [10.0; 4]); + set_radius(&mut s, g, 500.0); + assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii[0], 500.0, "the field keeps the value"); + assert!(!covers(&s, g, 40.0, 4.0), "25, not the 20 that half the 40-long edge would give"); + assert!(covers(&s, g, 27.0, 1.0), "and not more than 25"); + assert!(covers(&s, g, 100.0, 51.0) && !covers(&s, g, 100.0, 49.0), "the floor stays straight between 75 and 125"); + assert!(covers(&s, g, 4.0, 4.0)); +} + +#[test] +fn a_nested_group_keeps_its_own_radius() { + let mut s = Session::new(); + let (l, r) = (rect(&mut s, 0.0, 0.0, 120.0, 100.0), rect(&mut s, 80.0, 0.0, 120.0, 100.0)); + let inner = id_of(&ex(&mut s, "bool.union", json!({"ids": [l, r]}))["id"]).unwrap(); + set_radius(&mut s, inner, 10.0); + let g = notch(&mut s, inner, 20.0); + assert_notch_at_20(&s, g); +} + +#[test] +fn flatten_bakes_the_arcs_and_the_vector_has_no_radius() { + let mut s = Session::new(); + let g = l_group(&mut s); + set_radius(&mut s, g, 20.0); + let flat = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [g]}))["id"]).unwrap(); + let doc = s.doc().unwrap(); + let n = doc.node(flat).unwrap(); + let NodeKind::Vector { network } = &n.kind else { panic!("a vector: {:?}", n.kind) }; + assert_eq!(n.corners.radii, [0.0; 4]); + assert!(network.vertices.iter().all(|v| v.radius == 0.0)); + let path = figcraft_doc::geometry::shape_path(n).unwrap(); + assert!((path.area().abs() - L_AREA).abs() < 1.0, "{}", path.area()); + assert!((n.width - 250.0).abs() < 1e-6 && (n.height - 200.0).abs() < 1e-6); +} + +#[test] +fn clicks_follow_the_rounded_result() { + let mut s = Session::new(); + let g = l_group(&mut s); + set_radius(&mut s, g, 20.0); + click(&mut s, 1.0, 1.0, false); + assert!(s.selection().is_empty(), "the cut-off corner reaches what is behind"); + click(&mut s, 148.0, 102.0, false); + assert_eq!(s.selection(), vec![g], "the concave fillet belongs to the group"); + ex(&mut s, "select.none", json!({})); + click(&mut s, 100.0, 50.0, false); + assert_eq!(s.selection(), vec![g]); +} diff --git a/crates/engine/src/booleans_tests.rs b/crates/engine/src/booleans_tests.rs index 068719588..d29c28127 100644 --- a/crates/engine/src/booleans_tests.rs +++ b/crates/engine/src/booleans_tests.rs @@ -11,21 +11,21 @@ use crate::tools::{PointerEvent, PointerKind}; use figcraft_doc::{BoolOp, NodeId, NodeKind}; use figcraft_geom::Shape; -fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { +pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } -fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent { +pub(crate) fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent { PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) } } -fn click(s: &mut Session, x: f64, y: f64, cmd: bool) { +pub(crate) fn click(s: &mut Session, x: f64, y: f64, cmd: bool) { s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap(); s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap(); } /// A layer's filled area (a boolean group's result; none of these have holes). -fn area(s: &Session, id: NodeId) -> f64 { +pub(crate) fn area(s: &Session, id: NodeId) -> f64 { let doc = s.doc().unwrap(); figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs() } diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index a80e6d41b..1ccf4f8ca 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -32,7 +32,7 @@ use handles::DRAG_SLOP_PX; pub(crate) use handles::scale_factor; pub use handles::{ PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, - selection_box, selection_frame, + selection_box, selection_frame, shared_rotation_degrees, }; pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; #[cfg(test)] @@ -454,6 +454,8 @@ pub(crate) enum Gesture { Rotate { center: Point, start_angle: f64, + /// The rotation (Design panel convention) the layers share when the drag began, else 0. + base: f64, ids: Vec, }, Draw { diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 94f0e654e..ea336a142 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -1,6 +1,6 @@ use super::handles::{ SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, - shape_hint, + shape_hint, shared_rotation_degrees, }; use super::{ Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point, @@ -58,7 +58,8 @@ impl Session { .moved_origin() .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); self.begin_interaction("Rotate")?; - self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; + self.tool.gesture = + Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), base: shared_rotation_degrees(&doc, &ids), ids }; return Ok(()); } let nodes: Vec<(NodeId, Affine, f64, f64)> = @@ -362,10 +363,11 @@ impl Session { } return res; } - Gesture::Rotate { center, start_angle, ids } => { + Gesture::Rotate { center, start_angle, base, ids } => { let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); if ev.shift { - a = (a / 15.0).round() * 15.0; + // The panel rotation is base - a; Shift lands it on a multiple of 15 degrees. + a = base - ((base - a) / 15.0).round() * 15.0; } let (c, ids) = (*center, ids.clone()); self.preview(|doc, _| { diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index 9fa04d9bb..ecd29e67a 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -132,6 +132,14 @@ pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) } +/// The rotation `ids` share on the page (degrees, counter-clockwise positive), else 0 (the handle +/// box is page-aligned then). Figma's rotation field shows this page rotation, also for a layer +/// inside a rotated frame or group, and Shift lands it on multiples of 15 degrees. Axis-aligned +/// layers give 0, which is the same as 90, 180 and 270 for stepping in 15 degrees. +pub fn shared_rotation_degrees(doc: &Document, ids: &[NodeId]) -> f64 { + shared_rotation(doc, ids).map_or(0.0, |a| -a.to_degrees()) +} + /// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the /// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 749e246c0..ab9fcb79f 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -10,6 +10,8 @@ pub mod ops; pub(crate) mod point_snap; mod pointer; +#[cfg(test)] +mod position_tests; pub mod regions; #[cfg(test)] mod regions_tests; @@ -706,7 +708,61 @@ fn handle_list(p: &Value, key: &str) -> Option> { } } -/// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show). +/// One axis of `vector.edit.position`: left alone, set, or relative to each point's own value. +#[derive(Clone, Copy)] +enum AxisEdit { + Keep, + Set(f64), + Add(f64), + Mul(f64), + Div(f64), +} + +impl AxisEdit { + fn apply(self, cur: f64) -> Result { + let v = match self { + AxisEdit::Keep => cur, + AxisEdit::Set(v) => v, + AxisEdit::Add(n) => cur + n, + AxisEdit::Mul(n) => cur * n, + AxisEdit::Div(n) if n != 0.0 => cur / n, + AxisEdit::Div(_) => return Err(bad("vector.edit.position", "cannot divide by zero")), + }; + if v.is_finite() { Ok(v) } else { Err(bad("vector.edit.position", "the result is not a number")) } + } +} + +/// Parse `p[key]`: absent is `Keep`, a number is `Set`, `{add|mul|div: n}` is relative; anything else is refused. +fn axis_edit(p: &Value, key: &str) -> Result { + let junk = || bad("vector.edit.position", format!("`{key}` must be a number or {{add|mul|div: number}}")); + match get(p, key) { + None => Ok(AxisEdit::Keep), + Some(Value::Object(o)) => { + let (Some((k, v)), 1) = (o.iter().next(), o.len()) else { return Err(junk()) }; + let n = match v { + Value::Number(n) => n.as_f64().filter(|n| n.is_finite()), + _ => None, + } + .ok_or_else(junk)?; + match k.as_str() { + "add" => Ok(AxisEdit::Add(n)), + "mul" => Ok(AxisEdit::Mul(n)), + "div" if n != 0.0 => Ok(AxisEdit::Div(n)), + _ => Err(junk()), + } + } + Some(v) => f64_of(v).filter(|_| !v.is_boolean()).map(AxisEdit::Set).ok_or_else(junk), + } +} + +/// The selected points in the parent's coordinates (what the X/Y fields show). +pub fn selected_parent_points(doc: &Document, ve: &VectorEdit) -> Vec { + let Some(net) = network(doc, ve.id) else { return Vec::new() }; + let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default(); + selected_points(net, ve).into_iter().filter_map(|v| net.vertices.get(v)).map(|v| t * v.point()).collect() +} + +/// Selection bounds' top-left in the parent's coordinates (the `x` and `y` of `vector.edit.bounds`). pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option { let net = network(doc, ve.id)?; let t = doc.node(ve.id)?.transform; @@ -935,18 +991,50 @@ pub fn specs() -> Vec { edit_sets(s, sets, |net, v, w| ops::move_vertices(net, v, local_offset(w, d)))?; Ok(json!({"ok": true})) }), - spec!("vector.edit.position", "Point position", "{x?, y?} (the selected points' top-left, in the parent's coordinates)", true, |s, p| { - let ve = editing(s)?; - let doc = s.doc()?; - let cur = selection_position(doc, &ve).ok_or_else(|| bad("vector.edit.position", "select points"))?; - let target = Point::new(num_or(p, "x", cur.x), num_or(p, "y", cur.y)); - let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default(); - let inv = t.inverse(); - let d = inv * target - inv * cur; - let v = network(doc, ve.id).map(|n| selected_points(n, &ve)).unwrap_or_default(); - edit(s.doc_mut()?, ve.id, |net| ops::move_vertices(net, &v, d))?; - Ok(json!({"ok": true})) - }), + spec!( + "vector.edit.position", + "Point position", + "{x?, y?}: each a number (every selected point's own coordinate becomes it) or {add: n}, {mul: n} or {div: n} (applied to each selected point's own coordinate); in the parent's coordinates", + true, + |s, p| { + let ve = editing(s)?; + let doc = s.doc()?; + let (ax, ay) = (axis_edit(p, "x")?, axis_edit(p, "y")?); + let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default(); + let inv = t.inverse(); + let net = network(doc, ve.id).ok_or_else(|| bad("vector.edit.position", "select points"))?; + let mut moves: Vec<(usize, Vec2)> = Vec::new(); + for i in selected_points(net, &ve) { + let Some(v) = net.vertices.get(i) else { continue }; + let cur = t * v.point(); + let target = Point::new(ax.apply(cur.x)?, ay.apply(cur.y)?); + let d = inv * target - v.point(); + if !(d.x.is_finite() && d.y.is_finite()) { + return Err(bad("vector.edit.position", "the result is not a number")); + } + moves.push((i, d)); + } + if moves.is_empty() { + return Err(bad("vector.edit.position", "select points")); + } + // Points that all share one offset move as one, so moving every point of a layer + // by one offset still carries the regions' paint frames, as a drag of all of them + // does. Otherwise no one move covers every point, so each point moves alone. + let d0 = moves[0].1; + let uniform = moves.iter().all(|(_, d)| (*d - d0).hypot() < 1e-9); + edit(s.doc_mut()?, ve.id, |net| { + if uniform { + let all: Vec = moves.iter().map(|(i, _)| *i).collect(); + ops::move_vertices(net, &all, d0); + } else { + for (i, d) in &moves { + ops::move_vertices(net, &[*i], *d); + } + } + })?; + Ok(json!({"ok": true})) + } + ), spec!("vector.edit.setHandle", "Set handle", "{segment, start (bool), x, y (local), independent?}", true, |s, p| { let ve = editing(s)?; let seg = index("vector.edit.setHandle", p, "segment")?; diff --git a/crates/engine/src/canvas/vectoredit/position_tests.rs b/crates/engine/src/canvas/vectoredit/position_tests.rs new file mode 100644 index 000000000..efbddab8d --- /dev/null +++ b/crates/engine/src/canvas/vectoredit/position_tests.rs @@ -0,0 +1,171 @@ +//! `vector.edit.position`: with several points, a number sets every selected point's own +//! coordinate, and `{add}`, `{mul}`, `{div}` apply to each point's own. + +use serde_json::json; + +use super::tests::{ex, net, rect}; +use super::*; +use crate::Session; + +/// Every point in the parent's coordinates (the layer re-fits its box after an edit, so its own +/// coordinates shift). +fn xs(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + let t = s.doc().unwrap().node(id).unwrap().transform; + net(s, id).vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() +} + +fn parent_xy(s: &Session) -> Vec<(f64, f64)> { + let ve = s.ds().unwrap().vector_edit.clone().unwrap(); + selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect() +} + +fn undo_steps(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn a_number_sets_every_selected_points_own_coordinate() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + // The rectangle's points 0 and 1 differ in x; both end at -200 and keep their y. + assert!(!near(before[0].0, before[1].0)); + ex(&mut s, "vector.edit.position", json!({"x": -200})); + let after = xs(&s, id); + assert!(near(after[0].0, -200.0) && near(after[1].0, -200.0), "{after:?}"); + assert!(near(after[0].1, before[0].1) && near(after[1].1, before[1].1)); + assert_eq!(after[2], before[2]); + // One undo step restores. + assert!(s.undo().unwrap()); + assert_eq!(xs(&s, id), before); +} + +#[test] +fn one_point_sets_its_own_coordinates() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [2]})); + ex(&mut s, "vector.edit.position", json!({"x": 7, "y": 9})); + let after = xs(&s, id); + assert!(near(after[2].0, 7.0) && near(after[2].1, 9.0)); + assert_eq!(after[0], before[0]); +} + +#[test] +fn relative_edits_apply_to_each_points_own_value() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}})); + let a = xs(&s, id); + assert!(near(a[0].0, before[0].0 + 10.0) && near(a[1].0, before[1].0 + 10.0), "{a:?}"); + ex(&mut s, "vector.edit.position", json!({"y": {"mul": 2}})); + let b = xs(&s, id); + assert!(near(b[0].1, a[0].1 * 2.0) && near(b[1].1, a[1].1 * 2.0), "{b:?}"); + ex(&mut s, "vector.edit.position", json!({"x": {"div": 2}})); + let c = xs(&s, id); + assert!(near(c[0].0, a[0].0 / 2.0) && near(c[1].0, a[1].0 / 2.0), "{c:?}"); +} + +#[test] +fn a_point_keeps_its_handles_relative_to_it() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.select", json!({"vertices": [1]})); + ex(&mut s, "vector.edit.smooth", json!({})); + let before = net(&s, id); + assert!(before.segments.iter().any(|g| g.ta != (0.0, 0.0) || g.tb != (0.0, 0.0))); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 25}})); + let after = net(&s, id); + let tangents = |n: &VectorNetwork| n.segments.iter().map(|g| (g.ta, g.tb)).collect::>(); + assert_eq!(tangents(&before), tangents(&after)); +} + +#[test] +fn the_values_are_in_the_parents_coordinates() { + let mut s = Session::new(); + let r = ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 30, "width": 100, "height": 100})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + let before = parent_xy(&s); + assert!(near(before[0].0, 40.0), "{before:?}"); + ex(&mut s, "vector.edit.position", json!({"x": 10})); + let after = parent_xy(&s); + assert!(near(after[0].0, 10.0) && near(after[1].0, 10.0), "{after:?}"); + assert!(near(after[0].1, before[0].1)); +} + +#[test] +fn moving_every_point_by_one_offset_carries_the_regions_paint_frame() { + // `{add}` on every point is a plain translation: the region's paint frame goes with the + // points, as when they are dragged together. + let mut s = Session::new(); + let id = rect(&mut s); + { + let node = s.doc_mut().unwrap().get_mut(id).unwrap(); + let NodeKind::Vector { network } = &mut node.kind else { panic!("expected a vector") }; + assert!(!network.regions.is_empty(), "the rectangle has a region"); + for region in &mut network.regions { + region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::IDENTITY }); + } + } + let frame = |s: &Session| { + let world = s.doc().unwrap().world_transform(id); + world * net(s, id).regions[0].paint_space.as_ref().unwrap().transform + }; + let before = frame(&s); + let all: Vec = (0..net(&s, id).vertices.len()).collect(); + ex(&mut s, "vector.edit.select", json!({"vertices": all})); + ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}})); + let (want, got) = ((Affine::translate((10.0, 0.0)) * before).as_coeffs(), frame(&s).as_coeffs()); + assert!(want.iter().zip(got).all(|(a, b)| near(*a, b)), "{got:?} vs {want:?}"); +} + +#[test] +fn junk_is_refused_and_changes_nothing() { + let mut s = Session::new(); + let id = rect(&mut s); + let before = xs(&s, id); + ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]})); + let steps = undo_steps(&s); + for junk in [ + json!({"x": {"div": 0}}), + json!({"x": {"pow": 2}}), + json!({"x": "a"}), + json!({"x": {"add": 1, "mul": 2}}), + json!({"y": {"add": "x"}}), + json!({"x": {}}), + json!({"x": [1]}), + json!({"x": true}), + json!({"x": {"add": true}}), + // A valid x with a refused y changes neither. + json!({"x": 5, "y": {"div": 0}}), + // Point 1's x of 100 overflows; point 0's 0 alone would not. + json!({"x": {"mul": 1e308}}), + ] { + assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}"); + } + assert_eq!(xs(&s, id), before); + assert_eq!(undo_steps(&s), steps, "a refused edit leaves no undo step"); +} + +#[test] +fn nothing_selected_is_refused() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.select", json!({"vertices": []})); + let (before, steps) = (xs(&s, id), undo_steps(&s)); + assert!(parent_xy(&s).is_empty()); + let err = s.execute("vector.edit.position", &json!({"x": 5})).unwrap_err(); + assert!(err.to_string().contains("select points"), "{err}"); + assert_eq!((xs(&s, id), undo_steps(&s)), (before, steps)); +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 96be815d6..dc58926fe 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -434,6 +434,13 @@ pub fn specs() -> Vec { false, |s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom) ), + spec!( + "pref.invertZoom", + "Invert zoom direction", + "{on?} reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in (a trackpad pinch and panning are unchanged) → {on}", + false, + |s, p| pref(s, p, |x| &mut x.invert_zoom) + ), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!( "pref.uiScale", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2627d7efd..ebe88332f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -227,6 +227,8 @@ mod board_tools_tests; #[cfg(test)] mod boolean_cap_tests; #[cfg(test)] +mod boolean_radius_tests; +#[cfg(test)] mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 48d68a901..4225963f5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -36,6 +36,9 @@ pub struct Prefs { /// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the /// other way round). Trackpads are unchanged. pub scroll_wheel_zoom: bool, + /// Wheel zoom goes the other way: Cmd/Ctrl+wheel (and a plain mouse wheel with Scroll wheel + /// zoom on) zooms out where it would zoom in. A trackpad pinch is unchanged. + pub invert_zoom: bool, /// "system" by default; an explicit "light" or "dark" choice stays saved. pub theme: String, /// The widths of the main window's left and right panels (the interface keeps them within its @@ -136,6 +139,7 @@ impl Default for Prefs { number_keys_opacity: true, show_dimensions: true, scroll_wheel_zoom: false, + invert_zoom: false, theme: "system".into(), panel_widths: (240.0, 240.0), pages_height: None, diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index ca2d16390..39cf2c29b 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -189,6 +189,7 @@ fn corner_radius_follows_the_documented_rules() { (NodeKind::Polygon { count: 5 }, true, false, "polygons: one radius for every corner"), (NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, false, "stars: one radius for every corner"), (NodeKind::Vector { network: Default::default() }, true, false, "vectors: a radius, and one per point in edit mode"), + (NodeKind::BooleanOp { op: BoolOp::Union }, true, false, "boolean groups: one radius for every sharp corner of the result"), (NodeKind::Line, false, false, "lines and arrows take no radius"), ]; for (kind, radius, independent, why) in doc_rows { @@ -241,6 +242,7 @@ fn corner_smoothing_follows_the_documented_rules() { "vector networks that are closed shapes", ), (NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"), + (NodeKind::BooleanOp { op: BoolOp::Union }, true, "boolean groups (the plugin API's corner mixin; the look is pinned)"), (NodeKind::Line, false, "lines take no radius, so no smoothing"), ]; for (kind, smoothing, why) in doc_rows { diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index e22eb0418..fe6709ee4 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -369,6 +369,73 @@ fn resizing_and_flipping_one_layer() { assert!(near(rotation(&s, t).abs(), 30.0), "Shift: the nearest 15° step: {}", rotation(&s, t)); } +/// A rotate-handle drag on a layer at `start` degrees, turning it by `turn` degrees in the Design +/// panel's convention (positive is counter-clockwise on screen); returns the rotation it ends at. +fn rotate_drag_from(start: f64, turn: f64, m: Mods) -> f64 { + let mut s = session(); + let r = rect(&mut s, 0.0, 200.0, 100.0, 100.0, None); + set(&mut s, &[r], json!({"rotation": start})); + ex(&mut s, "select.set", json!({"ids": [r]})); + // The handle sits just outside the top-left corner of the turned box; the pointer then circles + // the centre by `turn` (the page angle runs the other way round, y being down). + let world = s.doc().unwrap().world_transform(r); + let (c, corner) = (world * Point::new(50.0, 50.0), world * Point::ZERO); + let from = corner + (corner - c) * 0.15; + let a = (from - c).atan2() - turn.to_radians(); + let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0; + drag(&mut s, from, to, m); + rotation(&s, r) +} + +#[test] +fn shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start() { + // Measured in Figma: a layer at 7° (or -4.27°) Shift-rotated by its corner ends at 15°, so the + // steps are absolute multiples of 15°, not 15° steps from where the drag began. + let got = rotate_drag_from(7.0, 37.0, SHIFT); + assert!(near(got, 45.0), "7° turned by 37° is 44°, nearest multiple of 15 is 45°: {got}"); + // Without Shift the turn is free. + let got = rotate_drag_from(7.0, 37.0, NONE); + assert!(near(got, 44.0), "a free turn: {got}"); + // A small turn: 7° + 3° = 10°, nearest multiple of 15 is 15° (not 7° or 10°). + let got = rotate_drag_from(7.0, 3.0, SHIFT); + assert!(near(got, 15.0), "10° rounds to 15°: {got}"); + let got = rotate_drag_from(-4.27, 2.0, SHIFT); + assert!(near(got, 0.0), "-2.27° rounds to 0°: {got}"); +} + +/// Rotates a 120 × 80 layer inside a frame turned `frame_rot`, the layer at `start` on the page, +/// by `turn` from its corner, and returns its rotation on the page. +fn rotate_drag_in_frame(frame_rot: f64, start: f64, turn: f64, m: Mods) -> f64 { + let mut s = session(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400}))); + set(&mut s, &[f], json!({"rotation": frame_rot})); + let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f)); + set(&mut s, &[r], json!({"rotation": start - frame_rot})); + let page = |s: &Session| rotation_degrees(s.doc().unwrap().world_transform(r)); + assert!(near(page(&s), start), "the layer starts at {start}° on the page: {}", page(&s)); + ex(&mut s, "select.set", json!({"ids": [r]})); + let world = s.doc().unwrap().world_transform(r); + let (c, corner) = (world * Point::new(60.0, 40.0), world * Point::ZERO); + let from = corner + (corner - c) * 0.15; + let a = (from - c).atan2() - turn.to_radians(); + let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0; + drag(&mut s, from, to, m); + page(&s) +} + +#[test] +fn shift_rotating_inside_a_rotated_frame_lands_the_page_rotation_on_multiples_of_15() { + // Measured in Figma (2026-11-10): the rotation field of a layer inside a frame turned 10° shows + // its rotation on the page, and Shift lands that on multiples of 15°: from 7°, turns of 40°, + // 6° and -10° end at 45°, 15° and 0°; without Shift 6° ends at 13°. Groups behave the same. + for (turn, want) in [(40.0, 45.0), (6.0, 15.0), (-10.0, 0.0)] { + let got = rotate_drag_in_frame(10.0, 7.0, turn, SHIFT); + assert!(near(got, want), "7° on the page turned by {turn}° with Shift: {got}, want {want}"); + } + let got = rotate_drag_in_frame(10.0, 7.0, 6.0, NONE); + assert!(near(got, 13.0), "a free turn: {got}"); +} + #[test] fn groups_scale_children_keeping_their_rotation_and_flip() { let mut s = session(); diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 4fd533545..221cd7cd9 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -811,6 +811,21 @@ fn generated_documents_round_trip() { } } +#[test] +fn a_boolean_groups_corner_radius_round_trips() { + let mut doc = Document::new("t"); + let page = doc.first_page().unwrap(); + let g = doc.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); + doc.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap(); + doc.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap(); + doc.fit_to_children(g).unwrap(); + doc.get_mut(g).unwrap().corners = Corners { radii: [20.0; 4], smoothing: 0.6 }; + let (back, _) = round_trip(&doc, "boolean radius"); + let n = back.nodes().find(|n| matches!(n.kind, NodeKind::BooleanOp { .. })).unwrap(); + assert!(n.corners.radii.iter().all(|r| (r - 20.0).abs() < 1e-4), "{:?}", n.corners); + assert!((n.corners.smoothing - 0.6).abs() < 1e-4, "{:?}", n.corners); +} + #[test] fn round_trip_keeps_the_details() { let doc = generate(3); diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 3ea88fad1..6dc710979 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -290,6 +290,8 @@ fn vectors_ellipses_stars_and_booleans() { counted["pointCount"] = json!(6); let mut boolean = base("Bool", "BOOLEAN_OPERATION"); boolean["booleanOperation"] = json!("SUBTRACT"); + boolean["cornerRadius"] = json!(12); + boolean["cornerSmoothing"] = json!(0.6); boolean["children"] = json!([base("A", "RECTANGLE"), {"id": "B", "name": "B", "type": "ELLIPSE", "relativeTransform": at(5.0, 5.0), "size": {"x": 10, "y": 10}}]); let (doc, warnings) = one(json!([tri, wire, empty, arc, star, plain_star, counted, boolean])); @@ -311,6 +313,8 @@ fn vectors_ellipses_stars_and_booleans() { assert!(matches!(b.kind, NodeKind::BooleanOp { op: BoolOp::Subtract })); assert_eq!(b.children.len(), 2); assert!(close(b.width, 15.0), "booleans wrap their children"); + assert_eq!(b.corners.radii, [12.0; 4], "a boolean group keeps its corner radius"); + assert!(close(b.corners.smoothing, 0.6)); } #[test] diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 85f1ecd10..0e418155d 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -4,6 +4,8 @@ //! sweep-line topology from `linesweeper`); results use the even-odd rule. //! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment, //! dashes, caps, joins, arrowheads, variable width profiles ([`width`]) and brushes ([`brush`]). +//! - [`round_corners`]: a corner radius (and smoothing) on every corner still sharp in a closed path, +//! as a boolean group draws its result. //! //! Every sweep is capped ([`MAX_SEGMENTS`], [`MAX_CROSSINGS`], and [`MAX_FOLD_SEGMENTS`] over a //! fold): past the caps an operation is refused with [`PathOpsError::TooComplex`]. @@ -24,6 +26,9 @@ mod dot_dash_tests; #[cfg(test)] mod ops_tests; mod points; +mod round; +#[cfg(test)] +mod round_tests; #[cfg(test)] mod rounded_side_strokes_tests; #[cfg(test)] @@ -36,6 +41,7 @@ use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Vect use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; pub use budget::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, complexity}; +pub use round::round_corners; #[derive(Debug, PartialEq)] pub enum PathOpsError { diff --git a/crates/pathops/src/round.rs b/crates/pathops/src/round.rs new file mode 100644 index 000000000..385f1faba --- /dev/null +++ b/crates/pathops/src/round.rs @@ -0,0 +1,314 @@ +//! A corner radius on a closed path: every vertex that is still sharp rounds with a circular arc, +//! convex and concave alike, the way a boolean group draws its result. +//! +//! What a sweep hands back is noisy: straight edges split at collinear points, pieces a hair long, +//! one real corner cut into two half turns. So each subpath is cleaned first ([`clean`]), then each +//! vertex is judged by the turn it makes, and a corner that does not fit shrinks to what the edges +//! beside it can hold ([`Ctx::corner`]). + +use figcraft_geom::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Shape, Vec2}; +use std::f64::consts::PI; + +/// The smallest turn (radians) that counts as a corner. The sweep moves end points by up to its +/// own 1e-6 accuracy, which can tilt a short piece by more than 1e-6 rad. +const SHARP_TURN: f64 = 1e-3; +/// Accuracy of arc length measurements. +const ACC: f64 = 1e-6; + +#[derive(Clone, Copy)] +enum Seg { + Line(Point, Point), + Cubic(CubicBez), +} + +impl Seg { + fn start(&self) -> Point { + match self { + Self::Line(a, _) => *a, + Self::Cubic(c) => c.p0, + } + } + + fn end(&self) -> Point { + match self { + Self::Line(_, b) => *b, + Self::Cubic(c) => c.p3, + } + } + + fn set_start(&mut self, p: Point) { + match self { + Self::Line(a, _) => *a = p, + Self::Cubic(c) => c.p0 = p, + } + } + + fn is_line(&self) -> bool { + matches!(self, Self::Line(..)) + } + + /// As a cubic (a line gets its handles at the thirds). + fn cubic(&self) -> CubicBez { + match self { + Self::Line(a, b) => CubicBez::new(*a, a.lerp(*b, 1.0 / 3.0), a.lerp(*b, 2.0 / 3.0), *b), + Self::Cubic(c) => *c, + } + } + + fn len(&self) -> f64 { + match self { + Self::Line(a, b) => (*b - *a).hypot(), + Self::Cubic(c) => c.arclen(ACC), + } + } + + /// The length of the control polygon: cheap, and zero only for a point. + fn span(&self) -> f64 { + let c = self.cubic(); + (c.p1 - c.p0).hypot() + (c.p2 - c.p1).hypot() + (c.p3 - c.p2).hypot() + } + + /// The cubic leaving the start of the segment, or (`from_start` false) leaving its end. + fn outward(&self, from_start: bool) -> CubicBez { + let c = self.cubic(); + if from_start { c } else { CubicBez::new(c.p3, c.p2, c.p1, c.p0) } + } +} + +/// Unit direction in which `c` leaves its start (skipping coincident handles). +fn start_dir(c: &CubicBez) -> Option { + [c.p1, c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot()) +} + +/// The point `t` along `c` from its start, the unit direction back toward the start there, and the +/// parameter reached. +fn trim(c: &CubicBez, t: f64, line: bool, len: f64) -> Option<(Point, Vec2, f64)> { + if line { + if !len.is_finite() || len <= 0.0 { + return None; + } + let u = (c.p3 - c.p0) / len; + return Some((c.p0 + u * t, -u, t / len)); + } + let k = c.inv_arclen(t, ACC).clamp(0.0, 1.0); + let d = c.deriv().eval(k).to_vec2(); + let back = if d.hypot() > 1e-9 { -d / d.hypot() } else { -start_dir(c)? }; + Some((c.eval(k), back, k)) +} + +/// One rounded corner: the curves from the cut on the incoming segment to the cut on the outgoing +/// one, and where those cuts sit on their segments (each segment's own parameter). +struct Corner { + curves: Vec, + t_in: f64, + t_out: f64, +} + +/// `path` with every corner still sharp rounded by `radius` (and corner `smoothing`, 0..=1, between +/// straight edges). A corner shrinks to what its edges can hold: two corners wanting more than +/// half an edge share it half and half, a corner whose far end is smooth may use all of it. A +/// closed subpath with no sharp corner, an open one, and a path with a bad radius or coordinates +/// come back as they are. +pub fn round_corners(path: &BezPath, radius: f64, smoothing: f64) -> BezPath { + if !(radius.is_finite() && radius > 0.0) || path.elements().is_empty() || !crate::finite(path) { + return path.clone(); + } + let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; + let mut out = BezPath::new(); + for sub in figcraft_geom::split_subpaths(path) { + // Each contour's own size sets its cleanup tolerance, so a large contour can't weld away + // the real short edges of a small island or hole beside it. + let bb = sub.bounding_box(); + let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9); + let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath)); + match closed.then(|| round_subpath(&sub, radius, smoothing, weld)).flatten() { + Some(p) => out.extend(p.elements().iter().copied()), + None => out.extend(sub.elements().iter().copied()), + } + } + out +} + +/// The segments of a closed subpath without the sweep's noise: pieces shorter than `weld` dropped, +/// each segment starting where the one before ends, and straight runs merged into one line. +fn clean(sub: &BezPath, weld: f64) -> Vec { + let mut segs: Vec = sub + .segments() + .map(|s| match s { + PathSeg::Line(l) => Seg::Line(l.p0, l.p1), + other => Seg::Cubic(other.to_cubic()), + }) + .filter(|s| s.span() >= weld) + .collect(); + let n = segs.len(); + for i in 0..n { + let prev_end = segs.get((i + n - 1) % n).map(Seg::end); + if let (Some(p), Some(s)) = (prev_end, segs.get_mut(i)) { + s.set_start(p); + } + } + merge_runs(segs, weld) +} + +/// Two lines A-B and B-C become A-C when B lies within `weld` of that line and between A and C +/// (a distance test, so a long edge with a real slight kink is not straightened). Cyclic. +fn merge_runs(segs: Vec, weld: f64) -> Vec { + let mergeable = |a: &Seg, b: &Seg| match (a, b) { + (Seg::Line(p, q), Seg::Line(_, r)) => { + let (d, e) = (*r - *p, *q - *p); + let l = d.hypot(); + l > 1e-12 && (d.cross(e) / l).abs() <= weld && e.dot(*r - *q) > 0.0 + } + _ => false, + }; + let mut merged: Vec = Vec::with_capacity(segs.len()); + for s in segs { + match merged.last_mut() { + Some(last) if mergeable(last, &s) => *last = Seg::Line(last.start(), s.end()), + _ => merged.push(s), + } + } + while merged.len() > 2 { + let (Some(first), Some(last)) = (merged.first().copied(), merged.last().copied()) else { break }; + if !mergeable(&last, &first) { + break; + } + if let Some(f) = merged.first_mut() { + *f = Seg::Line(last.start(), first.end()); + } + merged.pop(); + } + merged +} + +/// Everything the corner of one vertex needs. Vertex `i` is where segment `i - 1` ends and +/// segment `i` starts. +struct Ctx<'a> { + segs: &'a [Seg], + /// Segment lengths, measured only for segments with a corner at one end. + lens: &'a [Option], + /// Per vertex: the turn between its segments and the length it wants along each of them. + wants: &'a [Option<(f64, f64)>], + radius: f64, + smoothing: f64, + weld: f64, +} + +/// The angle between the segments at each vertex (pi is straight on, 0 folded back) and the +/// length a corner there wants along each; `None` where the vertex is not a corner. +fn wants(segs: &[Seg], radius: f64, smoothing: f64) -> Vec> { + let n = segs.len(); + (0..n) + .map(|i| { + let (a, b) = (segs.get((i + n - 1) % n)?, segs.get(i)?); + let (u1, u2) = (start_dir(&a.outward(false))?, start_dir(&b.outward(true))?); + let angle = u1.dot(u2).clamp(-1.0, 1.0).acos(); + if angle < 1e-6 || PI - angle < SHARP_TURN { + return None; + } + let t = radius / (angle / 2.0).tan(); + let want = if a.is_line() && b.is_line() { (1.0 + smoothing) * t } else { t }; + want.is_finite().then_some((angle, want)) + }) + .collect() +} + +impl Ctx<'_> { + fn want(&self, i: usize) -> f64 { + self.wants.get(i % self.segs.len().max(1)).copied().flatten().map_or(0.0, |(_, w)| w) + } + + /// What a corner may use of a segment of `len`: half of it, or what the corner at its other + /// end leaves, whichever is more. + fn room(len: f64, other: f64) -> f64 { + (len / 2.0).max(len - other) + } + + fn corner(&self, i: usize) -> Option { + let n = self.segs.len(); + let (angle, _) = self.wants.get(i).copied().flatten()?; + let prev = (i + n - 1) % n; + let (a, b) = (self.segs.get(prev)?, self.segs.get(i)?); + let (la, lb) = (self.lens.get(prev).copied().flatten()?, self.lens.get(i).copied().flatten()?); + let room = Self::room(la, self.want(prev)).min(Self::room(lb, self.want(i + 1))); + if !room.is_finite() || room <= self.weld { + return None; + } + let (c1, c2) = (a.outward(false), b.outward(true)); + if a.is_line() && b.is_line() { + self.between_lines(&c1, &c2, room, (la, lb)) + } else { + self.beside_curve((&c1, a.is_line(), la), (&c2, b.is_line(), lb), room, angle) + } + } + + fn between_lines(&self, c1: &CubicBez, c2: &CubicBez, room: f64, (la, lb): (f64, f64)) -> Option { + let (u1, u2) = (start_dir(c1)?, start_dir(c2)?); + let curves = figcraft_geom::smoothed_corner(c1.p0, u1, u2, room, self.radius, self.smoothing)?; + let (first, last) = (curves.first()?, curves.last()?); + let (t_in, t_out) = (1.0 - (first.p0 - c1.p0).hypot() / la, (last.p3 - c1.p0).hypot() / lb); + (t_in.is_finite() && t_out.is_finite() && curves.iter().all(|c| [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite()))) + .then_some(Corner { curves, t_in, t_out }) + } + + /// One cubic between the cuts on both sides, its handles along each side's direction at its cut. + fn beside_curve( + &self, + (c1, line1, la): (&CubicBez, bool, f64), + (c2, line2, lb): (&CubicBez, bool, f64), + room: f64, + angle: f64, + ) -> Option { + let t = (self.radius / (angle / 2.0).tan()).min(room); + let ((p, d1, k1), (q, d2, k2)) = (trim(c1, t, line1, la)?, trim(c2, t, line2, lb)?); + let turn = (-d1.dot(d2)).clamp(-1.0, 1.0).acos(); + let chord = (q - p).hypot(); + let k = if turn < 1e-9 { chord / 3.0 } else { 4.0 / 3.0 * (turn / 4.0).tan() * chord / (2.0 * (turn / 2.0).sin()) }; + let ok = k.is_finite() && [p, q].iter().all(|x| x.x.is_finite() && x.y.is_finite()); + ok.then(|| Corner { curves: vec![CubicBez::new(p, p + d1 * k, q + d2 * k, q)], t_in: 1.0 - k1, t_out: k2 }) + } +} + +/// The subpath with its corners rounded, or `None` when no vertex of it is a corner that rounds +/// (the caller keeps the subpath exactly as it came in). +fn round_subpath(sub: &BezPath, radius: f64, smoothing: f64, weld: f64) -> Option { + let segs = clean(sub, weld); + let n = segs.len(); + if n < 2 { + return None; + } + let wants = wants(&segs, radius, smoothing); + let touches = |i: usize| wants.get(i).is_some_and(Option::is_some) || wants.get((i + 1) % n).is_some_and(Option::is_some); + let lens: Vec> = segs.iter().enumerate().map(|(i, s)| touches(i).then(|| s.len())).collect(); + let ctx = Ctx { segs: &segs, lens: &lens, wants: &wants, radius, smoothing, weld }; + let corners: Vec> = (0..n).map(|i| ctx.corner(i)).collect(); + corners.iter().any(Option::is_some).then(|| emit(&segs, &corners))? +} + +/// Walk the segments: what is left of each between the corner at its start and the one at its +/// end, then the next corner's curves. +fn emit(segs: &[Seg], corners: &[Option]) -> Option { + let n = segs.len(); + let mut out = BezPath::new(); + let first = segs.first()?; + out.move_to(corners.first().and_then(Option::as_ref).and_then(|c| c.curves.last()).map_or(first.start(), |c| c.p3)); + for (i, seg) in segs.iter().enumerate() { + let next = corners.get((i + 1) % n).and_then(Option::as_ref); + let t0 = corners.get(i).and_then(Option::as_ref).map_or(0.0, |c| c.t_out); + let t1 = next.map_or(1.0, |c| c.t_in); + if t1 - t0 > 1e-9 { + match seg { + Seg::Line(a, b) => out.line_to(a.lerp(*b, t1)), + Seg::Cubic(c) => { + let q = c.subsegment(t0..t1); + out.curve_to(q.p1, q.p2, q.p3); + } + } + } + for c in next.iter().flat_map(|c| c.curves.iter()) { + out.curve_to(c.p1, c.p2, c.p3); + } + } + out.close_path(); + crate::finite(&out).then_some(out) +} diff --git a/crates/pathops/src/round_tests.rs b/crates/pathops/src/round_tests.rs new file mode 100644 index 000000000..0c22b5363 --- /dev/null +++ b/crates/pathops/src/round_tests.rs @@ -0,0 +1,256 @@ +//! `round_corners`: every sharp corner of a closed path, convex or concave, rounds with a circular +//! arc; smooth joins and straight-run leftovers of the sweep do not count; a radius larger than the +//! edges allow shrinks to fit. + +use figcraft_doc::BoolOp; +use figcraft_geom::kurbo::{Affine, BezPath, PathEl, PathSeg, Point, Shape, Vec2}; +use figcraft_geom::{ellipse, rounded_rect, split_subpaths}; + +use crate::{boolean, boolean_step, round_corners}; + +fn rect(x: f64, y: f64, w: f64, h: f64) -> BezPath { + kurbo_rect(x, y, w, h).to_path(0.1) +} + +fn kurbo_rect(x: f64, y: f64, w: f64, h: f64) -> figcraft_geom::Rect { + figcraft_geom::Rect::new(x, y, x + w, y + h) +} + +fn l_shape() -> BezPath { + boolean(&[rect(0.0, 0.0, 200.0, 100.0), rect(150.0, 0.0, 100.0, 200.0)], BoolOp::Union).unwrap() +} + +/// A (200 by 100, radius 10) with a 100 square cut out of its top edge: the notch of the measurement. +fn notch() -> BezPath { + let a = rounded_rect(200.0, 100.0, [10.0; 4], 0.0); + boolean_step(&a, &rect(50.0, -50.0, 100.0, 100.0), BoolOp::Subtract).unwrap() +} + +/// Every `CurveTo` as `[from, c1, c2, to]`. +fn arcs(path: &BezPath) -> Vec<[Point; 4]> { + let mut cur = Point::ZERO; + let mut out = Vec::new(); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => cur = p, + PathEl::CurveTo(a, b, c) => { + out.push([cur, a, b, c]); + cur = c; + } + _ => {} + } + } + out +} + +fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-3 +} + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +/// Some curve runs through exactly these four points, in either direction. +fn has_arc(path: &BezPath, want: [(f64, f64); 4]) -> bool { + let w = want.map(|(x, y)| pt(x, y)); + let rev = [w[3], w[2], w[1], w[0]]; + arcs(path).iter().any(|a| a.iter().zip(w).all(|(p, q)| near(*p, q)) || a.iter().zip(rev).all(|(p, q)| near(*p, q))) +} + +/// Some curve joins these two end points, in either direction. +fn has_arc_between(path: &BezPath, from: (f64, f64), to: (f64, f64)) -> bool { + let (f, t) = (pt(from.0, from.1), pt(to.0, to.1)); + arcs(path).iter().any(|a| (near(a[0], f) && near(a[3], t)) || (near(a[0], t) && near(a[3], f))) +} + +fn passes_through(path: &BezPath, p: Point) -> bool { + path.elements().iter().any(|el| match el { + PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => near(*q, p), + _ => false, + }) +} + +fn unit(v: Vec2) -> Option { + (v.hypot() > 1e-9).then(|| v / v.hypot()) +} + +fn start_dir(s: PathSeg) -> Option { + let c = s.to_cubic(); + [c.p1, c.p2, c.p3].into_iter().find_map(|p| unit(p - c.p0)) +} + +fn end_dir(s: PathSeg) -> Option { + let c = s.to_cubic(); + [c.p2, c.p1, c.p0].into_iter().find_map(|p| unit(c.p3 - p)) +} + +/// The largest direction change at any vertex of a closed subpath, in radians. +fn sharpest_turn(path: &BezPath) -> f64 { + let mut worst: f64 = 0.0; + for sub in split_subpaths(path) { + let segs: Vec = sub.segments().collect(); + for (i, s) in segs.iter().enumerate() { + let next = segs[(i + 1) % segs.len()]; + if let (Some(a), Some(b)) = (end_dir(*s), start_dir(next)) { + worst = worst.max(a.dot(b).clamp(-1.0, 1.0).acos()); + } + } + } + worst +} + +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn all_finite(p: &BezPath) -> bool { + crate::finite(p) +} + +#[test] +fn every_sharp_corner_of_the_result_rounds_convex_and_concave() { + let r = round_corners(&l_shape(), 20.0, 0.0); + assert_eq!(arcs(&r).len(), 6); + assert!(has_arc(&r, [(230.0, 0.0), (241.046, 0.0), (250.0, 8.95431), (250.0, 20.0)])); + assert!(has_arc(&r, [(150.0, 120.0), (150.0, 108.954), (141.046, 100.0), (130.0, 100.0)])); + assert!((area(&r) - (35000.0 - 4.0 * 85.84)).abs() < 0.5, "{}", area(&r)); + assert_eq!(r.winding(pt(1.0, 1.0)), 0); + assert_ne!(r.winding(pt(148.0, 102.0)), 0); + let bb = r.bounding_box(); + assert!((bb.width() - 250.0).abs() < 1e-6 && (bb.height() - 200.0).abs() < 1e-6); +} + +#[test] +fn a_childs_rounded_corners_are_kept_and_do_not_stack() { + let r = round_corners(¬ch(), 20.0, 0.0); + assert_eq!(arcs(&r).len(), 8); + assert!(has_arc(&r, [(30.0, 0.0), (41.0457, 0.0), (50.0, 8.954), (50.0, 20.0)])); + assert!(has_arc(&r, [(50.0, 30.0), (50.0, 41.0457), (58.954, 50.0), (70.0, 50.0)])); + assert!(has_arc_between(&r, (130.0, 50.0), (150.0, 30.0))); + assert!(has_arc_between(&r, (150.0, 20.0), (170.0, 0.0))); + assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)])); +} + +#[test] +fn a_radius_larger_than_the_edges_clamps_to_what_they_hold() { + let r = round_corners(¬ch(), 500.0, 0.0); + assert!(has_arc(&r, [(25.0, 0.0), (38.8071, 0.0), (50.0, 11.1929), (50.0, 25.0)])); + assert!(has_arc(&r, [(50.0, 25.0), (50.0, 38.807), (61.193, 50.0), (75.0, 50.0)])); + assert!(has_arc_between(&r, (125.0, 50.0), (150.0, 25.0))); + assert!(has_arc_between(&r, (150.0, 25.0), (175.0, 0.0))); + assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)])); + assert!(all_finite(&r)); + let l = round_corners(&l_shape(), 500.0, 0.0); + assert!(has_arc_between(&l, (0.0, 50.0), (50.0, 0.0))); + assert!(has_arc_between(&l, (150.0, 0.0), (250.0, 100.0))); + assert!((area(&round_corners(&l_shape(), 1e300, 0.0)) - area(&l)).abs() < 1e-6); +} + +#[test] +fn nothing_sharp_nothing_changes() { + let round = rounded_rect(100.0, 60.0, [20.0; 4], 0.0); + let oval = ellipse(100.0, 60.0, 0.0, std::f64::consts::TAU, 0.0); + assert_eq!(round_corners(&round, 20.0, 0.0), round); + assert_eq!(round_corners(&oval, 20.0, 0.0), oval); + let l = l_shape(); + for radius in [0.0, -5.0, f64::NAN, f64::INFINITY] { + assert_eq!(round_corners(&l, radius, 0.0), l); + } + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((50.0, 0.0)); + open.line_to((50.0, 50.0)); + assert_eq!(round_corners(&open, 10.0, 0.0), open); +} + +#[test] +fn points_the_sweep_leaves_on_an_edge_are_not_corners() { + let mut p = BezPath::new(); + for (i, q) in [(0.0, 0.0), (20.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 60.0), (100.0 + 1e-11, 60.0), (100.0, 100.0), (0.0, 100.0)] + .into_iter() + .enumerate() + { + if i == 0 { + p.move_to(q); + } else { + p.line_to(q); + } + } + p.close_path(); + let r = round_corners(&p, 500.0, 0.0); + assert_eq!(arcs(&r).len(), 4); + assert!(has_arc_between(&r, (0.0, 50.0), (50.0, 0.0))); +} + +fn two_circles(op: BoolOp) -> BezPath { + let c = ellipse(100.0, 100.0, 0.0, std::f64::consts::TAU, 0.0); + boolean(&[c.clone(), Affine::translate((70.0, 0.0)) * c], op).unwrap() +} + +#[test] +fn corners_beside_curves_round_too() { + for op in [BoolOp::Union, BoolOp::Intersect] { + let sharp = two_circles(op); + assert!(sharpest_turn(&sharp) > 1.0); + let r = round_corners(&sharp, 10.0, 0.0); + assert!(sharpest_turn(&r) < 1e-3, "{}", sharpest_turn(&r)); + assert!(all_finite(&r)); + match op { + BoolOp::Union => assert!(area(&r) > area(&sharp)), + _ => assert!(area(&r) < area(&sharp)), + } + } +} + +#[test] +fn smoothing_draws_the_polygon_profile_between_straight_edges() { + let l = l_shape(); + let plain = round_corners(&l, 20.0, 0.0); + let smooth = round_corners(&l, 20.0, 0.6); + assert!(passes_through(&smooth, pt(0.0, 32.0)) && passes_through(&smooth, pt(32.0, 0.0))); + assert!((area(&smooth) - area(&plain)).abs() > 1.0); + let circles = two_circles(BoolOp::Union); + assert_eq!(round_corners(&circles, 10.0, 0.6), round_corners(&circles, 10.0, 0.0)); +} + +#[test] +fn junk_never_panics() { + let mut bad = BezPath::new(); + bad.move_to((f64::NAN, 0.0)); + bad.line_to((10.0, f64::INFINITY)); + bad.close_path(); + assert_eq!(round_corners(&bad, 5.0, 0.0).elements().len(), 3); + assert!(round_corners(&BezPath::new(), 5.0, 0.0).elements().is_empty()); + let mut one = BezPath::new(); + one.move_to((0.0, 0.0)); + one.line_to((10.0, 0.0)); + one.close_path(); + assert!(all_finite(&round_corners(&one, 5.0, 0.5))); + assert!(all_finite(&round_corners(&rect(0.0, 0.0, 1.0, 0.5), 0.1, 0.0))); + let mut zig = BezPath::new(); + zig.move_to((0.0, 0.0)); + for i in 1..40_000 { + zig.line_to((f64::from(i) * 0.5, f64::from(i % 2))); + } + zig.close_path(); + assert!(all_finite(&round_corners(&zig, 2.0, 0.0))); +} + +/// The cleanup tolerance is each contour's own: a contour a million units wide doesn't weld away +/// the half-unit notch of a separate small contour in the same path. +#[test] +fn a_large_contour_does_not_weld_a_small_ones_short_edges() { + let mut p = rect(0.0, 0.0, 1e6, 1e6); + let x = 2e6; + for (i, (dx, y)) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.5, 10.0), (5.5, 9.5), (5.0, 9.5), (5.0, 10.0), (0.0, 10.0)].into_iter().enumerate() + { + if i == 0 { p.move_to((x + dx, y)) } else { p.line_to((x + dx, y)) } + } + p.close_path(); + let out = round_corners(&p, 0.001, 0.0); + let reaches = |q: Point| { + out.elements().iter().any(|el| matches!(el, PathEl::MoveTo(e) | PathEl::LineTo(e) | PathEl::CurveTo(_, _, e) if (*e - q).hypot() < 0.01)) + }; + assert!(reaches(pt(x + 5.0, 9.5)) && reaches(pt(x + 5.5, 9.5)), "the notch's floor is kept"); +} diff --git a/crates/render/src/boolean.rs b/crates/render/src/boolean.rs index 7fc642082..039a3967b 100644 --- a/crates/render/src/boolean.rs +++ b/crates/render/src/boolean.rs @@ -7,6 +7,11 @@ //! edited one are never recombined, and groups whose operands didn't change aren't recomputed at //! all. The caches drop their least recently used half when full, so a long drag doesn't evict //! every other group's result. +//! +//! The group's own corner radius is applied to the finished fold ([`result_cached`]) and cached in a +//! separate, smaller map keyed by the folded path plus the radius and smoothing: the fold's caches +//! are keyed by the operands alone, so scrubbing the radius never redoes the fold or evicts its +//! prefixes, and a rounded path is never stored where a fold would find it. use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; @@ -109,6 +114,30 @@ pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> Option { } } +fn rounded() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PathCache::new(512))) +} + +/// A boolean group's result as it is drawn: the fold of `operands` ([`fold_cached`]), then the +/// group's corner `radius` and `smoothing` on every corner still sharp. +pub fn result_cached(operands: &[(BezPath, BoolOp)], radius: f64, smoothing: f64) -> Option { + let folded = fold_cached(operands)?; + if !(radius.is_finite() && radius > 0.0) { + return Some(folded); + } + let mut h = DefaultHasher::new(); + hash_path(&folded, &mut h); + (radius.to_bits(), smoothing.to_bits(), 0x52u8).hash(&mut h); + let key = h.finish(); + if let Some(p) = locked(rounded()).get(key) { + return Some((*p).clone()); + } + let out = figcraft_pathops::round_corners(&folded, radius, smoothing); + locked(rounded()).insert(key, Arc::new(out.clone())); + Some(out) +} + /// `keys[i]`: the cache key of the fold of operands `0..=i`. fn fold_keys(operands: &[(BezPath, BoolOp)]) -> Vec { let mut h = DefaultHasher::new(); diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 7a34bf4f2..51e422425 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -7,12 +7,12 @@ use figcraft_color::{Color, Paint}; use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile}; use figcraft_geom::kurbo::{BezPath, Shape}; -fn dark(p: &Pixmap, x: u32, y: u32) -> bool { +pub(crate) fn dark(p: &Pixmap, x: u32, y: u32) -> bool { let i = ((y * p.width() + x) * 4) as usize; p.data().get(i).is_some_and(|r| *r < 40) } -fn render(d: &Document, p: NodeId) -> Pixmap { +pub(crate) fn render(d: &Document, p: NodeId) -> Pixmap { render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap() } @@ -28,7 +28,7 @@ fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec BezPath { +pub(crate) fn outline(d: &Document, id: NodeId) -> BezPath { node_outline(d, d.node(id).unwrap()).unwrap().0 } diff --git a/crates/render/src/boolean_radius_tests.rs b/crates/render/src/boolean_radius_tests.rs new file mode 100644 index 000000000..dfe98af03 --- /dev/null +++ b/crates/render/src/boolean_radius_tests.rs @@ -0,0 +1,60 @@ +//! A boolean group's corner radius: it rounds the result the canvas draws, and keys a cache of its +//! own so the fold underneath is not redone. + +use crate::boolean::boolean_steps; +use crate::boolean_cache_tests::{dark, outline, render}; +use figcraft_color::{Color, Paint}; +use figcraft_doc::{BoolOp, Document, NodeId, NodeKind}; +use figcraft_geom::kurbo::Shape; + +/// The L of the measurement: a union of 200 by 100 and 100 by 200 rectangles, on a page of its own. +fn l_group() -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let g = d.create(p, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap(); + d.get_mut(g).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + d.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap(); + let b = d.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap(); + d.fit_to_children(g).unwrap(); + (d, p, g, b) +} + +fn set_radius(d: &mut Document, g: NodeId, r: f64) { + d.get_mut(g).unwrap().corners.radii = [r; 4]; +} + +fn area(d: &Document, g: NodeId) -> f64 { + outline(d, g).area().abs() +} + +#[test] +fn a_boolean_groups_radius_rounds_what_the_canvas_draws() { + let (mut d, p, g, _) = l_group(); + let pix = render(&d, p); + assert!(dark(&pix, 1, 1) && !dark(&pix, 148, 102)); + set_radius(&mut d, g, 20.0); + let pix = render(&d, p); + assert!(!dark(&pix, 1, 1), "the corner is cut off"); + assert!(dark(&pix, 148, 102), "the concave corner is filled in"); + assert!(dark(&pix, 100, 50)); + assert!((area(&d, g) - 34656.6).abs() < 1.0, "{}", area(&d, g)); +} + +#[test] +fn the_radius_keys_the_result_and_the_fold_is_not_redone() { + let (mut d, _, g, b) = l_group(); + let sharp = outline(&d, g); + set_radius(&mut d, g, 20.0); + let _ = outline(&d, g); + let before = boolean_steps(); + set_radius(&mut d, g, 40.0); + // Four net corners of 343.36 each, plus the cubic arcs' own slight excess over a circle. + assert!((area(&d, g) - 33626.6).abs() < 2.0, "{}", area(&d, g)); + assert_eq!(boolean_steps(), before, "a new radius reuses the fold"); + set_radius(&mut d, g, 0.0); + assert_eq!(outline(&d, g), sharp, "radius 0 draws the fold itself"); + set_radius(&mut d, g, 20.0); + let rounded = area(&d, g); + d.get_mut(b).unwrap().transform = figcraft_geom::Affine::translate((10.0, 0.0)) * d.node(b).unwrap().transform; + assert!((area(&d, g) - rounded).abs() > 1.0, "moving an operand does not draw a stale rounded path"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index bf5e8953c..a5010006b 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -347,8 +347,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F Some((p, c.bool_op.unwrap_or(*op))) }) .collect(); - // Past the path operations' caps the group has no outline: it draws without its result. - crate::boolean::fold_cached(&operands).map(|p| (p, FillRule::EvenOdd)) + // Past the path operations' caps the group has no outline: it draws without its result. The + // group's corner radius rounds what is still sharp in the fold. + crate::boolean::result_cached(&operands, n.corners.radii[0], n.corners.smoothing).map(|p| (p, FillRule::EvenOdd)) } NodeKind::Text(_) => { let (l, dy) = figcraft_text::layout_node(n)?; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5bfaaed5c..6700fbc72 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -550,6 +550,8 @@ mod boolean_budget_tests; #[cfg(test)] mod boolean_cache_tests; #[cfg(test)] +mod boolean_radius_tests; +#[cfg(test)] mod bounds_memo_tests; #[cfg(test)] mod dots_tests; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ebe73a32c..b677b26a7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -886,6 +886,7 @@ name = "English" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}" "Remove these results from the file" = "Remove these results from the file" "Scroll wheel zoom" = "Scroll wheel zoom" +"Invert zoom direction" = "Invert zoom direction" "Shared by your team on {server}" = "Shared by your team on {server}" "Slot name" = "Slot name" "Mar" = "Mar" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e5957172a..5a65c5524 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -36,6 +36,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" "Create from color" = "Crear a partir del color" +"Invert zoom direction" = "Invertir la dirección del zoom" "New collection" = "Nueva colección" "Delete results" = "Eliminar resultados" "Pasted design layers" = "Capas de diseño pegadas" @@ -2216,6 +2217,7 @@ status = "draft — needs native review" "plugins.runLast" = "Ejecutar el último plugin" "pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" +"pref.invertZoom" = "Invertir la dirección del zoom" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" "pref.nudge" = "Cantidad de desplazamiento" diff --git a/crates/ui-egui/src/canvas/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs index dec2a1b31..d518dde37 100644 --- a/crates/ui-egui/src/canvas/cursors.rs +++ b/crates/ui-egui/src/canvas/cursors.rs @@ -1,7 +1,7 @@ //! The pointer on the canvas: each engine cursor ([`figcraft_engine::tools::Cursor`]) as one of //! the system's cursors, or as a small glyph FigCraft draws at the pointer with the system's //! hidden (a rotate cursor turned to its corner, the pen's states, the eyedropper, the comment -//! pin). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images. +//! pin, the crosshair of the shape and Text tools). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images. use egui::{Color32, CursorIcon, Painter, Pos2, Shape, Stroke, pos2, vec2}; use figcraft_engine::tools::{Cursor, PenCursor}; @@ -17,6 +17,9 @@ pub enum Glyph { Pen(PenCursor), Eyedropper, Comment, + /// A plus open at the centre, for the shape and Text tools (the system's follows the + /// system's pointer colour, and a white one is lost on a white frame). + Crosshair, } /// What the pointer shows. @@ -46,7 +49,7 @@ pub fn of(cursor: Cursor) -> Shown { use Shown::{Drawn, System}; match cursor { Cursor::Default => System(CursorIcon::Default), - Cursor::Crosshair => System(CursorIcon::Crosshair), + Cursor::Crosshair => Drawn(Glyph::Crosshair), Cursor::Move => System(CursorIcon::Move), Cursor::Resize(a) if a.is_finite() => System(resize_icon(a)), Cursor::Resize(_) => System(CursorIcon::Default), @@ -85,7 +88,7 @@ impl Shown { /// A name for it (`ui.inspect`): the CSS cursor names where there is one ("default", /// "nwse-resize", "copy"), and "rotate" (with its angle), "pen", "pen-close", "pen-add", - /// "pen-bend", "eyedropper" and "comment" for the drawn ones. + /// "pen-bend", "eyedropper", "comment" and "crosshair" for the drawn ones. pub fn name(self) -> String { match self { Shown::System(i) => match i { @@ -114,6 +117,7 @@ impl Shown { Shown::Drawn(Glyph::Pen(PenCursor::Bend)) => "pen-bend".into(), Shown::Drawn(Glyph::Eyedropper) => "eyedropper".into(), Shown::Drawn(Glyph::Comment) => "comment".into(), + Shown::Drawn(Glyph::Crosshair) => "crosshair".into(), } } @@ -127,7 +131,7 @@ impl Shown { } /// Dark lines over a light edge: readable on any page and artwork. -const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E); +pub(crate) const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E); const EDGE: Color32 = Color32::WHITE; /// A line through `pts` with the light edge under it. @@ -167,6 +171,22 @@ pub fn paint(painter: &Painter, at: Pos2, glyph: Glyph) { line(painter, &[tip - t * 4.5 + n * 3.5, tip + t * 1.0, tip - t * 4.5 - n * 3.5]); } } + Glyph::Crosshair => { + // A plus centred on the pointer, open at the centre so its pixel stays visible; the + // light edge goes under both arms of each line first, then the dark line on top. + let arms = [ + (vec2(-8.0, 0.0), vec2(-2.0, 0.0)), + (vec2(2.0, 0.0), vec2(8.0, 0.0)), + (vec2(0.0, -8.0), vec2(0.0, -2.0)), + (vec2(0.0, 2.0), vec2(0.0, 8.0)), + ]; + for (a, b) in arms { + painter.add(Shape::line_segment([at + a, at + b], Stroke::new(3.0, EDGE))); + } + for (a, b) in arms { + painter.add(Shape::line_segment([at + a, at + b], Stroke::new(1.0, INK))); + } + } Glyph::Pen(state) => { // A cross open at the centre (the point it lands on stays visible), and what a click // does at its lower right. diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5187fe866..b4cff10aa 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -81,10 +81,18 @@ fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) -> if let Some(mouse) = latest { app.canvas.wheel_is_mouse = mouse; } + let invert = app.session.prefs.invert_zoom && !pinch; if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch { - return (wheel, zoom); + return (wheel, if invert { invert_zoom_factor(zoom) } else { zoom }); } - swap_wheel(wheel, zoom, shift, speed) + let (scroll, zoom) = swap_wheel(wheel, zoom, shift, speed); + (scroll, if invert { invert_zoom_factor(zoom) } else { zoom }) +} + +/// The zoom factor for the opposite direction (Preferences ▸ Invert zoom direction): its +/// reciprocal. A factor that is zero, negative or not finite is left as it is. +pub(crate) fn invert_zoom_factor(zoom: f32) -> f32 { + if zoom.is_finite() && zoom > 0.0 { 1.0 / zoom } else { zoom } } /// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs index 5b196a4d4..b5a36a7de 100644 --- a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -6,7 +6,7 @@ use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, R use figcraft_engine::Session; use serde_json::json; -use super::input::{is_mouse_wheel, swap_wheel}; +use super::input::{invert_zoom_factor, is_mouse_wheel, swap_wheel}; use crate::{FigApp, NoServices}; /// The view's zoom, cx and cy. @@ -25,8 +25,13 @@ fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event { impl Canvas { fn new(scroll_wheel_zoom: bool) -> Self { + Self::with_invert(scroll_wheel_zoom, false) + } + + fn with_invert(scroll_wheel_zoom: bool, invert_zoom: bool) -> Self { let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 }; c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom})); + c.app.run("pref.invertZoom", json!({"on": invert_zoom})); for _ in 0..3 { c.frame(vec![], Modifiers::NONE); } @@ -126,3 +131,63 @@ fn the_swap_keeps_egui_rates() { assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0)); assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0)); } + +#[test] +fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() { + for mods in [Modifiers::COMMAND, Modifiers::CTRL] { + let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + assert!(b.0 > a.0 * 1.05, "{mods:?} off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "{mods:?} on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "{mods:?} by the same factor"); + } +} + +#[test] +fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() { + let (mut off, mut on) = (Canvas::with_invert(true, false), Canvas::with_invert(true, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); +} + +/// Panning is never inverted: the scroll vector is the same with the preference on and off. +#[test] +fn invert_zoom_leaves_panning_unchanged() { + // (Scroll wheel zoom, modifiers): a plain wheel without it, Cmd/Ctrl+wheel with it, Shift+wheel. + for (swz, mods) in [(false, Modifiers::NONE), (true, Modifiers::COMMAND), (true, Modifiers::CTRL), (true, Modifiers::SHIFT)] { + let (mut off, mut on) = (Canvas::with_invert(swz, false), Canvas::with_invert(swz, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + assert!((b.0 - a.0).abs() < 1e-9 && (d.0 - c.0).abs() < 1e-9, "{mods:?} no zoom"); + assert!(b.1 != a.1 || b.2 != a.2, "{mods:?} off pans: {a:?} -> {b:?}"); + assert!( + ((b.1 - a.1) - (d.1 - c.1)).abs() < 1e-9 && ((b.2 - a.2) - (d.2 - c.2)).abs() < 1e-9, + "{mods:?} same pan: {a:?} -> {b:?} vs {c:?} -> {d:?}" + ); + } +} + +#[test] +fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() { + for (swz, invert, factor) in + [(false, false, 1.5), (false, true, 1.5), (true, false, 1.5), (true, true, 1.5), (true, true, 0.5), (false, true, 0.5)] + { + let mut c = Canvas::with_invert(swz, invert); + let (a, b, _) = c.gesture(Event::Zoom(factor), Modifiers::NONE); + assert!((b.0 / a.0 - f64::from(factor)).abs() < 1e-3, "scroll wheel zoom {swz}, invert {invert}, x{factor}: {a:?} -> {b:?}"); + } +} + +#[test] +fn the_inverted_factor_is_the_reciprocal_and_guards_bad_values() { + assert!((invert_zoom_factor(2.0) - 0.5).abs() < 1e-6); + assert_eq!(invert_zoom_factor(1.0), 1.0); + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] { + let r = invert_zoom_factor(bad); + assert!(r == bad || (r.is_nan() && bad.is_nan()), "{bad} left alone"); + } +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 281ab6f2a..117b90db3 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -166,25 +166,42 @@ fn polygons_and_stars_take_corner_smoothing() { for cmd in ["shape.polygon", "shape.star"] { let mut s = Session::new(); let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap()); - s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); - s.execute("select.set", &json!({"ids": [id]})).unwrap(); - let mut u = Ui::new(s); - // With no corners of their own, the button is named for what it opens. - let t = named_button(&mut u, "Corner smoothing"); - click(&mut u, t); - assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); - let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center(); - let history = u.app.session.undo_depth().0; - click(&mut u, smoothing); - u.frame(vec![Event::Text("60".into())]); - enter(&mut u); - u.frame(vec![]); - let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); - assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing); - assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step"); + takes_corner_smoothing(s, id, cmd); } } +/// A boolean group has one radius for its whole result (#2222): like a polygon, its button opens +/// corner smoothing, with no field per corner. +#[test] +fn a_boolean_group_opens_corner_smoothing_and_has_no_corner_fields() { + let mut s = Session::new(); + let rect = |s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone(); + let (a, b) = (rect(&mut s, 0), rect(&mut s, 60)); + let g = NodeId(s.execute("bool.union", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap()); + takes_corner_smoothing(s, g, "bool.union"); +} + +/// With `id` rounded at 8 and selected: the panel's button is named Corner smoothing and opens +/// the smoothing field with no field per corner, and typing 60 sets the smoothing in one undo step. +fn takes_corner_smoothing(mut s: Session, id: NodeId, what: &str) { + s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut u = Ui::new(s); + // With no corners of their own, the button is named for what it opens. + let t = named_button(&mut u, "Corner smoothing"); + click(&mut u, t); + assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{what}: one radius, no corner fields"); + let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center(); + let history = u.app.session.undo_depth().0; + click(&mut u, smoothing); + u.frame(vec![Event::Text("60".into())]); + enter(&mut u); + u.frame(vec![]); + let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); + assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{what}: {}", n.corners.smoothing); + assert_eq!(u.app.session.undo_depth().0, history + 1, "{what}: one undo step"); +} + /// A closed vector shows the same Corner smoothing control, and its iOS button sets 60% in one /// undo step; an open vector, which has no closed shape to smooth, shows no smoothing control. #[test] diff --git a/crates/ui-egui/src/cursors_tests.rs b/crates/ui-egui/src/cursors_tests.rs index 94109dc22..bb24a2469 100644 --- a/crates/ui-egui/src/cursors_tests.rs +++ b/crates/ui-egui/src/cursors_tests.rs @@ -22,7 +22,7 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() { // (engine cursor, what shows, its name in `ui.inspect`) let rows: Vec<(Cursor, Shown, &str)> = vec![ (Cursor::Default, System(CursorIcon::Default), "default"), - (Cursor::Crosshair, System(CursorIcon::Crosshair), "crosshair"), + (Cursor::Crosshair, Drawn(Glyph::Crosshair), "crosshair"), (Cursor::Move, System(CursorIcon::Move), "move"), (Cursor::Resize(0.0), System(CursorIcon::ResizeHorizontal), "ew-resize"), (Cursor::Resize(90.0), System(CursorIcon::ResizeVertical), "ns-resize"), @@ -59,6 +59,44 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() { assert_eq!(of(Cursor::Rotate(f64::INFINITY)), Drawn(Glyph::Rotate(0.0))); } +#[test] +fn the_crosshair_is_drawn_dark_with_a_white_edge_so_it_shows_on_white() { + use egui::epaint::Shape; + let shown = of(Cursor::Crosshair); + assert_eq!(shown, Shown::Drawn(Glyph::Crosshair)); + // The system's pointer is hidden under it, whatever its colour. + assert_eq!(shown.icon(), CursorIcon::None); + let at = pos2(50.0, 40.0); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx().clone(); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("crosshair_test")); + crate::cursors::paint(&ctx.layer_painter(layer), at, Glyph::Crosshair); + }); + out.textures_delta.clear(); + let mut strokes = Vec::new(); + for clipped in &out.shapes { + if let Shape::LineSegment { points, stroke } = &clipped.shape { + strokes.push((points.to_vec(), stroke.color, stroke.width)); + } + } + let dark: Vec<_> = strokes.iter().filter(|s| s.1 == crate::cursors::INK).collect(); + let white: Vec<_> = strokes.iter().filter(|s| s.1 == egui::Color32::WHITE).collect(); + assert!(!dark.is_empty() && !white.is_empty(), "{strokes:?}"); + // The white edge is wider than the dark line and is painted first. + assert!(white.iter().all(|w| w.2 > dark[0].2), "{strokes:?}"); + assert_eq!(strokes[0].1, egui::Color32::WHITE); + // The arms are centred on the pointer and leave its pixel clear. + for (pts, ..) in &dark { + for p in pts { + let d = (p.x - at.x).abs().max((p.y - at.y).abs()); + assert!((2.0..=8.0).contains(&d), "{p:?}"); + assert!((p.x - at.x).abs() < 0.01 || (p.y - at.y).abs() < 0.01, "{p:?}"); + } + } + assert_eq!(dark.len(), 4); +} + #[test] fn resize_arrows_follow_the_angle_in_eighths() { // (degrees, the two-headed arrow): each covers 45° about its own direction, any turn. @@ -134,10 +172,10 @@ fn the_window_asks_for_the_cursor_of_the_tool_under_the_pointer() { ("move", edge, &[], no, CursorIcon::ResizeHorizontal, "ew-resize"), ("move", on, &[], alt, CursorIcon::Copy, "copy"), ("hand", empty, &[], no, CursorIcon::Grab, "grab"), - ("rectangle", empty, &[], no, CursorIcon::Crosshair, "crosshair"), - ("frame", on, &[], no, CursorIcon::Crosshair, "crosshair"), - ("slice", empty, &[], no, CursorIcon::Crosshair, "crosshair"), - ("text", empty, &[], no, CursorIcon::Crosshair, "crosshair"), + ("rectangle", empty, &[], no, CursorIcon::None, "crosshair"), + ("frame", on, &[], no, CursorIcon::None, "crosshair"), + ("slice", empty, &[], no, CursorIcon::None, "crosshair"), + ("text", empty, &[], no, CursorIcon::None, "crosshair"), ("pen", empty, &[], no, CursorIcon::None, "pen"), ("comment", empty, &[], no, CursorIcon::None, "comment"), ("move", empty, &[Key::Space], no, CursorIcon::Grab, "grab"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 52170af44..e60928fd1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { (tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"), (tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"), (tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"), + (tr("Invert zoom direction"), prefs.invert_zoom, "pref.invertZoom"), (tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"), ] { if crate::widgets::checkbox(ui, val, label) { diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 19ef933e2..cf37a65da 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -189,7 +189,7 @@ fn setup() -> (Ui, NodeId, NodeId) { /// The topmost field named `name` in the Design panel: its value centre and displayed text. /// Layer opacity precedes the equally named opacity fields in paint rows. -fn field(u: &mut Ui, name: &str) -> (Pos2, String) { +pub(crate) fn field(u: &mut Ui, name: &str) -> (Pos2, String) { u.frame(vec![]); let out = u.frame_output(vec![]); let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; @@ -234,7 +234,7 @@ fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) { (b.x0, b.y0, b.width(), b.height()) } -fn undo_steps(u: &Ui) -> usize { +pub(crate) fn undo_steps(u: &Ui) -> usize { u.app.session.ds().unwrap().history.undo.len() } @@ -252,7 +252,7 @@ fn click(u: &mut Ui, p: Pos2) { } /// Click into field `name`, type `text` over what it shows and press `end`. -fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { +pub(crate) fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { let (p, _) = field(u, name); click(u, p); u.frame(vec![Event::Text(text.into())]); @@ -261,7 +261,7 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { } /// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go. -fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) { +pub(crate) fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) { let from = label(u, name); drag(u, from, dx, modifiers); } @@ -1670,3 +1670,35 @@ fn alt_held_over_a_field_scrubs_it() { u.frame(vec![]); assert_eq!(geom(&u, a).0, 65.0, "ten more"); } + +#[test] +fn a_boolean_group_has_one_corner_radius_field_and_a_mixed_selection_reads_mixed() { + let (mut u, a, b) = setup(); + let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap()); + let r = id(u.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 0, "width": 50, "height": 50})).unwrap()); + u.app.run("select.set", json!({"ids": [g]})); + assert_eq!(field(&mut u, "Corner radius").1, "0"); + let steps = undo_steps(&u); + type_into(&mut u, "Corner radius", "20", Key::Enter); + let radii = |u: &Ui, n: NodeId| u.app.session.doc().unwrap().node(n).unwrap().corners.radii; + assert_eq!(radii(&u, g), [20.0; 4]); + assert_eq!(undo_steps(&u), steps + 1, "one undo step"); + u.app.run("select.set", json!({"ids": [g, r]})); + assert_eq!(field(&mut u, "Corner radius").1, "Mixed"); + type_into(&mut u, "Corner radius", "8", Key::Enter); + assert_eq!((radii(&u, g), radii(&u, r)), ([8.0; 4], [8.0; 4])); +} + +/// A boolean group draws its first radius; per-corner values a script or a file left behind don't +/// make its field read Mixed, and a relative edit starts from the radius it draws. +#[test] +fn a_boolean_groups_field_shows_the_radius_it_draws() { + let (mut u, a, b) = setup(); + let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap()); + let corners = json!({"topRightRadius": 30, "bottomRightRadius": 30, "bottomLeftRadius": 30}); + u.app.session.execute("node.set", &json!({"ids": [g], "props": corners})).unwrap(); + u.app.run("select.set", json!({"ids": [g]})); + assert_eq!(field(&mut u, "Corner radius").1, "0", "the radius it draws"); + type_into(&mut u, "Corner radius", "+10", Key::Enter); + assert_eq!(u.app.session.doc().unwrap().node(g).unwrap().corners.radii, [10.0; 4], "0 + 10, not 30 + 10"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 320f94ba2..2daa3f406 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -292,6 +292,8 @@ mod vector_keys_tests; #[cfg(test)] mod vector_panel_tests; #[cfg(test)] +mod vector_point_xy_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod vote_history_ui_tests; diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 4e257061b..df1ca5ee0 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -52,11 +52,16 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round(); let op = common(doc, sel, op_of); let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius()); - // Only rectangles and frames round each corner on its own. + // Only rectangles and frames round each corner on its own; a boolean group has one radius for + // every sharp corner of its result. let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners()); - // For polygons, stars and closed vectors the same panel holds only the Corner smoothing row. + // For polygons, stars, boolean groups and closed vectors the same panel holds only the Corner + // smoothing row. let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing()); - let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); + // A layer with one radius (polygon, star, boolean group, vector) draws its first one, whatever + // the other three hold; a rectangle or frame shows a value only when its four corners agree. + let shown = |n: &Node| if n.kind.has_independent_corners() { n.corners.uniform() } else { n.corners.radii.first().copied() }; + let r = common(doc, sel, |n| shown(n).map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { let lead = Lead::Icon(Icon::Opacity, tr("Opacity")); @@ -74,11 +79,13 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N "", col, ); - // A layer with corners of its own starts from its largest. - let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); + // A relative edit starts from the radius shown; a rectangle with differing corners + // starts from its largest. + let radius_of = |n: &Node| shown(n).unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false); - // Polygons and stars have no corners of their own: the button opens only Corner smoothing. + // Polygons, stars and boolean groups have no corners of their own: the button opens only + // Corner smoothing. let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") }; if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() { ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open)); diff --git a/crates/ui-egui/src/panels/vector_panel.rs b/crates/ui-egui/src/panels/vector_panel.rs index 07dda3ff4..3b91c9ffc 100644 --- a/crates/ui-egui/src/panels/vector_panel.rs +++ b/crates/ui-egui/src/panels/vector_panel.rs @@ -12,7 +12,7 @@ use crate::FigApp; use crate::i18n::tr; use crate::icons::Icon; use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row}; -use crate::widgets::{self, FieldEdit, Lead}; +use crate::widgets::{self, Edit, FieldEdit, Lead, Rel}; /// Run a point command from a field: typed values are one step, scrubs one step when released. fn point_field(app: &mut FigApp, cmd: &str, edit: Option, params: impl Fn(f64) -> Value) { @@ -31,6 +31,23 @@ fn point_field(app: &mut FigApp, cmd: &str, edit: Option, params: imp } } +/// A point position field's edit: a value (typed or scrubbed) sets every selected point's own +/// coordinate; a relative edit of a Mixed field (`+10`, `*2`) applies to each point's own. +fn point_edit(app: &mut FigApp, axis: &'static str, edit: Option) { + match edit { + Some(Edit::All(e)) => point_field(app, "vector.edit.position", Some(e), |v| json!({ axis: v })), + Some(Edit::Each(rel)) => { + let op = match rel { + Rel::Add(n) => json!({"add": n}), + Rel::Mul(n) => json!({"mul": n}), + Rel::Div(n) => json!({"div": n}), + }; + app.run("vector.edit.position", json!({ axis: op })); + } + None => {} + } +} + /// The Variable width tool's selected width point: where it is along the path and its width. fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { widgets::section_header(ui, tr("Width point"), &[], false); @@ -88,14 +105,19 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { return; } let col = widgets::col_width(ui.available_width()); - let pos = vectoredit::selection_position(doc, ve); let round = |v: f64| (v * 100.0).round() / 100.0; + let parent_pts = vectoredit::selected_parent_points(doc, ve); + // The value the selected points share (within 1e-6), or none: Mixed. + let shared = |axis: fn(&figcraft_geom::Point) -> f64| { + let first = parent_pts.first().map(axis)?; + parent_pts.iter().all(|p| (axis(p) - first).abs() < 1e-6).then(|| round(first)) + }; widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::unique("vpt_x"), Lead::Text("X"), pos.map(|p| round(p.x)), "", col, 1.0); - point_field(app, "vector.edit.position", e, |v| json!({"x": v})); - let e = widgets::number_field(ui, Id::unique("vpt_y"), Lead::Text("Y"), pos.map(|p| round(p.y)), "", col, 1.0); - point_field(app, "vector.edit.position", e, |v| json!({"y": v})); + let e = widgets::number_field_mixed(ui, Id::unique("vpt_x"), Lead::Text("X"), shared(|p| p.x), "", col, 1.0); + point_edit(app, "x", e); + let e = widgets::number_field_mixed(ui, Id::unique("vpt_y"), Lead::Text("Y"), shared(|p| p.y), "", col, 1.0); + point_edit(app, "y", e); }); if let Some(b) = vectoredit::transform::point_box(doc, ve).filter(|b| b.points.len() > 1) { widgets::sublabels(ui, tr("Dimensions"), None); diff --git a/crates/ui-egui/src/vector_point_xy_tests.rs b/crates/ui-egui/src/vector_point_xy_tests.rs new file mode 100644 index 000000000..e93f62873 --- /dev/null +++ b/crates/ui-egui/src/vector_point_xy_tests.rs @@ -0,0 +1,79 @@ +//! The vector edit panel's Point section: X and Y show the value the selected points share, or +//! "Mixed", and a typed value sets every selected point's own coordinate. + +use egui::{Key, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::field_tests::{field, scrub, type_into, undo_steps}; +use crate::layers_drag_tests::Ui; + +fn setup() -> (Ui, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("vector.edit.begin", &json!({"id": id})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + u.frame(vec![]); + (u, id) +} + +fn xs(u: &Ui, id: NodeId) -> Vec<(f64, f64)> { + let doc = u.app.session.doc().unwrap(); + // In the parent's coordinates: the layer re-fits its box after an edit. + let t = doc.node(id).unwrap().transform; + figcraft_engine::vectoredit::network(doc, id).unwrap().vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect() +} + +#[test] +fn x_reads_mixed_for_points_that_differ_and_typing_sets_every_point() { + let (mut u, id) = setup(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap(); + let before = xs(&u, id); + // Points 0 and 1 share their y but not their x. + assert_eq!(field(&mut u, "X").1, "Mixed"); + assert_eq!(field(&mut u, "Y").1, "0"); + type_into(&mut u, "X", "-200", Key::Enter); + let after = xs(&u, id); + assert_eq!((after[0].0, after[1].0), (-200.0, -200.0)); + assert_eq!((after[0].1, after[1].1), (before[0].1, before[1].1)); + assert_eq!(field(&mut u, "X").1, "-200", "the points share an x now"); + // A relative edit of a Mixed field goes to each point from its own value. + u.app.session.execute("vector.edit.select", &json!({"vertices": [1, 2]})).unwrap(); + assert_eq!(field(&mut u, "X").1, "Mixed"); + let b = xs(&u, id); + type_into(&mut u, "X", "+10", Key::Enter); + let a = xs(&u, id); + assert_eq!((a[1].0, a[2].0), (b[1].0 + 10.0, b[2].0 + 10.0)); +} + +#[test] +fn one_point_shows_its_own_value() { + let (mut u, id) = setup(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [2]})).unwrap(); + let p = xs(&u, id)[2]; + assert_eq!(field(&mut u, "X").1, format!("{}", p.0)); + assert_eq!(field(&mut u, "Y").1, format!("{}", p.1)); +} + +#[test] +fn a_shared_value_scrubs_every_point_in_one_step_and_mixed_does_not_scrub() { + let (mut u, id) = setup(); + // Points 0 and 1 share their y: a scrub moves both to the same new y, as one undo step. + u.app.session.execute("vector.edit.select", &json!({"vertices": [0, 1]})).unwrap(); + let (before, steps) = (xs(&u, id), undo_steps(&u)); + scrub(&mut u, "Y", 20.0, Modifiers::NONE); + let after = xs(&u, id); + assert!(after[0].1 > before[0].1, "the scrub moved y: {before:?} -> {after:?}"); + assert_eq!(after[0].1, after[1].1, "both points take the scrubbed value"); + assert_eq!((after[0].0, after[1].0), (before[0].0, before[1].0), "x is untouched"); + assert_eq!(undo_steps(&u), steps + 1, "one undo step for the whole scrub"); + // Their x differs (Mixed): there is no value to scrub from, so nothing moves. + assert_eq!(field(&mut u, "X").1, "Mixed"); + let (before, steps) = (xs(&u, id), undo_steps(&u)); + scrub(&mut u, "X", 20.0, Modifiers::NONE); + assert_eq!((xs(&u, id), undo_steps(&u)), (before, steps)); +} diff --git a/docs/gaps.md b/docs/gaps.md index ee855922c..e7cbdb76a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (gap 13: a boolean group's corner radius (#2222); gap 24: mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -383,14 +383,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). ### 13. Vector tail: none left +- **Landed (2026-11-10):** a boolean group takes a corner radius (and smoothing) that rounds every sharp + corner of its result, convex and concave, with the children's own radii kept and a radius larger + than the edges allow shrinking to fit (#2222; `crates/pathops/src/round_tests.rs`). - **Landed (2026-10-22):** the transform box around points selected in several layers at once, in the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags them, each in its own layer and one undo step for all (`transform_tests`). - **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner radius and smoothing on boolean groups that contain - rectangles, per-point caps and joins on dashed, variable-width and brush + selected open path. Left: per-point caps and joins on dashed, variable-width and brush strokes. - **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are @@ -593,6 +595,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | Gap 13: a boolean group's corner radius rounds every sharp corner of its result (#2222); the boolean clause leaves the 2026-10-12 "Left" list | | 2026-11-10 | minor | Gap 24: mind maps, first step (`board.mindMap`, `board.mindMapAdd`; the map lays itself out again after every edit, #2226); the toolbar entry, shortcuts, drag to re-parent and `.jam` import are what is missing of them. | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. | diff --git a/docs/mcp.md b/docs/mcp.md index 8499338bc..d722e7cfa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -767,7 +767,7 @@ page line crosses; parts that come apart move to layers of their own above, list `vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing), `vector.edit.cornerRadius` (rounds the selected sharp points, next to straight or curved segments: a curve is cut where the arc meets it and keeps the rest of its shape; drawn and exported; -`node.set {cornerRadius}` on a vector rounds every point), `vector.edit.strokeCap {cap, vertices?}` +`node.set {cornerRadius}` on a vector rounds every point, and on a boolean group every sharp corner of its result, while its layers keep their own radius), `vector.edit.strokeCap {cap, vertices?}` and `vector.edit.strokeJoin {join, vertices?}` (the stroke's cap where it ends at these points and its join where it turns there, `null` for the layer's; drawn, exported and outlined), `vector.edit.moveHandles {dx, dy, handles?}` (moves the selected handles by a page offset), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 448fbc729..031ef0726 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -99,7 +99,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 1 | Canvas & navigation | 6 | 98% | 74% | Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks; outline mode's Include hidden layers and Include object bounds landed) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 4 | Shapes & vector | 8 | 99% | 73% | per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation, a boolean group's corner radius and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed; a background blur or glass exported on its own keeps the content behind it within its bounds, measured 2026-11-10, #2213) | 2–5 | @@ -649,6 +649,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M4, #2222: a boolean group's corner radius rounds every sharp corner of its result (row 4). Numbers unchanged | +| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | | 2026-11-11 | patch | M4: typing a number, a full stop and a space at the start of a plain paragraph starts a numbered list at that number (`5. ` then "x", Enter, "y" reads 5. then 6.), as measured in Figma web on 2026-11-11 (it has no start-number field: the start comes only from the typed number); before, only `1. ` and `1)` started a list. `0. `, `12a. ` and a bracket after a number other than 1 stay text (not measured), and the number is capped at 9999. Also measured: a plain paragraph or a bullet item between numbered items restarts the count at 1, and an indented sub-item does not break the outer count; those rows were pinned as silent. Tests: `text_tail_tests::typed_list_starts`, `typed_number_starts_the_list_there`, `tail_tests` restart rows. Numbers unchanged | | 2026-11-11 | patch | M2.1: grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) (this replaces the 2026-10-11 rule from the documentation). Percentages unchanged | | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2d5863144..a9883b4d3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -65,7 +65,7 @@ interaction feel not yet compared. | Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | | Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`) | -| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation | +| Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation; Shift lands the rotation on multiples of 15° (measured in Figma: a layer at 7° or -4.27° ends at 15°), not 15° steps from where the drag began; layers with different rotations step the turn from 0 | | Flip, rotate 90° | done | | | Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) | | Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) | @@ -96,10 +96,11 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); the Point section's X and Y show the value the selected points share, or Mixed, and a typed value sets every selected point's own coordinate, in the layer being edited (measured in Figma on 2026-11-08). Not yet: areas of other layers edited at once; the Point section's X and Y over points of several edited layers | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377), and bakes a vector's point corner radius into its path (each rounded point becomes the arc's end points joined by curves, the layer stays a vector and its box fits the real geometry, #2195); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875). Not yet: areas of other layers edited at once | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875); with regions selected, the Fill section (its rows and the colour picker) and `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` edit those regions' own fills, the layer's untouched, or the regions named by `regions: [index, ...]`; Copy fill takes the region's paint, the colour picker and its canvas gradient and crop handles edit it (an image from Upload from computer included; a file dropped on the picker goes to the canvas instead), what belongs to the layer (its fill style, a variable bound to its fill, the image crop button) stays off a region's rows, and `fill.pattern` sets the layer's fill (#1875); Copy and Cut with points or segments selected take just those (a lone point: the segments touching it) as a vector with the layer's look, at their own place, so a paste outside edit mode is a new layer in place; Cut removes them as Delete does, the path splitting in the same layer with edit mode staying on; Paste while editing adds the clipboard's segments, or a copied shape's or boolean's outline, to the edited network in place with the new points selected, skipping layers with no outline (text, frames, groups; named in `skipped`) and refusing when none has one, as measured in Figma (`vector_clipboard_tests`, #1875). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | -| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) | +| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003); a boolean group takes a corner radius from the Design panel and from `node.set cornerRadius`: every sharp corner of its result rounds, convex and concave, layers inside keep their own radius and do not stack with it, a radius larger than the edges allow shrinks to fit while the field keeps the value typed, Flatten bakes the arcs and a mixed selection reads Mixed; measured in Figma's web app on 2026-11-10 (#2222; `crates/pathops/src/round_tests.rs`, `crates/render/src/boolean_radius_tests.rs`, `crates/engine/src/boolean_radius_tests.rs`, the boolean group test in `crates/ui-egui/src/field_tests.rs`). Pinned and not yet compared with Figma: corner smoothing on a boolean group draws the polygon profile between straight edges, a corner beside a curve stays within the curve piece next to it, and there is no radius handle on the canvas | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. At three levels (the text layer, a paragraph and a text style, per Explore text properties): the paragraph level is the caret's or the selection's paragraphs while editing (`text.wrapStyle`; the control follows them), or `start`/`end` on `node.set wrapStyle` (a paragraph's own style beats the layer's; setting the layer sets every paragraph again; Enter carries it to the new paragraph; inspect reports `lineWrapStyles` when paragraphs differ and CSS `text-wrap` only when they agree); the text-style level is a style's `wrapStyle` (shown read-only in the style editor, applied with the style, moved by `style.update`, unlinking when edited). The `.fig` import/export mapping stays open (#1827): a `.fig` export leaves the wrap styles out and a `.fig` import reads every text as Auto, so the wrap styles are lost through a `.fig` round trip. Test: `text_behaviour_tests::wrap_style_per_paragraph_overrides_the_layers`, `styles_behaviour_tests::text_styles_carry_the_wrap_style`, `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` | | Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Developer Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | @@ -115,6 +116,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| +| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); Preferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | | Frame labels (rotated, right-to-left), size badge | done | | @@ -128,7 +130,7 @@ interaction feel not yet compared. | Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) | | Gradient and padding/gap handles on the canvas | done | the gradient's handles and stops show while its picker is open, and dragging one keeps the picker open (#1852) | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots; the outline follows the layer's geometry, rounded corners (each radius and the smoothing) included, whatever the stroke alignment (#1506) | -| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side | +| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools, drawn dark with a white edge so it shows on any canvas colour whatever the system's pointer style (#2198); the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side | | Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions | ## Keyboard shortcuts @@ -211,6 +213,11 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| +| 2026-11-10 | patch | M1, #2198: with a shape, frame, slice or Text tool the pointer was the system's crosshair, white under some pointer styles on Windows and lost on the default white frame; it is now drawn at the pointer in dark lines with a white edge, open at the centre (`Glyph::Crosshair`), so it shows on any canvas colour. | +| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result (measured in Figma's web app), Boolean groups row | +| 2026-11-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are unchanged. | +| 2026-11-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. | +| 2026-11-07 | patch | Transform: Shift while rotating lands on absolute multiples of 15°, measured in Figma (a layer at 7° or -4.27° Shift-rotated by its corner ends at 15°); FigCraft rounded the turn since the drag began, so 7° landed on 7 + k*15. The gesture keeps the selection's shared rotation and rounds base + turn; a selection with different rotations keeps rounding the turn. Test: `shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start` | | 2026-11-10 | patch | M1 review follow-up: strengthen canvas alpha, clipping/coverage, grid geometry/order and drawn-theme tests; use settled fresh sidebars at each width, check HEX visibility and opacity alignment, and assert picker toggling preserves page alpha. Production behavior unchanged. | | 2026-11-09 | patch | M1: canvas page transparency uses a checkerboard that follows light/dark themes and applies background alpha once, including partial or missing renders. The page control also fits both sidebar width limits. Tests: `canvas::background::tests`, `field_tests::page_background_colour_and_opacity_fit_a_resized_sidebar`. Numbers unchanged | | 2026-11-09 | patch | M1: the Page background reuses the shared sidebar paint control; fractional opacity and picker toggling covered alongside alpha-aware HEX and scrub undo. Audited remaining custom sidebar colour controls. Numbers unchanged |