diff --git a/Cargo.lock b/Cargo.lock index 2d17ff64..6ff74fb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4504,6 +4504,8 @@ dependencies = [ "vectorcraft-engine", "vectorcraft-text", "vectorcraft-ui-egui", + "wayland-client", + "wayland-protocols", "webbrowser", "windows", "winit", diff --git a/ROADMAP.md b/ROADMAP.md index 477b8f5a..5dc9235d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). Linux: under Wayland, Automatic draws on the GPU the compositor says it draws with, so a hybrid laptop whose compositor runs on the discrete GPU no longer opens an empty window (#1200). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index fea62dd2..18663c55 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -67,6 +67,12 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std" # already in the build through eframe. pollster = "1" +[target.'cfg(target_os = "linux")'.dependencies] +# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), +# so Automatic draws on that one. Both are already in the build through winit. +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client"] } + [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" diff --git a/apps/vectorcraft/src/compositor_gpu.rs b/apps/vectorcraft/src/compositor_gpu.rs new file mode 100644 index 00000000..90e675b7 --- /dev/null +++ b/apps/vectorcraft/src/compositor_gpu.rs @@ -0,0 +1,178 @@ +//! The GPU the Wayland compositor draws with ([`crate::gpu`] ranks it first under Automatic). +//! +//! A window drawn on another GPU reaches the compositor as that GPU's buffers, and the compositor +//! may not show them: it can refuse them and end the window's connection (#502), or take them and +//! show nothing, with no error on either side, so neither the restart on a failed adapter nor +//! the first-frame watch notices. The second happened on a hybrid laptop whose Hyprland runs on +//! the NVIDIA GPU (`AQ_DRM_DEVICES`) while the built-in panel hangs off the Intel one: the panel +//! rule picked Intel and the window stayed empty. Which GPU drives a display is a guess at the +//! compositor's GPU; the compositor states it, as the `main_device` of its linux-dmabuf feedback +//! (protocol version 4): the device it imports client buffers on. + +#[cfg(target_os = "linux")] +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +#[cfg(target_os = "linux")] +use wayland_client::protocol::wl_registry::{self, WlRegistry}; +#[cfg(target_os = "linux")] +use wayland_client::{Connection, Dispatch, QueueHandle}; +#[cfg(target_os = "linux")] +use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{self, ZwpLinuxDmabufFeedbackV1}; +#[cfg(target_os = "linux")] +use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{self, ZwpLinuxDmabufV1}; + +/// PCI `(vendor, device)` ids of the GPU the Wayland compositor draws with. `None` outside a +/// Wayland session, when the compositor doesn't say (linux-dmabuf older than version 4), when its +/// device isn't a PCI GPU, and on other systems. +#[cfg(target_os = "linux")] +pub(crate) fn compositor_gpu() -> Option<(u32, u32)> { + let device = match main_device() { + Ok(device) => device, + Err(why) => { + log::debug!("the compositor's GPU is unknown: {why}"); + return None; + } + }; + let pci = pci_ids(std::path::Path::new("/sys/dev/char"), device); + match pci { + Some((vendor, id)) => log::info!("the compositor draws with {vendor:04x}:{id:04x}"), + None => log::debug!("the compositor's device {:?} has no PCI ids", major_minor(device)), + } + pci +} + +#[cfg(not(target_os = "linux"))] +pub(crate) fn compositor_gpu() -> Option<(u32, u32)> { + None +} + +/// The `main_device` of the compositor's default linux-dmabuf feedback, asked on a connection of +/// its own that is closed again before the window opens. +#[cfg(target_os = "linux")] +fn main_device() -> Result { + let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?; + let (globals, mut queue) = registry_queue_init::(&conn).map_err(|e| e.to_string())?; + let qh = queue.handle(); + // Version 4 brought the feedback; older compositors only list formats. + let dmabuf: ZwpLinuxDmabufV1 = globals.bind(&qh, 4..=4, ()).map_err(|e| format!("linux-dmabuf version 4: {e}"))?; + let _feedback = dmabuf.get_default_feedback(&qh, ()); + let mut state = Feedback::default(); + // The compositor sends the whole feedback, ending with `done`, in reply; the bound only keeps + // a compositor that never ends it from holding up the start. + for _ in 0..4 { + if state.done { + break; + } + queue.roundtrip(&mut state).map_err(|e| e.to_string())?; + } + // Dropping the connection releases the objects. + let device = state.main_device.ok_or("the compositor named no main device")?; + // A `dev_t`, in this machine's byte order. + let device: [u8; 8] = device.try_into().map_err(|d: Vec| format!("a {}-byte device number", d.len()))?; + Ok(u64::from_ne_bytes(device)) +} + +/// What [`main_device`] collects from the feedback events. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct Feedback { + main_device: Option>, + done: bool, +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + // Version 4 sends no format or modifier events: the feedback replaces them. + fn event(_: &mut Self, _: &ZwpLinuxDmabufV1, _: zwp_linux_dmabuf_v1::Event, _: &(), _: &Connection, _: &QueueHandle) {} +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + fn event( + state: &mut Self, + _: &ZwpLinuxDmabufFeedbackV1, + event: zwp_linux_dmabuf_feedback_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } => state.main_device = Some(device), + zwp_linux_dmabuf_feedback_v1::Event::Done => state.done = true, + _ => {} + } + } +} + +/// The major and minor numbers of a Linux `dev_t`, as glibc's and musl's `major()` and `minor()` +/// split it. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn major_minor(device: u64) -> (u64, u64) { + let major = ((device & 0x0000_0000_000f_ff00) >> 8) | ((device & 0xffff_f000_0000_0000) >> 32); + let minor = (device & 0x0000_0000_0000_00ff) | ((device & 0x0000_0fff_fff0_0000) >> 12); + (major, minor) +} + +/// The PCI ids of the GPU behind DRM device `device` (its `cardN` or `renderDN` node), from +/// `/MAJOR:MINOR/device/{vendor,device}`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn pci_ids(sys_dev_char: &std::path::Path, device: u64) -> Option<(u32, u32)> { + let read_hex = |p: std::path::PathBuf| -> Option { + let s = std::fs::read_to_string(p).ok()?; + u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok() + }; + let (major, minor) = major_minor(device); + let gpu = sys_dev_char.join(format!("{major}:{minor}")).join("device"); + Some((read_hex(gpu.join("vendor"))?, read_hex(gpu.join("device"))?)) +} + +#[cfg(test)] +mod tests { + /// glibc's `makedev()`. + fn makedev(major: u64, minor: u64) -> u64 { + ((major & 0x0000_0fff) << 8) | ((major & 0xffff_f000) << 32) | (minor & 0x0000_00ff) | ((minor & 0xffff_ff00) << 12) + } + + #[test] + fn device_numbers_split_into_major_and_minor() { + // DRM nodes: card1 is 226:1, renderD128 is 226:128. + assert_eq!(super::major_minor(0xe201), (226, 1)); + assert_eq!(super::major_minor(0xe280), (226, 128)); + for (major, minor) in [(0, 0), (226, 129), (511, 1_048_575), (0xfff, 0xff), (0x1000, 0x100), (0xffff_ffff, 0xffff_ffff)] { + assert_eq!(super::major_minor(makedev(major, minor)), (major, minor)); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("vectorcraft-dev-char-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU. + std::fs::create_dir_all(dir.join("226:128/device"))?; + std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?; + std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?; + // A device that isn't a PCI GPU (a virtual one) has no ids. + std::fs::create_dir_all(dir.join("226:0/device"))?; + let render = super::pci_ids(&dir, makedev(226, 128)); + let virtual_gpu = super::pci_ids(&dir, makedev(226, 0)); + let missing = super::pci_ids(&dir, makedev(226, 1)); + std::fs::remove_dir_all(&dir)?; + assert_eq!(render, Some((0x10de, 0x25ac))); + assert_eq!(virtual_gpu, None); + assert_eq!(missing, None); + Ok(()) + } + + #[test] + fn the_compositor_names_a_pci_gpu_or_nothing() { + // Whatever this session has: no compositor (CI), one that doesn't say, or a PCI GPU. + if let Some((vendor, _)) = super::compositor_gpu() { + assert_ne!(vendor, 0); + } + } +} diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs index 2442a4e9..51f6cbdd 100644 --- a/apps/vectorcraft/src/gpu.rs +++ b/apps/vectorcraft/src/gpu.rs @@ -4,8 +4,9 @@ //! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any //! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that //! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware -//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU that drives a -//! display and the one that drives the primary display ([`display_gpus`]), the power preference +//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the GPU the Wayland +//! compositor draws with ([`crate::compositor_gpu`]), the GPU that drives a display and the one +//! that drives the primary display ([`display_gpus`]), the power preference //! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545). //! eframe is handed [`selector`], which takes the first of that order. //! @@ -19,6 +20,10 @@ //! matches, the order is the power preference's as before. Power Saving and High Performance, //! chosen by the user, order by kind as they always did. //! +//! Under Wayland, the compositor's own GPU comes before the displays: the compositor says which +//! GPU it draws with, and frames from another one may never reach the screen, with no error, even +//! from the GPU behind the built-in panel when the compositor runs on the discrete one. +//! //! An adapter can report that it presents to the window and still fail once it does: on a hybrid //! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers //! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while @@ -93,6 +98,12 @@ pub fn preferred_displays(pref: Option<&str>, env: Option) -> V if automatic(pref, env) { display_gpus() } else { Vec::new() } } +/// The GPU to rank first: the one the Wayland compositor draws with, when the choice is +/// [`automatic`]. +pub fn preferred_compositor(pref: Option<&str>, env: Option) -> Option<(u32, u32)> { + if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None } +} + /// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL, /// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan /// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's @@ -130,6 +141,11 @@ impl Candidate { } } + /// Whether it is the GPU the compositor draws with (`compositor`, by PCI ids). + fn composites(&self, compositor: Option<(u32, u32)>) -> bool { + compositor == Some(self.pci) + } + /// Whether it drives one of `displays`, and whether that is the primary one. fn drives(&self, displays: &[DisplayGpu]) -> (bool, bool) { let shown = displays.iter().find(|g| g.pci == self.pci); @@ -266,15 +282,23 @@ fn key(backend: Backend, vendor: u32, device: u32, name: &str) -> String { /// The order to try `candidates` in (their indices): only those that present to the window and /// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before -/// software; native backends before OpenGL; the GPUs that drive one of `displays`, the primary -/// display's first; then by `power`; then DX12 before Vulkan, keeping the system's order among -/// equals (all of it for [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's +/// software; native backends before OpenGL; the GPU the compositor draws with (`compositor`); the +/// GPUs that drive one of `displays`, the primary display's first; then by `power`; then DX12 +/// before Vulkan, keeping the system's order among equals (all of it for +/// [`PowerPreference::None`]). Windows lists each GPU under both, and Intel's /// Vulkan driver made the whole window flicker black where DX12 and OpenGL didn't (#545); /// elsewhere there is no DX12 adapter, so the order is unchanged. /// /// With no `displays`, or none a candidate's PCI ids match (OpenGL reports no device id, Metal no /// ids at all), the order is the power preference's alone, as it was. -pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays: &[DisplayGpu], named: Option<&str>, skip: &[String]) -> Vec { +pub fn adapter_order( + candidates: &[Candidate], + power: PowerPreference, + displays: &[DisplayGpu], + compositor: Option<(u32, u32)>, + named: Option<&str>, + skip: &[String], +) -> Vec { let named = named.map(str::to_lowercase).filter(|n| !n.is_empty()); let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect(); // Stable, so equals keep the system's order. @@ -285,6 +309,7 @@ pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, displays: unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, + !c.composites(compositor), !drives_display, !drives_primary, power_rank(c.device_type, power), @@ -400,15 +425,21 @@ pub fn watch_first_frame(startup: Arc) { } } -/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPUs that drive a -/// `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart left out. Every adapter, the displays -/// and the choice are logged: which GPU draws is the first question in every black-window report. -pub fn selector(power: PowerPreference, displays: Vec, startup: Arc) -> NativeAdapterSelectorMethod { +/// eframe's adapter choice: the first of [`adapter_order`] for `power`, the GPU the `compositor` +/// draws with, the GPUs that drive a `displays`, `WGPU_ADAPTER_NAME` and the adapters a restart +/// left out. Every adapter, the displays and the choice are logged: which GPU draws is the first +/// question in every black-window report. +pub fn selector( + power: PowerPreference, + displays: Vec, + compositor: Option<(u32, u32)>, + startup: Arc, +) -> NativeAdapterSelectorMethod { let named = std::env::var(NAME_ENV).ok(); let skip = skipped(); Arc::new(move |adapters, surface| { let candidates: Vec = adapters.iter().map(|a| Candidate::new(a, surface)).collect(); - let order = adapter_order(&candidates, power, &displays, named.as_deref(), &skip); + let order = adapter_order(&candidates, power, &displays, compositor, named.as_deref(), &skip); let listed: Vec = candidates .iter() .map(|c| { @@ -417,6 +448,8 @@ pub fn selector(power: PowerPreference, displays: Vec, startup: Arc< ", can't show the window" } else if skip.contains(&c.key) { ", failed before" + } else if c.composites(compositor) { + ", the compositor draws with it" } else if drives_primary { ", drives the primary display" } else if drives_display { @@ -433,6 +466,7 @@ pub fn selector(power: PowerPreference, displays: Vec, startup: Arc< return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len())); }; let why = match chosen.drives(&displays) { + _ if chosen.composites(compositor) => "the compositor draws with it".to_string(), (_, true) => "it drives the primary display".to_string(), (true, false) => "it drives a display".to_string(), (false, false) if displays.is_empty() => format!("power preference {power:?}"), @@ -555,14 +589,14 @@ mod tests { fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { let c = ryzen_desktop(); for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); } // Without the display information the order is power saving's, as before. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]), [3, 1, 2, 0, 5, 4]); // After the NVIDIA adapters failed, the integrated GPU is next. let skip = vec![c[2].key.clone(), c[0].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &skip), [3, 1, 5, 4]); } /// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the @@ -571,7 +605,7 @@ mod tests { fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { let c = ryzen_desktop(); for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); } // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one @@ -583,9 +617,35 @@ mod tests { pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), ]; let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; - assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, None, &[]), [2, 0, 1, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, None, &[]), [0, 1, 2, 3]); + } + + /// A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with + /// `AQ_DRM_DEVICES` naming it first), as Vulkan lists it there: the integrated GPU drives the + /// built-in panel, yet the compositor shows nothing it draws, and reports no error. The GPU the + /// compositor draws with comes first; without it, the panel's, as before. + #[test] + fn the_gpu_the_compositor_draws_with_comes_before_the_panels() { + const RTX_3050: (u32, u32) = (0x10de, 0x25ac); + const INTEL_RPL: (u32, u32) = (0x8086, 0xa7a8); + let mut c = vec![ + pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU", Backend::Vulkan, DeviceType::DiscreteGpu, RTX_3050), + pci_gpu("Intel(R) Graphics (RPL-P)", Backend::Vulkan, DeviceType::IntegratedGpu, INTEL_RPL), + pci_gpu("NVIDIA GeForce RTX 3050 6GB Laptop GPU/PCIe/SSE2", Backend::Gl, DeviceType::Other, (RTX_3050.0, 0)), + ]; + c[2].presents = false; + let panel = [primary(INTEL_RPL)]; + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[]), [0, 1]); + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, None, None, &[]), [1, 0]); + // The usual hybrid laptop, with the compositor on the integrated GPU: the panel's GPU. + assert_eq!(adapter_order(&c, PowerPreference::None, &[primary(INTEL_RPL), monitor(RTX_3050)], Some(INTEL_RPL), None, &[]), [1, 0]); + // `WGPU_ADAPTER_NAME` still comes first, and a restart after a failure still moves on. + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), Some("intel"), &[]), [1, 0]); + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some(RTX_3050), None, &[c[0].key.clone()]), [1]); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(adapter_order(&c, PowerPreference::None, &panel, Some((0x1af4, 0x1050)), None, &[]), [1, 0]); } /// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so @@ -593,11 +653,11 @@ mod tests { #[test] fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { let c = ryzen_desktop(); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, None, &[]), [2, 0, 3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, None, &[]), [3, 1, 2, 0, 5, 4]); // The displays only matter among adapters that can show the window; a GPU driving no // display still loses to one that does. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, None, &[]), [3, 1, 2, 0, 5, 4]); } /// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, @@ -605,18 +665,21 @@ mod tests { #[test] fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { let c = ryzen_desktop(); - let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + let low = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), low, "{displays:?}"); } - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090"); + assert_eq!( + names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, None, &[]))[0], + "NVIDIA GeForce RTX 3090" + ); // The #502 machine's adapters report no ids in these tests: the primary display never matches. let c = issue_502(); assert_eq!( - adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]), - adapter_order(&c, PowerPreference::None, &[], None, &[]) + adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, None, &[]), + adapter_order(&c, PowerPreference::None, &[], None, None, &[]) ); - assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, None, &[]).is_empty()); } /// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. @@ -624,10 +687,10 @@ mod tests { fn a_named_adapter_comes_before_the_one_driving_the_display() { let mut c = ryzen_desktop(); let displays = [primary(NVIDIA_3090)]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); c[2].presents = false; c[0].presents = false; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, None, &[]), [3, 1, 5, 4]); } /// A user's own choice (Preferences or `WGPU_POWER_PREF`) is kept as it is; only Automatic @@ -645,6 +708,9 @@ mod tests { assert!(preferred_displays(None, Some(PowerPreference::HighPerformance)).is_empty()); // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). assert_eq!(preferred_displays(None, None), display_gpus()); + assert_eq!(preferred_compositor(Some("lowPower"), None), None); + assert_eq!(preferred_compositor(None, Some(PowerPreference::HighPerformance)), None); + assert_eq!(preferred_compositor(None, None), crate::compositor_gpu::compositor_gpu()); } #[test] @@ -741,7 +807,7 @@ mod tests { #[test] fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { let c = issue_502(); - let order = adapter_order(&c, power_preference(None, None), &[], None, &[]); + let order = adapter_order(&c, power_preference(None, None), &[], None, None, &[]); let first = &c[order[0]].name; if cfg!(any(windows, target_os = "macos")) { assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); @@ -754,7 +820,7 @@ mod tests { #[test] fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { let c = issue_502(); - let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[])); + let order = |power| names(&c, &adapter_order(&c, power, &[], None, None, &[])); assert_eq!( order(PowerPreference::None), [ @@ -800,10 +866,10 @@ mod tests { }; let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); - let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + let first = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); let skip = parse_skip(&c[first[0]].key); - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]); + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip)), ["NVIDIA GeForce GT 750M"]); // A comma in a name can't split the list. assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); } @@ -831,7 +897,7 @@ mod tests { let c = issue_502(); let mut skip = vec![]; let mut tried = vec![]; - while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() { + while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &skip).first() { tried.push(c[i].name.clone()); skip.push(c[i].key.clone()); } @@ -852,36 +918,36 @@ mod tests { c[0].presents = false; c[3].presents = false; for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { - let order = adapter_order(&c, power, &[], None, &[]); + let order = adapter_order(&c, power, &[], None, None, &[]); assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); assert_eq!(order.len(), 2); } for p in &mut c { p.presents = false; } - assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty()); - assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty()); + assert!(adapter_order(&c, PowerPreference::None, &[], None, None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, None, &[]).is_empty()); } #[test] fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { let c = issue_502(); - let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]); + let order = adapter_order(&c, PowerPreference::HighPerformance, &[], None, Some("radv"), &[]); assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); assert_eq!(order.len(), 4); // Any case; every match comes before the rest, natively first. - let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]); + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, Some("GeForce"), &[]); assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); // An empty name or one nothing matches changes nothing. for named in ["", "no such gpu"] { assert_eq!( - adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]), - adapter_order(&c, PowerPreference::LowPower, &[], None, &[]) + adapter_order(&c, PowerPreference::LowPower, &[], None, Some(named), &[]), + adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]) ); } // A named adapter that failed is left out like any other. let skip = vec![c[1].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0); + assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, Some("radv"), &skip)[0], 0); } /// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the @@ -895,9 +961,9 @@ mod tests { gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), ]; - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[]); assert_eq!(order, [3, 1, 2, 0, 4]); - assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]); + assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, None, &[]), [2, 0, 3, 1, 4]); } /// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black. @@ -912,9 +978,9 @@ mod tests { gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), ]; for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}"); + assert_eq!(adapter_order(&c, power, &[], None, None, &[]), [1, 0, 3, 2], "{power:?}"); } - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); } #[test] diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 22bc2fc6..1936d92b 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -23,6 +23,8 @@ compile_error!("the win7 target requires --no-default-features --features window mod automation_args; mod clipboard; +#[cfg(feature = "wgpu")] +mod compositor_gpu; mod control_server; #[cfg(feature = "wgpu")] mod gpu; @@ -670,6 +672,9 @@ fn main() -> std::process::ExitCode { // every black-window report. #[cfg(feature = "wgpu")] let displays = gpu::preferred_displays(gpu_pref, power_env); + // Under Wayland, the GPU the compositor draws with comes even before those (src/compositor_gpu.rs). + #[cfg(feature = "wgpu")] + let compositor = gpu::preferred_compositor(gpu_pref, power_env); #[cfg(feature = "wgpu")] let chosen_gpu = !gpu::automatic(gpu_pref, power_env); #[cfg(feature = "wgpu")] @@ -710,7 +715,7 @@ fn main() -> std::process::ExitCode { options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY; // Only adapters that can show the window, in the order `gpu` gives (#306, #502). if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - create.native_adapter_selector = Some(gpu::selector(power, displays, startup.clone())); + create.native_adapter_selector = Some(gpu::selector(power, displays, compositor, startup.clone())); // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only // costs a compute shader at start-up, one some drivers can't compile (OCLP-patched // Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on. diff --git a/contributors/people.toml b/contributors/people.toml index ab55eede..9bd8e374 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -76,3 +76,8 @@ display_name = "Jason Brashear" [people.Losminek] sync_github_name = true + +[people.atoslins] +real_name = "Atos Lins" +display_name = "Atos Lins" +sync_github_name = false diff --git a/docs/development.md b/docs/development.md index 89db8a76..0a4122c8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses. -- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; with Automatic, the GPU that drives a display, and among those the one that drives the primary display; then the power preference; then a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals. +- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; with Automatic, the GPU the Wayland compositor draws with, then the GPU that drives a display, and among those the one that drives the primary display; then the power preference; then a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals. - **Windows backends:** Direct3D 12, falling back to OpenGL. Vulkan isn't started at all unless `WGPU_BACKEND` asks for it (`WGPU_BACKEND=vulkan`): creating a Vulkan instance loads every installed Vulkan driver into the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window appeared. - **The GPU that drives the display.** A GPU without a monitor is the wrong one even when it says it can show the window: on a desktop with a Ryzen's integrated GPU (no monitor) and an NVIDIA card driving both monitors, power saving picked the integrated GPU, Windows had to present every frame across adapters, and the integrated GPU's driver reset under that: the graphics device was lost, or the desktop's compositor went down and every monitor stayed black for minutes (pdfcraft#378). So Automatic asks the system which GPU drives which display and puts that GPU first; the power preference only orders the rest, and decides alone where the system can't say. - **Windows:** `EnumDisplayDevices` (the display devices attached to the desktop, and the primary one), through the `winsafe` crate; the PCI ids in each device id are matched against wgpu's adapters. The registry doesn't tell a user which adapter drives a display, and WMI reports a resolution for a GPU without a monitor, so neither is used. @@ -54,6 +54,7 @@ The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU ( - The log's first lines list the display GPUs (`display GPUs (PCI vendor:device): 10de:2204 (primary)`), every adapter with whether it drives a display, and the one drawn on and why (`drawing on NVIDIA GeForce RTX 3090 (Dx12, DiscreteGpu): it drives the primary display`). That is the first thing to look for in a black-window report. - **Preferences › Performance › Graphics Processor** (`gpuPreference`): `automatic` (the default), `lowPower` (Power Saving, the integrated GPU) or `highPerformance` (the discrete GPU). The adapter is chosen when the window opens, so a change applies after a restart. Power Saving and High Performance are the user's own choice and order by kind alone, without looking at the displays; so does `WGPU_POWER_PREF`. - **Automatic on Windows and macOS** is the GPU that drives the primary display, then power saving: the system shows frames from any GPU, and presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some hybrid laptops (#306). On a hybrid laptop the integrated GPU drives the panel, so both rules agree. + - **Automatic under Wayland** puts the GPU the compositor draws with first: the compositor names it (the `main_device` of its linux-dmabuf feedback, protocol version 4; `apps/vectorcraft/src/compositor_gpu.rs`), and the log says `the compositor draws with 10de:25ac`. Which GPU drives a display only stands in for that, and can be wrong: on a hybrid laptop whose Hyprland ran on the NVIDIA GPU (`AQ_DRM_DEVICES`), the panel rule picked the Intel GPU behind the built-in panel, and the compositor showed nothing it drew, with no error, so neither the restart nor the first-frame watch below noticed. A compositor that doesn't say (linux-dmabuf older than version 4), or names a GPU no adapter matches, leaves the rules below in charge. - **Automatic on Linux and the BSDs** is the GPU that drives the panel or a monitor, then the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502). - 0.5.0 saved `powerSaving`, its default, for everyone; it reads as `automatic`. - **If the window fails on its adapter while starting up** (an error from wgpu, or a panic inside wgpu or egui-wgpu during the first frames), the app starts again without that adapter and tries the next one, telling which in the status bar; the run that failed keeps its log as `vectorcraft.1.log`. On Unix the new app replaces the process (an AppImage stays mounted); on Windows it starts beside it. Adapters are told apart by backend and PCI ids, or by name where the backend reports no ids (Metal lists every GPU of a dual-GPU Mac as `0000:0000`, #651). The adapters left out travel in `VECTORCRAFT_GPU_SKIP` (`backend:vendor:device`, such as `Vulkan:1002:164e`), one more on each restart, so the restarts end. When no adapter is left, the app logs why and exits with an error instead of panicking.