From 6dd202d27709596cb5c6318d0d2a0acb653ac6b0 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sun, 11 Oct 2026 19:09:47 -0400 Subject: [PATCH] Fix desktop launch, app icons and keyboard parameter editing --- .github/release-notes.md | 13 + .github/workflows/packaging-lint.yml | 4 + .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 9 + CONTRIBUTING.md | 39 +- Cargo.lock | 113 +- Cargo.toml | 5 +- README.md | 3 + ROADMAP.md | 12 + THIRD-PARTY-LICENSES.txt | 250 ++++- apps/app_icon.rs | 126 +++ apps/solvecraft-cli/src/main.rs | 110 +- apps/solvecraft/Cargo.toml | 3 + apps/solvecraft/build.rs | 61 ++ apps/solvecraft/src/main.rs | 60 +- crates/ui-egui/src/context_menu.rs | 64 +- crates/ui-egui/src/expression_complete.rs | 335 ++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/params_dialog.rs | 139 ++- crates/ui-egui/src/prefs.rs | 29 + crates/ui-egui/src/theme.rs | 61 +- crates/ui-egui/tests/parameter_enter.rs | 108 ++ docs/book/src/guide/getting-started.md | 25 + docs/book/src/guide/parameters.md | 6 + docs/nix.md | 90 ++ flake.nix | 60 +- packaging/flatpak-site.sh | 6 +- packaging/linux/flatpak-bundle.sh | 6 +- packaging/linux/flatpak-smoke.py | 260 +++++ packaging/tests/flatpak_test.py | 93 ++ packaging/tests/windows_icon_test.py | 82 ++ packaging/windows/check-icon.ps1 | 69 ++ packaging/windows/package.ps1 | 1 + vendor/README.md | 11 + vendor/egui-wgpu/Cargo.toml | 339 ++++++ vendor/egui-wgpu/Cargo.toml.orig | 75 ++ vendor/egui-wgpu/LICENSE-APACHE | 201 ++++ vendor/egui-wgpu/LICENSE-MIT | 25 + vendor/egui-wgpu/src/capture.rs | 267 +++++ vendor/egui-wgpu/src/egui.wgsl | 162 +++ vendor/egui-wgpu/src/lib.rs | 585 ++++++++++ vendor/egui-wgpu/src/renderer.rs | 1178 +++++++++++++++++++++ vendor/egui-wgpu/src/setup.rs | 331 ++++++ vendor/egui-wgpu/src/texture_copy.wgsl | 43 + vendor/egui-wgpu/src/winit.rs | 802 ++++++++++++++ 45 files changed, 5993 insertions(+), 272 deletions(-) create mode 100644 apps/app_icon.rs create mode 100644 apps/solvecraft/build.rs create mode 100644 crates/ui-egui/src/expression_complete.rs create mode 100644 crates/ui-egui/tests/parameter_enter.rs create mode 100644 docs/nix.md create mode 100644 packaging/linux/flatpak-smoke.py create mode 100644 packaging/tests/flatpak_test.py create mode 100644 packaging/tests/windows_icon_test.py create mode 100644 packaging/windows/check-icon.ps1 create mode 100644 vendor/egui-wgpu/Cargo.toml create mode 100644 vendor/egui-wgpu/Cargo.toml.orig create mode 100644 vendor/egui-wgpu/LICENSE-APACHE create mode 100644 vendor/egui-wgpu/LICENSE-MIT create mode 100644 vendor/egui-wgpu/src/capture.rs create mode 100644 vendor/egui-wgpu/src/egui.wgsl create mode 100644 vendor/egui-wgpu/src/lib.rs create mode 100644 vendor/egui-wgpu/src/renderer.rs create mode 100644 vendor/egui-wgpu/src/setup.rs create mode 100644 vendor/egui-wgpu/src/texture_copy.wgsl create mode 100644 vendor/egui-wgpu/src/winit.rs diff --git a/.github/release-notes.md b/.github/release-notes.md index 94f7479e..d819ab7d 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -21,6 +21,19 @@ SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliase - **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself. - `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`. +For Flatpak feed installation and updates, add Flathub for the required runtime before +installing SolidCraft; keep both remotes in the same user installation: + +```sh +flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo +flatpak install --user solvecraft ai.storyteller.solvecraft +flatpak run ai.storyteller.solvecraft +``` + +Single-file `.flatpak` bundles include the runtime repository address; missing runtimes +still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak). + The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose **Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`). On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**. diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 36c6cac4..602f73ef 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -37,6 +37,10 @@ jobs: run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun - name: Nightly release selection regression run: python3 packaging/tests/nightly_test.py + - name: Windows executable icon validation regression + run: python3 packaging/tests/windows_icon_test.py + - name: Flatpak desktop smoke validation regression + run: python3 packaging/tests/flatpak_test.py - name: PowerShell shell: pwsh run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9040adec..af194d24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,7 +177,7 @@ jobs: - name: Install flatpak-builder run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common + sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common xvfb xauth weston dbus-x11 # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5526f657..9d29fb8a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -57,3 +57,12 @@ Generated-in-code assets are original and have no file to list: | vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) | | vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) | | vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) | + +## Vendored renderer compatibility fix + +`vendor/egui-wgpu` is egui-wgpu 0.36.2 from https://github.com/emilk/egui +(MIT OR Apache-2.0; both original licence texts are included). Rust sources and +its original `egui.wgsl` shader are retained; no images or other artwork are copied. +SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter +and shares that effective count with the surface attachments and CAD callbacks. +Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8f3f1bd..bc982cdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,32 +48,21 @@ ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to creat ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found ``` -`nix develop` (the [`flake.nix`](flake.nix) in the repository root) puts the Wayland, X11, -EGL-loader and D-Bus libraries on `LD_LIBRARY_PATH`, so `cargo run --release -p solvecraft` works -from that shell as on any other distribution, file dialogs included. `nix run` builds the app and -wraps the binary the same way. The Mesa drivers stay the system's, from `/run/opengl-driver` -(`hardware.graphics.enable`): that is the driver stack the compositor runs on, and wgpu reaches the -integrated GPU through the EGL loader. The shell deliberately brings no Rust toolchain — the one on -`PATH` keeps its build cache, where a second `rustc` would make every build recompile the workspace -from scratch; `nix develop .#rust` is there for a machine with no Rust at all. +`nix develop` (the [`flake.nix`](flake.nix) in the repository root) supplies Wayland, +X11, EGL, Vulkan-loader and D-Bus runtime libraries. It also exposes the NixOS +driver manifests in `/run/opengl-driver/share`, so Vulkan can discover the system +drivers. Enable `hardware.graphics.enable` on NixOS; other Linux distributions +may need a matching [nixGL wrapper](https://github.com/nix-community/nixGL). +The default development shell retains the Rust/C toolchain already on `PATH`; +`nix develop .#rust` supplies the pinned toolchain and builds in `target/nix`. -**Anti-aliasing on the OpenGL backend.** NixOS keeps the Vulkan ICD descriptions -(`/run/opengl-driver/share/vulkan/icd.d`) off the Vulkan loader's search path, so wgpu picks its -OpenGL backend on the integrated GPU. On that backend any multisampling above 1× — the default is -4× — draws a black window: the UI still lays out and the control channel still answers, but every -frame, `ui.screenshot` included, comes out empty. Set **Preferences ▸ Graphics ▸ Anti-aliasing** to -1× and restart (the setting applies on the next start), or write it before the first run, since a -black window is no way to reach the dialog: - -```sh -mkdir -p ~/.local/share/solvecraft -echo '{"msaa": 1}' > ~/.local/share/solvecraft/graphics.json -``` - -`apps/solvecraft/src/main.rs` reads that file (`graphics_path()`, eframe's storage dir) before the -window opens; 2× and 4× are the same black window. The Vulkan backend is no way out of it either: -`libvulkan.so.1` is not on `LD_LIBRARY_PATH`, so `WGPU_BACKEND=vulkan` exits with a -`CreateSurfaceError` instead. +To launch, use `nix run .#solidcraft -- --sample`. To install the app's terminal +commands, desktop entry and generated icons, use `nix profile install .#solidcraft`. +`nix build` alone creates a build result; it does not install a launcher in your +desktop menu. The installed `solidcraft` command and compatible `solvecraft` +command run the same wrapped app; `solvecraft-cli` is included as well. See +[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands, +desktop integration and how to distinguish an old executable on `PATH`. ## How the code is laid out diff --git a/Cargo.lock b/Cargo.lock index 78a789a2..fb54fc4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -971,8 +971,6 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" dependencies = [ "ahash", "bytemuck", @@ -1041,6 +1039,20 @@ dependencies = [ "serde", ] +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml", + "vswhom", + "winreg", +] + [[package]] name = "endi" version = "1.1.1" @@ -3106,6 +3118,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3269,10 +3290,11 @@ dependencies = [ [[package]] name = "solvecraft" -version = "0.3.0" +version = "0.3.1" dependencies = [ "eframe", "egui", + "embed-resource", "hidapi", "log", "rfd", @@ -3284,7 +3306,7 @@ dependencies = [ [[package]] name = "solvecraft-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde_json", "solvecraft-engine", @@ -3293,7 +3315,7 @@ dependencies = [ [[package]] name = "solvecraft-doc" -version = "0.3.0" +version = "0.3.1" dependencies = [ "log", "serde", @@ -3308,7 +3330,7 @@ dependencies = [ [[package]] name = "solvecraft-engine" -version = "0.3.0" +version = "0.3.1" dependencies = [ "log", "serde", @@ -3324,7 +3346,7 @@ dependencies = [ [[package]] name = "solvecraft-geom" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde", "serde_json", @@ -3332,7 +3354,7 @@ dependencies = [ [[package]] name = "solvecraft-io" -version = "0.3.0" +version = "0.3.1" dependencies = [ "quick-xml 0.38.4", "solvecraft-doc", @@ -3344,7 +3366,7 @@ dependencies = [ [[package]] name = "solvecraft-kernel" -version = "0.3.0" +version = "0.3.1" dependencies = [ "log", "serde", @@ -3359,7 +3381,7 @@ dependencies = [ [[package]] name = "solvecraft-mcp" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde_json", "solvecraft-engine", @@ -3367,7 +3389,7 @@ dependencies = [ [[package]] name = "solvecraft-render" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "serde", @@ -3377,7 +3399,7 @@ dependencies = [ [[package]] name = "solvecraft-sketch" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde", "serde_json", @@ -3388,7 +3410,7 @@ dependencies = [ [[package]] name = "solvecraft-ui-egui" -version = "0.3.0" +version = "0.3.1" dependencies = [ "egui", "egui-wgpu", @@ -3403,7 +3425,7 @@ dependencies = [ [[package]] name = "solvecraft-web" -version = "0.3.0" +version = "0.3.1" dependencies = [ "eframe", "egui", @@ -3589,10 +3611,25 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +name = "toml" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -3611,13 +3648,19 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tracing" version = "0.1.44" @@ -3913,6 +3956,26 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -4698,6 +4761,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -4775,7 +4848,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 8650aaf6..fec5eddc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -100,3 +100,6 @@ truck-shapeops = { path = "vendor/truck-shapeops" } truck-stepio = { path = "vendor/truck-stepio" } truck-meshalgo = { path = "vendor/truck-meshalgo" } matext4cgmath = { path = "vendor/matext4cgmath" } + +# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count. +egui-wgpu = { path = "vendor/egui-wgpu" } diff --git a/README.md b/README.md index b16060a7..306b3aca 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ model through the built-in MCP server. Part of the storytold "craft" family. developer guide and command reference; sources in [docs/book](docs/book)), and the [web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser. +Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the +SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak). + **Status: early but usable for simple parts.** The historical benchmark report records 76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)). These checks do not establish complete geometric or workflow equivalence. Feature coverage diff --git a/ROADMAP.md b/ROADMAP.md index b019b0ee..56b90838 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -253,6 +253,18 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel. Parameters opens the user/model variable editor; Configurations opens the configuration table. Configure remains available in the panel dropdown. +- Parameter creation supports Enter with validation and continued keyboard entry. Shared + expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal; + accepting a suggestion does not submit a parameter or trigger viewport shortcuts. +- Movable window title bars follow the app's dark/light header palette. Radial right-click + menus are opt-in in Preferences > Navigation; ordinary context menus remain the default. +- Desktop distribution: running Windows windows and executable resources use the app icon; + packaging validates icon resources. Nix installs launchers, MIME data and generated icons, + with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions + register the required runtime source; release checks exercise desktop modelling on X11/Wayland. +- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD + callbacks to prevent black frames. Other backends retain the requested sample count, and + stored preferences remain unchanged. - Configuration rules: ordered typed conditions can drive parameters and suppressions or restrict values. Preview explains effective values without modifying the document; applying rules validates rows atomically. Length expressions retain their authoring units across diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 8a03f47e..b989687b 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -328,9 +328,9 @@ thiserror-impl 2.0.21 (MIT OR Apache-2.0) tiff 0.11.3 (MIT) tinystr 0.8.4 (Unicode-3.0) tokio 1.53.2 (MIT) -toml_datetime 1.1.1+spec-1.1.0 (MIT OR Apache-2.0) +toml_datetime 1.1.2+spec-1.1.0 (MIT OR Apache-2.0) toml_edit 0.25.15+spec-1.1.0 (MIT OR Apache-2.0) -toml_parser 1.1.3+spec-1.1.0 (MIT OR Apache-2.0) +toml_parser 1.1.5+spec-1.1.0 (MIT OR Apache-2.0) tracing 0.1.44 (MIT) tracing-attributes 0.1.31 (MIT) tracing-core 0.1.36 (MIT) @@ -2230,7 +2230,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, presser 0.3.1 +bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, egui-wgpu 0.36.2, presser 0.3.1 ================================================================================ Apache License @@ -3417,9 +3417,8 @@ SOFTWARE. ================================================================================ crc32fast 1.5.2, dpi 0.1.2, foreign-types 0.5.0, foreign-types-macros 0.2.4, foreign-types-shared -0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_datetime 1.1.1+spec-1.1.0, -toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0, unicode-general-category 1.1.0, winit -0.30.13 +0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_edit 0.25.15+spec-1.1.0, +unicode-general-category 1.1.0, winit 0.30.13 ================================================================================ Apache License @@ -4226,6 +4225,36 @@ and labelled as such in comments in the individual source files. All have been licensed under extremely permissive terms. ------------------------------------------------------------------------------ +================================================================================ +egui-wgpu 0.36.2 +================================================================================ + +Copyright (c) 2018-2021 Emil Ernerfeldt + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ================================================================================ either 1.19.0, itertools 0.13.0, itertools 0.15.0 ================================================================================ @@ -10207,7 +10236,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -toml_datetime 1.1.1+spec-1.1.0, toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0 +toml_datetime 1.1.2+spec-1.1.0, toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.5+spec-1.1.0 ================================================================================ Copyright (c) Individual contributors @@ -10230,6 +10259,212 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +toml_datetime 1.1.2+spec-1.1.0, toml_parser 1.1.5+spec-1.1.0 +================================================================================ + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright Individual contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + ================================================================================ tracing 0.1.44, tracing-attributes 0.1.31, tracing-core 0.1.36 ================================================================================ @@ -13061,7 +13296,6 @@ dispatch2 0.3.1: Zlib OR Apache-2.0 OR MIT (https://github.com/madsmtm/objc2) ecolor 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui) eframe 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/eframe) egui 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui) -egui-wgpu 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-wgpu) egui-winit 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-winit) egui_glow 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui_glow) emath 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/emath) diff --git a/apps/app_icon.rs b/apps/app_icon.rs new file mode 100644 index 00000000..ce5edb52 --- /dev/null +++ b/apps/app_icon.rs @@ -0,0 +1,126 @@ +//! Original SolidCraft mark, shared by desktop, packaging and CLI. +/// This block is the icon image data +pub fn rgba(size: usize) -> Result, String> { + if !(1..=2048).contains(&size) { + return Err("icon size must be 1..=2048".into()); + } + type P = (f32, f32); + // Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end. + let ss = if size > 512 { 2 } else { 4 }; + let n = size * ss; + let mut buf = vec![[0u8; 4]; n * n]; + let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height + let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways + // Fill a convex polygon given in SVG units. + let mut fill = |pts: &[P], col: [u8; 3]| { + let v: Vec

= pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect(); + let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1))); + for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) { + let cy = y as f32 + 0.5; + let (mut lo, mut hi) = (f32::MAX, f32::MIN); + for i in 0..v.len() { + let (a, b) = (v[i], v[(i + 1) % v.len()]); + if (a.1 <= cy) != (b.1 <= cy) { + let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0); + lo = lo.min(x); + hi = hi.max(x); + } + } + if lo > hi { + continue; + } + for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) { + buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255]; + } + } + }; + // Faces: each is a few overlapping convex pieces (the filler only does convex shapes). + let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]); + fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light); + fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light); + fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid); + fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid); + fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid); + fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark); + fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark); + fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark); + fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark); + // White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner. + let white = [255, 255, 255]; + let mut stroke = |pts: &[P], closed: bool| { + let m = pts.len(); + for i in 0..(if closed { m } else { m - 1 }) { + let (a, b) = (pts[i], pts[(i + 1) % m]); + let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt(); + let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0); + fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white); + } + for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] { + let disc: Vec

= (0..16) + .map(|k| { + let t = k as f32 * std::f32::consts::TAU / 16.0; + (c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin()) + }) + .collect(); + fill(&disc, white); + } + }; + stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false); + stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false); + stroke(&[(177.0, 206.1), (263.8, 156.0)], false); + stroke( + &[ + (178.5, 5.8), + (5.0, 105.9), + (5.0, 206.1), + (91.7, 256.2), + (91.7, 356.4), + (178.5, 406.4), + (352.0, 306.3), + (352.0, 206.1), + (265.2, 156.0), + (265.2, 55.9), + ], + true, + ); + // Average each ss x ss block down to one pixel (straight alpha = covered share). + let mut rgba = Vec::with_capacity(size * size * 4); + for y in 0..size { + for x in 0..size { + let (mut sum, mut cnt) = ([0u32; 3], 0u32); + for j in 0..ss { + for i in 0..ss { + let p = buf[(y * ss + j) * n + x * ss + i]; + if p[3] != 0 { + cnt += 1; + for k in 0..3 { + sum[k] += u32::from(p[k]); + } + } + } + } + if cnt == 0 { + rgba.extend_from_slice(&[0, 0, 0, 0]); + } else { + let a = 255 * cnt / (ss * ss) as u32; + rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]); + } + } + } + Ok(rgba) +} + +#[cfg(test)] +mod tests { + #[test] + fn shared_mark_has_transparency_and_original_colors_at_window_and_package_sizes() { + for size in [16, 24, 32, 48, 64, 128, 256] { + let pixels = super::rgba(size).expect("valid size"); + assert_eq!(pixels.len(), size * size * 4); + assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0)); + assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[2] > p[0])); + } + assert!(super::rgba(0).is_err()); + assert!(super::rgba(2049).is_err()); + } +} diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 44d50bca..91da5e22 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -25,6 +25,9 @@ mod seams; use std::process::ExitCode; use serde_json::{Value, json}; +#[path = "../../app_icon.rs"] +mod app_icon; + use solvecraft_engine::Session; use solvecraft_engine::render::{StandardView, render_png}; @@ -245,113 +248,8 @@ fn cmd_icon(args: &[String]) -> Result<(), String> { Ok(()) } -/// This block is the icon image data fn icon_png(size: usize) -> Result, String> { - use solvecraft_engine::render::png; - type P = (f32, f32); - // Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end. - let ss = if size > 512 { 2 } else { 4 }; - let n = size * ss; - let mut buf = vec![[0u8; 4]; n * n]; - let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height - let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways - // Fill a convex polygon given in SVG units. - let mut fill = |pts: &[P], col: [u8; 3]| { - let v: Vec

= pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect(); - let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1))); - for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) { - let cy = y as f32 + 0.5; - let (mut lo, mut hi) = (f32::MAX, f32::MIN); - for i in 0..v.len() { - let (a, b) = (v[i], v[(i + 1) % v.len()]); - if (a.1 <= cy) != (b.1 <= cy) { - let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0); - lo = lo.min(x); - hi = hi.max(x); - } - } - if lo > hi { - continue; - } - for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) { - buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255]; - } - } - }; - // Faces: each is a few overlapping convex pieces (the filler only does convex shapes). - let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]); - fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light); - fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light); - fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid); - fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid); - fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid); - fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark); - fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark); - fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark); - fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark); - // White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner. - let white = [255, 255, 255]; - let mut stroke = |pts: &[P], closed: bool| { - let m = pts.len(); - for i in 0..(if closed { m } else { m - 1 }) { - let (a, b) = (pts[i], pts[(i + 1) % m]); - let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt(); - let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0); - fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white); - } - for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] { - let disc: Vec

= (0..16) - .map(|k| { - let t = k as f32 * std::f32::consts::TAU / 16.0; - (c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin()) - }) - .collect(); - fill(&disc, white); - } - }; - stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false); - stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false); - stroke(&[(177.0, 206.1), (263.8, 156.0)], false); - stroke( - &[ - (178.5, 5.8), - (5.0, 105.9), - (5.0, 206.1), - (91.7, 256.2), - (91.7, 356.4), - (178.5, 406.4), - (352.0, 306.3), - (352.0, 206.1), - (265.2, 156.0), - (265.2, 55.9), - ], - true, - ); - // Average each ss x ss block down to one pixel (straight alpha = covered share). - let mut rgba = Vec::with_capacity(size * size * 4); - for y in 0..size { - for x in 0..size { - let (mut sum, mut cnt) = ([0u32; 3], 0u32); - for j in 0..ss { - for i in 0..ss { - let p = buf[(y * ss + j) * n + x * ss + i]; - if p[3] != 0 { - cnt += 1; - for k in 0..3 { - sum[k] += u32::from(p[k]); - } - } - } - } - if cnt == 0 { - rgba.extend_from_slice(&[0, 0, 0, 0]); - } else { - let a = 255 * cnt / (ss * ss) as u32; - rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]); - } - } - } - png(size, size, rgba).ok_or_else(|| "icon: PNG encoding failed".to_string()) + solvecraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string()) } /// A Windows icon file holding PNG images (Vista and later read these). diff --git a/apps/solvecraft/Cargo.toml b/apps/solvecraft/Cargo.toml index faecb6c1..0f791848 100644 --- a/apps/solvecraft/Cargo.toml +++ b/apps/solvecraft/Cargo.toml @@ -24,3 +24,6 @@ hidapi = { version = "2.6.7", default-features = false, features = ["windows-nat [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] } + +[build-dependencies] +embed-resource = "3.0.11" diff --git a/apps/solvecraft/build.rs b/apps/solvecraft/build.rs new file mode 100644 index 00000000..6a065a80 --- /dev/null +++ b/apps/solvecraft/build.rs @@ -0,0 +1,61 @@ +#![forbid(unsafe_code)] +#[path = "../app_icon.rs"] +mod app_icon; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=../app_icon.rs"); + println!("cargo:rerun-if-changed=build.rs"); + if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" { + return Ok(()); + } + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?); + let sizes = [16usize, 24, 32, 48, 64, 128, 256]; + let mut images = Vec::new(); + for size in sizes { + let rgba = app_icon::rgba(size)?; + // ICO DIB: bottom-up BGRA plus a padded one-bit transparency mask. + let mask_stride = size.div_ceil(32) * 4; + let mut dib = Vec::new(); + dib.extend_from_slice(&40u32.to_le_bytes()); + dib.extend_from_slice(&(size as i32).to_le_bytes()); + dib.extend_from_slice(&((size * 2) as i32).to_le_bytes()); + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&32u16.to_le_bytes()); + dib.extend_from_slice(&[0; 24]); + for row in rgba.chunks_exact(size * 4).rev() { + for p in row.as_chunks::<4>().0.iter() { + dib.extend_from_slice(&[p[2], p[1], p[0], p[3]]); + } + } + for row in rgba.chunks_exact(size * 4).rev() { + let mut mask = vec![0u8; mask_stride]; + for (x, p) in row.as_chunks::<4>().0.iter().enumerate() { + if p[3] == 0 { + mask[x / 8] |= 0x80 >> (x % 8); + } + } + dib.extend_from_slice(&mask); + } + images.push((size, dib)); + } + let mut ico = vec![0, 0, 1, 0]; + ico.extend_from_slice(&(images.len() as u16).to_le_bytes()); + let mut offset = 6 + 16 * images.len(); + for (size, data) in &images { + let dim = if *size == 256 { 0 } else { *size as u8 }; + ico.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]); + ico.extend_from_slice(&(data.len() as u32).to_le_bytes()); + ico.extend_from_slice(&(offset as u32).to_le_bytes()); + offset += data.len(); + } + for (_, data) in images { + ico.extend_from_slice(&data); + } + let icon = out.join("solidcraft.ico"); + std::fs::write(&icon, ico)?; + let rc = out.join("solidcraft.rc"); + std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?; + // Missing resource tools are a build error, rather than silently shipping a generic icon. + embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?; + Ok(()) +} diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 6fd0b3d9..f8fb1a4a 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -12,6 +12,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[path = "../../app_icon.rs"] +mod app_icon; mod control_server; #[cfg(target_os = "windows")] mod space_mouse; @@ -124,6 +126,24 @@ const DEPTH_BITS: u8 = 24; const STENCIL_BITS: u8 = 8; const MSAA: u16 = 4; +fn app_viewport() -> Result { + Ok(egui::ViewportBuilder::default() + .with_title("SolidCraft") + .with_icon(egui::IconData { + rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, + width: 64, + height: 64, + }) + .with_inner_size([1600.0, 1000.0]) + .with_min_inner_size([960.0, 600.0]) + .with_drag_and_drop(true) + .with_decorations(!CUSTOM_TITLEBAR) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + .with_app_id("ai.storyteller.solvecraft")) +} + fn main() -> eframe::Result { let samples = msaa(); let mut control_port: Option = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); @@ -146,16 +166,7 @@ fn main() -> eframe::Result { } } let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("SolidCraft") - .with_inner_size([1600.0, 1000.0]) - .with_min_inner_size([960.0, 600.0]) - .with_drag_and_drop(true) - .with_decorations(!CUSTOM_TITLEBAR) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - .with_app_id("ai.storyteller.solvecraft"), + viewport: app_viewport()?, depth_buffer: DEPTH_BITS, stencil_buffer: STENCIL_BITS, multisampling: samples, @@ -185,7 +196,8 @@ fn main() -> eframe::Result { app.load_prefs(&p); } if let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples)); + eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples); + app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples); } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); @@ -223,3 +235,29 @@ fn main() -> eframe::Result { }), ) } + +#[cfg(test)] +mod icon_tests { + #[test] + fn desktop_viewport_supplies_the_original_mark_to_the_window() { + let viewport = super::app_viewport().expect("viewport"); + let icon = viewport.icon.expect("running window must have an icon"); + assert_eq!((icon.width, icon.height), (64, 64)); + assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark")); + assert_eq!(viewport.app_id.as_deref(), Some("ai.storyteller.solvecraft")); + } +} + +#[cfg(test)] +mod msaa_tests { + #[test] + fn saved_multisampling_uses_the_selected_backend_policy() { + use eframe::wgpu::Backend; + for requested in [1, 2, 4, 8] { + assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1); + for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] { + assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested); + } + } + } +} diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index c0963e7c..0d2175ed 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -1,9 +1,10 @@ //! Context menus, one look everywhere. //! -//! The viewport's right-click opens a radial marking menu (eight commands around the cursor, -//! Repeat at the top) with a list underneath of what fits the selection or the thing under the -//! cursor: faces, edges, bodies, sketch entities or empty space. Holding the right button opens -//! it too; releasing over a direction picks that command (a flick). The browser's right-click +//! The viewport's right-click opens a list of commands for the selection or the thing under the +//! cursor: faces, edges, bodies, sketch entities or empty space. The optional radial marking +//! menu adds eight commands around the cursor, with Repeat at the top. With that preference on, +//! holding the right button opens it too; releasing over a direction picks a command (a flick). +//! The browser's right-click //! opens the same list styling for a body, sketch or component. //! //! Items are commands (by id, with parameters filled from the target) or `ui.*` actions on view @@ -1172,7 +1173,7 @@ pub fn open(app: &mut SolveApp, at: Pos2) { /// Open a menu for a target (the radial ring only for the viewport). pub fn open_for(app: &mut SolveApp, at: Pos2, target: Target) { app.menu.key_row = None; - app.menu.radial = target == Target::Viewport; + app.menu.radial = target == Target::Viewport && app.preferences.radial_context_menu; app.menu.target = target; app.menu.flick = false; app.viewport.context_menu = Some(at); @@ -1301,6 +1302,12 @@ fn slot_rect(c: Pos2, k: usize, size: Vec2) -> Rect { /// Press-and-hold of the right button in the viewport opens the menu; releasing over a /// direction picks it. fn track_hold(app: &mut SolveApp, ctx: &egui::Context) { + if !app.preferences.radial_context_menu { + app.menu.press = None; + app.menu.flick = false; + app.menu.radial = false; + return; + } let (pressed, down, released, pos, time) = ctx.input(|i| { ( i.pointer.button_pressed(egui::PointerButton::Secondary), @@ -1648,6 +1655,53 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn viewport_right_click_keeps_list_menu_and_radial_is_opt_in() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.settle(); + let point = h.at(&json!({"world":[0,0,0]})).unwrap(); + h.step(&json!({"click":point,"button":"right"})).unwrap(); + assert!(h.app.viewport.context_menu.is_some(), "ordinary right click must still open a menu"); + let list = describe(&h.app); + assert!(list["radial"].as_array().unwrap().is_empty()); + assert!(list["items"].as_array().unwrap().iter().any(|item| item["id"] == "ui.origin" && item["enabled"] == true)); + h.step(&json!({"click":{"handle":"menu:ui.origin"}})).unwrap(); + assert!(!h.app.ui.show_origin && h.app.viewport.context_menu.is_none(), "the ordinary menu must execute Hide Origin and close"); + h.call("ui.prefs", json!({"radial_context_menu":true})); + h.step(&json!({"click":point,"button":"right"})).unwrap(); + assert_eq!(describe(&h.app)["radial"].as_array().unwrap().len(), DIRS); + open_for(&mut h.app, pos2(point[0] as f32, point[1] as f32), Target::Origin); + assert!(!h.app.menu.radial, "browser menus remain lists when radial menus are enabled"); + } + + #[test] + fn disabled_radial_menu_does_not_capture_right_button_hold() { + let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + let ctx = egui::Context::default(); + let point = pos2(600.0, 400.0); + app.viewport.mouse = Some(point); + for (time, events) in [ + ( + 0.0, + vec![ + egui::Event::PointerMoved(point), + egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Secondary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + ), + (1.0, vec![]), + ] { + let mut output = ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |_| track_hold(&mut app, &ctx)); + output.textures_delta.clear(); + } + assert!(app.viewport.context_menu.is_none() && app.menu.press.is_none() && !app.menu.flick); + } + #[test] fn sectors_go_clockwise_from_the_top() { assert_eq!(sector(vec2(0.0, -50.0)), Some(0)); diff --git a/crates/ui-egui/src/expression_complete.rs b/crates/ui-egui/src/expression_complete.rs new file mode 100644 index 00000000..10c235b4 --- /dev/null +++ b/crates/ui-egui/src/expression_complete.rs @@ -0,0 +1,335 @@ +//! Focus-scoped completion keyboard handling before editors and CAD shortcuts see input. + +use egui::{Key, RichText}; + +use crate::params_dialog::{suggestions, word_before}; +use crate::theme::Tokens; + +#[derive(Clone)] +struct Popup { + editor: egui::Id, + frame: u64, + word: String, + cursor: usize, + selected: usize, + count: usize, + accept: bool, + dismissed: bool, +} + +fn popup_id() -> egui::Id { + egui::Id::new("expression_completion_keyboard") +} + +/// Run before any TextEdit or viewport shortcuts. Only the focused, recently drawn popup +/// owns these keys. Raw events are removed too: several expression editors inspect raw Tab. +pub(crate) fn begin_frame(ctx: &egui::Context) { + let Some(mut popup) = ctx.data(|d| d.get_temp::(popup_id())) else { return }; + let escape_blurred = ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.memory(|m| m.focused().is_none() && m.had_focus_last_frame(popup.editor)); + let focused = ctx.memory(|m| m.has_focus(popup.editor)) || escape_blurred; + if popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() || !focused || popup.dismissed || popup.count == 0 { + if !focused || popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() { + ctx.data_mut(|d| d.remove::(popup_id())); + } + return; + } + // Let the editor process a newly typed/deleted prefix before deciding what Enter means. + // A prior frame's completion must never replace text changed in this input batch. + if ctx.input(|i| { + i.raw.events.iter().any(|e| { + matches!(e, egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Key { key: Key::Backspace | Key::Delete, pressed: true, .. }) + }) + }) { + return; + } + let mut consumed = false; + ctx.input_mut(|input| { + let mut owned = Vec::new(); + for event in &input.raw.events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = event + && *modifiers == egui::Modifiers::NONE + { + match key { + Key::ArrowDown => popup.selected = (popup.selected + 1) % popup.count, + Key::ArrowUp => popup.selected = (popup.selected + popup.count - 1) % popup.count, + Key::Enter | Key::Tab => popup.accept = true, + Key::Escape => { + popup.dismissed = true; + popup.accept = false; + } + _ => continue, + } + owned.push(*key); + consumed = true; + } + } + let keep = |event: &egui::Event| { + !matches!(event, egui::Event::Key { key, modifiers, .. } + if owned.contains(key) && *modifiers == egui::Modifiers::NONE) + }; + input.events.retain(keep); + input.raw.events.retain(keep); + }); + if consumed { + // egui schedules navigation (and Escape blur) at begin_pass, before this hook. + ctx.memory_mut(|m| { + m.move_focus(egui::FocusDirection::None); + m.request_focus(popup.editor); + }); + } + ctx.data_mut(|d| d.insert_temp(popup_id(), popup)); +} + +pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> bool { + let ctx = ui.ctx().clone(); + if !ctx.memory(|m| m.has_focus(resp.id)) { + return false; + } + let state = egui::TextEdit::load_state(&ctx, resp.id); + let cursor = state.as_ref().and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0).unwrap_or_else(|| text.chars().count()); + let (start, word) = word_before(text, cursor); + let list = suggestions(&ctx, &word); + let previous = ctx.data(|d| d.get_temp::(popup_id())); + let mut popup = previous.filter(|p| p.editor == resp.id && p.word == word && p.cursor == cursor).unwrap_or(Popup { + editor: resp.id, + frame: 0, + word, + cursor, + selected: 0, + count: list.len(), + accept: false, + dismissed: false, + }); + popup.frame = ctx.cumulative_frame_nr(); + popup.count = list.len(); + popup.selected = popup.selected.min(list.len().saturating_sub(1)); + if list.is_empty() || popup.dismissed { + ctx.data_mut(|d| d.insert_temp(popup_id(), popup)); + return false; + } + let mut pick = if popup.accept { list.get(popup.selected).map(|s| s.text.clone()) } else { None }; + popup.accept = false; + let t = Tokens::get(); + crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show( + &ctx, + |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(resp.rect.width().max(180.0)); + for (i, suggestion) in list.iter().enumerate() { + let colour = match suggestion.kind.as_str() { + "parameter" => t.text, + "function" => t.accent, + _ => t.text_dim, + }; + let r = ui + .add(egui::Button::new(RichText::new(&suggestion.text).color(colour)).selected(i == popup.selected)) + .on_hover_text(&suggestion.detail); + if i == popup.selected { + ui.label(RichText::new(&suggestion.detail).small().color(t.text_dim)); + } + if r.clicked() { + pick = Some(suggestion.text.clone()); + } + } + ui.label(RichText::new("↑ ↓ choose · Enter / Tab complete · Esc dismiss").small().color(t.text_dim)); + }); + }, + ); + ctx.data_mut(|d| d.insert_temp(popup_id(), popup)); + let Some(pick) = pick else { return false }; + let chars: Vec = text.chars().collect(); + let before: String = chars.get(..start).map(|s| s.iter().collect()).unwrap_or_default(); + let after: String = chars.get(cursor.min(chars.len())..).map(|s| s.iter().collect()).unwrap_or_default(); + *text = format!("{before}{pick}{after}"); + let mut state = state.unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(start + pick.chars().count())))); + state.store(&ctx, resp.id); + resp.request_focus(); + // Do not leave the accepted popup owning Enter on the next frame. + ctx.data_mut(|d| d.remove::(popup_id())); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{SolveApp, params_dialog}; + use serde_json::json; + + struct Editor { + ctx: egui::Context, + app: SolveApp, + text: String, + id: egui::Id, + accepted: bool, + leaked: Vec, + } + + impl Editor { + fn new(text: &str) -> Self { + let ctx = egui::Context::default(); + let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default()); + for (name, value) in [("width", 40), ("width_extra", 50), ("wing", 20), ("wire", 10)] { + app.run("parameters.add", json!({"name":name,"expression":format!("{value} mm")})).unwrap(); + } + params_dialog::publish(&mut app, &ctx); + let id = egui::Id::new("completion_test_editor"); + ctx.memory_mut(|m| m.request_focus(id)); + let mut editor = Self { ctx, app, text: text.into(), id, accepted: false, leaked: Vec::new() }; + editor.frame(Vec::new()); + let mut state = egui::TextEdit::load_state(&editor.ctx, id).unwrap(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(text.chars().count())))); + state.store(&editor.ctx, id); + editor.frame(Vec::new()); + editor + } + + fn frame(&mut self, events: Vec) { + self.accepted = false; + self.leaked.clear(); + let _ = self.ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))), + events, + ..Default::default() + }, + |ui| { + begin_frame(ui.ctx()); + params_dialog::publish(&mut self.app, ui.ctx()); + egui::CentralPanel::default().show(ui, |ui| { + let response = ui.add(egui::TextEdit::singleline(&mut self.text).id(self.id)); + self.accepted = complete(ui, &response, &mut self.text); + for key in [Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Tab, Key::Escape] { + if ui.input(|i| { + i.key_pressed(key) + || i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key:k, pressed:true, .. } if *k == key)) + }) { + self.leaked.push(key); + } + } + }); + }, + ); + } + + fn key(&mut self, key: Key) { + self.frame(vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]); + } + + fn cursor(&self) -> usize { + egui::TextEdit::load_state(&self.ctx, self.id).unwrap().cursor.char_range().unwrap().primary.index.0 + } + + fn selected(&self) -> usize { + self.ctx.data(|d| d.get_temp::(popup_id())).unwrap().selected + } + } + + #[test] + fn arrows_cycle_without_moving_caret_enter_accepts_selected_without_leaking() { + let mut e = Editor::new("2 * wi"); + let list = suggestions(&e.ctx, "wi"); + assert!(list.len() >= 3); + let cursor = e.cursor(); + e.key(Key::ArrowDown); + assert_eq!(e.selected(), 1); + assert_eq!(e.cursor(), cursor); + assert!(e.leaked.is_empty()); + e.key(Key::ArrowUp); + assert_eq!(e.selected(), 0); + e.key(Key::ArrowUp); + assert_eq!(e.selected(), list.len() - 1); + e.key(Key::Enter); + assert_eq!(e.text, format!("2 * {}", list.last().unwrap().text)); + assert!(e.accepted); + assert!(e.leaked.is_empty()); + assert_eq!(e.cursor(), e.text.chars().count()); + assert!(e.ctx.memory(|m| m.has_focus(e.id))); + } + + #[test] + fn tab_accepts_selection_filtering_resets_it_and_escape_dismisses() { + let mut e = Editor::new("wi"); + e.key(Key::ArrowDown); + let expected = suggestions(&e.ctx, "wi").get(1).unwrap().text.clone(); + e.key(Key::Tab); + assert_eq!(e.text, expected); + assert!(e.accepted && e.leaked.is_empty()); + let mut e = Editor::new("wi"); + e.key(Key::ArrowDown); + e.frame(vec![egui::Event::Text("d".into())]); + assert_eq!(e.text, "wid"); + assert_eq!(e.selected(), 0); + e.key(Key::Escape); + assert!(e.leaked.is_empty()); + assert_eq!(e.text, "wid"); + e.key(Key::Enter); + assert!(!e.accepted); + assert!(e.leaked.contains(&Key::Enter)); + } + + #[test] + fn absent_popup_preserves_editor_and_application_keys() { + let mut e = Editor::new("123"); + e.key(Key::ArrowDown); + assert!(e.leaked.contains(&Key::ArrowDown)); + e.key(Key::Escape); + assert!(e.leaked.contains(&Key::Escape)); + assert!(!e.ctx.memory(|m| m.has_focus(e.id))); + e.ctx.memory_mut(|m| m.request_focus(e.id)); + e.frame(Vec::new()); + let mut state = egui::TextEdit::load_state(&e.ctx, e.id).unwrap(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(e.text.chars().count())))); + state.store(&e.ctx, e.id); + e.frame(vec![egui::Event::Text("4".into())]); + assert_eq!(e.text, "1234"); + } + + #[test] + fn exact_tokens_submit_without_completing_longer_names() { + for text in ["width", "2 mm"] { + let mut e = Editor::new(text); + e.key(Key::Enter); + assert_eq!(e.text, text); + assert!(!e.accepted); + assert!(e.leaked.contains(&Key::Enter)); + } + } + + #[test] + fn typing_and_enter_in_one_batch_does_not_accept_stale_prefix() { + let mut e = Editor::new("wi"); + e.frame(vec![ + egui::Event::Text("dth".into()), + egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }, + ]); + assert_eq!(e.text, "width"); + assert!(!e.accepted); + assert!(e.leaked.contains(&Key::Enter)); + } + + #[test] + fn modified_navigation_remains_available_to_the_editor() { + let mut e = Editor::new("wi"); + e.frame(vec![egui::Event::Key { + key: Key::ArrowUp, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { ctrl: true, ..Default::default() }, + }]); + assert!(e.leaked.contains(&Key::ArrowUp)); + assert!(!e.accepted); + let mut e = Editor::new("wi"); + e.frame(vec![egui::Event::Key { + key: Key::Tab, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { shift: true, ..Default::default() }, + }]); + assert!(e.leaked.contains(&Key::Tab)); + assert_eq!(e.text, "wi"); + assert!(!e.accepted); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 722aa133..f5d09ced 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,6 +61,7 @@ mod status_tests { assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last()); } } +mod expression_complete; #[cfg(test)] mod extrude_edit_tests; mod file_load; @@ -953,6 +954,7 @@ impl SolveApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { + expression_complete::begin_frame(ui.ctx()); let t0 = now_ms(); let _language = crate::i18n::scope(&self.preferences.language); let mut report_input = None; diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 5954c98d..1383f5df 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -11,7 +11,7 @@ use std::sync::Arc; -use egui::{Color32, RichText}; +use egui::RichText; use serde_json::{Value, json}; use crate::SolveApp; @@ -275,7 +275,9 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R if bad { te = te.text_color(t.error); } - let resp = ui.add_sized([170.0, 20.0], te); + let id = ui.make_persistent_id(("pexpr", &r.name)); + let focused = ui.memory(|m| m.has_focus(id)); + let resp = ui.add_sized([170.0, 20.0], te.id(id)); crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center()); let completed = complete(ui, &resp, &mut e); if resp.changed() || completed { @@ -284,10 +286,10 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R if resp.has_focus() && matches!(live, Some(Ok(_))) { d.preview = Some((r.name.clone(), e.clone())); } - if resp.lost_focus() { + if !completed && (resp.lost_focus() || enter_for(ui, focused)) { d.preview = None; if e != r.expression { - let res = app.run("parameters.change", json!({"name": r.name, "expression": e})); + let res = check(app, &e, &r.unit).and_then(|_| app.run("parameters.change", json!({"name": r.name, "expression": e}))); d.error = res.err(); if d.error.is_none() { d.editing.remove(&r.name); @@ -336,41 +338,79 @@ fn unit_label(u: &str) -> &str { if u.is_empty() { "none" } else { u } } +/// Only a fresh Enter in this field submits; held keys and unrelated focus do not. +fn enter_for(ui: &mut egui::Ui, focused: bool) -> bool { + focused + && ui.input_mut(|i| { + let fresh = i.raw.events.iter().any(|event| { + matches!(event, + egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, modifiers, .. } if *modifiers == egui::Modifiers::NONE) + }); + if fresh { + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter); + } + fresh + }) +} + fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog) { let t = Tokens::get(); ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut d.new_name).hint_text("new parameter").desired_width(120.0)); - egui::ComboBox::from_id_salt("pnew_unit").width(56.0).selected_text(unit_label(UNITS.get(d.new_unit).copied().unwrap_or("mm"))).show_ui( - ui, - |ui| { + let name_id = ui.make_persistent_id("pnew_name"); + let expr_id = ui.make_persistent_id("pnew_expr"); + let comment_id = ui.make_persistent_id("pnew_comment"); + let focused_id = ui.memory(|m| [name_id, expr_id, comment_id].into_iter().find(|id| m.has_focus(*id))); + let name = ui.add(egui::TextEdit::singleline(&mut d.new_name).id(name_id).hint_text("new parameter").desired_width(120.0)); + crate::scenario::publish_handle("param:new:name", name.rect.center()); + let unit_response = egui::ComboBox::from_id_salt("pnew_unit") + .width(56.0) + .selected_text(unit_label(UNITS.get(d.new_unit).copied().unwrap_or("mm"))) + .show_ui(ui, |ui| { for (i, u) in UNITS.iter().enumerate() { ui.selectable_value(&mut d.new_unit, i, unit_label(u)); } - }, - ); + }); + crate::scenario::publish_handle("param:new:unit", unit_response.response.rect.center()); let unit = UNITS.get(d.new_unit).copied().unwrap_or("mm"); - let live = check(app, &d.new_expr, unit); - let mut te = egui::TextEdit::singleline(&mut d.new_expr).desired_width(170.0); - if live.is_err() { + let bad = check(app, &d.new_expr, unit).is_err(); + let mut te = egui::TextEdit::singleline(&mut d.new_expr).id(expr_id).desired_width(170.0); + if bad { te = te.text_color(t.error); } let resp = ui.add(te); - complete(ui, &resp, &mut d.new_expr); + crate::scenario::publish_handle("param:new:expression", resp.rect.center()); + let completed = complete(ui, &resp, &mut d.new_expr); + let live = check(app, &d.new_expr, unit); match &live { Ok(v) => ui.label(RichText::new(v).color(t.text_dim)), Err(m) => ui.label(RichText::new(m).color(t.error)), }; - ui.add(egui::TextEdit::singleline(&mut d.new_comment).hint_text("comment").desired_width(120.0)); + let comment = ui.add(egui::TextEdit::singleline(&mut d.new_comment).id(comment_id).hint_text("comment").desired_width(120.0)); + crate::scenario::publish_handle("param:new:comment", comment.rect.center()); + let submit = !completed && enter_for(ui, focused_id.is_some()); let ok = !d.new_name.trim().is_empty() && live.is_ok(); - if ui.add_enabled(ok, egui::Button::new("Add")).clicked() { + if ui.add_enabled(ok, egui::Button::new("Add")).clicked() || submit { + if let Err(error) = &live { + d.error = Some(error.clone()); + if let Some(id) = focused_id { + ui.memory_mut(|m| m.request_focus(id)); + } + return; + } let p = json!({"name": d.new_name.trim(), "expression": d.new_expr, "unit": unit, "comment": d.new_comment}); match app.run("parameters.add", p) { Ok(_) => { d.new_name.clear(); d.new_comment.clear(); d.error = None; + name.request_focus(); + } + Err(e) => { + d.error = Some(e); + if let Some(id) = focused_id { + ui.memory_mut(|m| m.request_focus(id)); + } } - Err(e) => d.error = Some(e), } } }); @@ -421,7 +461,7 @@ pub fn publish(app: &mut SolveApp, ctx: &egui::Context) { } /// The identifier being typed before `cursor` (chars) and where it starts. -fn word_before(text: &str, cursor: usize) -> (usize, String) { +pub(crate) fn word_before(text: &str, cursor: usize) -> (usize, String) { let chars: Vec = text.chars().collect(); let end = cursor.min(chars.len()); let mut start = end; @@ -437,67 +477,18 @@ pub fn suggestions(ctx: &egui::Context, word: &str) -> Vec { return Vec::new(); } let all = ctx.data(|d| d.get_temp::(cache_id())).map(|c| c.all).unwrap_or_default(); + // A complete token is ready to submit, even when longer names share its prefix. + if all.iter().any(|s| s.text == word) { + return Vec::new(); + } let w = word.to_ascii_lowercase(); all.iter().filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && s.text.trim_end_matches('(') != word).take(8).cloned().collect() } -/// Offer completions under an expression box while it has focus; Tab or a click takes the first -/// (or the clicked) one. Returns true when the text changed. +/// Offer keyboard and pointer completions under a focused expression box. +/// Returns true when a suggestion was accepted; callers must not submit that frame. pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> bool { - let ctx = ui.ctx().clone(); - let state = egui::TextEdit::load_state(&ctx, resp.id); - let cursor = state.as_ref().and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0).unwrap_or_else(|| text.chars().count()); - let (start, word) = word_before(text, cursor); - // Raw events: egui's focus navigation consumes Tab before widgets see it. - let tab = ui.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Tab, pressed: true, .. }))); - // Tab moves focus away before we see it: accept on that frame and take focus back. - // Keyboard focus in egui's memory (not `has_focus`, which also needs the OS window focused). - let focused = ctx.memory(|m| m.has_focus(resp.id)); - let active = focused || (resp.lost_focus() && tab); - if !active { - return false; - } - let list = suggestions(&ctx, &word); - if list.is_empty() { - return false; - } - let mut pick: Option = if tab { list.first().map(|s| s.text.clone()) } else { None }; - let t = Tokens::get(); - crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show( - &ctx, - |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(resp.rect.width().max(180.0)); - for (i, s) in list.iter().enumerate() { - let colour = match s.kind.as_str() { - "parameter" => t.text, - "function" => t.accent, - _ => t.text_dim, - }; - let label = RichText::new(&s.text).color(colour); - let r = ui.add(egui::Button::new(label).frame(false).fill(Color32::TRANSPARENT)).on_hover_text(&s.detail); - if i == 0 { - ui.label(RichText::new(&s.detail).small().color(t.text_dim)); - } - if r.clicked() { - pick = Some(s.text.clone()); - } - } - ui.label(RichText::new("Tab to complete").small().color(t.text_dim)); - }); - }, - ); - let Some(p) = pick else { return false }; - let chars: Vec = text.chars().collect(); - let before: String = chars.get(..start).map(|s| s.iter().collect()).unwrap_or_default(); - let after: String = chars.get(cursor.min(chars.len())..).map(|s| s.iter().collect()).unwrap_or_default(); - *text = format!("{before}{p}{after}"); - let at = start + p.chars().count(); - let mut st = state.unwrap_or_default(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); - st.store(&ctx, resp.id); - resp.request_focus(); - true + crate::expression_complete::complete(ui, resp, text) } #[cfg(test)] diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index f30de5fb..18c35d4a 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -35,6 +35,8 @@ pub struct Prefs { pub default_units: String, /// Mouse mapping: `fusion`, `solidworks` or `inventor`. pub nav: String, + /// Show the radial ring and enable hold/flick gestures for viewport context menus. + pub radial_context_menu: bool, /// The wheel zooms out when turned forward. pub zoom_reverse: bool, /// Orbit around the point under the cursor (else around the view's centre). @@ -70,6 +72,7 @@ impl Default for Prefs { Prefs { default_units: "mm".into(), nav: "fusion".into(), + radial_context_menu: false, zoom_reverse: false, orbit_cursor: false, space_mouse_enabled: true, @@ -178,6 +181,7 @@ fn defaults(app: &mut SolveApp, page: usize) { } 1 => { p.nav = d.nav; + p.radial_context_menu = d.radial_context_menu; p.zoom_reverse = d.zoom_reverse; p.zoom_to_cursor = d.zoom_to_cursor; p.orbit_cursor = d.orbit_cursor; @@ -563,6 +567,9 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { }); ui.end_row(); note(ui, &format!("{}; hold Shift during orbit to snap; the wheel zooms", nav_help(&app.preferences.nav))); + ui.label("Context menu"); + ui.checkbox(&mut app.preferences.radial_context_menu, "Radial context menu"); + ui.end_row(); ui.label("Reverse zoom direction"); ui.checkbox(&mut app.preferences.zoom_reverse, ""); ui.end_row(); @@ -753,6 +760,28 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result egui::Visuals { + let t = if dark { Tokens::dark() } else { Tokens::light() }; + let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.panel; v.window_stroke = Stroke::new(1.0, t.border); @@ -282,11 +282,62 @@ pub fn apply(ctx: &egui::Context) { v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); v.widgets.hovered.weak_bg_fill = t.hover; v.widgets.hovered.bg_fill = t.hover; + // egui uses the open-state fill for a movable window's active title bar. + v.widgets.open.weak_bg_fill = t.panel_header; v.window_corner_radius = egui::CornerRadius::same(6); - ctx.set_theme(if is_dark() { egui::Theme::Dark } else { egui::Theme::Light }); - ctx.set_visuals(v); + v +} + +pub fn apply(ctx: &egui::Context) { + let dark = is_dark(); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.set_visuals(visuals(dark)); ctx.global_style_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(6.0, 3.0); }); } + +#[cfg(test)] +mod tests { + #[test] + fn active_movable_window_header_uses_theme_panel_header() { + for dark in [false, true] { + let ctx = egui::Context::default(); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.set_visuals(super::visuals(dark)); + let mut output = egui::FullOutput::default(); + for _ in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + ..Default::default() + }; + output = ctx.run_ui(raw, |ui| { + crate::frame::window(ui.ctx(), "Parameters", crate::frame::Width::Normal).fade_in(false).show(ui.ctx(), |ui| { + ui.label("Parameter values"); + }); + }); + output.textures_delta.clear(); + } + let title = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text() == "Parameters" => Some(text.pos + text.galley.rect.center().to_vec2()), + _ => None, + }) + .expect("window title must be drawn"); + let header = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) if rect.fill.a() > 0 && rect.rect.contains(title) => Some(rect), + _ => None, + }) + .min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())) + .expect("window header must be drawn behind its title"); + let expected = if dark { super::Tokens::dark() } else { super::Tokens::light() }; + assert_eq!(header.fill, expected.panel_header, "active title bar should follow the {dark} palette"); + } + } +} diff --git a/crates/ui-egui/tests/parameter_enter.rs b/crates/ui-egui/tests/parameter_enter.rs new file mode 100644 index 00000000..977f7ed4 --- /dev/null +++ b/crates/ui-egui/tests/parameter_enter.rs @@ -0,0 +1,108 @@ +//! Enter submits only the focused Parameters editor row. +use egui::{Event, Key, Modifiers}; +use serde_json::json; +use solvecraft_ui_egui::scenario::Harness; + +fn open() -> Harness { + let mut h = Harness::new(); + h.step(&json!({"widget":"Parameters"})).unwrap(); + assert!(h.app.params.is_some()); + h +} +fn fill(h: &mut Harness, field: &str, text: &str) { + h.run(&json!([ + {"click":{"handle":format!("param:new:{field}")}}, + {"key":"A","cmd":true}, {"text":text} + ])) + .unwrap(); +} +fn enter(h: &mut Harness, repeat: bool) { + h.app.synthetic.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }); + h.frame(); + h.app.synthetic.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.frames(3); +} +#[test] +fn parameter_enter_adds_once_and_focuses_next_name() { + let mut h = open(); + fill(&mut h, "name", "width"); + fill(&mut h, "expression", "25 mm"); + let undo = h.app.session.undo.len(); + enter(&mut h, false); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert!(h.app.params.as_ref().unwrap().new_name.is_empty()); + assert!(h.app.params.as_ref().unwrap().error.is_none()); + let saved = h.app.session.doc.to_json(); + enter(&mut h, true); + assert_eq!(h.app.session.doc.to_json(), saved); + assert_eq!(h.app.session.undo.len(), undo + 1); + // Name has focus after successful creation; no mouse needed for the next name. + h.run(&json!([{"text":"height"}])).unwrap(); + assert_eq!(h.app.params.as_ref().unwrap().new_name, "height"); + enter(&mut h, false); + assert_eq!(h.app.session.undo.len(), undo + 2); + let result = h.app.session.execute("parameters.list", &json!({})).unwrap(); + assert!(result["parameters"].as_array().unwrap().iter().any(|p| p["name"] == "height")); +} +#[test] +fn parameter_enter_invalid_preserves_draft_and_other_rows() { + let mut h = open(); + h.app.session.execute("parameters.add", &json!({"name":"existing","expression":"10 mm"})).unwrap(); + h.frames(3); + fill(&mut h, "name", "bad"); + fill(&mut h, "expression", "missing + 1 mm"); + let saved = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + enter(&mut h, false); + assert_eq!(h.app.session.doc.to_json(), saved); + assert_eq!(h.app.session.undo.len(), undo); + let d = h.app.params.as_ref().unwrap(); + assert_eq!(d.new_name, "bad"); + assert_eq!(d.new_expr, "missing + 1 mm"); + assert!(d.error.is_some()); + fill(&mut h, "expression", "15 mm"); + fill(&mut h, "name", "existing"); + enter(&mut h, false); + assert_eq!(h.app.session.doc.to_json(), saved); + assert!(h.app.params.as_ref().unwrap().error.is_some()); +} +#[test] +fn parameter_enter_edits_expression_without_adding_draft() { + let mut h = open(); + h.app.session.execute("parameters.add", &json!({"name":"width","expression":"10 mm"})).unwrap(); + h.frames(3); + fill(&mut h, "name", "unsubmitted"); + let undo = h.app.session.undo.len(); + h.run(&json!([{"click":{"handle":"param:width"}}, {"key":"A","cmd":true}, {"text":"20 mm"}])).unwrap(); + enter(&mut h, false); + assert_eq!(h.app.session.undo.len(), undo + 1); + assert_eq!(h.app.params.as_ref().unwrap().new_name, "unsubmitted"); + let result = h.app.session.execute("parameters.list", &json!({})).unwrap(); + let ps = result["parameters"].as_array().unwrap(); + assert!(ps.iter().any(|p| p["name"] == "width" && p["expression"] == "20 mm")); + assert!(!ps.iter().any(|p| p["name"] == "unsubmitted")); + let saved = h.app.session.doc.to_json(); + h.run(&json!([{"click":{"handle":"param:width"}}, {"key":"A","cmd":true}, {"text":"missing + 2 mm"}])).unwrap(); + enter(&mut h, false); + assert_eq!(h.app.session.doc.to_json(), saved); + assert!(h.app.params.as_ref().unwrap().error.is_some()); + assert_eq!(h.app.params.as_ref().unwrap().editing.get("width").map(String::as_str), Some("missing + 2 mm")); +} + +#[test] +fn parameter_enter_comment_uses_selected_unit_and_unrelated_focus_does_not_submit() { + let mut h = open(); + fill(&mut h, "name", "length"); + h.run(&json!([{"click":{"handle":"param:new:unit"}}, {"widget":"cm"}])).unwrap(); + fill(&mut h, "expression", "2 cm"); + fill(&mut h, "comment", "Overall length"); + let undo = h.app.session.undo.len(); + h.run(&json!([{"click":{"handle":"params:title"}}])).unwrap(); + enter(&mut h, false); + assert_eq!(h.app.session.undo.len(), undo); + h.run(&json!([{"click":{"handle":"param:new:comment"}}])).unwrap(); + enter(&mut h, false); + let result = h.app.session.execute("parameters.list", &json!({})).unwrap(); + assert!(result["parameters"].as_array().unwrap().iter().any(|p| p["name"] == "length" && p["unit"] == "cm" && p["comment"] == "Overall length")); + assert_eq!(h.app.session.undo.len(), undo + 1); +} diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index cf990719..7a7b123d 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -19,6 +19,31 @@ cargo run --release -p solvecraft -- --sample # opens with a sample part `solvecraft design.solvecraft` opens a saved design. Windows builds cross-compile from Linux with `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`. +### Linux Flatpak + +Install Flatpak using your distribution's package manager. For an installation that receives +updates, add Flathub for the required Freedesktop runtime and the SolidCraft application feed +to the same user installation: + +```sh +flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo +flatpak install --user solvecraft ai.storyteller.solvecraft +flatpak run ai.storyteller.solvecraft +``` + +The application ID and remote name retain their compatibility names. To update an existing +remote that still uses the former website address, run +`flatpak remote-modify --user --url=https://bherbruck.github.io/solidcraft/flatpak/repo/ solvecraft` +and then `flatpak update --user`. + +Alternatively, download the release's `.flatpak` file for your architecture and run +`flatpak install --user ./solvecraft-VERSION-linux-ARCH.flatpak`. The bundle supplies the +Flathub runtime repository address; installation still needs internet access to fetch any +missing runtime. Launch with `flatpak run ai.storyteller.solvecraft`, rather than looking for +a host `solvecraft` binary. Files chosen through Open/Save use the desktop file-chooser portal; +command-line paths outside Documents may need a specific additional filesystem permission. + ## Web The same app runs in the browser: [open SolidCraft on the web](../app/) (`?sample` opens the sample diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index dbe92a21..965e0c56 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -9,6 +9,12 @@ In the SOLID toolbar's CONFIGURE panel, click **Parameters** to open the user an parameter editor. The separate **Configurations** button opens the configuration table; **Configure** remains in the dropdown for configuring columns. +Press **Enter** in the new parameter's name, expression or comment field to add it. +Valid entries return focus to the name field for the next parameter; invalid entries remain +editable. In expression fields, **Up/Down** selects an autocomplete suggestion, +**Enter/Tab** accepts it, and **Escape** dismisses the suggestions. Accepting a suggestion +does not also submit the parameter. + Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in + 3 mm`, using `+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod trunc` and unit conversion. A length where an angle is expected (or the other way round) is an diff --git a/docs/nix.md b/docs/nix.md new file mode 100644 index 00000000..2db47bba --- /dev/null +++ b/docs/nix.md @@ -0,0 +1,90 @@ +# SolidCraft with Nix + +The flake supports `x86_64-linux` and `aarch64-linux`. Enable Nix's `nix-command` +and `flakes` experimental features. The committed `flake.lock` pins nixpkgs and +its Rust toolchain; package evaluation checks that compiler against the Rust +floor in `Cargo.toml`. + +## Run, build or install + +Run the current app without installing it: + +```sh +nix run github:bherbruck/solidcraft#solidcraft -- --sample +``` + +Inside a checkout, `nix run .` and `nix run .#solidcraft` launch the desktop app. +`nix run .#cli -- --version` launches the CLI. The legacy `.#solvecraft` app name +also works. These commands use the flake's exact wrapped binary, rather than an +older executable already on your shell's `PATH`. + +Build without installing: + +```sh +nix build github:bherbruck/solidcraft#solidcraft +./result/bin/solidcraft --version +./result/bin/solidcraft --sample +``` + +The result contains `solidcraft`, the compatible `solvecraft` binary name, and +`solvecraft-cli`. **Building does not install a desktop-menu entry.** For a user +profile installation, run: + +```sh +nix profile install github:bherbruck/solidcraft#solidcraft +solidcraft --sample +``` + +This installs the SolidCraft desktop entry, MIME definition and generated icon +sizes alongside the commands. NixOS/Home Manager can instead put the flake's +`packages.${system}.solidcraft` in `environment.systemPackages`/`home.packages`. +If a non-NixOS desktop does not discover profile-installed launchers, ensure that +its session includes `~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and +back in. The existing `ai.storyteller.solvecraft` desktop/application ID and +`.solvecraft` design files remain compatible. + +Check which executable a plain terminal command would run: + +```sh +type -a solidcraft solvecraft +readlink -f "$(command -v solidcraft)" +solidcraft --version +nix run github:bherbruck/solidcraft#solidcraft -- --version +``` + +An unrelated old `solvecraft` executable on `PATH` is not updated by `nix build`. +Use the flake app or `./result/bin/solidcraft` to verify the package you just built. + +## Graphics and file dialogs + +The app wrapper includes Wayland, X11, xkbcommon, EGL, Vulkan-loader and D-Bus +libraries. It exposes `/run/opengl-driver/lib` and +`/run/opengl-driver/share` so NixOS's graphics drivers and Vulkan manifests are +discoverable. Enable `hardware.graphics.enable = true` on NixOS and keep the +system's driver stack consistent with the compositor. File pickers also need a +working session D-Bus and the desktop's `xdg-desktop-portal` service. + +On another distribution, graphics drivers outside the Nix store may need a +matching [nixGL wrapper](https://github.com/nix-community/nixGL). Follow its +GPU-specific instructions; a wrapper for a different driver is not a substitute. +For Intel/Mesa, after building: + +```sh +nix run github:nix-community/nixGL#nixGLIntel -- ./result/bin/solidcraft --sample +``` + +If a window is black, first verify that you launched the newly wrapped package +and that your system exposes a functioning GPU driver. `WGPU_BACKEND=vulkan` +selects Vulkan for diagnosis; a loader without a usable driver still cannot render. +OpenGL rendering uses 1x anti-aliasing to avoid the multisampled black-frame failure. +Vulkan and other backends keep the requested sample count; saved preferences are preserved. +For older builds, including 0.3.0, set Anti-aliasing to 1x and restart when using OpenGL. +If that window cannot show the settings dialog, edit only the `msaa` field to `1` in +`~/.local/share/solvecraft/graphics.json`; preserve any other settings. + +## Development shells + +`nix develop` supplies runtime libraries and uses the Rust/C toolchain already on +your `PATH`. `nix develop .#rust` supplies the pinned toolchain, C linker and +`target/nix` build directory. Both retain the GUI and CLI's existing Cargo package +names (`solvecraft`, `solvecraft-cli`). diff --git a/flake.nix b/flake.nix index 6cab8489..3c73e857 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "SolveCraft — native app development shell and package for Linux"; + description = "SolidCraft — native app development shell and package for Linux"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -33,6 +33,7 @@ wayland libxkbcommon libglvnd + vulkan-loader dbus ]) ++ (with pkgs; [ @@ -46,14 +47,16 @@ libxcb ]); driverLibDir = "/run/opengl-driver/lib"; + driverShareDir = "/run/opengl-driver/share"; libraryPath = pkgs: "${pkgs.lib.makeLibraryPath (runtimeLibs pkgs)}:${driverLibDir}"; shellHook = '' if [ ! -d ${driverLibDir} ]; then - echo "solvecraft: ${driverLibDir} is missing — set hardware.graphics.enable = true so the Mesa drivers and their EGL vendor descriptions exist." >&2 + echo "SolidCraft: ${driverLibDir} is missing — on NixOS enable hardware.graphics; on other distributions use a matching nixGL driver wrapper." >&2 fi command -v cc >/dev/null || echo "solvecraft: no C linker on PATH, cargo cannot link — use nix develop .#rust" >&2 echo "solvecraft: cargo run --release -p solvecraft -- --sample" + export XDG_DATA_DIRS="${driverShareDir}:''${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" ''; in { @@ -63,7 +66,7 @@ # second rustc — or mkShell's second gcc, with another glibc behind it — would make every # `cargo build` produce binaries from somewhere else. default = pkgs.mkShellNoCC { - name = "solvecraft"; + name = "solidcraft"; LD_LIBRARY_PATH = libraryPath pkgs; inherit shellHook; }; @@ -71,7 +74,7 @@ # For a machine with no Rust at all: `nix develop .#rust`. Its own target directory, so # switching between this toolchain and the system one does not thrash the cache. rust = pkgs.mkShell { - name = "solvecraft-rust"; + name = "solidcraft-rust"; packages = [ pkgs.cargo pkgs.rustc @@ -88,22 +91,29 @@ }; }); - packages = forAllSystems (pkgs: { - default = pkgs.rustPlatform.buildRustPackage { - pname = "solvecraft"; + packages = forAllSystems (pkgs: rec { + default = solidcraft; + solidcraft = assert pkgs.lib.assertMsg + (pkgs.lib.versionAtLeast pkgs.rustc.version (pkgs.lib.importTOML ./Cargo.toml).workspace.package.rust-version) + "SolidCraft requires the Rust version advertised in Cargo.toml; update the pinned nixpkgs input."; + pkgs.rustPlatform.buildRustPackage { + pname = "solidcraft"; version = (pkgs.lib.importTOML ./Cargo.toml).workspace.package.version; src = self; cargoLock.lockFile = ./Cargo.lock; - # Build and install the app alone: the workspace also holds the CLI, the web app, xtask - # and the crates only the test suites need. + # The CLI draws the same packaging icons as the other Linux distributions. cargoBuildFlags = [ "-p" "solvecraft" + "-p" + "solvecraft-cli" ]; cargoInstallFlags = [ "-p" "solvecraft" + "-p" + "solvecraft-cli" ]; # `cargo xtask ci` is the test gate (it wants a wasm target and a GPU); the sandbox has # neither. @@ -112,22 +122,48 @@ nativeBuildInputs = [ pkgs.makeWrapper ]; buildInputs = runtimeLibs pkgs; + postInstall = '' + install -Dm644 packaging/linux/ai.storyteller.solvecraft.desktop \ + $out/share/applications/ai.storyteller.solvecraft.desktop + install -Dm644 packaging/linux/ai.storyteller.solvecraft.mime.xml \ + $out/share/mime/packages/ai.storyteller.solvecraft.xml + for size in 16 24 32 48 64 128 256 512; do + icon=$out/share/icons/hicolor/''${size}x''${size}/apps/ai.storyteller.solvecraft.png + mkdir -p "$(dirname "$icon")" + $out/bin/solvecraft-cli icon --size "$size" --out "$icon" >/dev/null + done + ln -s solvecraft $out/bin/solidcraft + ''; + # The app reaches the window through dlopen'd libraries, so the packaged binary needs the # same LD_LIBRARY_PATH the shell exports. postFixup = '' - wrapProgram $out/bin/solvecraft --prefix LD_LIBRARY_PATH : "${libraryPath pkgs}" + wrapProgram $out/bin/solvecraft \ + --prefix LD_LIBRARY_PATH : "${libraryPath pkgs}" \ + --set-default XDG_DATA_DIRS /usr/local/share:/usr/share \ + --prefix XDG_DATA_DIRS : "$out/share:${driverShareDir}" ''; meta = { description = "Rust-native parametric 3D CAD app"; - homepage = "https://github.com/bherbruck/solvecraft"; + homepage = "https://github.com/bherbruck/solidcraft"; license = with pkgs.lib.licenses; [ mit asl20 ]; - mainProgram = "solvecraft"; + mainProgram = "solidcraft"; }; }; }); + + apps = forAllSystems (pkgs: let + package = self.packages.${pkgs.stdenv.hostPlatform.system}.solidcraft; + desktop = { type = "app"; program = "${package}/bin/solidcraft"; }; + in { + default = desktop; + solidcraft = desktop; + solvecraft = { type = "app"; program = "${package}/bin/solvecraft"; }; + cli = { type = "app"; program = "${package}/bin/solvecraft-cli"; }; + }); }; } diff --git a/packaging/flatpak-site.sh b/packaging/flatpak-site.sh index 35589bf4..9e2169f3 100644 --- a/packaging/flatpak-site.sh +++ b/packaging/flatpak-site.sh @@ -62,7 +62,9 @@ Url=$base_url/flatpak/repo/ Homepage=https://github.com/$GITHUB_REPOSITORY META cat >"$site/flatpak/index.html" <SolidCraft Flatpak repository

SolidCraft Flatpak repository

Latest published release: $tag

Add the SolidCraft repository

flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
-flatpak install solvecraft $app_id
+SolidCraft Flatpak repository

SolidCraft Flatpak repository

Latest published release: $tag

Add the SolidCraft repository

Flathub provides the required Freedesktop runtime. Add both remotes to the same user installation:

flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
+flatpak remote-add --user --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
+flatpak install --user solvecraft $app_id
+flatpak run $app_id
HTML touch "$site/.nojekyll" diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 815fee75..58a09754 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -9,8 +9,9 @@ # is built for the host architecture (x86_64 or aarch64), which must match the tarball's. # Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's # `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are -# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and -# `solvecraft-cli --version` is run inside the sandbox as a smoke test. +# installed per-user from Flathub. Unless --no-test, the bundle is installed per-user, the CLI +# is checked, and the desktop creates/measures a box on headless X11 and Wayland. The desktop +# check also needs python3, dbus-x11, xvfb, xauth and weston; no screenshots are produced. # Manifest: packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml. # Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md). set -euo pipefail @@ -67,6 +68,7 @@ if [ "$TEST" = 1 ]; then flatpak info --user "$APP_ID" flatpak run --command=solvecraft-cli "$APP_ID" --version flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/256x256/apps /app/share/doc/solvecraft' + python3 "$HERE/flatpak-smoke.py" --backend both fi echo "==> done" ls -lh "$OUT" diff --git a/packaging/linux/flatpak-smoke.py b/packaging/linux/flatpak-smoke.py new file mode 100644 index 00000000..55a3d486 --- /dev/null +++ b/packaging/linux/flatpak-smoke.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Exercise desktop startup and modelling in the installed Flatpak, without images. + +Uses the app's normal sandbox permissions. The control client runs inside that +sandbox, so this does not grant host network access or save Flatpak overrides. +Each headless backend has private runtime/configuration directories and a bus. +""" +import argparse +import base64 +import json +import math +import os +from pathlib import Path +import shutil +import struct +import subprocess +import sys +import tempfile +import time +import zlib + +APP_ID = "ai.storyteller.solvecraft" +MAX_PIXELS = 16_000_000 +MAX_PNG_BYTES = 70_000_000 +SANDBOX_PROBE = r''' +set -euo pipefail +work=$(mktemp -d) +export XDG_CONFIG_HOME="$work/config" XDG_DATA_HOME="$work/data" XDG_CACHE_HOME="$work/cache" +mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" +unset WGPU_BACKEND WGPU_BACKENDS +solvecraft --control 18837 >"$work/app.log" 2>&1 & +app=$! +cleanup() { + kill "$app" 2>/dev/null || true + wait "$app" 2>/dev/null || true + cat "$work/app.log" >&2 + rm -rf -- "$work" +} +trap cleanup EXIT +ready=0 +for ((attempt=0; attempt<300; attempt++)); do + if ! kill -0 "$app" 2>/dev/null; then exit 1; fi + if { exec 3<>/dev/tcp/127.0.0.1/18837; } 2>/dev/null; then ready=1; break; fi + sleep .1 +done +test "$ready" = 1 +for request in '{"id":1,"method":"ui.inspect","params":{}}' '{"id":2,"method":"engine.execute","params":{"command":"solid.box","params":{"length":30,"width":20,"height":10}}}' '{"id":3,"method":"document.inspect","params":{"measure":true}}'; do + printf '%s\n' "$request" >&3 + IFS= read -r -t 30 reply <&3 + printf '%s\n' "$reply" +done +printf '{"id":4,"method":"ui.screenshot","params":{"path":"%s/frame.png"}}\n' "$work" >&3 +IFS= read -r -t 30 reply <&3 +printf '%s\n' "$reply" +base64 -w0 "$work/frame.png" +printf '\n' +''' + + +def validate_replies(stdout): + replies = [json.loads(line) for line in stdout.splitlines()] + if len(replies) != 3 or any(reply.get("id") != i or reply.get("ok") is not True + for i, reply in enumerate(replies, 1)): + raise ValueError(f"desktop control failed: {replies}") + ui = replies[0]["result"] + viewport = ui["viewport"] + if (ui["renderer"] not in {"gpu", "cpu"} or len(viewport) != 4 + or not all(math.isfinite(value) for value in viewport) + or viewport[2] <= 0 or viewport[3] <= 0): + raise ValueError("desktop did not create a usable viewport") + document = replies[2]["result"] + bodies = document["bodies"] + if (len(bodies) != 1 or len(document["timeline"]) != 1 + or document["timeline"][0]["error"] is not None + or not math.isclose(bodies[0]["volume_mm3"], 6000, rel_tol=0, abs_tol=1e-7) + or bodies[0]["bbox"] != {"min": [0, 0, 0], "max": [30, 20, 10]}): + raise ValueError(f"desktop modelling failed: {document}") + return ui["renderer"] + + +def frame_statistics(png): + """Bounded PNG readback check for the app's non-interlaced RGBA8 output.""" + if len(png) > MAX_PNG_BYTES or not png.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("invalid or oversized presented-frame PNG") + offset, width, height, ended = 8, 0, 0, False + compressed = bytearray() + while offset < len(png): + if offset + 12 > len(png): + raise ValueError("truncated PNG chunk") + length = struct.unpack_from(">I", png, offset)[0] + kind = png[offset + 4:offset + 8] + end = offset + 12 + length + if end > len(png): + raise ValueError("truncated PNG data") + data = memoryview(png)[offset + 8:offset + 8 + length] + crc = zlib.crc32(data, zlib.crc32(kind)) & 0xffffffff + if crc != struct.unpack_from(">I", png, end - 4)[0]: + raise ValueError("PNG checksum mismatch") + if kind == b"IHDR": + if offset != 8 or length != 13: + raise ValueError("invalid PNG header") + width, height, depth, colour, compression, filtering, interlace = struct.unpack(">IIBBBBB", data) + if (width == 0 or height == 0 or width * height > MAX_PIXELS + or (depth, colour, compression, filtering, interlace) != (8, 6, 0, 0, 0)): + raise ValueError("unsupported PNG dimensions or format") + elif kind == b"IDAT": + if not width or len(compressed) + length > MAX_PNG_BYTES: + raise ValueError("invalid PNG image data") + compressed.extend(data) + elif kind == b"IEND": + if length or end != len(png): + raise ValueError("invalid PNG end") + ended = True + break + elif not (kind[0] & 32): + raise ValueError("unsupported critical PNG chunk") + offset = end + if not ended or not width or not compressed: + raise ValueError("incomplete PNG") + stride = width * 4 + expected = height * (stride + 1) + inflater = zlib.decompressobj() + try: + raw = inflater.decompress(compressed, expected + 1) + except zlib.error as error: + raise ValueError("invalid compressed PNG data") from error + if (len(raw) != expected or not inflater.eof or inflater.unconsumed_tail or inflater.unused_data): + raise ValueError("PNG decompressed size mismatch") + previous = bytearray(stride) + first = None + nonblack, varied = 0, False + for y in range(height): + start = y * (stride + 1) + method = raw[start] + if method > 4: + raise ValueError("unsupported PNG row filter") + row = bytearray(raw[start + 1:start + 1 + stride]) + if method: + for i in range(stride): + left = row[i - 4] if i >= 4 else 0 + above = previous[i] + corner = previous[i - 4] if i >= 4 else 0 + if method == 1: + prediction = left + elif method == 2: + prediction = above + elif method == 3: + prediction = (left + above) // 2 + else: + p = left + above - corner + a, b, c = abs(p - left), abs(p - above), abs(p - corner) + prediction = left if a <= b and a <= c else (above if b <= c else corner) + row[i] = (row[i] + prediction) & 255 + if first is None: + first = tuple(row[:3]) + for i in range(0, stride, 4): + nonblack += row[i + 3] > 8 and (row[i] > 8 or row[i + 1] > 8 or row[i + 2] > 8) + varied |= row[i] != first[0] or row[i + 1] != first[1] or row[i + 2] != first[2] + previous = row + if not varied or nonblack <= width * height // 10: + raise ValueError(f"blank presented frame: {nonblack}/{width * height} nonblack pixels, varied={varied}") + return {"width": width, "height": height, "nonblack": nonblack, "pixels": width * height} + + +def probe(gl_saved_four=False): + shell = SANDBOX_PROBE + if gl_saved_four: + shell = shell.replace('solvecraft --control', + 'mkdir -p "$XDG_DATA_HOME/solvecraft"\n' + 'printf \'{"msaa":4}\' >"$XDG_DATA_HOME/solvecraft/graphics.json"\n' + 'export WGPU_BACKEND=gl\nsolvecraft --control') + result = subprocess.run(["flatpak", "run", "--user", "--command=bash", APP_ID, "-c", shell], + text=True, capture_output=True, timeout=125) + if result.returncode: + raise RuntimeError(f"Flatpak desktop exited {result.returncode}\n{result.stderr[-8000:]}") + lines = result.stdout.splitlines() + if len(lines) != 5 or len(lines[4]) > ((MAX_PNG_BYTES + 2) // 3) * 4: + raise ValueError("invalid presented-frame response") + renderer = validate_replies("\n".join(lines[:3])) + capture = json.loads(lines[3]) + if capture.get("id") != 4 or capture.get("ok") is not True or renderer != "gpu": + raise ValueError("GPU presented-frame capture failed") + statistics = frame_statistics(base64.b64decode(lines[4], validate=True)) + if (capture["result"]["width"], capture["result"]["height"]) != (statistics["width"], statistics["height"]): + raise ValueError("presented-frame dimensions disagree") + if gl_saved_four and "SolidCraft graphics: Gl, MSAA 1x (requested 4x)" not in result.stderr: + raise ValueError("forced GL did not apply the safe sample count") + mode = "GL saved 4x" if gl_saved_four else "default backend" + print(f"Flatpak desktop PASS: {mode}, renderer={renderer}, box=6000 mm³, frame={statistics}") + + +def run_backend(backend): + tools = ["flatpak", "dbus-run-session", "xvfb-run"] if backend == "x11" else ["flatpak", "dbus-run-session", "weston"] + for tool in tools: + if not shutil.which(tool): + raise RuntimeError(f"{tool} missing: install flatpak, dbus-x11, xvfb, xauth and weston") + with tempfile.TemporaryDirectory(prefix="solidcraft-flatpak-smoke-") as temporary: + runtime = Path(temporary) / "runtime" + runtime.mkdir(mode=0o700) + env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime)) + env.pop("WAYLAND_DISPLAY", None) + env.pop("WAYLAND_SOCKET", None) + env.pop("DISPLAY", None) + env.pop("SOLVECRAFT_WAYLAND", None) + command = ["dbus-run-session", "--"] + compositor = None + with (Path(temporary) / "weston.log").open("wb") as log: + try: + if backend == "x11": + command += ["xvfb-run", "-a", "-s", "-screen 0 1600x1000x24 -nolisten tcp"] + else: + env["WAYLAND_DISPLAY"] = "solidcraft-smoke" + compositor = subprocess.Popen(["weston", "--backend=headless", "--renderer=pixman", "--no-config", + "--socket=solidcraft-smoke", "--width=1600", "--height=1000"], + env=env, stdout=log, stderr=subprocess.STDOUT) + deadline = time.monotonic() + 20 + while not (runtime / "solidcraft-smoke").exists(): + if compositor.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError("headless Weston failed: " + (Path(temporary) / "weston.log").read_text()) + time.sleep(.1) + command += [sys.executable, str(Path(__file__).resolve()), "--probe"] + subprocess.run(command, env=env, check=True, timeout=150) + if backend == "wayland": + subprocess.run(command + ["--gl-saved-four"], env=env, check=True, timeout=150) + print(f"{backend}: default sandbox desktop startup and modelling PASS") + finally: + if compositor: + compositor.terminate() + try: + compositor.wait(timeout=10) + except subprocess.TimeoutExpired: + compositor.kill() + compositor.wait() + # The isolated document portal can leave a disconnected FUSE + # mount after its private session bus exits. Unmount only our + # temporary runtime's portal before deleting that directory. + if shutil.which("fusermount3"): + subprocess.run(["fusermount3", "-u", str(runtime / "doc")], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=["x11", "wayland", "both"], default="both") + parser.add_argument("--probe", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--gl-saved-four", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if args.probe: + probe(args.gl_saved_four) + else: + for backend in (["x11", "wayland"] if args.backend == "both" else [args.backend]): + run_backend(backend) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError, TypeError, RuntimeError, subprocess.SubprocessError) as error: + print(f"Flatpak desktop smoke failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/packaging/tests/flatpak_test.py b/packaging/tests/flatpak_test.py new file mode 100644 index 00000000..ef3b3d5b --- /dev/null +++ b/packaging/tests/flatpak_test.py @@ -0,0 +1,93 @@ +"""Fast failure-path checks for the desktop Flatpak process probe.""" +import importlib.util +import json +import struct +import zlib +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("flatpak_smoke", ROOT / "packaging/linux/flatpak-smoke.py") +SMOKE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SMOKE) + + +def healthy_replies(): + return [ + {"id": 1, "ok": True, "result": {"renderer": "gpu", "viewport": [250, 126, 1000, 700]}}, + {"id": 2, "ok": True, "result": {"feature": 1, "bodies": ["Body1"]}}, + {"id": 3, "ok": True, "result": { + "timeline": [{"error": None}], + "bodies": [{"volume_mm3": 20 * 30 * 10, "bbox": {"min": [0, 0, 0], "max": [30, 20, 10]}}], + }}, + ] + + +def output(replies): + return "\n".join(json.dumps(reply) for reply in replies) + + +def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(data, zlib.crc32(kind))) + + +def rgba_png(width, height, rows): + header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")) + + +class DesktopProbe(unittest.TestCase): + def test_real_presented_frame_is_required_not_just_responsive_control(self): + black = rgba_png(2, 2, b"\x00" + bytes([0, 0, 0, 255]) * 2 + b"\x00" + bytes([0, 0, 0, 255]) * 2) + with self.assertRaisesRegex(ValueError, "blank presented frame"): + SMOKE.frame_statistics(black) + # Independently encoded two rows: red/green, blue/white. + colour = rgba_png(2, 2, b"\x00" + bytes([255, 0, 0, 255, 0, 255, 0, 255]) + + b"\x00" + bytes([0, 0, 255, 255, 255, 255, 255, 255])) + self.assertEqual(SMOKE.frame_statistics(colour), {"width": 2, "height": 2, "nonblack": 4, "pixels": 4}) + # Same pixels encoded with PNG Sub filtering (not a second decoder). + sub = rgba_png(2, 2, b"\x01" + bytes([255, 0, 0, 255, 1, 255, 0, 0]) + + b"\x01" + bytes([0, 0, 255, 255, 255, 255, 0, 0])) + self.assertEqual(SMOKE.frame_statistics(sub), SMOKE.frame_statistics(colour)) + + def test_frame_byte_dimensions_and_decompression_are_bounded(self): + with self.assertRaisesRegex(ValueError, "dimensions"): + SMOKE.frame_statistics(rgba_png(SMOKE.MAX_PIXELS + 1, 1, b"")) + with self.assertRaisesRegex(ValueError, "decompressed size"): + SMOKE.frame_statistics(rgba_png(1, 1, b"\x00" * 10000)) + truncated = rgba_png(1, 1, b"\x00\xff\x00\x00\xff")[:-1] + with self.assertRaises(ValueError): + SMOKE.frame_statistics(truncated) + + def test_ui_thread_and_independent_box_measurement_are_required(self): + replies = healthy_replies() + self.assertEqual(SMOKE.validate_replies(output(replies)), "gpu") + replies[2]["result"]["bodies"][0]["volume_mm3"] = 5900 + with self.assertRaises(ValueError): + SMOKE.validate_replies(output(replies)) + + def test_cli_version_or_partial_control_output_is_not_desktop_success(self): + with self.assertRaises(ValueError): + SMOKE.validate_replies("solvecraft-cli 0.3.0\n") + with self.assertRaises(ValueError): + SMOKE.validate_replies(output(healthy_replies()[:1])) + + def test_startup_without_viewport_or_failed_feature_is_not_success(self): + for failure in ["viewport", "feature", "control", "bounds"]: + with self.subTest(failure=failure): + replies = healthy_replies() + if failure == "viewport": + replies[0]["result"]["viewport"][2] = 0 + elif failure == "feature": + replies[2]["result"]["timeline"][0]["error"] = "operation failed" + elif failure == "control": + replies[1] = {"id": 2, "ok": False, "error": "startup failure"} + else: + replies[2]["result"]["bodies"][0]["bbox"]["max"][0] = 29 + with self.assertRaises(ValueError): + SMOKE.validate_replies(output(replies)) + + +if __name__ == "__main__": + unittest.main() diff --git a/packaging/tests/windows_icon_test.py b/packaging/tests/windows_icon_test.py new file mode 100644 index 00000000..9a1a8c96 --- /dev/null +++ b/packaging/tests/windows_icon_test.py @@ -0,0 +1,82 @@ +"""PE icon guard: Windows PE32/PE32+ resources, and missing/broken groups.""" +import pathlib +import shutil +import struct +import subprocess +import tempfile +import unittest + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "windows/check-icon.ps1" + + +def executable(pe64=True, icon=True, broken=False): + data = bytearray(1024) + def u16(at, value): struct.pack_into("", + "embotech ", + "Emil Ernerfeldt ", +] +build = false +include = [ + "LICENSE-APACHE", + "LICENSE-MIT", + "**/*.rs", + "**/*.wgsl", + "Cargo.toml", +] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Bindings for using egui natively using the wgpu library" +homepage = "https://github.com/emilk/egui/tree/main/crates/egui-wgpu" +keywords = [ + "wgpu", + "egui", + "gui", + "gamedev", +] +categories = [ + "gui", + "game-development", +] +license = "MIT OR Apache-2.0" +repository = "https://github.com/emilk/egui/tree/main/crates/egui-wgpu" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--generate-link-to-definition"] + +[features] +capture = ["dep:egui"] +default = [ + "fragile-send-sync-non-atomic-wasm", + "macos-window-resize-jitter-fix", + "wgpu/default", + "wgpu/webgl", +] +fragile-send-sync-non-atomic-wasm = ["wgpu/fragile-send-sync-non-atomic-wasm"] +macos-window-resize-jitter-fix = ["wgpu/metal"] +wayland = ["winit?/wayland"] +winit = [ + "dep:winit", + "winit/rwh_06", + "dep:egui", + "capture", +] +x11 = ["winit?/x11"] + +[lib] +name = "egui_wgpu" +path = "src/lib.rs" + +[dependencies.ahash] +version = "0.8.12" +features = [ + "no-rng", + "std", +] +default-features = false + +[dependencies.bytemuck] +version = "1.25" + +[dependencies.document-features] +version = "0.2.12" + +[dependencies.egui] +version = "0.36.2" +optional = true +default-features = false + +[dependencies.epaint] +version = "0.36.2" +features = ["bytemuck"] +default-features = false + +[dependencies.log] +version = "0.4.33" +features = ["std"] + +[dependencies.profiling] +version = "1.0" +default-features = false + +[dependencies.thiserror] +version = "2.0" + +[dependencies.type-map] +version = "0.5.1" + +[dependencies.web-time] +version = "1.1" + +[dependencies.wgpu] +version = "30.0" +features = [ + "std", + "wgsl", +] +default-features = false + +[dependencies.winit] +version = "0.30.13" +optional = true +default-features = false + +[lints.clippy] +allow_attributes = "warn" +as_ptr_cast_mut = "warn" +assigning_clones = "allow" +bool_to_int_with_if = "warn" +branches_sharing_code = "warn" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +checked_conversions = "warn" +clear_with_drain = "warn" +clone_on_ref_ptr = "warn" +cloned_instead_of_copied = "warn" +coerce_container_to_any = "warn" +comparison_chain = "allow" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +decimal_bitwise_operands = "warn" +default_union_representation = "warn" +derive_partial_eq_without_eq = "warn" +disallowed_script_idents = "warn" +doc_broken_link = "warn" +doc_comment_double_space_linebreaks = "warn" +doc_include_without_cfg = "warn" +doc_link_with_quotes = "warn" +doc_markdown = "warn" +duration_suboptimal_units = "warn" +elidable_lifetime_names = "warn" +empty_enum_variants_with_brackets = "warn" +empty_enums = "warn" +enum_glob_use = "warn" +equatable_if_let = "warn" +exit = "warn" +expl_impl_clone_on_copy = "warn" +explicit_deref_methods = "warn" +explicit_into_iter_loop = "warn" +explicit_iter_loop = "warn" +fallible_impl_from = "warn" +filter_map_next = "warn" +flat_map_option = "warn" +float_cmp_const = "warn" +fn_params_excessive_bools = "warn" +fn_to_numeric_cast_any = "warn" +format_push_string = "warn" +from_iter_instead_of_collect = "warn" +get_unwrap = "warn" +ignore_without_reason = "warn" +ignored_unit_patterns = "warn" +implicit_clone = "warn" +imprecise_flops = "warn" +inconsistent_struct_constructor = "warn" +index_refutable_slice = "warn" +inefficient_to_string = "warn" +infinite_loop = "warn" +into_iter_without_iter = "warn" +invalid_upcast_comparisons = "warn" +ip_constant = "warn" +iter_filter_is_ok = "warn" +iter_filter_is_some = "warn" +iter_not_returning_iterator = "warn" +iter_on_empty_collections = "warn" +iter_on_single_items = "warn" +iter_over_hash_type = "warn" +iter_without_into_iter = "warn" +large_digit_groups = "warn" +large_futures = "warn" +large_include_file = "warn" +large_stack_arrays = "warn" +large_stack_frames = "warn" +large_types_passed_by_value = "warn" +let_underscore_must_use = "allow" +let_underscore_untyped = "allow" +linkedlist = "warn" +literal_string_with_formatting_args = "warn" +lossy_float_literal = "warn" +macro_use_imports = "warn" +manual_assert = "warn" +manual_ilog2 = "warn" +manual_instant_elapsed = "warn" +manual_is_power_of_two = "warn" +manual_is_variant_and = "warn" +manual_let_else = "warn" +manual_midpoint = "warn" +manual_range_contains = "allow" +manual_string_new = "warn" +map_err_ignore = "warn" +map_unwrap_or = "allow" +match_bool = "warn" +match_same_arms = "warn" +match_wild_err_arm = "warn" +match_wildcard_for_single_variants = "warn" +mem_forget = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_errors_doc = "warn" +missing_fields_in_debug = "warn" +mut_mut = "warn" +mutex_integer = "warn" +needless_continue = "warn" +needless_for_each = "warn" +needless_pass_by_ref_mut = "warn" +needless_pass_by_value = "warn" +needless_raw_string_hashes = "warn" +needless_type_cast = "warn" +negative_feature_names = "warn" +non_std_lazy_statics = "warn" +non_zero_suggestions = "warn" +nonstandard_macro_braces = "warn" +option_as_ref_cloned = "warn" +option_option = "warn" +or_fun_call = "warn" +path_buf_push_overwrite = "warn" +pathbuf_init_then_push = "warn" +precedence_bits = "warn" +print_stderr = "warn" +print_stdout = "warn" +ptr_as_ptr = "warn" +ptr_cast_constness = "warn" +pub_underscore_fields = "warn" +pub_without_shorthand = "warn" +rc_mutex = "warn" +redundant_type_annotations = "warn" +ref_as_ptr = "warn" +ref_option = "warn" +ref_option_ref = "warn" +ref_patterns = "warn" +rest_pat_in_fully_bound_structs = "warn" +return_and_then = "warn" +same_functions_in_if_condition = "warn" +same_length_and_capacity = "warn" +self_named_module_files = "allow" +self_only_used_in_recursion = "warn" +semicolon_if_nothing_returned = "warn" +set_contains_or_insert = "warn" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" +single_char_pattern = "warn" +single_match_else = "warn" +single_option_map = "warn" +std_instead_of_core = "warn" +str_split_at_newline = "warn" +str_to_string = "warn" +string_add = "warn" +string_add_assign = "warn" +string_lit_as_bytes = "warn" +string_lit_chars_any = "warn" +suspicious_xor_used_as_pow = "warn" +todo = "warn" +too_long_first_doc_paragraph = "warn" +too_many_lines = "allow" +trailing_empty_array = "warn" +trait_duplication_in_bounds = "warn" +transmute_ptr_to_ptr = "warn" +tuple_array_conversions = "warn" +unchecked_time_subtraction = "warn" +undocumented_unsafe_blocks = "warn" +unimplemented = "warn" +uninhabited_references = "warn" +uninlined_format_args = "warn" +unnecessary_box_returns = "warn" +unnecessary_debug_formatting = "warn" +unnecessary_literal_bound = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unnecessary_self_imports = "warn" +unnecessary_semicolon = "warn" +unnecessary_struct_initialization = "warn" +unnecessary_trailing_comma = "warn" +unnecessary_wraps = "warn" +unnested_or_patterns = "warn" +unused_async = "warn" +unused_peekable = "warn" +unused_rounding = "warn" +unused_self = "warn" +unused_trait_names = "warn" +unwrap_used = "warn" +use_self = "warn" +useless_let_if_seq = "warn" +verbose_file_reads = "warn" +wildcard_dependencies = "warn" +wildcard_imports = "allow" +zero_sized_map_values = "warn" + +[lints.clippy.all] +level = "warn" +priority = -1 + +[lints.rust] +trivial_casts = "allow" +trivial_numeric_casts = "warn" +unsafe_code = "deny" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_qualifications = "allow" + +[lints.rust.future_incompatible] +level = "warn" +priority = -1 + +[lints.rust.nonstandard_style] +level = "warn" +priority = -1 + +[lints.rust.rust_2018_idioms] +level = "warn" +priority = -1 + +[lints.rustdoc] +all = "warn" diff --git a/vendor/egui-wgpu/Cargo.toml.orig b/vendor/egui-wgpu/Cargo.toml.orig new file mode 100644 index 00000000..59090518 --- /dev/null +++ b/vendor/egui-wgpu/Cargo.toml.orig @@ -0,0 +1,75 @@ +[package] +name = "egui-wgpu" +version.workspace = true +description = "Bindings for using egui natively using the wgpu library" +authors = [ + "Nils Hasenbanck ", + "embotech ", + "Emil Ernerfeldt ", +] +edition.workspace = true +rust-version.workspace = true +homepage = "https://github.com/emilk/egui/tree/main/crates/egui-wgpu" +license.workspace = true +readme = "README.md" +repository = "https://github.com/emilk/egui/tree/main/crates/egui-wgpu" +categories = ["gui", "game-development"] +keywords = ["wgpu", "egui", "gui", "gamedev"] +include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "**/*.wgsl", "Cargo.toml"] + +[lints] +workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--generate-link-to-definition"] + +[features] +default = [ + "fragile-send-sync-non-atomic-wasm", + "macos-window-resize-jitter-fix", + "wgpu/default", + "wgpu/webgl", # A very important fallback for web support +] + +## Enables the `capture` module for capturing screenshots. +capture = ["dep:egui"] + +## Enable [`winit`](https://docs.rs/winit) integration. On Linux, requires either `wayland` or `x11` +winit = ["dep:winit", "winit/rwh_06", "dep:egui", "capture"] + +## Enables Wayland support for winit. +wayland = ["winit?/wayland"] + +## Enables x11 support for winit. +x11 = ["winit?/x11"] + +## Make the renderer `Sync` on wasm, exploiting that by default wasm isn't multithreaded. +## It may make code easier, especially when targeting both native and web. +## On native most wgpu objects are send and sync, on the web they are not (by nature of the WebGPU specification). +## This is not supported in [multithreaded WASM](https://gpuweb.github.io/gpuweb/explainer/#multithreading-transfer). +## Thus that usage is guarded against with compiler errors in wgpu. +fragile-send-sync-non-atomic-wasm = ["wgpu/fragile-send-sync-non-atomic-wasm"] + +## Enables the macOS live-resize jitter fix, which uses `present_with_transaction`. +## This requires the `wgpu/metal` backend. Disable this feature if you want to use egui-wgpu +## with a different backend (e.g. Vulkan or GL) on macOS without pulling in Metal. +macos-window-resize-jitter-fix = ["wgpu/metal"] + +[dependencies] +epaint = { workspace = true, default-features = false, features = ["bytemuck"] } + +ahash.workspace = true +bytemuck.workspace = true +document-features.workspace = true +log.workspace = true +profiling.workspace = true +thiserror.workspace = true +type-map.workspace = true +web-time.workspace = true +wgpu = { workspace = true, features = ["wgsl"] } + +# Optional dependencies: + +egui = { workspace = true, optional = true, default-features = false } +winit = { workspace = true, optional = true, default-features = false } diff --git a/vendor/egui-wgpu/LICENSE-APACHE b/vendor/egui-wgpu/LICENSE-APACHE new file mode 100644 index 00000000..11069edd --- /dev/null +++ b/vendor/egui-wgpu/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/vendor/egui-wgpu/LICENSE-MIT b/vendor/egui-wgpu/LICENSE-MIT new file mode 100644 index 00000000..673ea5f0 --- /dev/null +++ b/vendor/egui-wgpu/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2018-2021 Emil Ernerfeldt + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/vendor/egui-wgpu/src/capture.rs b/vendor/egui-wgpu/src/capture.rs new file mode 100644 index 00000000..54ddeb88 --- /dev/null +++ b/vendor/egui-wgpu/src/capture.rs @@ -0,0 +1,267 @@ +use egui::{UserData, ViewportId}; +use epaint::ColorImage; +use std::sync::{Arc, mpsc}; +use wgpu::{BindGroupLayout, MultisampleState, StoreOp}; + +/// A texture and a buffer for reading the rendered frame back to the cpu. +/// +/// The texture is required since [`wgpu::TextureUsages::COPY_SRC`] is not an allowed +/// flag for the surface texture on all platforms. This means that anytime we want to +/// capture the frame, we first render it to this texture, and then we can copy it to +/// both the surface texture (via a render pass) and the buffer (via a texture to buffer copy), +/// from where we can pull it back +/// to the cpu. +pub struct CaptureState { + padding: BufferPadding, + pub texture: wgpu::Texture, + pipeline: wgpu::RenderPipeline, + bind_group: wgpu::BindGroup, +} + +pub type CaptureReceiver = mpsc::Receiver<(ViewportId, Vec, ColorImage)>; +pub type CaptureSender = mpsc::Sender<(ViewportId, Vec, ColorImage)>; +pub use mpsc::channel as capture_channel; + +impl CaptureState { + pub fn new(device: &wgpu::Device, surface_texture: &wgpu::Texture) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("texture_copy.wgsl")); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("texture_copy"), + layout: None, + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(surface_texture.format().into())], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + + let bind_group_layout = pipeline.get_bind_group_layout(0); + + let (texture, padding, bind_group) = + Self::create_texture(device, surface_texture, &bind_group_layout); + + Self { + padding, + texture, + pipeline, + bind_group, + } + } + + fn create_texture( + device: &wgpu::Device, + surface_texture: &wgpu::Texture, + layout: &BindGroupLayout, + ) -> (wgpu::Texture, BufferPadding, wgpu::BindGroup) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("egui_screen_capture_texture"), + size: surface_texture.size(), + mip_level_count: surface_texture.mip_level_count(), + sample_count: surface_texture.sample_count(), + dimension: surface_texture.dimension(), + format: surface_texture.format(), + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + let padding = BufferPadding::new(surface_texture.width()); + + let view = texture.create_view(&Default::default()); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }], + label: None, + }); + + (texture, padding, bind_group) + } + + /// Updates the [`CaptureState`] if the size of the surface texture has changed + pub fn update(&mut self, device: &wgpu::Device, texture: &wgpu::Texture) { + if self.texture.size() != texture.size() { + let (new_texture, padding, bind_group) = + Self::create_texture(device, texture, &self.pipeline.get_bind_group_layout(0)); + self.texture = new_texture; + self.padding = padding; + self.bind_group = bind_group; + } + } + + /// Handles copying from the [`CaptureState`] texture to the surface texture and the buffer. + /// Pass the returned buffer to [`CaptureState::read_screen_rgba`] to read the data back to the cpu. + pub fn copy_textures( + &mut self, + device: &wgpu::Device, + output_frame: &wgpu::SurfaceTexture, + encoder: &mut wgpu::CommandEncoder, + ) -> wgpu::Buffer { + debug_assert_eq!( + self.texture.size(), + output_frame.texture.size(), + "Texture sizes must match, `CaptureState::update` was probably not called" + ); + + // It would be more efficient to reuse the Buffer, e.g. via some kind of ring buffer, but + // for most screenshot use cases this should be fine. When taking many screenshots (e.g. for a video) + // it might make sense to revisit this and implement a more efficient solution. + #[allow(clippy::allow_attributes, clippy::arc_with_non_send_sync)] // For wasm + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("egui_screen_capture_buffer"), + size: (self.padding.padded_bytes_per_row * self.texture.height()) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let padding = self.padding; + let tex = &mut self.texture; + + let tex_extent = tex.size(); + + encoder.copy_texture_to_buffer( + tex.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padding.padded_bytes_per_row), + rows_per_image: None, + }, + }, + tex_extent, + ); + + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("texture_copy"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output_frame.texture.create_view(&Default::default()), + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.draw(0..3, 0..1); + + buffer + } + + /// Handles copying from the [`CaptureState`] texture to the surface texture and the cpu + /// This function is non-blocking and will send the data to the given sender when it's ready. + /// Pass in the buffer returned from [`CaptureState::copy_textures`]. + /// Make sure to call this after the encoder has been submitted. + pub fn read_screen_rgba( + &self, + ctx: egui::Context, + buffer: wgpu::Buffer, + data: Vec, + tx: CaptureSender, + viewport_id: ViewportId, + ) { + #[allow(clippy::allow_attributes, clippy::arc_with_non_send_sync)] // For wasm + let buffer = Arc::new(buffer); + let buffer_clone = Arc::clone(&buffer); + let buffer_slice = buffer_clone.slice(..); + let format = self.texture.format(); + let tex_extent = self.texture.size(); + let padding = self.padding; + let to_rgba = match format { + wgpu::TextureFormat::Rgba8Unorm => [0, 1, 2, 3], + wgpu::TextureFormat::Bgra8Unorm => [2, 1, 0, 3], + _ => { + log::error!( + "Screen can't be captured unless the surface format is Rgba8Unorm or Bgra8Unorm. Current surface format is {format:?}" + ); + return; + } + }; + buffer_slice.map_async(wgpu::MapMode::Read, move |result| { + if let Err(err) = result { + log::error!("Failed to map buffer for reading: {err}"); + return; + } + let buffer_slice = buffer.slice(..); + + let mut pixels = Vec::with_capacity((tex_extent.width * tex_extent.height) as usize); + let mapped_range = match buffer_slice.get_mapped_range() { + Ok(range) => range, + Err(err) => { + log::error!("Failed to get mapped range for reading: {err}"); + return; + } + }; + for padded_row in mapped_range.chunks(padding.padded_bytes_per_row as usize) { + let row = &padded_row[..padding.unpadded_bytes_per_row as usize]; + for color in row.chunks(4) { + pixels.push(epaint::Color32::from_rgba_premultiplied( + color[to_rgba[0]], + color[to_rgba[1]], + color[to_rgba[2]], + color[to_rgba[3]], + )); + } + } + drop(mapped_range); + buffer.unmap(); + + tx.send(( + viewport_id, + data, + ColorImage::new( + [tex_extent.width as usize, tex_extent.height as usize], + pixels, + ), + )) + .ok(); + ctx.request_repaint(); + }); + } +} + +#[derive(Copy, Clone)] +struct BufferPadding { + unpadded_bytes_per_row: u32, + padded_bytes_per_row: u32, +} + +impl BufferPadding { + fn new(width: u32) -> Self { + let bytes_per_pixel = core::mem::size_of::() as u32; + let unpadded_bytes_per_row = width * bytes_per_pixel; + let padded_bytes_per_row = + wgpu::util::align_to(unpadded_bytes_per_row, wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + Self { + unpadded_bytes_per_row, + padded_bytes_per_row, + } + } +} diff --git a/vendor/egui-wgpu/src/egui.wgsl b/vendor/egui-wgpu/src/egui.wgsl new file mode 100644 index 00000000..39210841 --- /dev/null +++ b/vendor/egui-wgpu/src/egui.wgsl @@ -0,0 +1,162 @@ +// Vertex shader bindings + +struct VertexOutput { + @location(0) tex_coord: vec2, + @location(1) color: vec4, // gamma 0-1 + @builtin(position) position: vec4, +}; + +struct Locals { + screen_size: vec2, + + /// 1 if dithering is enabled, 0 otherwise + dithering: u32, + + /// 1 to do manual filtering for more predictable kittest snapshot images. + /// See also https://github.com/emilk/egui/issues/5295 + predictable_texture_filtering: u32, +}; +@group(0) @binding(0) var r_locals: Locals; + + +// ----------------------------------------------- +// Adapted from +// https://www.shadertoy.com/view/llVGzG +// Originally presented in: +// Jimenez 2014, "Next Generation Post-Processing in Call of Duty" +// +// A good overview can be found in +// https://blog.demofox.org/2022/01/01/interleaved-gradient-noise-a-different-kind-of-low-discrepancy-sequence/ +// via https://github.com/rerun-io/rerun/ +fn interleaved_gradient_noise(n: vec2) -> f32 { + let f = 0.06711056 * n.x + 0.00583715 * n.y; + return fract(52.9829189 * fract(f)); +} + +fn dither_interleaved(rgb: vec3, levels: f32, frag_coord: vec4) -> vec3 { + var noise = interleaved_gradient_noise(frag_coord.xy); + // scale down the noise slightly to ensure flat colors aren't getting dithered + noise = (noise - 0.5) * 0.95; + return rgb + noise / (levels - 1.0); +} + +// 0-1 linear from 0-1 sRGB gamma +fn linear_from_gamma_rgb(srgb: vec3) -> vec3 { + let cutoff = srgb < vec3(0.04045); + let lower = srgb / vec3(12.92); + let higher = pow((srgb + vec3(0.055)) / vec3(1.055), vec3(2.4)); + return select(higher, lower, cutoff); +} + +// 0-1 sRGB gamma from 0-1 linear +fn gamma_from_linear_rgb(rgb: vec3) -> vec3 { + let cutoff = rgb < vec3(0.0031308); + let lower = rgb * vec3(12.92); + let higher = vec3(1.055) * pow(rgb, vec3(1.0 / 2.4)) - vec3(0.055); + return select(higher, lower, cutoff); +} + +// 0-1 sRGBA gamma from 0-1 linear +fn gamma_from_linear_rgba(linear_rgba: vec4) -> vec4 { + return vec4(gamma_from_linear_rgb(linear_rgba.rgb), linear_rgba.a); +} + +// [u8; 4] SRGB as u32 -> [r, g, b, a] in 0.-1 +fn unpack_color(color: u32) -> vec4 { + return vec4( + f32(color & 255u), + f32((color >> 8u) & 255u), + f32((color >> 16u) & 255u), + f32((color >> 24u) & 255u), + ) / 255.0; +} + +fn position_from_screen(screen_pos: vec2) -> vec4 { + return vec4( + 2.0 * screen_pos.x / r_locals.screen_size.x - 1.0, + 1.0 - 2.0 * screen_pos.y / r_locals.screen_size.y, + 0.0, + 1.0, + ); +} + +@vertex +fn vs_main( + @location(0) a_pos: vec2, + @location(1) a_tex_coord: vec2, + @location(2) a_color: u32, +) -> VertexOutput { + var out: VertexOutput; + out.tex_coord = a_tex_coord; + out.color = unpack_color(a_color); + out.position = position_from_screen(a_pos); + return out; +} + +// Fragment shader bindings + +@group(1) @binding(0) var r_tex_color: texture_2d; +@group(1) @binding(1) var r_tex_sampler: sampler; + +fn sample_texture(in: VertexOutput) -> vec4 { + if r_locals.predictable_texture_filtering == 0 { + // Hardware filtering: fast, but varies across GPUs and drivers. + return textureSample(r_tex_color, r_tex_sampler, in.tex_coord); + } else { + // Manual bilinear filtering with four taps at pixel centers using textureLoad + let texture_size = vec2(textureDimensions(r_tex_color, 0)); + let texture_size_f = vec2(texture_size); + let pixel_coord = in.tex_coord * texture_size_f - 0.5; + let pixel_fract = fract(pixel_coord); + let pixel_floor = vec2(floor(pixel_coord)); + + // Manual texture clamping + let max_coord = texture_size - vec2(1, 1); + let p00 = clamp(pixel_floor + vec2(0, 0), vec2(0, 0), max_coord); + let p10 = clamp(pixel_floor + vec2(1, 0), vec2(0, 0), max_coord); + let p01 = clamp(pixel_floor + vec2(0, 1), vec2(0, 0), max_coord); + let p11 = clamp(pixel_floor + vec2(1, 1), vec2(0, 0), max_coord); + + // Load at pixel centers + let tl = textureLoad(r_tex_color, p00, 0); + let tr = textureLoad(r_tex_color, p10, 0); + let bl = textureLoad(r_tex_color, p01, 0); + let br = textureLoad(r_tex_color, p11, 0); + + // Manual bilinear interpolation + let top = mix(tl, tr, pixel_fract.x); + let bottom = mix(bl, br, pixel_fract.x); + return mix(top, bottom, pixel_fract.y); + } +} + +@fragment +fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4 { + // We expect "normal" textures that are NOT sRGB-aware. + let tex_gamma = sample_texture(in); + var out_color_gamma = in.color * tex_gamma; + // Dither the float color down to eight bits to reduce banding. + // This step is optional for egui backends. + // Note that dithering is performed on the gamma encoded values, + // because this function is used together with a srgb converting target. + if r_locals.dithering == 1 { + let out_color_gamma_rgb = dither_interleaved(out_color_gamma.rgb, 256.0, in.position); + out_color_gamma = vec4(out_color_gamma_rgb, out_color_gamma.a); + } + let out_color_linear = linear_from_gamma_rgb(out_color_gamma.rgb); + return vec4(out_color_linear, out_color_gamma.a); +} + +@fragment +fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4 { + // We expect "normal" textures that are NOT sRGB-aware. + let tex_gamma = sample_texture(in); + var out_color_gamma = in.color * tex_gamma; + // Dither the float color down to eight bits to reduce banding. + // This step is optional for egui backends. + if r_locals.dithering == 1 { + let out_color_gamma_rgb = dither_interleaved(out_color_gamma.rgb, 256.0, in.position); + out_color_gamma = vec4(out_color_gamma_rgb, out_color_gamma.a); + } + return out_color_gamma; +} diff --git a/vendor/egui-wgpu/src/lib.rs b/vendor/egui-wgpu/src/lib.rs new file mode 100644 index 00000000..08eee42b --- /dev/null +++ b/vendor/egui-wgpu/src/lib.rs @@ -0,0 +1,585 @@ +//! This crates provides bindings between [`egui`](https://github.com/emilk/egui) and [wgpu](https://crates.io/crates/wgpu). +//! +//! If you're targeting WebGL you also need to turn on the +//! `webgl` feature of the `wgpu` crate: +//! +//! ```toml +//! # Enable both WebGL and WebGPU backends on web. +//! wgpu = { version = "*", features = ["webgpu", "webgl"] } +//! ``` +//! +//! You can control whether WebGL or WebGPU will be picked at runtime by configuring +//! [`WgpuConfiguration::wgpu_setup`]. +//! The default is to prefer WebGPU and fall back on WebGL. +//! +//! ## Feature flags +#![doc = document_features::document_features!()] +//! + +pub use wgpu; + +/// Low-level painting of [`egui`](https://github.com/emilk/egui) on [`wgpu`]. +mod renderer; + +mod setup; + +pub use renderer::*; +pub use setup::{ + EguiDisplayHandle, NativeAdapterSelectorMethod, WgpuSetup, WgpuSetupCreateNew, + WgpuSetupExisting, +}; + +/// Helpers for capturing screenshots of the UI. +#[cfg(feature = "capture")] +pub mod capture; + +/// Module for painting [`egui`](https://github.com/emilk/egui) with [`wgpu`] on [`winit`]. +#[cfg(feature = "winit")] +pub mod winit; + +use std::sync::Arc; + +use epaint::mutex::RwLock; + +/// An error produced by egui-wgpu. +#[derive(thiserror::Error, Debug)] +pub enum WgpuError { + #[error(transparent)] + RequestAdapterError(#[from] wgpu::RequestAdapterError), + + #[error("Adapter selection failed: {0}")] + CustomNativeAdapterSelectionError(String), + + #[error("There was no valid format for the surface at all.")] + NoSurfaceFormatsAvailable, + + #[error(transparent)] + RequestDeviceError(#[from] wgpu::RequestDeviceError), + + #[error(transparent)] + CreateSurfaceError(#[from] wgpu::CreateSurfaceError), + + #[cfg(feature = "winit")] + #[error(transparent)] + HandleError(#[from] ::winit::raw_window_handle::HandleError), +} + +/// Runtime-mutable subset of [`WgpuConfiguration`]. +/// +/// Edit any field to have the surface reconfigured on the next paint. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SurfaceConfig { + /// Present mode used for the primary surface. + pub present_mode: wgpu::PresentMode, + + /// Desired maximum number of frames that the presentation engine should queue in advance. + /// + /// Use `1` for low-latency, and `2` for high-throughput. + /// + /// See [`wgpu::SurfaceConfiguration::desired_maximum_frame_latency`] for details. + /// + /// `None` => Let `wgpu` pick a default (currently `2`). + pub desired_maximum_frame_latency: Option, +} + +impl SurfaceConfig { + /// Good default for GUIs with very little (or no) extra GPU work. + pub const LOW_LATENCY: Self = Self { + present_mode: wgpu::PresentMode::AutoVsync, + + desired_maximum_frame_latency: if cfg!(target_os = "ios") { + None // The default is good on iOS, while `Some(1)` cuts FPS in half + } else { + Some(1) + }, + }; + + /// Good default for GUIs with a lot of extra GPU work, + /// or that want to prioritize smoothness over latency. + pub const HIGH_THROUGHPUT: Self = Self { + present_mode: wgpu::PresentMode::AutoVsync, + desired_maximum_frame_latency: Some(2), // High-throughput. + }; +} + +/// Access to the render state for egui. +#[derive(Clone)] +pub struct RenderState { + /// Wgpu adapter used for rendering. + pub adapter: wgpu::Adapter, + + /// All the available adapters. + /// + /// This is not available on web. + /// On web, we always select WebGPU is available, then fall back to WebGL if not. + #[cfg(not(target_arch = "wasm32"))] + pub available_adapters: Vec, + + /// Wgpu instance used for creating surfaces and adapters. + pub instance: wgpu::Instance, + + /// Wgpu device used for rendering, created from the adapter. + pub device: wgpu::Device, + + /// Wgpu queue used for rendering, created from the adapter. + pub queue: wgpu::Queue, + + /// The target texture format used for presenting to the window. + pub target_format: wgpu::TextureFormat, + + /// Effective sample count after backend compatibility handling. + /// Custom paint callbacks must use this, rather than the requested setting. + pub msaa_samples: u32, + + /// Egui renderer responsible for drawing the UI. + pub renderer: Arc>, + + /// Runtime-mutable subset of the wgpu configuration. + /// + /// Update this to have the surface reconfigured on the next paint. + pub surface_config: SurfaceConfig, +} + +async fn request_adapter( + instance: &wgpu::Instance, + power_preference: wgpu::PowerPreference, + compatible_surface: Option<&wgpu::Surface<'_>>, + available_adapters: &[wgpu::Adapter], +) -> Result { + profiling::function_scope!(); + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference, + compatible_surface, + // We don't expose this as an option right now since it's fairly rarely useful: + // * only has an effect on native + // * fails if there's no software rasterizer available + // * can achieve the same with `native_adapter_selector` + force_fallback_adapter: false, + apply_limit_buckets: false, + }) + .await + .inspect_err(|_err| { + if cfg!(target_arch = "wasm32") { + // Nothing to add here + } else if available_adapters.is_empty() { + if std::env::var("DYLD_LIBRARY_PATH").is_ok() { + // DYLD_LIBRARY_PATH can sometimes lead to loading dylibs that cause + // us to find zero adapters. Very strange. + // I don't want to debug this again. + // See https://github.com/rerun-io/rerun/issues/11351 for more + log::warn!( + "No wgpu adapter found. This could be because DYLD_LIBRARY_PATH causes dylibs to be loaded that interfere with Metal device creation. Try restarting with DYLD_LIBRARY_PATH=''" + ); + } else { + log::info!("No wgpu adapter found"); + } + } else if available_adapters.len() == 1 { + log::info!( + "The only available wgpu adapter was not suitable: {}", + adapter_info_summary(&available_adapters[0].get_info()) + ); + } else { + log::info!( + "No suitable wgpu adapter found out of the {} available ones: {}", + available_adapters.len(), + describe_adapters(available_adapters) + ); + } + })?; + + if 1 < available_adapters.len() { + log::info!( + "There are {} available wgpu adapters: {}", + available_adapters.len(), + describe_adapters(available_adapters) + ); + } + + Ok(adapter) +} + +impl RenderState { + /// Creates a new [`RenderState`], containing everything needed for drawing egui with wgpu. + /// + /// # Errors + /// Wgpu initialization may fail due to incompatible hardware or driver for a given config. + pub async fn create( + config: &WgpuConfiguration, + instance: &wgpu::Instance, + compatible_surface: Option<&wgpu::Surface<'static>>, + options: RendererOptions, + ) -> Result { + profiling::scope!("RenderState::create"); // async yield give bad names using `profile_function` + + // This is always an empty list on web. + #[cfg(not(target_arch = "wasm32"))] + let available_adapters = { + let backends = if let WgpuSetup::CreateNew(create_new) = &config.wgpu_setup { + create_new.instance_descriptor.backends + } else { + wgpu::Backends::all() + }; + + instance.enumerate_adapters(backends).await + }; + + let (instance, adapter, device, queue) = match config.wgpu_setup.clone() { + WgpuSetup::CreateNew(WgpuSetupCreateNew { + instance_descriptor: _, + display_handle: _, + power_preference, + native_adapter_selector: _native_adapter_selector, + device_descriptor, + }) => { + let adapter = { + #[cfg(target_arch = "wasm32")] + { + request_adapter(instance, power_preference, compatible_surface, &[]).await + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(native_adapter_selector) = _native_adapter_selector { + native_adapter_selector(&available_adapters, compatible_surface) + .map_err(WgpuError::CustomNativeAdapterSelectionError) + } else { + request_adapter( + instance, + power_preference, + compatible_surface, + &available_adapters, + ) + .await + } + }?; + + let (device, queue) = { + profiling::scope!("request_device"); + adapter + .request_device(&(*device_descriptor)(&adapter)) + .await? + }; + + (instance.clone(), adapter, device, queue) + } + WgpuSetup::Existing(WgpuSetupExisting { + instance, + adapter, + device, + queue, + }) => (instance, adapter, device, queue), + }; + + log_adapter_info(&adapter.get_info()); + + let surface_formats = { + profiling::scope!("get_capabilities"); + compatible_surface.map_or_else( + || vec![wgpu::TextureFormat::Rgba8Unorm], + |s| s.get_capabilities(&adapter).formats, + ) + }; + let target_format = crate::preferred_framebuffer_format(&surface_formats)?; + + // SolidCraft: the OpenGL resolve path can present an entirely black frame with MSAA + // (notably Mesa/llvmpipe). Decide after selecting the real surface adapter, + // before creating either pipelines or attachments; no second GPU probe. + let options = RendererOptions { + msaa_samples: effective_msaa_samples(adapter.get_info().backend, options.msaa_samples), + ..options + }; + let renderer = Renderer::new(&device, target_format, options); + + // On wasm, depending on feature flags, wgpu objects may or may not implement sync. + // It doesn't make sense to switch to Rc for that special usecase, so simply disable the lint. + #[allow(clippy::allow_attributes, clippy::arc_with_non_send_sync)] // For wasm + Ok(Self { + instance, + adapter, + #[cfg(not(target_arch = "wasm32"))] + available_adapters, + device, + queue, + target_format, + msaa_samples: options.msaa_samples, + renderer: Arc::new(RwLock::new(renderer)), + surface_config: config.surface, + }) + } +} + +fn describe_adapters(adapters: &[wgpu::Adapter]) -> String { + if adapters.is_empty() { + "(none)".to_owned() + } else if adapters.len() == 1 { + adapter_info_summary(&adapters[0].get_info()) + } else { + adapters + .iter() + .map(|a| format!("{{{}}}", adapter_info_summary(&a.get_info()))) + .collect::>() + .join(", ") + } +} + +/// Specifies which action should be taken as consequence of a surface error. +pub enum SurfaceErrorAction { + /// Do nothing and skip the current frame. + SkipFrame, + + /// Reconfigure the existing surface, then skip the current frame. + /// + /// Calls [`wgpu::Surface::configure`] on the current surface object. + /// Use for [`wgpu::CurrentSurfaceTexture::Outdated`]. + Reconfigure, + + /// Drop the surface, create a new one via [`wgpu::Instance::create_surface`], configure it, + /// then skip the current frame. + /// + /// Use for [`wgpu::CurrentSurfaceTexture::Lost`], where reconfiguring the same surface + /// object cannot recover. + RecreateSurface, +} + +/// Configuration for using wgpu with eframe or the egui-wgpu winit feature. +#[derive(Clone)] +pub struct WgpuConfiguration { + /// Runtime-mutable configuration for the surface (present mode, frame latency). + /// + /// These are the fields exposed via [`RenderState::surface_config`] for live + /// reconfiguration at runtime. + pub surface: SurfaceConfig, + + /// How to create the wgpu adapter & device + pub wgpu_setup: WgpuSetup, + + /// Callback for surface status changes. + /// + /// Called with the [`wgpu::CurrentSurfaceTexture`] result whenever acquiring a frame + /// does not return [`wgpu::CurrentSurfaceTexture::Success`]. For + /// [`wgpu::CurrentSurfaceTexture::Suboptimal`], egui uses the frame as-is and + /// defers surface reconfiguration to the next frame — the callback is not invoked + /// in that case either. + pub on_surface_status: + Arc SurfaceErrorAction + Send + Sync>, +} + +#[test] +fn wgpu_config_impl_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +impl core::fmt::Debug for WgpuConfiguration { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let Self { + surface, + wgpu_setup, + on_surface_status: _, + } = self; + f.debug_struct("WgpuConfiguration") + .field("surface", &surface) + .field("wgpu_setup", &wgpu_setup) + .finish_non_exhaustive() + } +} + +impl WgpuConfiguration { + #[inline] + pub fn with_surface_config(mut self, surface_config: SurfaceConfig) -> Self { + self.surface = surface_config; + self + } +} + +impl Default for WgpuConfiguration { + fn default() -> Self { + Self { + surface: SurfaceConfig::HIGH_THROUGHPUT, + + // No display handle available at this point — callers should replace this with + // `WgpuSetup::from_display_handle(...)` before creating the instance if one is available. + wgpu_setup: WgpuSetup::without_display_handle(), + on_surface_status: Arc::new(|status| match status { + wgpu::CurrentSurfaceTexture::Outdated => { + // The compositor changed the surface (resize, scale, output, …). wgpu + // requires us to reconfigure before the next acquire. Skipping would mean + // we are stuck in `Outdated` forever. + log::trace!("Dropped frame with error: {status:?}"); + SurfaceErrorAction::Reconfigure + } + wgpu::CurrentSurfaceTexture::Lost => { + // The underlying surface is gone and we need a fresh one from the `wgpu::Instance`. + log::debug!("Dropped frame with error: {status:?}"); + SurfaceErrorAction::RecreateSurface + } + wgpu::CurrentSurfaceTexture::Occluded => { + // App is hidden (minimized / behind another window). Skip silently. + log::trace!("Skipping frame due to occlusion."); + SurfaceErrorAction::SkipFrame + } + _ => { + log::warn!("Dropped frame with error: {status:?}"); + SurfaceErrorAction::SkipFrame + } + }), + } + } +} + +/// Find the framebuffer format that egui prefers +/// +/// # Errors +/// Returns [`WgpuError::NoSurfaceFormatsAvailable`] if the given list of formats is empty. +pub fn preferred_framebuffer_format( + formats: &[wgpu::TextureFormat], +) -> Result { + for &format in formats { + if matches!( + format, + wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Bgra8Unorm + ) { + return Ok(format); + } + } + + formats + .first() + .copied() + .ok_or(WgpuError::NoSurfaceFormatsAvailable) +} + +/// Take's epi's depth/stencil bits and returns the corresponding wgpu format. +pub fn depth_format_from_bits(depth_buffer: u8, stencil_buffer: u8) -> Option { + match (depth_buffer, stencil_buffer) { + (0, 8) => Some(wgpu::TextureFormat::Stencil8), + (16, 0) => Some(wgpu::TextureFormat::Depth16Unorm), + (24, 0) => Some(wgpu::TextureFormat::Depth24Plus), + (24, 8) => Some(wgpu::TextureFormat::Depth24PlusStencil8), + (32, 0) => Some(wgpu::TextureFormat::Depth32Float), + (32, 8) => Some(wgpu::TextureFormat::Depth32FloatStencil8), + _ => None, + } +} + +// --------------------------------------------------------------------------- + +fn log_adapter_info(info: &wgpu::AdapterInfo) { + let summary = adapter_info_summary(info); + + let is_test = cfg!(test); // Software rasterizers are expected (and preferred) during testing! + + if info.device_type == wgpu::DeviceType::Cpu && !is_test { + log::warn!("Software rasterizer detected - loss of performance expected. {summary}"); + } else { + log::debug!("wgpu adapter: {summary}"); + } +} + +/// A human-readable summary about an adapter +pub fn adapter_info_summary(info: &wgpu::AdapterInfo) -> String { + let wgpu::AdapterInfo { + name, + vendor, + device, + device_type, + driver, + driver_info, + backend, + device_pci_bus_id, + subgroup_min_size, + subgroup_max_size, + transient_saves_memory, + limit_bucket, + } = &info; + + // Example values: + // > name: "llvmpipe (LLVM 16.0.6, 256 bits)", device_type: Cpu, backend: Vulkan, driver: "llvmpipe", driver_info: "Mesa 23.1.6-arch1.4 (LLVM 16.0.6)" + // > name: "Apple M1 Pro", device_type: IntegratedGpu, backend: Metal, driver: "", driver_info: "" + // > name: "ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)", device_type: IntegratedGpu, backend: Gl, driver: "", driver_info: "" + + use core::fmt::Write as _; + + let mut summary = format!("backend: {backend:?}, device_type: {device_type:?}"); + + if !name.is_empty() { + write!(summary, ", name: {name:?}").ok(); + } + if !driver.is_empty() { + write!(summary, ", driver: {driver:?}").ok(); + } + if !driver_info.is_empty() { + write!(summary, ", driver_info: {driver_info:?}").ok(); + } + if *vendor != 0 { + #[cfg(not(target_arch = "wasm32"))] + { + write!( + summary, + ", vendor: {} (0x{vendor:04X})", + parse_vendor_id(*vendor) + ) + .ok(); + } + #[cfg(target_arch = "wasm32")] + { + write!(summary, ", vendor: 0x{vendor:04X}").ok(); + } + } + if *device != 0 { + write!(summary, ", device: 0x{device:02X}").ok(); + } + if !device_pci_bus_id.is_empty() { + write!(summary, ", pci_bus_id: {device_pci_bus_id:?}").ok(); + } + if *subgroup_min_size != 0 || *subgroup_max_size != 0 { + write!( + summary, + ", subgroup_size: {subgroup_min_size}..={subgroup_max_size}" + ) + .ok(); + } + write!( + summary, + ", transient_saves_memory: {transient_saves_memory:?}" + ) + .ok(); + write!(summary, ", limit_bucket: {limit_bucket:?}").ok(); + + summary +} + +/// Tries to parse the adapter's vendor ID to a human-readable string. +#[cfg(not(target_arch = "wasm32"))] +pub fn parse_vendor_id(vendor_id: u32) -> &'static str { + match vendor_id { + wgpu::hal::auxil::db::amd::VENDOR => "AMD", + wgpu::hal::auxil::db::apple::VENDOR => "Apple", + wgpu::hal::auxil::db::arm::VENDOR => "ARM", + wgpu::hal::auxil::db::broadcom::VENDOR => "Broadcom", + wgpu::hal::auxil::db::imgtec::VENDOR => "Imagination Technologies", + wgpu::hal::auxil::db::intel::VENDOR => "Intel", + wgpu::hal::auxil::db::mesa::VENDOR => "Mesa", + wgpu::hal::auxil::db::nvidia::VENDOR => "NVIDIA", + wgpu::hal::auxil::db::qualcomm::VENDOR => "Qualcomm", + _ => "Unknown", + } +} + +/// Selected-adapter policy shared by egui pipelines, attachments and host callbacks. +pub fn effective_msaa_samples(backend: wgpu::Backend, requested: u32) -> u32 { + if backend == wgpu::Backend::Gl { 1 } else { requested.max(1) } +} + +#[cfg(test)] +mod msaa_tests { + #[test] + fn gl_saved_multisampling_is_clamped_and_native_backends_keep_it() { + for requested in [0, 1, 2, 4, 8] { + assert_eq!(super::effective_msaa_samples(wgpu::Backend::Gl, requested), 1); + for backend in [wgpu::Backend::Vulkan, wgpu::Backend::Dx12, wgpu::Backend::Metal, wgpu::Backend::BrowserWebGpu] { + assert_eq!(super::effective_msaa_samples(backend, requested), requested.max(1)); + } + } + } +} diff --git a/vendor/egui-wgpu/src/renderer.rs b/vendor/egui-wgpu/src/renderer.rs new file mode 100644 index 00000000..de8808de --- /dev/null +++ b/vendor/egui-wgpu/src/renderer.rs @@ -0,0 +1,1178 @@ +use core::{num::NonZeroU64, ops::Range}; +use std::borrow::Cow; + +use ahash::HashMap; +use bytemuck::Zeroable as _; +use epaint::{PaintCallbackInfo, Primitive, Vertex, emath::NumExt as _}; + +use wgpu::util::DeviceExt as _; + +// Only implements Send + Sync on wasm32 in order to allow storing wgpu resources on the type map. +#[cfg(not(all( + target_arch = "wasm32", + not(feature = "fragile-send-sync-non-atomic-wasm"), +)))] +/// You can use this for storage when implementing [`CallbackTrait`]. +pub type CallbackResources = type_map::concurrent::TypeMap; +#[cfg(all( + target_arch = "wasm32", + not(feature = "fragile-send-sync-non-atomic-wasm"), +))] +/// You can use this for storage when implementing [`CallbackTrait`]. +pub type CallbackResources = type_map::TypeMap; + +/// You can use this to do custom [`wgpu`] rendering in an egui app. +/// +/// Implement [`CallbackTrait`] and call [`Callback::new_paint_callback`]. +/// +/// This can be turned into a [`epaint::PaintCallback`] and [`epaint::Shape`]. +pub struct Callback(Box); + +impl Callback { + /// Creates a new [`epaint::PaintCallback`] from a callback trait instance. + pub fn new_paint_callback( + rect: epaint::emath::Rect, + callback: impl CallbackTrait + 'static, + ) -> epaint::PaintCallback { + epaint::PaintCallback { + rect, + callback: std::sync::Arc::new(Self(Box::new(callback))), + } + } +} + +/// A callback trait that can be used to compose an [`epaint::PaintCallback`] via [`Callback`] +/// for custom WGPU rendering. +/// +/// Callbacks in [`Renderer`] are done in three steps: +/// * [`CallbackTrait::prepare`]: called for all registered callbacks before the main egui render pass. +/// * [`CallbackTrait::finish_prepare`]: called for all registered callbacks after all callbacks finished calling prepare. +/// * [`CallbackTrait::paint`]: called for all registered callbacks during the main egui render pass. +/// +/// Each callback has access to an instance of [`CallbackResources`] that is stored in the [`Renderer`]. +/// This can be used to store wgpu resources that need to be accessed during the [`CallbackTrait::paint`] step. +/// +/// The callbacks implementing [`CallbackTrait`] itself must always be Send + Sync, but resources stored in +/// [`Renderer::callback_resources`] are not required to implement Send + Sync when building for wasm. +/// (this is because wgpu stores references to the JS heap in most of its resources which can not be shared with other threads). +/// +/// +/// # Command submission +/// +/// ## Command Encoder +/// +/// The passed-in [`wgpu::CommandEncoder`] is egui's and can be used directly to register +/// wgpu commands for simple use cases. +/// This allows reusing the same [`wgpu::CommandEncoder`] for all callbacks and egui +/// rendering itself. +/// +/// ## Command Buffers +/// +/// For more complicated use cases, one can also return a list of arbitrary +/// [`wgpu::CommandBuffer`]s and have complete control over how they get created and fed. +/// In particular, this gives an opportunity to parallelize command registration and +/// prevents a faulty callback from poisoning the main wgpu pipeline. +/// +/// When using eframe, the main egui command buffer, as well as all user-defined +/// command buffers returned by this function, are guaranteed to all be submitted +/// at once in a single call. +/// +/// Command Buffers returned by [`CallbackTrait::finish_prepare`] will always be issued *after* +/// those returned by [`CallbackTrait::prepare`]. +/// Order within command buffers returned by [`CallbackTrait::prepare`] is dependent +/// on the order the respective [`epaint::Shape::Callback`]s were submitted in. +/// +/// # Example +/// +/// See the [`custom3d_wgpu`](https://github.com/emilk/egui/blob/main/crates/egui_demo_app/src/apps/custom3d_wgpu.rs) demo source for a detailed usage example. +pub trait CallbackTrait: Send + Sync { + fn prepare( + &self, + _device: &wgpu::Device, + _queue: &wgpu::Queue, + _screen_descriptor: &ScreenDescriptor, + _egui_encoder: &mut wgpu::CommandEncoder, + _callback_resources: &mut CallbackResources, + ) -> Vec { + Vec::new() + } + + /// Called after all [`CallbackTrait::prepare`] calls are done. + fn finish_prepare( + &self, + _device: &wgpu::Device, + _queue: &wgpu::Queue, + _egui_encoder: &mut wgpu::CommandEncoder, + _callback_resources: &mut CallbackResources, + ) -> Vec { + Vec::new() + } + + /// Called after all [`CallbackTrait::finish_prepare`] calls are done. + /// + /// It is given access to the [`wgpu::RenderPass`] so that it can issue draw commands + /// into the same [`wgpu::RenderPass`] that is used for all other egui elements. + fn paint( + &self, + info: PaintCallbackInfo, + render_pass: &mut wgpu::RenderPass<'static>, + callback_resources: &CallbackResources, + ); +} + +/// Information about the screen used for rendering. +pub struct ScreenDescriptor { + /// Size of the window in physical pixels. + pub size_in_pixels: [u32; 2], + + /// High-DPI scale factor (pixels per point). + pub pixels_per_point: f32, +} + +impl ScreenDescriptor { + /// size in "logical" points + fn screen_size_in_points(&self) -> [f32; 2] { + [ + self.size_in_pixels[0] as f32 / self.pixels_per_point, + self.size_in_pixels[1] as f32 / self.pixels_per_point, + ] + } +} + +/// Uniform buffer used when rendering. +#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] +#[repr(C)] +struct UniformBuffer { + screen_size_in_points: [f32; 2], + dithering: u32, + + /// 1 to do manual filtering for more predictable kittest snapshot images. + /// + /// See also . + predictable_texture_filtering: u32, +} + +struct SlicedBuffer { + buffer: wgpu::Buffer, + slices: Vec>, + capacity: wgpu::BufferAddress, +} + +pub struct Texture { + /// The texture may be None if the `TextureId` is just a handle to a user-provided bind-group. + pub texture: Option, + + /// Bindgroup for the texture + sampler. + pub bind_group: wgpu::BindGroup, + + /// Options describing the sampler used in the bind group. This may be None if the `TextureId` + /// is just a handle to a user-provided bind-group. + pub options: Option, +} + +/// Ways to configure [`Renderer`] during creation. +#[derive(Clone, Copy, Debug)] +pub struct RendererOptions { + /// Set the level of the multisampling anti-aliasing (MSAA). + /// + /// Must be a power-of-two. Higher = more smooth 3D. + /// + /// A value of `0` or `1` turns it off (default). + /// + /// `egui` already performs anti-aliasing via "feathering" + /// (controlled by [`egui::epaint::TessellationOptions`]), + /// but if you are embedding 3D in egui you may want to turn on multisampling. + pub msaa_samples: u32, + + /// What format to use for the depth and stencil buffers, + /// e.g. [`wgpu::TextureFormat::Depth32FloatStencil8`]. + /// + /// egui doesn't need depth/stencil, so the default value is `None` (no depth or stancil buffers). + pub depth_stencil_format: Option, + + /// Controls whether to apply dithering to minimize banding artifacts. + /// + /// Dithering assumes an sRGB output and thus will apply noise to any input value that lies between + /// two 8bit values after applying the sRGB OETF function, i.e. if it's not a whole 8bit value in "gamma space". + /// This means that only inputs from texture interpolation and vertex colors should be affected in practice. + /// + /// Defaults to true. + pub dithering: bool, + + /// Perform texture filtering in software? + /// + /// This is useful when you want predictable rendering across + /// different hardware, e.g. for kittest snapshots. + /// + /// Default is `false`. + /// + /// See also . + pub predictable_texture_filtering: bool, +} + +impl RendererOptions { + /// Set options that produce the most predicatable output. + /// + /// Useful for image snapshot tests. + pub const PREDICTABLE: Self = Self { + msaa_samples: 1, + depth_stencil_format: None, + dithering: false, + predictable_texture_filtering: true, + }; +} + +impl Default for RendererOptions { + fn default() -> Self { + Self { + msaa_samples: 0, + depth_stencil_format: None, + dithering: true, + predictable_texture_filtering: false, + } + } +} + +/// Renderer for a egui based GUI. +pub struct Renderer { + pipeline: wgpu::RenderPipeline, + + index_buffer: SlicedBuffer, + vertex_buffer: SlicedBuffer, + + uniform_buffer: wgpu::Buffer, + previous_uniform_buffer_content: UniformBuffer, + uniform_bind_group: wgpu::BindGroup, + texture_bind_group_layout: wgpu::BindGroupLayout, + + /// Map of egui texture IDs to textures and their associated bindgroups (texture view + + /// sampler). The texture may be None if the `TextureId` is just a handle to a user-provided + /// sampler. + textures: HashMap, + next_user_texture_id: u64, + samplers: HashMap, + + options: RendererOptions, + + /// Storage for resources shared with all invocations of [`CallbackTrait`]'s methods. + /// + /// See also [`CallbackTrait`]. + pub callback_resources: CallbackResources, +} + +impl Renderer { + /// Creates a renderer for a egui UI. + /// + /// `output_color_format` should preferably be [`wgpu::TextureFormat::Rgba8Unorm`] or + /// [`wgpu::TextureFormat::Bgra8Unorm`], i.e. in gamma-space. + pub fn new( + device: &wgpu::Device, + output_color_format: wgpu::TextureFormat, + options: RendererOptions, + ) -> Self { + profiling::function_scope!(); + + let shader = wgpu::ShaderModuleDescriptor { + label: Some("egui"), + source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("egui.wgsl"))), + }; + let module = { + profiling::scope!("create_shader_module"); + device.create_shader_module(shader) + }; + + let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("egui_uniform_buffer"), + contents: bytemuck::cast_slice(&[UniformBuffer { + screen_size_in_points: [0.0, 0.0], + dithering: u32::from(options.dithering), + predictable_texture_filtering: u32::from(options.predictable_texture_filtering), + }]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + let uniform_bind_group_layout = { + profiling::scope!("create_bind_group_layout"); + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("egui_uniform_bind_group_layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + core::mem::size_of::() as _ + ), + ty: wgpu::BufferBindingType::Uniform, + }, + count: None, + }], + }) + }; + + let uniform_bind_group = { + profiling::scope!("create_bind_group"); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("egui_uniform_bind_group"), + layout: &uniform_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &uniform_buffer, + offset: 0, + size: None, + }), + }], + }) + }; + + let texture_bind_group_layout = { + profiling::scope!("create_bind_group_layout"); + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("egui_texture_bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) + }; + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("egui_pipeline_layout"), + bind_group_layouts: &[ + Some(&uniform_bind_group_layout), + Some(&texture_bind_group_layout), + ], + immediate_size: 0, + }); + + let depth_stencil = options + .depth_stencil_format + .map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }); + + let pipeline = { + profiling::scope!("create_render_pipeline"); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("egui_pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + entry_point: Some("vs_main"), + module: &module, + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: 5 * 4, + step_mode: wgpu::VertexStepMode::Vertex, + // 0: vec2 position + // 1: vec2 texture coordinates + // 2: uint color + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32], + })], + compilation_options: wgpu::PipelineCompilationOptions::default() + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + unclipped_depth: false, + conservative: false, + cull_mode: None, + front_face: wgpu::FrontFace::default(), + polygon_mode: wgpu::PolygonMode::default(), + strip_index_format: None, + }, + depth_stencil, + multisample: wgpu::MultisampleState { + alpha_to_coverage_enabled: false, + count: options.msaa_samples.max(1), + mask: !0, + }, + + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(if output_color_format.is_srgb() { + log::warn!("Detected a linear (sRGBA aware) framebuffer {output_color_format:?}. egui prefers Rgba8Unorm or Bgra8Unorm"); + "fs_main_linear_framebuffer" + } else { + "fs_main_gamma_framebuffer" // this is what we prefer + }), + targets: &[Some(wgpu::ColorTargetState { + format: output_color_format, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::OneMinusDstAlpha, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default() + }), + multiview_mask: None, + cache: None, + } + ) + }; + + const VERTEX_BUFFER_START_CAPACITY: wgpu::BufferAddress = + (core::mem::size_of::() * 1024) as _; + const INDEX_BUFFER_START_CAPACITY: wgpu::BufferAddress = + (core::mem::size_of::() * 1024 * 3) as _; + + Self { + pipeline, + vertex_buffer: SlicedBuffer { + buffer: create_vertex_buffer(device, VERTEX_BUFFER_START_CAPACITY), + slices: Vec::with_capacity(64), + capacity: VERTEX_BUFFER_START_CAPACITY, + }, + index_buffer: SlicedBuffer { + buffer: create_index_buffer(device, INDEX_BUFFER_START_CAPACITY), + slices: Vec::with_capacity(64), + capacity: INDEX_BUFFER_START_CAPACITY, + }, + uniform_buffer, + // Buffers on wgpu are zero initialized, so this is indeed its current state! + previous_uniform_buffer_content: UniformBuffer::zeroed(), + uniform_bind_group, + texture_bind_group_layout, + textures: HashMap::default(), + next_user_texture_id: 0, + samplers: HashMap::default(), + options, + callback_resources: CallbackResources::default(), + } + } + + /// Executes the egui renderer onto an existing wgpu renderpass. + /// + /// Note that the lifetime of `render_pass` is `'static` which requires a call to [`wgpu::RenderPass::forget_lifetime`]. + /// This allows users to pass resources that live outside of the callback resources to the render pass. + /// The render pass internally keeps all referenced resources alive as long as necessary. + /// The only consequence of `forget_lifetime` is that any operation on the parent encoder will cause a runtime error + /// instead of a compile time error. + /// + /// # Panic + /// Always ensure that [`Renderer::update_buffers`] has been called otherwise calling [`Renderer::render`] will panic! + pub fn render( + &self, + render_pass: &mut wgpu::RenderPass<'static>, + paint_jobs: &[epaint::ClippedPrimitive], + screen_descriptor: &ScreenDescriptor, + ) { + profiling::function_scope!(); + + let pixels_per_point = screen_descriptor.pixels_per_point; + let size_in_pixels = screen_descriptor.size_in_pixels; + + // Whether or not we need to reset the render pass because a paint callback has just + // run. + let mut needs_reset = true; + + let mut index_buffer_slices = self.index_buffer.slices.iter(); + let mut vertex_buffer_slices = self.vertex_buffer.slices.iter(); + + for epaint::ClippedPrimitive { + clip_rect, + primitive, + } in paint_jobs + { + if needs_reset { + render_pass.set_viewport( + 0.0, + 0.0, + size_in_pixels[0] as f32, + size_in_pixels[1] as f32, + 0.0, + 1.0, + ); + render_pass.set_pipeline(&self.pipeline); + render_pass.set_bind_group(0, &self.uniform_bind_group, &[]); + needs_reset = false; + } + + { + let rect = ScissorRect::new(clip_rect, pixels_per_point, size_in_pixels); + + if rect.width == 0 || rect.height == 0 { + // Skip rendering zero-sized clip areas. + if let Primitive::Mesh(_) = primitive { + // If this is a mesh, we need to advance the index and vertex buffer iterators: + index_buffer_slices + .next() + .expect("You must call .update_buffers() before .render()"); + vertex_buffer_slices + .next() + .expect("You must call .update_buffers() before .render()"); + } + continue; + } + + render_pass.set_scissor_rect(rect.x, rect.y, rect.width, rect.height); + } + + match primitive { + Primitive::Mesh(mesh) => { + let index_buffer_slice = index_buffer_slices + .next() + .expect("You must call .update_buffers() before .render()"); + let vertex_buffer_slice = vertex_buffer_slices + .next() + .expect("You must call .update_buffers() before .render()"); + + if let Some(Texture { bind_group, .. }) = self.textures.get(&mesh.texture_id) { + render_pass.set_bind_group(1, bind_group, &[]); + render_pass.set_index_buffer( + self.index_buffer.buffer.slice( + index_buffer_slice.start as u64..index_buffer_slice.end as u64, + ), + wgpu::IndexFormat::Uint32, + ); + render_pass.set_vertex_buffer( + 0, + self.vertex_buffer.buffer.slice( + vertex_buffer_slice.start as u64..vertex_buffer_slice.end as u64, + ), + ); + render_pass.draw_indexed(0..mesh.indices.len() as u32, 0, 0..1); + } else { + log::warn!("Missing texture: {:?}", mesh.texture_id); + } + } + Primitive::Callback(callback) => { + let Some(cbfn) = callback.callback.downcast_ref::() else { + // We already warned in the `prepare` callback + continue; + }; + + let info = PaintCallbackInfo { + viewport: callback.rect, + clip_rect: *clip_rect, + pixels_per_point, + screen_size_px: size_in_pixels, + }; + + let viewport_px = info.viewport_in_pixels(); + if viewport_px.width_px > 0 && viewport_px.height_px > 0 { + profiling::scope!("callback"); + + needs_reset = true; + + // We're setting a default viewport for the render pass as a + // courtesy for the user, so that they don't have to think about + // it in the simple case where they just want to fill the whole + // paint area. + // + // The user still has the possibility of setting their own custom + // viewport during the paint callback, effectively overriding this + // one. + render_pass.set_viewport( + viewport_px.left_px as f32, + viewport_px.top_px as f32, + viewport_px.width_px as f32, + viewport_px.height_px as f32, + 0.0, + 1.0, + ); + + cbfn.0.paint(info, render_pass, &self.callback_resources); + } + } + } + } + + render_pass.set_scissor_rect(0, 0, size_in_pixels[0], size_in_pixels[1]); + } + + /// Should be called before [`Self::render`]. + pub fn update_texture( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + id: epaint::TextureId, + image_delta: &epaint::ImageDelta, + ) { + profiling::function_scope!(); + + let width = image_delta.image.width() as u32; + let height = image_delta.image.height() as u32; + + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + + let data_color32 = match &image_delta.image { + epaint::ImageData::Color(image) => { + assert_eq!( + width as usize * height as usize, + image.pixels.len(), + "Mismatch between texture size and texel count" + ); + Cow::Borrowed(&image.pixels) + } + }; + let data_bytes: &[u8] = bytemuck::cast_slice(data_color32.as_slice()); + + let queue_write_data_to_texture = |texture, origin| { + profiling::scope!("write_texture"); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin, + aspect: wgpu::TextureAspect::All, + }, + data_bytes, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * width), + rows_per_image: Some(height), + }, + size, + ); + }; + + // Use same label for all resources associated with this texture id (no point in retyping the type) + let label_str = format!("egui_texid_{id:?}"); + let label = Some(label_str.as_str()); + + let (texture, origin, bind_group) = if let Some(pos) = image_delta.pos { + // update the existing texture + let Texture { + texture, + bind_group, + options, + } = self + .textures + .remove(&id) + .expect("Tried to update a texture that has not been allocated yet."); + let texture = texture.expect("Tried to update user texture."); + let options = options.expect("Tried to update user texture."); + let origin = wgpu::Origin3d { + x: pos[0] as u32, + y: pos[1] as u32, + z: 0, + }; + + ( + texture, + origin, + // If the TextureOptions are the same as the previous ones, we can reuse the bind group. Otherwise we + // have to recreate it. + if image_delta.options == options { + Some(bind_group) + } else { + None + }, + ) + } else { + // allocate a new texture + let texture = { + profiling::scope!("create_texture"); + device.create_texture(&wgpu::TextureDescriptor { + label, + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[wgpu::TextureFormat::Rgba8Unorm], + }) + }; + let origin = wgpu::Origin3d::ZERO; + (texture, origin, None) + }; + + let bind_group = bind_group.unwrap_or_else(|| { + let sampler = self + .samplers + .entry(image_delta.options) + .or_insert_with(|| create_sampler(image_delta.options, device)); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label, + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &texture.create_view(&wgpu::TextureViewDescriptor::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) + }); + + queue_write_data_to_texture(&texture, origin); + + // A full update must (re)create the texture at exactly the delta's size, + // or glyph UVs (normalized by the CPU atlas size) will sample the wrong rows. + debug_assert!( + image_delta.pos.is_some() || [texture.width(), texture.height()] == [width, height], + "egui texture {id:?}: GPU texture is {}x{} but full delta is {width}x{height}", + texture.width(), + texture.height(), + ); + + self.textures.insert( + id, + Texture { + texture: Some(texture), + bind_group, + options: Some(image_delta.options), + }, + ); + } + + pub fn free_texture(&mut self, id: &epaint::TextureId) { + if let Some(texture) = self.textures.remove(id).and_then(|t| t.texture) { + texture.destroy(); + } + } + + /// Get the WGPU texture and bind group associated to a texture that has been allocated by egui. + /// + /// This could be used by custom paint hooks to render images that have been added through + /// [`epaint::Context::load_texture`](https://docs.rs/egui/latest/egui/struct.Context.html#method.load_texture). + pub fn texture(&self, id: &epaint::TextureId) -> Option<&Texture> { + self.textures.get(id) + } + + /// Registers a [`wgpu::Texture`] with a [`epaint::TextureId`]. + /// + /// This enables the application to reference the texture inside an image ui element. + /// This effectively enables off-screen rendering inside the egui UI. Texture must have + /// the texture format [`wgpu::TextureFormat::Rgba8Unorm`]. + pub fn register_native_texture( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + texture_filter: wgpu::FilterMode, + ) -> epaint::TextureId { + self.register_native_texture_with_sampler_options( + device, + texture, + wgpu::SamplerDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + mag_filter: texture_filter, + min_filter: texture_filter, + ..Default::default() + }, + ) + } + + /// Registers a [`wgpu::Texture`] with an existing [`epaint::TextureId`]. + /// + /// This enables applications to reuse [`epaint::TextureId`]s. + pub fn update_egui_texture_from_wgpu_texture( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + texture_filter: wgpu::FilterMode, + id: epaint::TextureId, + ) { + self.update_egui_texture_from_wgpu_texture_with_sampler_options( + device, + texture, + wgpu::SamplerDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + mag_filter: texture_filter, + min_filter: texture_filter, + ..Default::default() + }, + id, + ); + } + + /// Registers a [`wgpu::Texture`] with a [`epaint::TextureId`] while also accepting custom + /// [`wgpu::SamplerDescriptor`] options. + /// + /// This allows applications to specify individual minification/magnification filters as well as + /// custom mipmap and tiling options. + /// + /// The texture must have the format [`wgpu::TextureFormat::Rgba8Unorm`]. + /// Any compare function supplied in the [`wgpu::SamplerDescriptor`] will be ignored. + #[expect(clippy::needless_pass_by_value)] // false positive + pub fn register_native_texture_with_sampler_options( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + sampler_descriptor: wgpu::SamplerDescriptor<'_>, + ) -> epaint::TextureId { + profiling::function_scope!(); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + compare: None, + ..sampler_descriptor + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + let id = epaint::TextureId::User(self.next_user_texture_id); + self.textures.insert( + id, + Texture { + texture: None, + bind_group, + options: None, + }, + ); + self.next_user_texture_id += 1; + + id + } + + /// Registers a [`wgpu::Texture`] with an existing [`epaint::TextureId`] while also accepting custom + /// [`wgpu::SamplerDescriptor`] options. + /// + /// This allows applications to reuse [`epaint::TextureId`]s created with custom sampler options. + #[expect(clippy::needless_pass_by_value)] // false positive + pub fn update_egui_texture_from_wgpu_texture_with_sampler_options( + &mut self, + device: &wgpu::Device, + texture: &wgpu::TextureView, + sampler_descriptor: wgpu::SamplerDescriptor<'_>, + id: epaint::TextureId, + ) { + profiling::function_scope!(); + + let Texture { + bind_group: user_texture_binding, + .. + } = self + .textures + .get_mut(&id) + .expect("Tried to update a texture that has not been allocated yet."); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + compare: None, + ..sampler_descriptor + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(format!("egui_user_image_{}", self.next_user_texture_id).as_str()), + layout: &self.texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + *user_texture_binding = bind_group; + } + + /// Uploads the uniform, vertex and index data used by the renderer. + /// Should be called before [`Self::render`]. + /// + /// Returns all user-defined command buffers gathered from [`CallbackTrait::prepare`] & [`CallbackTrait::finish_prepare`] callbacks. + pub fn update_buffers( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + paint_jobs: &[epaint::ClippedPrimitive], + screen_descriptor: &ScreenDescriptor, + ) -> Vec { + profiling::function_scope!(); + + let screen_size_in_points = screen_descriptor.screen_size_in_points(); + + let uniform_buffer_content = UniformBuffer { + screen_size_in_points, + dithering: u32::from(self.options.dithering), + predictable_texture_filtering: u32::from(self.options.predictable_texture_filtering), + }; + if uniform_buffer_content != self.previous_uniform_buffer_content { + profiling::scope!("update uniforms"); + queue.write_buffer( + &self.uniform_buffer, + 0, + bytemuck::cast_slice(&[uniform_buffer_content]), + ); + self.previous_uniform_buffer_content = uniform_buffer_content; + } + + // Determine how many vertices & indices need to be rendered, and gather prepare callbacks + let mut callbacks = Vec::new(); + let (vertex_count, index_count) = { + profiling::scope!("count_vertices_indices"); + paint_jobs.iter().fold((0, 0), |acc, clipped_primitive| { + match &clipped_primitive.primitive { + Primitive::Mesh(mesh) => { + (acc.0 + mesh.vertices.len(), acc.1 + mesh.indices.len()) + } + Primitive::Callback(callback) => { + if let Some(c) = callback.callback.downcast_ref::() { + callbacks.push(c.0.as_ref()); + } else { + log::warn!("Unknown paint callback: expected `egui_wgpu::Callback`"); + } + acc + } + } + }) + }; + + if index_count > 0 { + profiling::scope!("indices", index_count.to_string().as_str()); + + self.index_buffer.slices.clear(); + + let required_index_buffer_size = (core::mem::size_of::() * index_count) as u64; + if self.index_buffer.capacity < required_index_buffer_size { + // Resize index buffer if needed. + self.index_buffer.capacity = + (self.index_buffer.capacity * 2).at_least(required_index_buffer_size); + self.index_buffer.buffer = create_index_buffer(device, self.index_buffer.capacity); + } + + let index_buffer_staging = queue.write_buffer_with( + &self.index_buffer.buffer, + 0, + #[expect(clippy::unwrap_used)] // Checked above + NonZeroU64::new(required_index_buffer_size).unwrap(), + ); + + let Some(mut index_buffer_staging) = index_buffer_staging else { + panic!( + "Failed to create staging buffer for index data. Index count: {index_count}. Required index buffer size: {required_index_buffer_size}. Actual size {} and capacity: {} (bytes)", + self.index_buffer.buffer.size(), + self.index_buffer.capacity + ); + }; + + let mut index_offset = 0; + for epaint::ClippedPrimitive { primitive, .. } in paint_jobs { + match primitive { + Primitive::Mesh(mesh) => { + let size = mesh.indices.len() * core::mem::size_of::(); + let slice = index_offset..(size + index_offset); + index_buffer_staging + .slice(slice.clone()) + .copy_from_slice(bytemuck::cast_slice(&mesh.indices)); + self.index_buffer.slices.push(slice); + index_offset += size; + } + Primitive::Callback(_) => {} + } + } + } + if vertex_count > 0 { + profiling::scope!("vertices", vertex_count.to_string().as_str()); + + self.vertex_buffer.slices.clear(); + + let required_vertex_buffer_size = + (core::mem::size_of::() * vertex_count) as u64; + if self.vertex_buffer.capacity < required_vertex_buffer_size { + // Resize vertex buffer if needed. + self.vertex_buffer.capacity = + (self.vertex_buffer.capacity * 2).at_least(required_vertex_buffer_size); + self.vertex_buffer.buffer = + create_vertex_buffer(device, self.vertex_buffer.capacity); + } + + let vertex_buffer_staging = queue.write_buffer_with( + &self.vertex_buffer.buffer, + 0, + #[expect(clippy::unwrap_used)] // Checked above + NonZeroU64::new(required_vertex_buffer_size).unwrap(), + ); + + let Some(mut vertex_buffer_staging) = vertex_buffer_staging else { + panic!( + "Failed to create staging buffer for vertex data. Vertex count: {vertex_count}. Required vertex buffer size: {required_vertex_buffer_size}. Actual size {} and capacity: {} (bytes)", + self.vertex_buffer.buffer.size(), + self.vertex_buffer.capacity + ); + }; + + let mut vertex_offset = 0; + for epaint::ClippedPrimitive { primitive, .. } in paint_jobs { + match primitive { + Primitive::Mesh(mesh) => { + let size = mesh.vertices.len() * core::mem::size_of::(); + let slice = vertex_offset..(size + vertex_offset); + vertex_buffer_staging + .slice(slice.clone()) + .copy_from_slice(bytemuck::cast_slice(&mesh.vertices)); + self.vertex_buffer.slices.push(slice); + vertex_offset += size; + } + Primitive::Callback(_) => {} + } + } + } + + let mut user_cmd_bufs = Vec::new(); + { + profiling::scope!("prepare callbacks"); + for callback in &callbacks { + user_cmd_bufs.extend(callback.prepare( + device, + queue, + screen_descriptor, + encoder, + &mut self.callback_resources, + )); + } + } + { + profiling::scope!("finish prepare callbacks"); + for callback in &callbacks { + user_cmd_bufs.extend(callback.finish_prepare( + device, + queue, + encoder, + &mut self.callback_resources, + )); + } + } + + user_cmd_bufs + } +} + +fn create_sampler( + options: epaint::textures::TextureOptions, + device: &wgpu::Device, +) -> wgpu::Sampler { + let mag_filter = match options.magnification { + epaint::textures::TextureFilter::Nearest => wgpu::FilterMode::Nearest, + epaint::textures::TextureFilter::Linear => wgpu::FilterMode::Linear, + }; + let min_filter = match options.minification { + epaint::textures::TextureFilter::Nearest => wgpu::FilterMode::Nearest, + epaint::textures::TextureFilter::Linear => wgpu::FilterMode::Linear, + }; + let address_mode = match options.wrap_mode { + epaint::textures::TextureWrapMode::ClampToEdge => wgpu::AddressMode::ClampToEdge, + epaint::textures::TextureWrapMode::Repeat => wgpu::AddressMode::Repeat, + epaint::textures::TextureWrapMode::MirroredRepeat => wgpu::AddressMode::MirrorRepeat, + }; + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some(&format!( + "egui sampler (mag: {mag_filter:?}, min {min_filter:?})" + )), + mag_filter, + min_filter, + address_mode_u: address_mode, + address_mode_v: address_mode, + ..Default::default() + }) +} + +fn create_vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer { + profiling::function_scope!(); + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("egui_vertex_buffer"), + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + size, + mapped_at_creation: false, + }) +} + +fn create_index_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer { + profiling::function_scope!(); + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("egui_index_buffer"), + usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, + size, + mapped_at_creation: false, + }) +} + +/// A Rect in physical pixel space, used for setting clipping rectangles. +struct ScissorRect { + x: u32, + y: u32, + width: u32, + height: u32, +} + +impl ScissorRect { + fn new(clip_rect: &epaint::Rect, pixels_per_point: f32, target_size: [u32; 2]) -> Self { + // Transform clip rect to physical pixels: + let clip_min_x = pixels_per_point * clip_rect.min.x; + let clip_min_y = pixels_per_point * clip_rect.min.y; + let clip_max_x = pixels_per_point * clip_rect.max.x; + let clip_max_y = pixels_per_point * clip_rect.max.y; + + // Round to integer: + let clip_min_x = clip_min_x.round() as u32; + let clip_min_y = clip_min_y.round() as u32; + let clip_max_x = clip_max_x.round() as u32; + let clip_max_y = clip_max_y.round() as u32; + + // Clamp: + let clip_min_x = clip_min_x.clamp(0, target_size[0]); + let clip_min_y = clip_min_y.clamp(0, target_size[1]); + let clip_max_x = clip_max_x.clamp(clip_min_x, target_size[0]); + let clip_max_y = clip_max_y.clamp(clip_min_y, target_size[1]); + + Self { + x: clip_min_x, + y: clip_min_y, + width: clip_max_x - clip_min_x, + height: clip_max_y - clip_min_y, + } + } +} + +// Look at the feature flag for an explanation. +#[cfg(not(all( + target_arch = "wasm32", + not(feature = "fragile-send-sync-non-atomic-wasm"), +)))] +#[test] +fn renderer_impl_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} diff --git a/vendor/egui-wgpu/src/setup.rs b/vendor/egui-wgpu/src/setup.rs new file mode 100644 index 00000000..3c9ceae5 --- /dev/null +++ b/vendor/egui-wgpu/src/setup.rs @@ -0,0 +1,331 @@ +use std::sync::Arc; + +/// A cloneable display handle for use with [`wgpu::InstanceDescriptor`]. +/// +/// [`wgpu::InstanceDescriptor`] stores its display handle as a non-cloneable +/// `Box`. This trait wraps it so it can be cloned +/// alongside the rest of the egui wgpu configuration. +/// +/// Automatically implemented for all types that satisfy the bounds +/// (including [`winit::event_loop::OwnedDisplayHandle`]). +pub trait EguiDisplayHandle: + wgpu::rwh::HasDisplayHandle + core::fmt::Debug + Send + Sync + 'static +{ + /// Clone into a `Box` for [`wgpu::InstanceDescriptor::display`]. + fn clone_for_wgpu(&self) -> Box; + + /// Clone into a new `Box`. + fn clone_display_handle(&self) -> Box; +} + +impl Clone for Box { + fn clone(&self) -> Self { + // We need to deref here, otherwise this causes infinite recursion stack overflow. + (**self).clone_display_handle() + } +} + +impl EguiDisplayHandle for T +where + T: wgpu::rwh::HasDisplayHandle + Clone + core::fmt::Debug + Send + Sync + 'static, +{ + fn clone_for_wgpu(&self) -> Box { + Box::new(self.clone()) + } + + fn clone_display_handle(&self) -> Box { + Box::new(self.clone()) + } +} + +#[derive(Clone)] +#[expect(clippy::large_enum_variant)] +pub enum WgpuSetup { + /// Construct a wgpu setup using some predefined settings & heuristics. + /// This is the default option. You can customize most behaviours overriding the + /// supported backends, power preferences, and device description. + /// + /// By default can also be configured with various environment variables: + /// * `WGPU_BACKEND`: `vulkan`, `dx12`, `metal`, `opengl`, `webgpu` + /// * `WGPU_POWER_PREF`: `low`, `high` or `none` + /// * `WGPU_TRACE`: Path to a file to output a wgpu trace file. + /// + /// Each instance flag also comes with an environment variable (for details see [`wgpu::InstanceFlags`]): + /// * `WGPU_VALIDATION`: Enables validation (enabled by default in debug builds). + /// * `WGPU_DEBUG`: Generate debug information in shaders and objects (enabled by default in debug builds). + /// * `WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER`: Whether wgpu should expose adapters that run on top of non-compliant adapters. + /// * `WGPU_GPU_BASED_VALIDATION`: Enable GPU-based validation. + CreateNew(WgpuSetupCreateNew), + + /// Run on an existing wgpu setup. + Existing(WgpuSetupExisting), +} + +impl WgpuSetup { + /// Creates a new [`WgpuSetup::CreateNew`] with the given display handle. + /// + /// See [`WgpuSetupCreateNew::from_display_handle`] for details. + pub fn from_display_handle(display_handle: impl EguiDisplayHandle) -> Self { + Self::CreateNew(WgpuSetupCreateNew::from_display_handle(display_handle)) + } + + /// Creates a new [`WgpuSetup::CreateNew`] without a display handle. + /// + /// See [`WgpuSetupCreateNew::without_display_handle`] for details. + pub fn without_display_handle() -> Self { + Self::CreateNew(WgpuSetupCreateNew::without_display_handle()) + } +} + +impl core::fmt::Debug for WgpuSetup { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::CreateNew(create_new) => f + .debug_tuple("WgpuSetup::CreateNew") + .field(create_new) + .finish(), + Self::Existing { .. } => f.debug_tuple("WgpuSetup::Existing").finish(), + } + } +} + +impl WgpuSetup { + /// Creates a new [`wgpu::Instance`] or clones the existing one. + /// + /// Does *not* store the wgpu instance, so calling this repeatedly may + /// create a new instance every time! + pub async fn new_instance(&self) -> wgpu::Instance { + match self { + Self::CreateNew(create_new) => { + #[allow(clippy::allow_attributes, unused_mut)] + let mut backends = create_new.instance_descriptor.backends; + + // Don't try WebGPU if we're not in a secure context. + // Emscripten is excluded: wgpu gates both its `webgpu` backend and + // its `web_sys` re-export on `not(Emscripten)` (see the cfg aliases + // in `wgpu/build.rs`). + #[cfg(all(target_arch = "wasm32", not(target_os = "emscripten")))] + if backends.contains(wgpu::Backends::BROWSER_WEBGPU) { + let is_secure_context = + wgpu::web_sys::window().is_some_and(|w| w.is_secure_context()); + if !is_secure_context { + log::info!( + "WebGPU is only available in secure contexts, i.e. on HTTPS and on localhost." + ); + backends.remove(wgpu::Backends::BROWSER_WEBGPU); + } + } + + log::debug!("Creating wgpu instance with backends {backends:?}"); + let desc = &create_new.instance_descriptor; + let descriptor = wgpu::InstanceDescriptor { + backends: desc.backends, + flags: desc.flags, + backend_options: desc.backend_options.clone(), + memory_budget_thresholds: desc.memory_budget_thresholds, + display: create_new + .display_handle + .as_ref() + .map(|handle| handle.clone_for_wgpu()), + }; + wgpu::util::new_instance_with_webgpu_detection(descriptor).await + } + Self::Existing(existing) => existing.instance.clone(), + } + } +} + +impl From for WgpuSetup { + fn from(create_new: WgpuSetupCreateNew) -> Self { + Self::CreateNew(create_new) + } +} + +impl From for WgpuSetup { + fn from(existing: WgpuSetupExisting) -> Self { + Self::Existing(existing) + } +} + +/// Method for selecting an adapter on native. +/// +/// This can be used for fully custom adapter selection. +/// If available, `wgpu::Surface` is passed to allow checking for surface compatibility. +pub type NativeAdapterSelectorMethod = Arc< + dyn Fn(&[wgpu::Adapter], Option<&wgpu::Surface<'_>>) -> Result + + Send + + Sync, +>; + +/// Configuration for creating a new wgpu setup. +/// +/// Used for [`WgpuSetup::CreateNew`]. +/// +/// Prefer [`Self::from_display_handle`] when you have a display handle available. +/// Most platforms work without one, but some (e.g. Wayland with GLES, or WebGL) +/// require it, so providing one ensures maximum compatibility. +/// With winit, pass [`EventLoop::owned_display_handle`](winit::event_loop::EventLoop::owned_display_handle). +/// +/// Note: The display handle is stored in [`Self::display_handle`] rather than in +/// [`Self::instance_descriptor`] so the config can be cloned +/// ([`wgpu::InstanceDescriptor`] is not `Clone`). It is injected at instance creation time. +pub struct WgpuSetupCreateNew { + /// Descriptor for the wgpu instance. + /// + /// Leave [`wgpu::InstanceDescriptor::display`] as `None` — use [`Self::display_handle`] + /// instead (injected at instance creation time). + /// + /// The most important field is [`wgpu::InstanceDescriptor::backends`], which controls + /// which backends are supported (wgpu will pick one of these). For example, set it to + /// [`wgpu::Backends::GL`] to use only WebGL. By default on web, WebGPU is preferred + /// with WebGL as a fallback (requires the `webgl` feature of crate `wgpu`). + pub instance_descriptor: wgpu::InstanceDescriptor, + + /// Display handle passed to wgpu at instance creation time. + /// + /// Required on some platforms (e.g. Wayland with GLES, WebGL); optional elsewhere. + /// With winit, use [`winit::event_loop::OwnedDisplayHandle`]. + /// + /// `eframe` 's winit & web integrations will attempt to fill the display handle automatically if it is left empty. + pub display_handle: Option>, + + /// Power preference for the adapter if [`Self::native_adapter_selector`] is not set or targeting web. + pub power_preference: wgpu::PowerPreference, + + /// Optional selector for native adapters. + /// + /// This field has no effect when targeting web! + /// Otherwise, if set [`Self::power_preference`] is ignored and the adapter is instead selected by this method. + /// Note that [`Self::instance_descriptor`]'s [`wgpu::InstanceDescriptor::backends`] + /// are still used to filter the adapter enumeration in the first place. + /// + /// Defaults to `None`. + pub native_adapter_selector: Option, + + /// Configuration passed on device request, given an adapter + pub device_descriptor: + Arc wgpu::DeviceDescriptor<'static> + Send + Sync>, +} + +impl WgpuSetupCreateNew { + /// Creates a new configuration with the given display handle. + /// + /// This is the recommended constructor. Most platforms (Windows, macOS/iOS, Android, web) + /// work fine without a display handle, but some (e.g. Wayland on Linux with GLES) require + /// one. Providing it unconditionally ensures your app works everywhere. + /// + /// If you don't have a display handle available, use [`Self::without_display_handle`] + /// instead — it will still work on the majority of platforms. + /// + /// With winit, pass [`EventLoop::owned_display_handle`](winit::event_loop::EventLoop::owned_display_handle). + pub fn from_display_handle(display_handle: impl EguiDisplayHandle) -> Self { + Self { + display_handle: Some(Box::new(display_handle)), + ..Self::without_display_handle() + } + } + + /// Creates a new configuration without a display handle. + /// + /// A display handle is not required for headless operation (offscreen rendering, tests, + /// compute-only workloads). It also isn't needed on most platforms even when presenting + /// to a window — only some configurations (e.g. Wayland on Linux with GLES) require one. + /// + /// If you do have a display handle available, prefer [`Self::from_display_handle`] for + /// maximum compatibility. + /// + /// With winit you can obtain one via [`EventLoop::owned_display_handle`](winit::event_loop::EventLoop::owned_display_handle). + /// + /// `eframe` 's winit & web integrations will attempt to fill the display handle automatically if it is left empty. + pub fn without_display_handle() -> Self { + Self { + instance_descriptor: wgpu::InstanceDescriptor { + // Add GL backend, primarily because WebGPU is not stable enough yet. + // (note however, that the GL backend needs to be opted-in via the wgpu feature flag "webgl") + backends: wgpu::Backends::from_env() + .unwrap_or(wgpu::Backends::PRIMARY | wgpu::Backends::GL), + flags: wgpu::InstanceFlags::from_build_config().with_env(), + backend_options: wgpu::BackendOptions::from_env_or_default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }, + + display_handle: None, + + power_preference: wgpu::PowerPreference::from_env() + .unwrap_or(wgpu::PowerPreference::HighPerformance), + + native_adapter_selector: None, + + device_descriptor: Arc::new(|adapter| { + let base_limits = if adapter.get_info().backend == wgpu::Backend::Gl { + wgpu::Limits::downlevel_webgl2_defaults() + } else { + wgpu::Limits::default() + }; + + wgpu::DeviceDescriptor { + label: Some("egui wgpu device"), + required_limits: wgpu::Limits { + // When using a depth buffer, we have to be able to create a texture + // large enough for the entire surface, and we want to support 4k+ displays. + max_texture_dimension_2d: 8192, + ..base_limits + }, + ..Default::default() + } + }), + } + } +} + +impl Clone for WgpuSetupCreateNew { + fn clone(&self) -> Self { + let desc = &self.instance_descriptor; + Self { + instance_descriptor: wgpu::InstanceDescriptor { + backends: desc.backends, + flags: desc.flags, + backend_options: desc.backend_options.clone(), + memory_budget_thresholds: desc.memory_budget_thresholds, + display: None, + }, + display_handle: self.display_handle.clone(), + power_preference: self.power_preference, + native_adapter_selector: self.native_adapter_selector.clone(), + device_descriptor: Arc::clone(&self.device_descriptor), + } + } +} + +impl core::fmt::Debug for WgpuSetupCreateNew { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let Self { + instance_descriptor, + display_handle, + power_preference, + native_adapter_selector, + device_descriptor: _, + } = self; + f.debug_struct("WgpuSetupCreateNew") + .field("instance_descriptor", instance_descriptor) + .field("display_handle", display_handle) + .field("power_preference", power_preference) + .field( + "native_adapter_selector", + &native_adapter_selector.is_some(), + ) + .finish_non_exhaustive() + } +} + +/// Configuration for using an existing wgpu setup. +/// +/// Used for [`WgpuSetup::Existing`]. +#[derive(Clone)] +pub struct WgpuSetupExisting { + pub instance: wgpu::Instance, + pub adapter: wgpu::Adapter, + pub device: wgpu::Device, + pub queue: wgpu::Queue, +} diff --git a/vendor/egui-wgpu/src/texture_copy.wgsl b/vendor/egui-wgpu/src/texture_copy.wgsl new file mode 100644 index 00000000..4096d164 --- /dev/null +++ b/vendor/egui-wgpu/src/texture_copy.wgsl @@ -0,0 +1,43 @@ +struct VertexOutput { + @builtin(position) position: vec4, +}; + +var positions: array = array( + vec2f(-1.0, -3.0), + vec2f(-1.0, 1.0), + vec2f(3.0, 1.0) +); + +// meant to be called with 3 vertex indices: 0, 1, 2 +// draws one large triangle over the clip space like this: +// (the asterisks represent the clip space bounds) +//-1,1 1,1 +// --------------------------------- +// | * . +// | * . +// | * . +// | * . +// | * . +// | * . +// |*************** +// | . 1,-1 +// | . +// | . +// | . +// | . +// |. +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var result: VertexOutput; + result.position = vec4f(positions[vertex_index], 0.0, 1.0); + return result; +} + +@group(0) +@binding(0) +var r_color: texture_2d; + +@fragment +fn fs_main(vertex: VertexOutput) -> @location(0) vec4 { + return textureLoad(r_color, vec2i(vertex.position.xy), 0); +} diff --git a/vendor/egui-wgpu/src/winit.rs b/vendor/egui-wgpu/src/winit.rs new file mode 100644 index 00000000..7c61f2f1 --- /dev/null +++ b/vendor/egui-wgpu/src/winit.rs @@ -0,0 +1,802 @@ +#![expect(clippy::missing_errors_doc)] +#![expect(clippy::undocumented_unsafe_blocks)] +#![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps +#![expect(unsafe_code)] + +use crate::{RenderState, SurfaceConfig, SurfaceErrorAction, WgpuConfiguration, renderer}; +use crate::{ + RendererOptions, + capture::{CaptureReceiver, CaptureSender, CaptureState, capture_channel}, +}; +use core::num::NonZeroU32; +use egui::{Context, Event, UserData, ViewportId, ViewportIdMap, ViewportIdSet}; +use std::sync::Arc; + +struct SurfaceState { + surface: wgpu::Surface<'static>, + alpha_mode: wgpu::CompositeAlphaMode, + width: u32, + height: u32, + resizing: bool, + needs_reconfigure: bool, + needs_recreate: bool, +} + +/// Everything you need to paint egui with [`wgpu`] on [`winit`]. +/// +/// Alternatively you can use [`crate::Renderer`] directly. +/// +/// NOTE: all egui viewports share the same painter. +pub struct Painter { + context: Context, + config: WgpuConfiguration, + options: RendererOptions, + support_transparent_backbuffer: bool, + screen_capture_state: Option, + + instance: wgpu::Instance, + render_state: Option, + + // Per viewport/window: + depth_texture_view: ViewportIdMap, + msaa_texture_view: ViewportIdMap, + surfaces: ViewportIdMap, + capture_tx: CaptureSender, + capture_rx: CaptureReceiver, +} + +impl Painter { + /// Manages [`wgpu`] state, including surface state, required to render egui. + /// + /// Only the [`wgpu::Instance`] is initialized here. Device selection and the initialization + /// of render + surface state is deferred until the painter is given its first window target + /// via [`set_window()`](Self::set_window). (Ensuring that a device that's compatible with the + /// native window is chosen) + /// + /// Before calling [`paint_and_update_textures()`](Self::paint_and_update_textures) a + /// [`wgpu::Surface`] must be initialized (and corresponding render state) by calling + /// [`set_window()`](Self::set_window) once you have + /// a [`winit::window::Window`] with a valid `.raw_window_handle()` + /// associated. + pub async fn new( + context: Context, + config: WgpuConfiguration, + support_transparent_backbuffer: bool, + options: RendererOptions, + ) -> Self { + let (capture_tx, capture_rx) = capture_channel(); + let instance = config.wgpu_setup.new_instance().await; + + Self { + context, + config, + options, + support_transparent_backbuffer, + screen_capture_state: None, + + instance, + render_state: None, + + depth_texture_view: Default::default(), + surfaces: Default::default(), + msaa_texture_view: Default::default(), + + capture_tx, + capture_rx, + } + } + + /// Get the [`RenderState`]. + /// + /// Will return [`None`] if the render state has not been initialized yet. + pub fn render_state(&self) -> Option { + self.render_state.clone() + } + + fn configure_surface( + surface_state: &SurfaceState, + render_state: &RenderState, + config: &SurfaceConfig, + ) { + profiling::function_scope!(); + + let SurfaceConfig { + present_mode, + desired_maximum_frame_latency, + } = *config; + + // Transaction presentation can hold a drawable during AppKit live resize. Keep the + // configured low-latency path normally, but use three Metal drawables while resizing. + #[cfg(all(target_os = "macos", feature = "macos-window-resize-jitter-fix"))] + let desired_maximum_frame_latency = if surface_state.resizing { + Some(desired_maximum_frame_latency.unwrap_or(2).max(2)) + } else { + desired_maximum_frame_latency + }; + + let width = surface_state.width; + let height = surface_state.height; + + let mut surf_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: render_state.target_format, + present_mode, + alpha_mode: surface_state.alpha_mode, + view_formats: vec![render_state.target_format], + ..surface_state + .surface + .get_default_config(&render_state.adapter, width, height) + .expect("The surface isn't supported by this adapter") + }; + + if let Some(desired_maximum_frame_latency) = desired_maximum_frame_latency { + surf_config.desired_maximum_frame_latency = desired_maximum_frame_latency; + } + + surface_state + .surface + .configure(&render_state.device, &surf_config); + } + + /// Drop the existing [`wgpu::Surface`] for `viewport_id` and create a fresh one for the + /// given window via [`wgpu::Instance::create_surface`], then configure it. + /// + /// Used to recover from [`wgpu::CurrentSurfaceTexture::Lost`], where reconfiguring the + /// existing surface object cannot recover. + fn recreate_surface( + &mut self, + viewport_id: ViewportId, + window: &Arc, + ) -> Result<(), crate::WgpuError> { + profiling::function_scope!(); + + let Some(old_state) = self.surfaces.remove(&viewport_id) else { + return Ok(()); + }; + + let surface = self.instance.create_surface(Arc::clone(window))?; + self.install_surface( + surface, + viewport_id, + old_state.width, + old_state.height, + old_state.resizing, + ); + Ok(()) + } + + /// Updates (or clears) the [`winit::window::Window`] associated with the [`Painter`] + /// + /// This creates a [`wgpu::Surface`] for the given Window (as well as initializing render + /// state if needed) that is used for egui rendering. + /// + /// This must be called before trying to render via + /// [`paint_and_update_textures`](Self::paint_and_update_textures) + /// + /// # Portability + /// + /// _In particular it's important to note that on Android a it's only possible to create + /// a window surface between `Resumed` and `Paused` lifecycle events, and Winit will panic on + /// attempts to query the raw window handle while paused._ + /// + /// On Android [`set_window`](Self::set_window) should be called with `Some(window)` for each + /// `Resumed` event and `None` for each `Paused` event. Currently, on all other platforms + /// [`set_window`](Self::set_window) may be called with `Some(window)` as soon as you have a + /// valid [`winit::window::Window`]. + /// + /// # Errors + /// If the provided wgpu configuration does not match an available device. + pub async fn set_window( + &mut self, + viewport_id: ViewportId, + window: Option>, + ) -> Result<(), crate::WgpuError> { + profiling::scope!("Painter::set_window"); // profile_function gives bad names for async functions + + if let Some(window) = window { + let size = window.inner_size(); + if !self.surfaces.contains_key(&viewport_id) { + let surface = self.instance.create_surface(window)?; + self.add_surface(surface, viewport_id, size).await?; + } + } else { + log::warn!("No window - clearing all surfaces"); + self.surfaces.clear(); + } + Ok(()) + } + + /// Updates (or clears) the [`winit::window::Window`] associated with the [`Painter`] without taking ownership of the window. + /// + /// Like [`set_window`](Self::set_window) except: + /// + /// # Safety + /// The user is responsible for ensuring that the window is alive for as long as it is set. + pub async unsafe fn set_window_unsafe( + &mut self, + viewport_id: ViewportId, + window: Option<&winit::window::Window>, + ) -> Result<(), crate::WgpuError> { + profiling::scope!("Painter::set_window_unsafe"); // profile_function gives bad names for async functions + + if let Some(window) = window { + let size = window.inner_size(); + if !self.surfaces.contains_key(&viewport_id) { + let surface = unsafe { + self.instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::from_window(&window)?)? + }; + self.add_surface(surface, viewport_id, size).await?; + } + } else { + log::warn!("No window - clearing all surfaces"); + self.surfaces.clear(); + } + Ok(()) + } + + async fn add_surface( + &mut self, + surface: wgpu::Surface<'static>, + viewport_id: ViewportId, + size: winit::dpi::PhysicalSize, + ) -> Result<(), crate::WgpuError> { + if self.render_state.is_none() { + let render_state = + RenderState::create(&self.config, &self.instance, Some(&surface), self.options) + .await?; + // SolidCraft: attachment sample counts must match the backend-adjusted pipelines. + self.options.msaa_samples = render_state.msaa_samples; + self.render_state = Some(render_state); + } + self.install_surface(surface, viewport_id, size.width, size.height, false); + Ok(()) + } + + /// Inserts a freshly created surface into [`Self::surfaces`] and configures it. + /// + /// Render state must already be initialised before calling this. + // NOTE: The same assumption is already required by `resize_and_generate_depth_texture_view_and_msaa_view`. + fn install_surface( + &mut self, + surface: wgpu::Surface<'static>, + viewport_id: ViewportId, + width: u32, + height: u32, + resizing: bool, + ) { + let alpha_mode = { + // Panic: We use the same failure mode as `resize_and_generate_depth_texture_view_and_msaa_view` + let render_state = self + .render_state + .as_ref() + .expect("install_surface called before render_state initialization"); + if self.support_transparent_backbuffer { + let supported_alpha_modes = + surface.get_capabilities(&render_state.adapter).alpha_modes; + // Prefer pre multiplied over post multiplied! + if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + wgpu::CompositeAlphaMode::PreMultiplied + } else if supported_alpha_modes.contains(&wgpu::CompositeAlphaMode::PostMultiplied) + { + wgpu::CompositeAlphaMode::PostMultiplied + } else { + log::warn!( + "Transparent window was requested, but the active wgpu surface does not support a `CompositeAlphaMode` with transparency." + ); + wgpu::CompositeAlphaMode::Auto + } + } else { + wgpu::CompositeAlphaMode::Auto + } + }; + self.surfaces.insert( + viewport_id, + SurfaceState { + surface, + width, + height, + alpha_mode, + resizing, + needs_reconfigure: false, + needs_recreate: false, + }, + ); + let Some(width) = NonZeroU32::new(width) else { + log::debug!("The window width was zero; skipping generate textures"); + return; + }; + let Some(height) = NonZeroU32::new(height) else { + log::debug!("The window height was zero; skipping generate textures"); + return; + }; + self.resize_and_generate_depth_texture_view_and_msaa_view(viewport_id, width, height); + } + + /// Returns the maximum texture dimension supported if known + /// + /// This API will only return a known dimension after `set_window()` has been called + /// at least once, since the underlying device and render state are initialized lazily + /// once we have a window (that may determine the choice of adapter/device). + pub fn max_texture_side(&self) -> Option { + self.render_state + .as_ref() + .map(|rs| rs.device.limits().max_texture_dimension_2d as usize) + } + + fn resize_and_generate_depth_texture_view_and_msaa_view( + &mut self, + viewport_id: ViewportId, + width_in_pixels: NonZeroU32, + height_in_pixels: NonZeroU32, + ) { + profiling::function_scope!(); + + let width = width_in_pixels.get(); + let height = height_in_pixels.get(); + + let render_state = self.render_state.as_ref().unwrap(); + let surface_state = self.surfaces.get_mut(&viewport_id).unwrap(); + + surface_state.width = width; + surface_state.height = height; + + Self::configure_surface(surface_state, render_state, &self.config.surface); + + if let Some(depth_format) = self.options.depth_stencil_format { + self.depth_texture_view.insert( + viewport_id, + render_state + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("egui_depth_texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: self.options.msaa_samples.max(1), + dimension: wgpu::TextureDimension::D2, + format: depth_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[depth_format], + }) + .create_view(&wgpu::TextureViewDescriptor::default()), + ); + } + + if let Some(render_state) = (self.options.msaa_samples > 1) + .then_some(self.render_state.as_ref()) + .flatten() + { + let texture_format = render_state.target_format; + self.msaa_texture_view.insert( + viewport_id, + render_state + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("egui_msaa_texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: self.options.msaa_samples.max(1), + dimension: wgpu::TextureDimension::D2, + format: texture_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[texture_format], + }) + .create_view(&wgpu::TextureViewDescriptor::default()), + ); + } + } + + /// Handles changes of the resizing state. + /// + /// Should be called prior to the first [`Painter::on_window_resized`] call and after the last in + /// the chain. Used to apply platform-specific logic, e.g. OSX Metal window resize jitter fix. + pub fn on_window_resize_state_change(&mut self, viewport_id: ViewportId, resizing: bool) { + profiling::function_scope!(); + + let Some(state) = self.surfaces.get_mut(&viewport_id) else { + return; + }; + if state.resizing == resizing { + if resizing { + log::debug!( + "Painter::on_window_resize_state_change() redundant call while resizing" + ); + } else { + log::debug!( + "Painter::on_window_resize_state_change() redundant call after resizing" + ); + } + return; + } + + // Set before reconfiguring so macOS live resize uses the temporary latency bump above. + state.resizing = resizing; + + // Resizing is a bit tricky on macOS. + // It requires enabling ["present_with_transaction"](https://developer.apple.com/documentation/quartzcore/cametallayer/presentswithtransaction) + // flag to avoid jittering during the resize. Even though resize jittering on macOS + // is common across rendering backends, the solution for wgpu/metal is known. + // + // See https://github.com/emilk/egui/issues/903 + #[cfg(all(target_os = "macos", feature = "macos-window-resize-jitter-fix"))] + { + // SAFETY: `as_hal::()` returns `None` unless this surface is backed by wgpu's + // Metal backend. + unsafe { + if let (Some(render_state), Some(hal_surface)) = ( + self.render_state.as_ref(), + state.surface.as_hal::(), + ) { + hal_surface + .render_layer() + .lock() + .setPresentsWithTransaction(resizing); + + Self::configure_surface(state, render_state, &self.config.surface); + } + } + } + } + + pub fn on_window_resized( + &mut self, + viewport_id: ViewportId, + width_in_pixels: NonZeroU32, + height_in_pixels: NonZeroU32, + ) { + profiling::function_scope!(); + + if self.surfaces.contains_key(&viewport_id) { + self.resize_and_generate_depth_texture_view_and_msaa_view( + viewport_id, + width_in_pixels, + height_in_pixels, + ); + } else { + log::warn!( + "Ignoring window resize notification with no surface created via Painter::set_window()" + ); + } + } + + /// Returns two things: + /// + /// The approximate number of seconds spent on vsync-waiting (if any), + /// and the captures captured screenshot if it was requested. + /// + /// If `capture_data` isn't empty, a screenshot will be captured. + #[expect(clippy::too_many_arguments)] + pub fn paint_and_update_textures( + &mut self, + viewport_id: ViewportId, + pixels_per_point: f32, + clear_color: [f32; 4], + clipped_primitives: &[epaint::ClippedPrimitive], + textures_delta: &mut epaint::textures::TexturesDelta, + capture_data: Vec, + window: &Arc, + ) -> f32 { + profiling::function_scope!(); + + /// Guard to ensure that commands are always submitted to the renderer queue + /// so that calls to [`write_buffer()`](https://docs.rs/wgpu/latest/wgpu/struct.Queue.html#method.write_buffer) + /// are completed even if we take a codepath which doesn't submit commands and avoids + /// internal buffers growing indefinitely. + /// + /// This may happen, for example, if no output frame is resolved. + /// See for full context. + struct RendererQueueGuard<'q> { + queue: &'q wgpu::Queue, + commands_submitted: bool, + } + + impl Drop for RendererQueueGuard<'_> { + fn drop(&mut self) { + // Only submit an empty command buffer array if no commands were + // explicitly submitted. + if !self.commands_submitted { + self.queue.submit([]); + } + } + } + + let capture = !capture_data.is_empty(); + let mut vsync_sec = 0.0; + + // If the previous frame produced `CurrentSurfaceTexture::Lost`, the action match + // below set `needs_recreate`. Recreate the surface now, before re-borrowing + // `self.render_state` / `self.surfaces` for the rest of the paint. + if self + .surfaces + .get(&viewport_id) + .is_some_and(|s| s.needs_recreate) + && let Err(err) = self.recreate_surface(viewport_id, window) + { + log::error!("Failed to recreate surface for {viewport_id:?}: {err}"); + return vsync_sec; + } + + // Apply any runtime changes requested via `RenderState::surface_config`. + // We diff against the already-applied values in `self.config.surface` + // and, if anything differs, mark every surface as needing reconfiguration so + // the existing `needs_reconfigure` pathway below picks them up. + if let Some(render_state) = self.render_state.as_ref() + && render_state.surface_config != self.config.surface + { + self.config.surface = render_state.surface_config; + #[expect(clippy::iter_over_hash_type)] + for surface in self.surfaces.values_mut() { + surface.needs_reconfigure = true; + } + } + + let Some(render_state) = self.render_state.as_mut() else { + return vsync_sec; + }; + + let mut render_queue_guard = RendererQueueGuard { + queue: &render_state.queue, + commands_submitted: false, + }; + + let Some(surface_state) = self.surfaces.get_mut(&viewport_id) else { + return vsync_sec; + }; + + let mut encoder = + render_state + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("encoder"), + }); + + // Upload all resources for the GPU. + let screen_descriptor = renderer::ScreenDescriptor { + size_in_pixels: [surface_state.width, surface_state.height], + pixels_per_point, + }; + + let user_cmd_bufs = { + let mut renderer = render_state.renderer.write(); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for (id, image_deltas) in textures_delta.set.drain() { + for image_delta in image_deltas { + renderer.update_texture( + &render_state.device, + &render_state.queue, + id, + &image_delta, + ); + } + } + + renderer.update_buffers( + &render_state.device, + &render_state.queue, + &mut encoder, + clipped_primitives, + &screen_descriptor, + ) + }; + + if surface_state.needs_reconfigure { + Self::configure_surface(surface_state, render_state, &self.config.surface); + surface_state.needs_reconfigure = false; + } + + let output_frame = { + profiling::scope!("get_current_texture"); + // This is what vsync-waiting happens on my Mac. + let start = web_time::Instant::now(); + let output_frame = surface_state.surface.get_current_texture(); + vsync_sec += start.elapsed().as_secs_f32(); + output_frame + }; + + let output_frame = match output_frame { + wgpu::CurrentSurfaceTexture::Success(frame) => frame, + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { + surface_state.needs_reconfigure = true; + frame + } + other => { + match (*self.config.on_surface_status)(&other) { + SurfaceErrorAction::Reconfigure => { + Self::configure_surface(surface_state, render_state, &self.config.surface); + self.context.request_repaint_of(viewport_id); + } + SurfaceErrorAction::RecreateSurface => { + // Because of ownership, I could not find an easy way to do a full recovery here, + // as that would involve dropping the old surface and creating a new one. + // For now, we defer the recreation to the beginning of the next frame (which + // we ensure to arrive via `request_repaint_of`). A cleaner solution would be + // to untangle the ownership of `RenderState`. + surface_state.needs_recreate = true; + self.context.request_repaint_of(viewport_id); + } + SurfaceErrorAction::SkipFrame => {} + } + return vsync_sec; + } + }; + + let mut capture_buffer = None; + { + let renderer = render_state.renderer.read(); + + let target_texture = if capture { + let capture_state = self.screen_capture_state.get_or_insert_with(|| { + CaptureState::new(&render_state.device, &output_frame.texture) + }); + capture_state.update(&render_state.device, &output_frame.texture); + + &capture_state.texture + } else { + &output_frame.texture + }; + let target_view = target_texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let (view, resolve_target) = (self.options.msaa_samples > 1) + .then_some(self.msaa_texture_view.get(&viewport_id)) + .flatten() + .map_or((&target_view, None), |texture_view| { + (texture_view, Some(&target_view)) + }); + + let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("egui_render"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: clear_color[0] as f64, + g: clear_color[1] as f64, + b: clear_color[2] as f64, + a: clear_color[3] as f64, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: self.depth_texture_view.get(&viewport_id).map(|view| { + wgpu::RenderPassDepthStencilAttachment { + view, + depth_ops: self + .options + .depth_stencil_format + .is_some_and(|depth_stencil_format| { + depth_stencil_format.has_depth_aspect() + }) + .then_some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + // It is very unlikely that the depth buffer is needed after egui finished rendering + // so no need to store it. (this can improve performance on tiling GPUs like mobile chips or Apple Silicon) + store: wgpu::StoreOp::Discard, + }), + stencil_ops: self + .options + .depth_stencil_format + .is_some_and(|depth_stencil_format| { + depth_stencil_format.has_stencil_aspect() + }) + .then_some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0), + store: wgpu::StoreOp::Discard, + }), + } + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + + // Forgetting the pass' lifetime means that we are no longer compile-time protected from + // runtime errors caused by accessing the parent encoder before the render pass is dropped. + // Since we don't pass it on to the renderer, we should be perfectly safe against this mistake here! + renderer.render( + &mut render_pass.forget_lifetime(), + clipped_primitives, + &screen_descriptor, + ); + + if capture && let Some(capture_state) = &mut self.screen_capture_state { + capture_buffer = Some(capture_state.copy_textures( + &render_state.device, + &output_frame, + &mut encoder, + )); + } + } + + let encoded = { + profiling::scope!("CommandEncoder::finish"); + encoder.finish() + }; + + // Submit the commands: both the main buffer and user-defined ones. + { + profiling::scope!("Queue::submit"); + // wgpu doesn't document where vsync can happen. Maybe here? + let start = web_time::Instant::now(); + render_state + .queue + .submit(core::iter::chain(user_cmd_bufs, [encoded])); + vsync_sec += start.elapsed().as_secs_f32(); + }; + + // Ensure that the queue guard does not do unnecessary work when dropped + render_queue_guard.commands_submitted = true; + + // Free textures marked for destruction **after** queue submit since they might still be used in the current frame. + // Calling `wgpu::Texture::destroy` on a texture that is still in use would invalidate the command buffer(s) it is used in. + // However, once we called `wgpu::Queue::submit`, it is up for wgpu to determine how long the underlying gpu resource has to live. + { + let mut renderer = render_state.renderer.write(); + #[expect(clippy::iter_over_hash_type)] // Order doesn't matter here + for id in textures_delta.free.drain() { + renderer.free_texture(&id); + } + } + + if let Some(capture_buffer) = capture_buffer + && let Some(screen_capture_state) = &mut self.screen_capture_state + { + screen_capture_state.read_screen_rgba( + self.context.clone(), + capture_buffer, + capture_data, + self.capture_tx.clone(), + viewport_id, + ); + } + + window.pre_present_notify(); + + { + profiling::scope!("present"); + // wgpu doesn't document where vsync can happen. Maybe here? + let start = web_time::Instant::now(); + render_state.queue.present(output_frame); + vsync_sec += start.elapsed().as_secs_f32(); + } + + vsync_sec + } + + /// Call this at the beginning of each frame to receive the requested screenshots. + pub fn handle_screenshots(&self, events: &mut Vec) { + for (viewport_id, user_data, screenshot) in self.capture_rx.try_iter() { + let screenshot = Arc::new(screenshot); + for data in user_data { + events.push(Event::Screenshot { + viewport_id, + user_data: data, + image: Arc::clone(&screenshot), + }); + } + } + } + + pub fn gc_viewports(&mut self, active_viewports: &ViewportIdSet) { + self.surfaces.retain(|id, _| active_viewports.contains(id)); + self.depth_texture_view + .retain(|id, _| active_viewports.contains(id)); + self.msaa_texture_view + .retain(|id, _| active_viewports.contains(id)); + } + + #[expect(clippy::needless_pass_by_ref_mut, clippy::unused_self)] + pub fn destroy(&mut self) { + // TODO(emilk): something here? + } +}