Compare commits

...
69 changed files with 7239 additions and 408 deletions

No files matched your search

+13
View File
@@ -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**.
+4
View File
@@ -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: |
+1 -1
View File
@@ -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
+3 -4
View File
@@ -2,10 +2,9 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
+9
View File
@@ -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.
+3 -4
View File
@@ -2,10 +2,9 @@
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name and compatibility
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
+22 -26
View File
@@ -16,12 +16,19 @@ You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
```sh
git clone https://github.com/bherbruck/solidcraft && cd solvecraft
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
SolidCraft is the product and repository name. Cargo packages and executables retain
their `solvecraft` names for compatibility with existing scripts and MCP clients.
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
`cargo build --release -p solvecraft-cli`. Run the CLI with
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
after building). There is no Cargo package named `solidcraft`.
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
HTTPS or localhost, because it uses the Origin Private File System.
@@ -41,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
Generated
+93 -20
View File
@@ -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.2"
dependencies = [
"eframe",
"egui",
"embed-resource",
"hidapi",
"log",
"rfd",
@@ -3284,7 +3306,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3293,7 +3315,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"log",
"serde",
@@ -3308,7 +3330,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"log",
"serde",
@@ -3324,7 +3346,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"serde",
"serde_json",
@@ -3332,7 +3354,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3344,7 +3366,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"log",
"serde",
@@ -3359,7 +3381,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3367,7 +3389,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"image",
"serde",
@@ -3377,7 +3399,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"serde",
"serde_json",
@@ -3388,7 +3410,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"egui",
"egui-wgpu",
@@ -3403,7 +3425,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.3.0"
version = "0.3.2"
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.2"
dependencies = [
"serde_json",
]
+4 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.3.0"
version = "0.3.2"
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" }
+4 -1
View File
@@ -4,7 +4,7 @@
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server. Part of the storytold "craft" family.
model through the built-in MCP server.
![SolidCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
@@ -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
+31
View File
@@ -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
@@ -277,7 +289,26 @@ headline counts in-scope tabs only and lists the deferred ones separately.
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## Document-opening performance
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
on a local case assembly; shape measurements and topology match the original build.
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
- Prepared assembly geometry survives selection and view changes while real geometry and
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
## Kernel
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
+242 -8
View File
@@ -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 <[email protected]>
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)
+126
View File
@@ -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<Vec<u8>, 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<P> = 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<P> = (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());
}
}
+4 -106
View File
@@ -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<Vec<u8>, 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<P> = 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<P> = (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).
+3
View File
@@ -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"
+61
View File
@@ -0,0 +1,61 @@
#![forbid(unsafe_code)]
#[path = "../app_icon.rs"]
mod app_icon;
fn main() -> Result<(), Box<dyn std::error::Error>> {
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(())
}
+49 -11
View File
@@ -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<egui::ViewportBuilder, eframe::Error> {
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<u16> = 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);
}
}
}
}
+121
View File
@@ -457,3 +457,124 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
}
}
}
#[test]
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("public stepped tab case");
doc.set_param("length", "40", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
for (name, kind) in [
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
] {
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
}
doc.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
None,
)
.unwrap();
let tool = doc
.add_feature(
serde_json::from_value(
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
)
.unwrap(),
Some("Pocket tool"),
)
.unwrap();
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
let cut = doc
.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
Some("Pocket"),
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
let before = model.state_before(cut);
// A real per-face paint control preserves the former slab construction;
// no historical names or geometry are fabricated.
let old_stock = before
.body("Case")
.unwrap()
.body
.clone()
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(cut).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let at = Vec3::new(0., 15., 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
let mut old_state = crate::ModelState::default();
old_state.bodies.push(old_body);
let plane = solvecraft_geom::Plane::YZ;
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
let current_state = model.state();
let current = current_state.body("Case").unwrap();
let stem = crate::naming::strip_piece(&former);
let current_names = crate::naming::face_names(current);
let merged = current_names.iter().position(|name| name == stem).unwrap();
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
assert!(!current_names.contains(&former));
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
assert_eq!(captured.len(), 4);
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
let material = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Saved band stock"),
)
.unwrap();
for (length, wall) in [(40., 2.), (50., 3.)] {
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let reopened = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&reopened);
for current in [&model, &cold] {
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none(), "{result:?}");
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
let state = current.state();
let saved = state.sketch(sk).unwrap();
assert!(saved.sketch.links[0].lost);
assert_eq!(saved.sketch.points, original.points);
assert_eq!(saved.sketch.curves, original.curves);
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
assert!(
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
solvecraft_kernel::measure(&stock.body).unwrap().volume,
stock.mesh().bounds(),
original.points
);
let case = state.body("Case").unwrap();
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
}
}
}
+93 -3
View File
@@ -3436,9 +3436,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let name = doc.world_body_name(st, &b.name, comp, &path);
// A body moved in from another component keeps its place (its offset).
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
let mut placed = ModelBody::new(name, body, b.feature);
// Identical geometry also shares its display mesh cell with the definition.
// A changed placement keeps a distinct mesh, at that placement's coordinates.
let mut placed = if crate::is_identity(&bm) {
let mut placed = b.clone();
placed.name = name;
placed
} else {
let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone());
ModelBody::new(name, body, b.feature)
};
// World states keep their previous display-only evaluation-key semantics.
placed.key = 0;
placed.source_name = Some(b.definition_name().to_string());
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
placed.component = comp;
@@ -3481,6 +3490,87 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
out
}
#[cfg(test)]
mod world_placement_cache_tests {
use super::*;
#[test]
fn placed_real_feature_retains_names_from_its_original_feature_context() {
let mut doc = Document::new("authored names");
let component = doc.add_component(Some("Part"), 0).unwrap();
let feature = doc
.add_feature(
FeatureKind::Box {
corner: crate::point_expr(Vec3::ZERO),
length: "2 mm".into(),
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
},
Some("AuthoredBox"),
)
.unwrap();
doc.feature_mut(feature).unwrap().component = component;
let matrix = crate::rigid(Vec3::new(15., -2., 8.), Vec3::ZERO, Vec3::X, 0.7);
doc.occurrences.iter_mut().find(|o| o.component == component).unwrap().transform = matrix;
let mut model = Model::default();
model.evaluate(&doc);
let state = model.state();
let source = &state.bodies[0];
assert!(source.names.origin.is_some());
assert!(source.mesh.get().is_none());
let world = world_state(&doc, &state);
assert!(source.mesh.get().is_some(), "source naming is prepared before adoption, as before");
let names = crate::naming::face_names(&world.bodies[0]);
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(source)));
assert!(names.iter().all(|name| name.starts_with(&format!("F{feature}:"))));
let local = source.mesh();
let placed = world.bodies[0].mesh();
assert_eq!(local.tri_face, placed.tri_face);
let mapped: Vec<_> = local.positions.iter().map(|p| crate::apply_point(&matrix, *p)).collect();
assert!(mapped.iter().all(|p| placed.positions.iter().any(|q| p.dist(*q) < 1e-8)));
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
}
#[test]
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
let mut doc = Document::new("placements");
let component = doc.add_component(Some("Part"), 0).unwrap();
doc.body_components.insert("Block".into(), component);
let matrix = crate::rigid(Vec3::new(10., 20., 0.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2);
let second = doc.add_occurrence(component, 0, matrix).unwrap();
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1));
state.bodies[0].key = 1234;
let source = &state.bodies[0];
let world = world_state(&doc, &state);
assert_eq!(world.bodies.len(), 2);
let first = world.body("Block").unwrap();
let second_name = doc.world_body_name(&state, "Block", component, &[second]);
let placed = world.body(&second_name).unwrap();
assert!(source.mesh.get().is_some(), "persistent source names stay prepared before adoption");
assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell");
assert_eq!(first.key, 0);
assert_eq!(placed.key, 0);
assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates");
assert_eq!(first.source_name.as_deref(), Some("Block"));
assert_eq!(placed.source_name.as_deref(), Some("Block"));
assert_eq!(placed.source_transform, matrix);
assert_eq!(doc.model_body_component(placed), component);
let bounds = placed.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
let names = crate::naming::face_names(source);
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(first)));
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(placed)));
let original_mesh = source.mesh();
assert!(Arc::ptr_eq(&original_mesh, &first.mesh()));
let again = world_state(&doc, &state);
assert!(Arc::ptr_eq(&original_mesh, &again.body("Block").unwrap().mesh()));
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(again.body(&second_name).unwrap())));
}
}
#[cfg(test)]
mod chain_tests {
use super::*;
+37 -12
View File
@@ -150,6 +150,28 @@ pub struct Preview {
pub after: Arc<ModelState>,
}
/// Prepared world geometry depends on design/model identity and placement context, not
/// selection or the view revision. Retained Arcs prevent pointer reuse after a design closes.
#[derive(Clone)]
struct WorldCache {
doc: Arc<Document>,
model: Arc<solvecraft_doc::ModelState>,
pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
world: Arc<solvecraft_doc::ModelState>,
}
impl WorldCache {
fn matches(&self, session: &Session, model: &Arc<solvecraft_doc::ModelState>) -> bool {
Arc::ptr_eq(&self.doc, &session.doc)
&& Arc::ptr_eq(&self.model, model)
&& self.pending_moves == session.pending_moves
&& self.active_component == session.active_component
&& self.active_occurrence_path == session.active_occurrence_path
}
}
/// An editing session on one design.
pub struct Session {
pub doc: Arc<Document>,
@@ -173,7 +195,7 @@ pub struct Session {
pub active_occurrence_path: Option<Vec<u64>>,
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
world_cache: std::sync::Mutex<Option<WorldCache>>,
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solvecraft_doc::Feature>,
@@ -308,10 +330,10 @@ impl Session {
return st;
}
if let Ok(c) = self.world_cache.lock()
&& let Some((rev, w)) = c.as_ref()
&& *rev == self.revision
&& let Some(cached) = c.as_ref()
&& cached.matches(self, &st)
{
return w.clone();
return cached.world.clone();
}
let mut w = if self.pending_moves.is_empty() {
solvecraft_doc::world_state(&self.doc, &st)
@@ -350,7 +372,14 @@ impl Session {
}
let w = Arc::new(w);
if let Ok(mut c) = self.world_cache.lock() {
*c = Some((self.revision, w.clone()));
*c = Some(WorldCache {
doc: self.doc.clone(),
model: st,
pending_moves: self.pending_moves.clone(),
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
world: w.clone(),
});
}
w
}
@@ -503,7 +532,7 @@ impl Session {
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
pending_moves: self.pending_moves.clone(),
world_cache: Default::default(),
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
occurrence_visibility_cache: Default::default(),
clipboard: self.clipboard.clone(),
section: None,
@@ -519,12 +548,6 @@ impl Session {
pub fn invalidate_view(&mut self, previous_revision: u64) {
let old = self.revision;
self.revision = old.max(previous_revision) + 1;
if let Ok(mut cache) = self.world_cache.lock()
&& let Some((revision, _)) = cache.as_mut()
&& *revision == old
{
*revision = self.revision;
}
}
/// Commit commands evaluated on a scratch copy without losing this session's file,
@@ -651,3 +674,5 @@ mod split_face_tests;
mod tests;
#[cfg(test)]
mod thread_tests;
#[cfg(test)]
mod world_placement_cache_tests;
@@ -0,0 +1,177 @@
//! Placement-cache changes must retain physical picking, pending positions and named joints.
use crate::{Session, geom::Vec3};
use serde_json::{Value, json};
use std::sync::Arc;
fn run(session: &mut Session, command: &str, params: Value) -> Value {
session.execute(command, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
#[test]
fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joint_frames() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
run(&mut session, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false}));
let pending = session.pending_moves.clone();
let path = vec![second];
let placement = session.occurrence_placement(&path).unwrap();
let name = placement.bodies[0].world.clone();
let world = session.world_state();
assert!(Arc::ptr_eq(&world, &session.world_state()));
let source_state = session.model.state();
let source = source_state.body("Block").unwrap();
let placed = world.body(&name).unwrap();
assert_eq!(placed.source_name.as_deref(), Some("Block"));
assert_eq!(placed.component, component);
let bounds = placed.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
assert_eq!(session.doc.model_body_look(placed), session.doc.model_body_look(source));
let source_names = crate::doc::naming::face_names(source);
assert_eq!(source_names, crate::doc::naming::face_names(placed));
let pick = Vec3::new(8.5, 21., 4.);
let top = placed.body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap().index;
run(&mut session, "select.set", json!({"items":[{"type":"face","body":name,"index":top,"point":[pick.x,pick.y,pick.z]}]}));
assert!(matches!(&session.selection[0], crate::Sel::Face { body, index, .. } if body == &name && *index == top));
assert!(Arc::ptr_eq(&world, &session.world_state()), "selection changes must not discard prepared placements");
let scratch = session.scratch();
assert_eq!(scratch.pending_moves, pending);
let scratch_world = scratch.world_state();
assert!(Arc::ptr_eq(&world, &scratch_world));
let scratch_body = scratch_world.body(&name).unwrap();
assert!(scratch_body.mesh().bounds().min.dist(bounds.min) < 1e-9);
assert_eq!(crate::doc::naming::face_names(scratch_body), source_names);
run(&mut session, "component.capture_position", json!({}));
run(&mut session, "joint.origin", json!({"name":"Top joint","occurrence":second,"face":[pick.x,pick.y,pick.z]}));
let joint = session.doc.assembly.origins[0].origin.clone();
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let before_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&before_joint, Vec3::ZERO).dist(pick) < 1e-9);
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":true}));
let moved = session.world_state();
assert!(!Arc::ptr_eq(&world, &moved), "a changed transform must invalidate placed meshes");
assert!(moved.body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert!(world.body(&name).unwrap().mesh().bounds().min.dist(bounds.min) < 1e-9, "prior snapshots must remain immutable");
assert_eq!(crate::doc::naming::face_names(moved.body(&name).unwrap()), source_names);
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let after_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&after_joint, Vec3::ZERO).dist(pick + Vec3::new(5., 0., 0.)) < 1e-9);
let reopened = Session::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
assert!(reopened.world_state().body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert_eq!(reopened.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
}
#[test]
fn selection_visibility_section_and_view_revisions_reuse_world_geometry() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let world = session.world_state();
let revision = session.revision;
run(&mut session, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
assert!(session.revision > revision);
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "select.clear", json!({}));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "browser.visibility", json!({"items":["Block"],"visible":false}));
assert!(!session.body_visible("Block"));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "inspect.section", json!({"plane":{"origin":[0,0,2],"normal":[0,0,1]}}));
assert!(session.section.is_some());
assert!(Arc::ptr_eq(&world, &session.world_state()));
session.invalidate_view(session.revision + 10);
assert!(Arc::ptr_eq(&world, &session.world_state()));
assert!(Arc::ptr_eq(&world, &session.scratch().world_state()));
}
#[test]
fn world_cache_requires_exact_design_model_pending_and_author_context() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let sketch = run(&mut session, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut session, "sketch.finish", json!({}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
let initial = session.world_state();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"capture":false}));
let pending = session.world_state();
assert!(!Arc::ptr_eq(&initial, &pending));
let name = session.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
assert!(pending.body(&name).unwrap().mesh().bounds().min.dist(Vec3::new(10., 20., 0.)) < 1e-9);
run(&mut session, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
let authored = session.world_state();
assert!(!Arc::ptr_eq(&pending, &authored));
assert!(authored.sketch(sketch).unwrap().plane.origin.dist(Vec3::new(10., 20., 0.)) < 1e-9);
let mut scratch = session.scratch();
assert!(Arc::ptr_eq(&authored, &scratch.world_state()));
scratch.active_occurrence_path = Some(vec![first]);
let scratch_world = scratch.world_state();
assert!(!Arc::ptr_eq(&authored, &scratch_world));
assert!(scratch_world.sketch(sketch).unwrap().plane.origin.dist(Vec3::ZERO) < 1e-9);
assert!(Arc::ptr_eq(&authored, &session.world_state()), "scratch context must not replace the source cache");
run(&mut session, "appearance.assign", json!({"bodies":["Block"],"color":"#ff0000"}));
let painted = session.world_state();
assert!(!Arc::ptr_eq(&authored, &painted));
assert_ne!(session.doc.model_body_look(painted.body("Block").unwrap()), None);
run(&mut session, "edit.undo", json!({}));
let undone = session.world_state();
assert!(!Arc::ptr_eq(&painted, &undone));
run(&mut session, "edit.redo", json!({}));
let redone = session.world_state();
assert!(!Arc::ptr_eq(&undone, &redone));
assert!(session.doc.model_body_look(redone.body("Block").unwrap()).is_some());
let before_edit = session.world_state();
run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Other","operation":"new"}));
let edited = session.world_state();
assert!(!Arc::ptr_eq(&before_edit, &edited));
assert!(edited.body("Other").is_some());
session.doc = Arc::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
let replaced = session.world_state();
assert!(!Arc::ptr_eq(&edited, &replaced), "equal contents in a different document cannot share the cache key");
session.model = crate::doc::Model::default();
session.model.evaluate(&session.doc);
assert!(!Arc::ptr_eq(&replaced, &session.world_state()), "a new evaluated model must invalidate even when the document Arc is unchanged");
}
#[test]
fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let source = session.world_state();
let source_json = session.doc.to_json();
let mut scratch = session.scratch();
run(&mut scratch, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Draft","operation":"new"}));
let warmed = scratch.world_state();
assert!(!Arc::ptr_eq(&source, &warmed));
assert_eq!(session.doc.to_json(), source_json);
assert!(Arc::ptr_eq(&source, &session.world_state()));
assert!(source.body("Draft").is_none());
let feature = warmed.body("Draft").unwrap().feature;
session.accept_computed(scratch);
assert!(Arc::ptr_eq(&warmed, &session.world_state()), "adoption/view revision must retain the worker's prepared geometry");
session.invalidate_view(session.revision + 20);
assert!(Arc::ptr_eq(&warmed, &session.world_state()));
run(&mut session, "timeline.rollback", json!({"position":1}));
let rolled = session.world_state();
assert!(!Arc::ptr_eq(&warmed, &rolled));
assert!(rolled.body("Block").is_some());
assert!(rolled.body("Draft").is_none());
run(&mut session, "timeline.rollback", json!({}));
let restored = session.world_state();
assert!(!Arc::ptr_eq(&rolled, &restored));
assert!(restored.body("Draft").is_some());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":true}));
let suppressed = session.world_state();
assert!(!Arc::ptr_eq(&restored, &suppressed));
assert!(suppressed.body("Draft").is_none());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false}));
assert!(session.world_state().body("Draft").is_some());
}
+87 -1
View File
@@ -244,8 +244,24 @@ impl Seg2 {
}
}
}
/// Parameter of the point of the segment closest to `p` (sampling, then Newton).
/// Parameter of the closest point: exact projection for lines,
/// sampling followed by Newton for the remaining curves.
pub fn closest_param(&self, p: Vec2) -> f64 {
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite();
match *self {
Seg2::Line { a, b } if finite(a) && finite(b) && finite(p) => {
let d = b - a;
if a == b {
return 0.0;
}
let denominator = d.len2();
let t = (p - a).dot(d) / denominator;
if denominator > 0.0 && denominator.is_finite() && t.is_finite() {
return t.clamp(0.0, 1.0);
}
}
_ => {}
}
let n = 64;
let mut t =
(0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0);
@@ -540,6 +556,76 @@ impl Region2 {
mod tests {
use super::*;
#[test]
fn closest_line_projection_and_degeneracy() {
let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) };
for (p, t) in [(Vec2::new(-9.0, 20.0), 0.0), (Vec2::new(2.0, -5.0), 0.5), (Vec2::new(15.0, 4.0), 1.0)] {
assert_eq!(line.closest_param(p), t);
assert_eq!(line.reversed().closest_param(p), 1.0 - t);
}
let a = Vec2::new(1e100, -1e100);
let b = a + Vec2::new(1e90, 2e90);
let large = Seg2::Line { a, b };
assert!((large.closest_param(a.lerp(b, 0.5)) - 0.5).abs() < 1e-6);
assert_eq!(Seg2::Line { a, b: a }.closest_param(Vec2::ZERO), 0.0);
let tiny = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-12, 0.0) };
assert_eq!(tiny.closest_param(Vec2::new(0.5e-12, 1.0)), 0.5);
for line in [line, line.reversed(), tiny] {
for k in 0..53 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let closest = line.point_at(line.closest_param(p)).dist(p);
let dense = (0..=8192).map(|i| line.point_at(i as f64 / 8192.0).dist(p)).fold(f64::INFINITY, f64::min);
assert!(closest <= dense + 1e-12);
}
}
}
fn sampled_closest(s: &Seg2, p: Vec2) -> f64 {
let mut t = (0..=64).map(|i| i as f64 / 64.0).min_by(|x, y| s.point_at(*x).dist(p).total_cmp(&s.point_at(*y).dist(p))).unwrap_or(0.0);
for _ in 0..30 {
let (q, d) = (s.point_at(t), s.derivative(t));
let (d2, h) = ((s.derivative((t + 1e-6).min(1.0)) - s.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p);
let df = d.dot(d) + h.dot(d2);
if df.abs() < 1e-300 {
break;
}
let nt = (t - h.dot(d) / df).clamp(0.0, 1.0);
if (nt - t).abs() < 1e-14 {
t = nt;
break;
}
t = nt;
}
t
}
#[test]
fn closest_projection_preserves_clearance_tolerance_and_generic_fallback() {
use std::f64::consts::TAU;
let curves = [
Seg2::Line { a: Vec2::new(-4.0, 7.0), b: Vec2::new(3.0, -2.0) },
Seg2::Arc { center: Vec2::new(3.0, -2.0), radius: 6.0, start: 5.9, sweep: 1.3 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.2, sweep: -TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 0.0 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1e-12 },
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(2.0, 7.0), p2: Vec2::new(6.0, -3.0), p3: Vec2::new(8.0, 4.0) },
];
for (i, s) in curves.iter().enumerate() {
for k in 0..101 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let old = sampled_closest(s, p);
let new = s.closest_param(p);
if i > 0 {
assert_eq!(new.to_bits(), old.to_bits());
}
assert!(s.point_at(new).dist(p) <= s.point_at(old).dist(p) + 1e-10);
let on_curve = s.point_at(k as f64 / 100.0);
assert!(s.point_at(s.closest_param(on_curve)).dist(on_curve) < 1e-9);
}
}
}
#[test]
fn triangulated_area() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
+94 -31
View File
@@ -33,6 +33,8 @@ pub struct Body {
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
/// Exact construction/import source identities, in this body's face order.
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
pub(crate) regularization_protected: bool,
}
#[derive(Clone, Debug, PartialEq)]
@@ -120,6 +122,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
/// `None` when nothing needs splitting or the parameters don't match the surface.
#[allow(clippy::type_complexity)]
fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> {
refined_with_cache(pm, surf, tol, true)
}
#[allow(clippy::type_complexity)]
fn refined_with_cache(pm: &PolygonMesh, surf: &mt::Surface, tol: f64, memoize: bool) -> Option<Vec<[(Vec3, Vec3); 3]>> {
use mt::{ParametricSurface, ParametricSurface3D};
let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals());
if uvs.is_empty() {
@@ -148,6 +155,15 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
// Edges on the face's boundary are shared with the next face: never split them.
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
// Only exact finite values identify reusable evaluations. In particular,
// the rounded UV keys used for boundary incidence cannot identify a point
// whose derivatives or midpoint may be reused.
let vertex_key = |v: &V| {
let values = [v.0.x, v.0.y, v.0.z, v.1.0, v.1.1, v.2.x, v.2.y, v.2.z];
values.iter().all(|x| x.is_finite()).then(|| values.map(f64::to_bits))
};
let cache_limit = cap * 3;
let mut singular_cache = std::collections::HashMap::<(u64, u64), bool>::new();
let mut changed = false;
for _ in 0..4 {
let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new();
@@ -156,34 +172,60 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
*uses.entry(e).or_default() += 1;
}
}
let at = |a: &V, b: &V| -> Option<V> {
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
return None;
let mut edge_cache = std::collections::HashMap::<([u64; 8], [u64; 8]), Option<V>>::new();
let mut at = |a: &V, b: &V| -> Option<V> {
let key = if memoize { vertex_key(a).zip(vertex_key(b)).map(|(a, b)| if a < b { (a, b) } else { (b, a) }) } else { None };
if let Some(key) = key
&& let Some(value) = edge_cache.get(&key)
{
return *value;
}
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
let singular = |x: &V| {
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300
let mut calculate = || -> Option<V> {
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
return None;
}
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
let mut singular = |x: &V| {
let key = (x.1.0.to_bits(), x.1.1.to_bits());
let cacheable = memoize && x.1.0.is_finite() && x.1.1.is_finite();
if cacheable && let Some(value) = singular_cache.get(&key) {
return *value;
}
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
let value = c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300;
if cacheable && singular_cache.len() < cache_limit {
singular_cache.insert(key, value);
}
value
};
if singular(a) || singular(b) {
return None;
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
};
if singular(a) || singular(b) {
return None;
let value = calculate();
if let Some(key) = key
&& edge_cache.len() < cache_limit
&& value.is_none_or(|v| vertex_key(&v).is_some())
{
edge_cache.insert(key, value);
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
value
};
let mut next = Vec::with_capacity(tris.len());
let mut any = false;
@@ -320,6 +362,7 @@ impl Body {
&& self.paint == other.paint
&& self.splits == other.splits
&& self.face_sources == other.face_sources
&& self.regularization_protected == other.regularization_protected
}
pub(crate) fn new(solid: Solid) -> Result<Body> {
@@ -358,9 +401,15 @@ impl Body {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
})
}
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
self.regularization_protected |= input.regularization_protected;
self
}
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
self.face_sources.as_deref().map(Vec::as_slice)
}
@@ -588,6 +637,7 @@ impl Body {
/// The same body with the lines its faces were split along on purpose.
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
self.splits = s.filter(|l| !l.is_empty());
self.regularization_protected |= self.splits.is_some();
self
}
@@ -612,6 +662,7 @@ impl Body {
})
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
.map(std::sync::Arc::new);
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
self
}
@@ -658,11 +709,15 @@ impl Body {
let flip = !face.orientation();
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
// Curved faces: split triangles that cut through the surface.
if refine
&& let Some(sf) = solid_faces.get(fi)
&& !matches!(sf.surface(), mt::Surface::Plane(_))
&& let Some(tris) = refined(&pm, &sf.surface(), tol)
{
let refinement = if refine {
solid_faces.get(fi).and_then(|sf| {
let surface = sf.surface();
if matches!(surface, mt::Surface::Plane(_)) { None } else { refined(&pm, &surface, tol) }
})
} else {
None
};
if let Some(tris) = refinement {
let fid = u32::try_from(fi).unwrap_or(u32::MAX);
for t in tris {
let mut idx = [0u32; 3];
@@ -1019,7 +1074,7 @@ impl Body {
})
.collect()
});
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)))
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(self))
})
.collect()
})
@@ -1029,3 +1084,11 @@ impl Body {
#[cfg(test)]
#[path = "body_lump_tests.rs"]
mod lump_tests;
#[cfg(test)]
#[path = "refine_cache_tests.rs"]
mod refine_cache_tests;
#[cfg(test)]
#[path = "regularization_protection_tests.rs"]
mod regularization_protection_tests;
+2
View File
@@ -109,6 +109,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
})
}
@@ -201,6 +202,7 @@ impl Body {
paint: self.paint.clone(),
splits: self.splits.clone(),
face_sources: None,
regularization_protected: self.regularization_protected,
})
}
}
+9 -3
View File
@@ -221,6 +221,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
)),
};
}
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
}
@@ -538,7 +539,11 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
_ => {
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
Body::new(solid).map(Some)
let protected = pieces.iter().any(|piece| piece.regularization_protected);
Body::new(solid).map(|mut body| {
body.regularization_protected = protected;
Some(body)
})
}
}
}
@@ -558,8 +563,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
);
// An affine placement retains face order and appearance metadata.
let sources = body.transformed_sources(m)?;
Body::new(placed_topology(&body.solid, mat)?)
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources))
Body::new(placed_topology(&body.solid, mat)?).map(|placed| {
placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)
})
})
}
+49 -3
View File
@@ -117,6 +117,36 @@ fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &
impl Body {
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
self.regularize_planar_faces(false)
}
/// Only newly produced Cut walls call this path. The temporary planes carry
/// construction support, never persistent ancestor/source-role authority.
pub(crate) fn regularize_generated_planar_faces(self) -> Result<Self> {
let body = self.regularize_planar_faces(true)?;
let sources = body.face_sources().map(|sources| {
sources
.iter()
.map(|source| {
let mut source = source.clone();
if let Some(super::FaceSource::ExtrudeSide { plane, coalesced, .. }) = &mut source {
if plane.is_some() && !*coalesced {
source = None;
} else {
*plane = None;
}
}
source
})
.collect()
});
Ok(body.with_face_sources(sources.map(Arc::new)))
}
fn regularize_planar_faces(self, generated: bool) -> Result<Self> {
if self.regularization_protected {
return Ok(self);
}
let Some(sources) = self.face_sources() else { return Ok(self) };
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
return Ok(self);
@@ -148,7 +178,16 @@ impl Body {
.zip(sources)
.map(|(f, s)| {
s.as_ref()
.and_then(super::planar_sources::certified_plane)
.and_then(|source| {
if generated {
match source {
super::FaceSource::ExtrudeSide { segment: solvecraft_geom::Seg2::Line { .. }, plane: Some(p), .. } => Some(*p),
_ => None,
}
} else {
super::planar_sources::certified_plane(source)
}
})
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
})
.collect();
@@ -179,7 +218,7 @@ impl Body {
};
let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
identity = left;
if sa != sb {
if !generated && sa != sb {
continue;
}
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
@@ -235,7 +274,14 @@ impl Body {
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
metadata = left;
}
output_sources.push(source.cloned());
let mut source = source.cloned();
if generated
&& replacements.contains_key(&leader)
&& let Some(super::FaceSource::ExtrudeSide { coalesced, .. }) = &mut source
{
*coalesced = true;
}
output_sources.push(source);
offset += 1;
}
shells.push(mt::Shell::from(output));
+4 -2
View File
@@ -114,13 +114,15 @@ impl Body {
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
/// supports without a competing coplanar tool. No imported or fitted role is inferred.
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
let self_ = self.inherit_regularization_protection(input).inherit_regularization_protection(tool);
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
return Ok(self);
return Ok(self_);
}
self.map_planar_sources(input, None, Some(tool), &[], constructed)
self_.map_planar_sources(input, None, Some(tool), &[], constructed)
}
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
self.regularization_protected |= input.regularization_protected;
let Some(sources) = input.face_sources() else { return Ok(self) };
if input.solid.face_iter().count() > 16_384
|| self.solid.face_iter().count() > 16_384
+9 -1
View File
@@ -342,7 +342,15 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
/// by the thickness (open faces outward, past the body) keeping the body's topology, which
/// gives the cavity; the shell is the body minus the cavity.
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
// Face paint and authored splits are construction boundaries. Retain their
// eligibility veto through the temporary body used for the cavity Cut.
// Paint indices stay in source order by avoiding healing in that case.
let solid = if b.paint().is_some_and(|paint| !paint.faces.is_empty()) {
b.deep_copy()
} else {
crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep())
};
let healed = Body::new(solid)?.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b);
let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
// Faces to open: the face of the triangle nearest each point.
+95 -2
View File
@@ -848,6 +848,16 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
}
let mut faces: Vec<FaceSpec> = Vec::new();
let mut sources = Vec::new();
let frame_is_orthonormal = plane.origin.is_finite()
&& plane.x.is_finite()
&& plane.y.is_finite()
&& d.is_finite()
&& (plane.x.len2() - 1.).abs() <= 64. * f64::EPSILON
&& (plane.y.len2() - 1.).abs() <= 64. * f64::EPSILON
&& (d.len2() - 1.).abs() <= 64. * f64::EPSILON
&& plane.x.dot(plane.y).abs() <= 64. * f64::EPSILON
&& plane.x.dot(d).abs() <= 64. * f64::EPSILON
&& plane.y.dot(d).abs() <= 64. * f64::EPSILON;
for (first, end, sg) in runs {
let (z0, z1) = (levels[first], levels[end]);
let (a, b) = (sg.start(), sg.end());
@@ -868,7 +878,28 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
// Axis-aligned generated lines have a common oriented support frame
// independent of axial stations or along-line endpoint interpolation.
let generated_plane = if coalesce
&& frame_is_orthonormal
&& let Seg2::Line { a, b } = sg
{
let (origin, x) = if a.y == b.y && a.x != b.x {
(at(Vec2::new(0., a.y), 0.), plane.x * (b.x - a.x).signum())
} else if a.x == b.x && a.y != b.y {
(at(Vec2::new(a.x, 0.), 0.), plane.y * (b.y - a.y).signum())
} else {
(Vec3::ZERO, Vec3::ZERO)
};
(origin.is_finite() && x.len2() > 0.).then_some(Plane { origin, x, y: d })
} else {
None
};
sources.push((generated_plane.is_some() || end > first + 1).then_some(crate::FaceSource::ExtrudeSide {
segment: sg,
plane: generated_plane,
coalesced: end > first + 1,
}));
}
for (z, up, rs) in &caps {
for r in rs {
@@ -885,7 +916,11 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
sources.push(None);
}
}
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
Some(crate::sew::sew_touching_with_sources(&faces, &sources).and_then(
|body| {
if coalesce { body.regularize_generated_planar_faces() } else { Ok(body) }
},
))
}
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1109,6 +1144,8 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
&slabs,
tol,
op == BoolOp::Cut
&& !a.regularization_protected
&& !b.regularization_protected
&& a.split_keep().is_empty()
&& b.split_keep().is_empty()
&& a.paint().is_none_or(|p| p.faces.is_empty())
@@ -1216,6 +1253,62 @@ mod planar_tests {
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
}
fn stepped_tab_stock() -> Body {
let stock = crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap();
let tab = crate::box_solid(Vec3::new(-12., -25., 8.), Vec3::new(-4., -15., 16.)).unwrap();
crate::boolean(&stock, &tab, BoolOp::Union).unwrap().unwrap()
}
fn coplanar_joins(body: &Body) -> usize {
let faces: Vec<_> = body.solid.face_iter().collect();
let mut adjacency = std::collections::HashMap::<_, Vec<usize>>::new();
for (i, face) in faces.iter().enumerate() {
for edge in face.edge_iter() {
adjacency.entry(edge.id()).or_default().push(i);
}
}
adjacency
.values()
.filter(|faces| {
let [a, b] = faces.as_slice() else { return false };
let (Some(pa), Some(pb)) = (body.planar_face(*a), body.planar_face(*b)) else { return false };
let (na, nb) = (pa.normal(), pb.normal());
na.dot(nb) > 1. - 1e-12 && (pb.origin - pa.origin).dot(na).abs() < 1e-10
})
.count()
}
#[test]
fn shell_of_joined_stepped_tabs_has_no_artificial_planar_stations() {
let stock = stepped_tab_stock();
let snapshot = crate::step_export(&[&stock], "unchanged stock").unwrap();
assert_eq!(coplanar_joins(&stock), 0);
let shell = crate::shell(&stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap();
assert_eq!(coplanar_joins(&shell), 0, "Shell must not add internal edges across continuous planar walls");
assert!(shell.validity().is_empty());
let mesh = shell.tessellate(1e-5).unwrap();
let expected = 40. * 30. * 24. + 8. * 10. * 8. - (38. * 28. * 23. + 6. * 10. * 6.);
assert!((mesh.measure().volume - expected).abs() < 1e-6);
for (point, material) in [
(Vec3::new(0., 0., 0.5), true),
(Vec3::new(0., 0., 10.), false),
(Vec3::new(0., -14.5, 10.), true),
(Vec3::new(19.5, 0., 10.), true),
(Vec3::new(-8., -24.5, 12.), true),
(Vec3::new(-8., -20., 12.), false),
(Vec3::new(0., -18., 9.), false),
] {
assert_eq!(mesh.contains(point), material, "wrong material at {point:?}");
}
let step = crate::step_export(&[&shell], "stepped tab shell").unwrap();
let reopened = crate::step_import(&step).unwrap();
let body = &reopened.bodies[0].body;
assert_eq!(body.face_count(), shell.face_count());
assert_eq!(coplanar_joins(body), 0);
assert!((body.tessellate(1e-5).unwrap().measure().volume - expected).abs() < 1e-6);
assert_eq!(crate::step_export(&[&stock], "unchanged stock").unwrap(), snapshot);
}
#[test]
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
for curved in [false, true] {
+53
View File
@@ -0,0 +1,53 @@
use super::*;
fn bits(v: Vec3) -> [u64; 3] {
[v.x.to_bits(), v.y.to_bits(), v.z.to_bits()]
}
fn check_body(body: &Body) -> usize {
let meshes = mesh_shells(&body.solid, 0.5);
let mut changed = 0;
for (face, meshed) in body.solid.face_iter().zip(meshes.iter().flat_map(|shell| shell.face_iter())) {
let Some(mesh) = meshed.surface() else { continue };
let surface = face.surface();
let cached = refined_with_cache(&mesh, &surface, 0.002, true);
let original = refined_with_cache(&mesh, &surface, 0.002, false);
match (cached, original) {
(None, None) => {}
(Some(cached), Some(original)) => {
changed += 1;
assert_eq!(cached.len(), original.len(), "triangle count/winding must not change");
for (cached, original) in cached.into_iter().zip(original) {
for ((point, normal), (old_point, old_normal)) in cached.into_iter().zip(original) {
assert_eq!(bits(point), bits(old_point), "every refined coordinate must remain bit-identical");
assert_eq!(bits(normal), bits(old_normal), "every refined normal must remain bit-identical");
}
}
}
_ => panic!("memoization changed the refine/no-refine decision"),
}
}
changed
}
#[test]
fn exact_refinement_cache_preserves_periodic_seams_and_normals() {
let cylinder = crate::cylinder(Vec3::ZERO, Vec3::Z, 15., 8.).unwrap();
let torus = crate::torus(Vec3::ZERO, 18., 4.).unwrap();
assert!(check_body(&cylinder) > 0, "the fixture must actually refine");
assert!(check_body(&torus) > 0, "the periodic fixture must actually refine");
}
#[test]
fn exact_refinement_cache_preserves_singular_poles_and_placed_surfaces() {
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
assert!(check_body(&sphere) > 0, "the polar fixture must actually refine");
let placed = crate::transform(&sphere, Vec3::new(120., -85., 40.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.371).unwrap();
assert!(check_body(&placed) > 0);
}
#[test]
fn exact_refinement_cache_keeps_unnecessary_planar_refinement_absent() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
assert_eq!(check_body(&body), 0);
}
@@ -0,0 +1,65 @@
use super::*;
use solvecraft_geom::Plane;
fn stamped_stock() -> Body {
crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap().with_box_certificate(7).unwrap()
}
fn cup(stock: &Body) -> Body {
crate::shell(stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap().with_shell_certificate(9).unwrap()
}
#[test]
fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
let stock = stamped_stock();
let ordinary = cup(&stock);
assert!(!ordinary.regularization_protected);
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let side = stock
.solid
.face_iter()
.enumerate()
.find(|(i, _)| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.))
.unwrap()
.0;
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
assert!(split.split_lines().is_some());
for protected in [painted, split] {
let result = cup(&protected);
assert!(result.regularization_protected);
assert!(result.face_count() > ordinary.face_count(), "authored boundaries must survive both Shell regularizers");
assert!(result.validity().is_empty());
let expected = 40. * 30. * 24. - 38. * 28. * 23.;
let mesh = result.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 1e-6);
assert!(mesh.contains(Vec3::new(19.5, 0., 12.)));
assert!(!mesh.contains(Vec3::new(0., 0., 12.)));
let count = result.face_count();
assert_eq!(result.clone().regularize_certified_planar_faces().unwrap().face_count(), count);
assert_eq!(result.clone().regularize_generated_planar_faces().unwrap().face_count(), count);
let placed = crate::transform(&result, Vec3::new(30., -40., 7.), Vec3::ZERO, Vec3::Z, 0.3).unwrap();
assert!(placed.regularization_protected);
for lump in placed.lumps().unwrap() {
assert!(lump.regularization_protected);
}
let cleared = result.clone().with_paint(None).with_split_lines(None);
assert!(cleared.regularization_protected, "cleared appearance must not erase inherited boundary protection");
assert_eq!(cleared.regularize_certified_planar_faces().unwrap().face_count(), count);
}
}
#[test]
fn protection_is_identity_and_survives_tool_operand_and_compounds() {
let a = stamped_stock();
let protected = a.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1. }] }));
let cleared = protected.with_paint(None);
assert!(!a.same(&cleared));
let tool = crate::box_solid(Vec3::new(-5., -5., 23.), Vec3::new(5., 5., 30.)).unwrap().inherit_regularization_protection(&cleared);
let cut = crate::boolean(&a, &tool, crate::BoolOp::Cut).unwrap().unwrap();
assert!(cut.regularization_protected);
let remote = crate::box_solid(Vec3::new(100., 0., 0.), Vec3::new(110., 10., 10.)).unwrap();
let joined = crate::ops::join_pieces(vec![cleared, remote]).unwrap().unwrap();
assert!(joined.regularization_protected);
assert_eq!(joined.lumps().unwrap().len(), 2);
assert!(joined.lumps().unwrap().iter().all(|body| body.regularization_protected));
}
+1
View File
@@ -886,6 +886,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
paint: None,
splits: body.splits.clone(),
face_sources: None,
regularization_protected: true,
},
imp,
))
+2
View File
@@ -30,6 +30,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
}
}
@@ -47,6 +48,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
})
}
}
+59 -5
View File
@@ -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));
+335
View File
@@ -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>(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>(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>(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<char> = 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>(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<Key>,
}
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<egui::Event>) {
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>(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);
}
}
+18 -1
View File
@@ -102,6 +102,8 @@ fn start(app: &mut SolveApp) {
#[cfg(not(target_arch = "wasm32"))]
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
let profile = std::env::var_os("SOLVECRAFT_PROFILE_OPEN").is_some();
let began = std::time::Instant::now();
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
@@ -113,16 +115,31 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can
"file.insert_step"
};
let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?;
let geometry_ms = began.elapsed().as_secs_f64() * 1000.0;
let view_began = std::time::Instant::now();
phase.store(1, Ordering::Relaxed);
let world = session.world_state();
let placement_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let mesh_began = std::time::Instant::now();
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
for b in &session.world_state().bodies {
for b in &world.bodies {
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
let _ = b.mesh();
}
let tessellation_ms = mesh_began.elapsed().as_secs_f64() * 1000.0;
phase.store(2, Ordering::Relaxed);
let view_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let thumbnail_began = std::time::Instant::now();
let image = if insert { None } else { crate::home::remember(&session) };
if profile {
eprintln!(
"SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}",
thumbnail_began.elapsed().as_secs_f64() * 1000.0,
began.elapsed().as_secs_f64() * 1000.0
);
}
Ok(Made { session, result, image })
}
+2
View File
@@ -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;
+65 -74
View File
@@ -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<char> = 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<Suggestion> {
return Vec::new();
}
let all = ctx.data(|d| d.get_temp::<Cache>(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<String> = 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<char> = 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)]
+57
View File
@@ -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).
@@ -56,6 +58,8 @@ pub struct Prefs {
pub ui_scale: f32,
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
pub zoom_to_cursor: bool,
/// Spread discrete wheel input across frames (off: apply each event immediately).
pub smooth_wheel_zoom: bool,
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
pub look_at_anim: bool,
pub look_at_speed: f32,
@@ -70,6 +74,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,
@@ -81,6 +86,7 @@ impl Default for Prefs {
language: "en".into(),
ui_scale: 1.0,
zoom_to_cursor: true,
smooth_wheel_zoom: true,
look_at_anim: true,
look_at_speed: 1.0,
up_axis: "z".into(),
@@ -178,8 +184,10 @@ 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.smooth_wheel_zoom = d.smooth_wheel_zoom;
p.orbit_cursor = d.orbit_cursor;
p.space_mouse_enabled = d.space_mouse_enabled;
p.space_mouse_speed = d.space_mouse_speed;
@@ -563,6 +571,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();
@@ -570,6 +581,10 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
ui.checkbox(&mut app.preferences.zoom_to_cursor, "")
.on_hover_text("The wheel zooms toward the point under the cursor (off: toward the view's centre)");
ui.end_row();
ui.label("Smooth wheel zoom");
ui.checkbox(&mut app.preferences.smooth_wheel_zoom, "")
.on_hover_text("Turn off to apply each wheel event immediately, without a smoothing tail");
ui.end_row();
ui.label("Orbit around");
ui.horizontal(|ui| {
ui.radio_value(&mut app.preferences.orbit_cursor, false, "View centre");
@@ -753,6 +768,48 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
mod tests {
use super::*;
#[test]
fn radial_context_menu_defaults_off_and_follows_preferences_transactions() {
assert!(!Prefs::default().radial_context_menu);
let old: Prefs = serde_json::from_value(json!({"nav":"solidworks","zoom_reverse":true})).unwrap();
assert!(!old.radial_context_menu && old.zoom_reverse && old.nav == "solidworks");
let mut h = crate::scenario::Harness::new();
h.call("ui.prefs", json!({"open":"Navigation"}));
h.step(&json!({"widget":"Radial context menu"})).unwrap();
assert!(h.app.preferences.radial_context_menu, "the actual checkbox enables radial menus");
let mut restored = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
restored.load_prefs(&h.app.prefs());
assert!(restored.preferences.radial_context_menu, "enabled preference survives save/load");
h.call("ui.prefs", json!({"button":"cancel"}));
assert!(!h.app.preferences.radial_context_menu, "Cancel restores the default-off setting");
h.call("ui.prefs", json!({"open":"Navigation","radial_context_menu":true}));
h.call("ui.prefs", json!({"button":"apply"}));
h.call("ui.prefs", json!({"button":"defaults"}));
assert!(!h.app.preferences.radial_context_menu, "Navigation defaults disable the ring");
h.call("ui.prefs", json!({"button":"cancel"}));
assert!(h.app.preferences.radial_context_menu, "Cancel restores the last Apply");
}
#[test]
fn wheel_smoothing_persists_and_follows_dialog_buttons() {
assert!(serde_json::from_value::<Prefs>(json!({})).unwrap().smooth_wheel_zoom);
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(app.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"open":"Navigation", "smooth_wheel_zoom":false})).unwrap();
control(&mut app, &json!({"button":"apply"})).unwrap();
control(&mut app, &json!({"smooth_wheel_zoom":true, "button":"cancel"})).unwrap();
assert!(!app.preferences.smooth_wheel_zoom);
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert!(!other.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"open":"Navigation", "button":"defaults"})).unwrap();
assert!(app.preferences.smooth_wheel_zoom);
control(&mut app, &json!({"button":"cancel"})).unwrap();
assert!(!app.preferences.smooth_wheel_zoom);
}
#[test]
fn presets_map_the_mouse_and_prefs_round_trip() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
+56 -5
View File
@@ -263,9 +263,9 @@ pub fn set_dark(dark: bool) {
DARK.store(dark, Ordering::Relaxed);
}
pub fn apply(ctx: &egui::Context) {
let t = Tokens::get();
let mut v = if is_dark() { egui::Visuals::dark() } else { egui::Visuals::light() };
fn visuals(dark: bool) -> 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");
}
}
}
+203 -63
View File
@@ -62,6 +62,8 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>,
/// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool,
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
wheel_zoom_raw_until_idle: bool,
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
pub axis_drag: Option<Pos2>,
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
@@ -1471,6 +1473,34 @@ pub fn delete_selection(app: &mut SolveApp) {
crate::delete::delete_selection(app);
}
/// Match egui's wheel units and modifier routing, without its temporal smoothing.
fn immediate_wheel_delta(input: &egui::InputState, options: egui::InputOptions, page_height: f32) -> f32 {
input
.events
.iter()
.filter_map(|event| {
let egui::Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move } = event else { return None };
if modifiers.matches_any(options.zoom_modifier) {
return None;
}
let delta = match unit {
egui::MouseWheelUnit::Point => *delta,
egui::MouseWheelUnit::Line => *delta * options.line_scroll_speed,
egui::MouseWheelUnit::Page => *delta * page_height,
};
let horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
let vertical = modifiers.matches_any(options.vertical_scroll_modifier);
Some(if horizontal && !vertical {
0.0
} else if vertical && !horizontal {
delta.x + delta.y
} else {
delta.y
})
})
.sum()
}
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let rect = ui.max_rect();
@@ -1499,6 +1529,18 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::drag_snap::set_suppressed(mods.ctrl || mods.alt || mods.command);
// ---- navigation ----
if !app.preferences.smooth_wheel_zoom {
app.viewport.wheel_zoom_raw_until_idle = true;
} else if !ui.input(|i| i.is_scrolling()) {
app.viewport.wheel_zoom_raw_until_idle = false;
}
let scroll = if app.viewport.wheel_zoom_raw_until_idle {
let options = ui.ctx().options(|o| o.input_options);
let page_height = ui.ctx().viewport_rect().height();
ui.input(|i| immediate_wheel_delta(i, options, page_height))
} else {
scroll
};
let h = rect.height().max(1.0) as f64;
if inside && scroll != 0.0 {
app.viewport.orbit_snap = None;
@@ -1534,7 +1576,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if mode == Some(NavMode::Orbit) {
let press = ui.input(|i| i.pointer.press_origin());
app.cancel_view_animation();
orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, mods.shift, true);
// Shift can be required to enter orbit (Fusion/Inventor middle drag), rather
// than an additional request for snapping. Only an extra modifier snaps.
let without_shift = egui::Modifiers { shift: false, ..mods };
let snap = mods.shift && (!middle || crate::prefs::nav_mode(app, middle, secondary_down, without_shift) == Some(NavMode::Orbit));
orbit_drag(app, rect, f64::from(delta.x), f64::from(delta.y), press, snap, true);
} else {
if matches!(mode, Some(NavMode::Pan | NavMode::Zoom)) {
app.viewport.orbit_snap = None;
@@ -3109,72 +3155,144 @@ mod tests {
}
}
#[test]
fn immediate_wheel_zoom_applies_normalized_input_once() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.smooth_wheel_zoom = false;
h.app.preferences.zoom_to_cursor = false;
h.call("ui.move", json!({"x":800,"y":500}));
let before = h.app.cam;
let line = h.ctx.options(|o| o.input_options.line_scroll_speed);
for (unit, amount, points) in
[(egui::MouseWheelUnit::Point, 120.0, 120.0), (egui::MouseWheelUnit::Line, 1.0, line), (egui::MouseWheelUnit::Page, 0.1, 100.0)]
{
let height = h.app.cam.half_height();
h.app.synthetic.push(egui::Event::MouseWheel {
unit,
delta: vec2(0.0, amount),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.frame();
let expected = height * (-f64::from(points) * 0.0015).exp();
assert!((h.app.cam.half_height() - expected).abs() < 1e-6);
let after = h.app.cam;
h.frames(30);
assert_eq!(h.app.cam, after, "idle frames must not replay wheel input");
}
assert_eq!(h.app.cam.target, before.target);
h.app.preferences.zoom_reverse = true;
let height = h.app.cam.half_height();
h.call("ui.scroll", json!({"x":800,"y":500,"delta":120}));
assert!((h.app.cam.half_height() - height * 0.18_f64.exp()).abs() < 1e-6);
}
#[test]
fn wheel_zoom_setting_changes_do_not_replay_smoothing() {
let mut h = crate::scenario::Harness::new();
h.app.preferences.zoom_to_cursor = false;
h.call("ui.move", json!({"x":800,"y":500}));
h.app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.frame();
assert!(h.ctx.input(|i| i.is_scrolling()));
h.app.preferences.smooth_wheel_zoom = false;
let stopped = h.app.cam;
h.frame();
assert_eq!(h.app.cam, stopped);
h.app.preferences.smooth_wheel_zoom = true;
h.frames(90);
assert_eq!(h.app.cam, stopped, "re-enabling must not replay the old smoothing tail");
assert!(!h.app.viewport.wheel_zoom_raw_until_idle);
h.app.preferences.smooth_wheel_zoom = false;
for _ in 0..3 {
h.app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 120.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
h.frame();
}
assert!((h.app.cam.half_height() - stopped.half_height() * (-0.54_f64).exp()).abs() < 1e-6);
let after = h.app.cam;
h.frames(90);
assert_eq!(h.app.cam, after);
}
#[test]
fn shortcuts_window_wheel_does_not_zoom_the_viewport() {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.viewport.no_pixels = true;
crate::help::run(&mut app, &ctx, "shortcuts");
let mut time = 0.0;
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
time += 1.0 / 60.0;
let mut events = Vec::new();
if let Some(p) = pointer {
events.push(egui::Event::PointerMoved(p));
for smooth in [true, false] {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solvecraft_engine::Session::default(), crate::Services::default());
app.preferences.smooth_wheel_zoom = smooth;
app.viewport.no_pixels = true;
crate::help::run(&mut app, &ctx, "shortcuts");
let mut time = 0.0;
let mut frame = |app: &mut SolveApp, pointer: Option<Pos2>, wheel: f32| {
time += 1.0 / 60.0;
let mut events = Vec::new();
if let Some(p) = pointer {
events.push(egui::Event::PointerMoved(p));
}
if wheel != 0.0 {
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, wheel),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
}
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
time: Some(time),
events,
..Default::default()
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
crate::prefs::show(app, ui.ctx());
},
);
output.textures_delta.clear();
};
// Let egui establish the floating window's size and hit-test widgets.
for _ in 0..4 {
frame(&mut app, None, 0.0);
}
if wheel != 0.0 {
events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, wheel),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
});
}
let mut output = ctx.run_ui(
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
time: Some(time),
events,
..Default::default()
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| show(app, ui));
crate::prefs::show(app, ui.ctx());
},
);
output.textures_delta.clear();
};
// Let egui establish the floating window's size and hit-test widgets.
for _ in 0..4 {
frame(&mut app, None, 0.0);
}
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
let list = window.center() + vec2(140.0, 0.0);
let height = app.cam.half_height();
let target = app.cam.target;
frame(&mut app, Some(list), 0.0);
for n in 0..12 {
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
assert_eq!(app.cam.target, target);
}
// The window's non-scrolling sidebar must also shield the model.
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
assert_eq!(app.cam.half_height(), height);
// An exposed part of the viewport still accepts wheel navigation with the popup open.
let exposed = pos2(40.0, 700.0);
assert!(!window.contains(exposed));
frame(&mut app, Some(exposed), 0.0);
frame(&mut app, Some(exposed), -120.0);
assert_ne!(app.cam.half_height(), height);
// Closing the popup returns its former area to the viewport.
app.prefs_window.open = false;
for _ in 0..12 {
let window = ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap();
let list = window.center() + vec2(140.0, 0.0);
let height = app.cam.half_height();
let target = app.cam.target;
frame(&mut app, Some(list), 0.0);
for n in 0..12 {
frame(&mut app, Some(list), if n == 0 { -120.0 } else { 0.0 });
assert_eq!(app.cam.half_height(), height, "wheel input over shortcuts must stay in the popup");
assert_eq!(app.cam.target, target);
}
// The window's non-scrolling sidebar must also shield the model.
frame(&mut app, Some(window.left_center() + vec2(30.0, 0.0)), -120.0);
assert_eq!(app.cam.half_height(), height);
// An exposed part of the viewport still accepts wheel navigation with the popup open.
let exposed = pos2(40.0, 700.0);
assert!(!window.contains(exposed));
frame(&mut app, Some(exposed), 0.0);
frame(&mut app, Some(exposed), -120.0);
assert_ne!(app.cam.half_height(), height);
// Closing the popup returns its former area to the viewport.
app.prefs_window.open = false;
for _ in 0..12 {
frame(&mut app, Some(list), 0.0);
}
let height = app.cam.half_height();
frame(&mut app, Some(list), -120.0);
assert_ne!(app.cam.half_height(), height);
}
let height = app.cam.half_height();
frame(&mut app, Some(list), -120.0);
assert_ne!(app.cam.half_height(), height);
}
#[test]
@@ -3408,7 +3526,8 @@ mod tests {
h.call("ui.view", json!({"view":"front"}));
let before = h.app.cam;
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40}));
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam);
assert!((h.app.cam.yaw - 1.76).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam);
assert!(h.app.viewport.orbit_snap.is_none());
h.call("ui.view", json!({"view":"front"}));
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"}));
assert_eq!(h.app.cam.yaw, before.yaw);
@@ -3416,6 +3535,27 @@ mod tests {
assert_ne!(h.app.cam.target, before.target);
}
#[test]
fn preset_shift_middle_orbit_is_free_but_shift_right_orbit_snaps() {
for preset in ["fusion", "inventor"] {
let mut h = crate::scenario::Harness::new();
h.app.preferences.nav = preset.into();
h.call("ui.view", json!({"view":"front"}));
let before = h.app.cam;
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle","shift":true,"steps":20}));
let mut expected = before;
expected.orbit(50.0, 20.0);
assert!((h.app.cam.yaw - expected.yaw).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
assert!((h.app.cam.pitch - expected.pitch).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
assert_eq!(h.app.cam.target, before.target);
assert_eq!(h.app.cam.distance, before.distance);
assert!(h.app.viewport.orbit_snap.is_none());
h.call("ui.view", json!({"view":"front"}));
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"right","shift":true,"steps":40}));
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "{preset}: {:?}", h.app.cam);
}
}
#[test]
fn axis_view_click_again_flips_and_drag_snaps() {
let mut h = crate::scenario::Harness::new();
+108
View File
@@ -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);
}
+25
View File
@@ -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
+6
View File
@@ -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
+8 -2
View File
@@ -14,8 +14,9 @@
row gives that item's menu. Rows drag into groups and other components.
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
preset in Preferences switches to SolidWorks or Inventor style). Hold Shift during an orbit
to snap to the nearest standard or diagonal view; the default Shift+middle gesture also snaps.
preset in Preferences switches to SolidWorks or Inventor style). Shift+middle orbits freely
in the default and Inventor presets. Shift+right-drag snaps to the nearest standard or
diagonal view; Shift does not request snapping when the preset already requires it to orbit.
The axis view shows the world's X/Y/Z directions in SolidCraft's colors. Click a signed
axis tip to look along that axis; click the aligned tip again to look from the opposite side.
Drag the axis view to orbit around the view centre, with Shift for snapping. Top and bottom
@@ -38,5 +39,10 @@ Preferences (File › Preferences) hold the theme, the units of new designs, the
visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard
Shortcuts changes them.
Navigation › **Smooth wheel zoom** is on by default. Turn it off to apply each wheel or
trackpad event immediately, with no smoothing tail. It is independent of **Animate view
changes**, which controls axis views, Look At, Home and Fit. Re-enabling smoothing during
a wheel gesture takes effect after that gesture ends, avoiding leftover zoom being replayed.
Shift-orbit snapping includes 45-degree edge views and isometric corner views, with a
lightly damped spring transition and a small boundary margin to prevent flicker.
+1 -1
View File
@@ -70,7 +70,7 @@ rolls the numbering back with the design), so they can go straight into later co
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
+90
View File
@@ -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`).
+55
View File
@@ -1,5 +1,60 @@
# Design loading
## Cold geometry rebuilds after 0.3.1
Three alternating cold-process comparisons on the same Windows machine and optimized
`ci` profile, with no concurrent build, compare the unchanged 0.3.1 source with the
straight-line projection and exact surface-refinement reuse changes:
| Saved design | Before, median evaluation | After | Reduction |
|---|---:|---:|---:|
| Public sensor enclosure, 21 features, 52 KB | 11.96 s | 8.85 s | 26.0% |
| Local Pi case assembly, 25 features, 2,990 bodies | 14.75 s | 11.57 s | 21.6% |
These are geometry rebuild times, not complete desktop opening times. The public fixture
can be generated with `solvecraft-cli run examples/acceptance/sensor_enclosure.json
--save sensor.solvecraft --quiet`, then measured with `solvecraft-cli bench-open
sensor.solvecraft`. Each benchmark starts a new process; this does not measure a warm
timeline-cache hit. The local assembly is private and is not part of the repository.
Straight segments now use guarded algebraic closest-point projection instead of sampling
65 points and running Newton iterations. Other curve types retain their existing behavior.
Curved-face refinement reuses derivative and midpoint evaluations with exact finite input
keys and bounded, local caches. It keeps the original tolerance, seam and singularity
checks, refinement passes, and triangle limits.
Identity assembly placements retain the definition body's shared display-mesh cache.
Changed placements continue to build separate meshes in their own coordinates. Face-name
preparation remains eager during placement: deferring it merely shifted expensive work to
thumbnails and the first face query, so that approach was excluded.
Both saved fixtures retain exactly the same body names, face/edge/vertex/shell counts,
bounds, volumes, and surface areas. Focused tests compare cached and uncached refinement
coordinates and normals bit for bit on periodic, singular and transformed surfaces.
For desktop phase profiling, set `SOLVECRAFT_PROFILE_OPEN=1` before launching the app.
Interactive Open reports geometry, placement, tessellation, thumbnail and total worker
times to stderr without logging the source path. Thumbnails were about 0.1 seconds of
the slow assembly's open and were not the main bottleneck. Further cold Boolean and
import costs remain; these changes do not make all documents open instantly.
### Prepared assembly geometry and selection
Prepared world geometry is now keyed by the document, evaluated model and exact placement
context instead of the session revision. Selecting a face or edge, changing visibility,
or moving the view no longer discards already prepared assembly bodies and meshes.
Geometry edits, pending occurrence moves, authoring context changes, undo/redo and timeline
rollback still invalidate the cache. Scratch sessions share prepared geometry while their
inputs match, and adopting a computed session retains its warm cache.
On the private 2,990-body Pi assembly, a matched Linux desktop control probe reduced an
edge-selection round trip from 9.24 s to 0.57 s. Initial opening stayed essentially unchanged
(33.54 s to 33.32 s); this fixes a separate selection stall, not cold geometry evaluation.
Three fresh Windows desktop runs with the corrected cache measured a median 15.87 s to
ready: 13.04 s geometry, 2.20 s placement, 0.53 s meshing and 0.07 s thumbnails. These used
the optimized `ci` profile and actual Vulkan rendering, with isolated settings and caches.
Linux and Windows timings are separate measurements and must not be compared as a speedup.
`solvecraft-cli bench-open design.solvecraft` reports JSON lines for file reading,
JSON decoding and migrations (including input-name repair), timeline evaluation, individual
features, and world placement/display meshing. `--parse-only` stops before geometry evaluation.
+1 -1
View File
@@ -54,7 +54,7 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
2. Update `[workspace.package] version` in root `Cargo.toml`. Then run
`cargo metadata --format-version 1 --no-deps > /dev/null` to refresh workspace package
versions in `Cargo.lock`. Inspect the diff and keep unrelated dependency changes out.
SolidCraft does not currently implement the siblings' `cargo xtask version set` command.
SolidCraft does not currently implement `cargo xtask version set`.
3. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
Commit the version files through the normal review flow and land them on `main`.
Do not use `[skip ci]`. Record the exact commit SHA to release. If it changes, revalidate it.
+48 -12
View File
@@ -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"; };
});
};
}
+4 -2
View File
@@ -62,7 +62,9 @@ Url=$base_url/flatpak/repo/
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolidCraft Flatpak repository</title></head><body><main><h1>SolidCraft Flatpak repository</h1><p>Latest published release: <strong>$tag</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolidCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
flatpak install solvecraft $app_id</pre></main></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolidCraft Flatpak repository</title></head><body><main><h1>SolidCraft Flatpak repository</h1><p>Latest published release: <strong>$tag</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolidCraft repository</a></p><p>Flathub provides the required Freedesktop runtime. Add both remotes to the same user installation:</p><pre>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</pre></main></body></html>
HTML
touch "$site/.nojekyll"
+4 -2
View File
@@ -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"
+260
View File
@@ -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)
+93
View File
@@ -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()
+82
View File
@@ -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("<H", data, at, value)
def u32(at, value): struct.pack_into("<I", data, at, value)
u16(0, 0x5A4D)
u32(0x3C, 0x80)
u32(0x80, 0x4550)
u16(0x86, 1)
optional = 0x98
size = 240 if pe64 else 224
u16(0x94, size)
u16(optional, 0x20B if pe64 else 0x10B)
directories = optional + (112 if pe64 else 96)
u32(directories + 16, 0x1000)
u32(directories + 20, 512)
section = optional + size
u32(section + 12, 0x1000)
u32(section + 16, 512)
u32(section + 20, 512)
base = 512
def directory(relative, entries):
u16(base + relative + 14, len(entries))
for i, (name, offset) in enumerate(entries):
u32(base + relative + 16 + 8*i, name)
u32(base + relative + 20 + 8*i, offset)
if icon:
directory(0, [(3, 0x80000030), (14, 0x80000070)])
directory(0x30, [(1, 0x80000050)])
directory(0x50, [(1033, 0xB0)])
directory(0x70, [(1, 0x80000090)])
directory(0x90, [(1033, 0xC0)])
u32(base + 0xB0, 0x1100)
u32(base + 0xB4, 40)
u32(base + 0xC0, 0x1140)
u32(base + 0xC4, 20)
u16(base + 0x142, 1)
u16(base + 0x144, 1)
u32(base + 0x14E, 40)
u16(base + 0x152, 2 if broken else 1)
return data
class IconGuard(unittest.TestCase):
def run_guard(self, data):
with tempfile.TemporaryDirectory() as folder:
path = pathlib.Path(folder) / "app.exe"
path.write_bytes(data)
return subprocess.run([shutil.which("pwsh") or "powershell", "-NoProfile", "-File", str(SCRIPT), "-Path", str(path)], capture_output=True, text=True)
def test_valid_pe32_and_pe64(self):
for pe64 in (False, True):
result = self.run_guard(executable(pe64))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("1 images, 1 group", result.stdout)
def test_generic_executable_is_rejected(self):
result = self.run_guard(executable(icon=False))
self.assertNotEqual(result.returncode, 0)
self.assertIn("RT_ICON", result.stderr)
def test_group_must_reference_a_real_image(self):
result = self.run_guard(executable(broken=True))
self.assertNotEqual(result.returncode, 0)
self.assertIn("missing image", result.stderr)
def test_truncated_executable_is_rejected(self):
self.assertNotEqual(self.run_guard(b"MZ").returncode, 0)
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
# Validate the actual executable used by MSI, portable and standalone distributions.
param([Parameter(Mandatory)][string] $Path)
$ErrorActionPreference = 'Stop'
$bytes = [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path))
function U16([int] $At) { if ($At -lt 0 -or $At + 2 -gt $bytes.Length) { throw 'truncated PE' }; [BitConverter]::ToUInt16($bytes, $At) }
function U32([int] $At) { if ($At -lt 0 -or $At + 4 -gt $bytes.Length) { throw 'truncated PE' }; [BitConverter]::ToUInt32($bytes, $At) }
if ((U16 0) -ne 0x5A4D) { throw 'not an executable' }
$pe = U32 0x3C
if ((U32 $pe) -ne 0x4550) { throw 'missing PE signature' }
$optional = $pe + 24
$magic = U16 $optional
$directories = switch ($magic) { 0x10B { $optional + 96 }; 0x20B { $optional + 112 }; default { throw 'unknown PE format' } }
$resourceRva = U32 ($directories + 16)
$resourceSize = U32 ($directories + 20)
if (!$resourceRva -or !$resourceSize) { throw 'executable has no resources' }
$sections = $optional + (U16 ($pe + 20))
function RvaOffset([uint32] $Rva) {
for ($i = 0; $i -lt (U16 ($pe + 6)); $i++) {
$section = $sections + 40 * $i
$start = U32 ($section + 12)
$rawSize = U32 ($section + 16)
if ($Rva -ge $start -and [uint64]$Rva -lt [uint64]$start + $rawSize) { return (U32 ($section + 20)) + ($Rva - $start) }
}
throw 'resource RVA outside file sections'
}
$base = RvaOffset $resourceRva
function Entries([uint32] $Relative) {
if ([uint64]$Relative + 16 -gt $resourceSize) { throw 'resource directory outside bounds' }
$at = $base + $Relative
$count = (U16 ($at + 12)) + (U16 ($at + 14))
if ([uint64]$Relative + 16 + 8 * $count -gt $resourceSize) { throw 'resource entries outside bounds' }
for ($i = 0; $i -lt $count; $i++) {
$entry = $at + 16 + 8 * $i
@{ Name = (U32 $entry); Offset = (U32 ($entry + 4)) }
}
}
function Payloads($Entry) {
if (!( $Entry.Offset -band 0x80000000L )) { throw 'expected resource directory' }
foreach ($name in @(Entries ($Entry.Offset -band 0x7FFFFFFFL))) {
if (!( $name.Offset -band 0x80000000L )) { throw 'expected resource language directory' }
foreach ($language in @(Entries ($name.Offset -band 0x7FFFFFFFL))) {
if ($language.Offset -band 0x80000000L) { throw 'unexpected nested resource directory' }
if ([uint64]$language.Offset + 16 -gt $resourceSize) { throw 'resource data outside bounds' }
$data = $base + $language.Offset
$offset = RvaOffset (U32 $data)
$length = U32 ($data + 4)
if (!$length -or [uint64]$offset + $length -gt $bytes.Length) { throw 'empty or truncated icon resource' }
@{ Id = $name.Name; At = $offset; Length = $length }
}
}
}
$root = @(Entries 0)
$iconType = @($root | Where-Object Name -eq 3)
$groupType = @($root | Where-Object Name -eq 14)
if ($iconType.Count -ne 1 -or $groupType.Count -ne 1) { throw 'missing RT_ICON or RT_GROUP_ICON' }
$icons = @(Payloads $iconType[0])
$groups = @(Payloads $groupType[0])
foreach ($group in $groups) {
if ((U16 $group.At) -ne 0 -or (U16 ($group.At + 2)) -ne 1) { throw 'invalid icon group' }
$count = U16 ($group.At + 4)
if (!$count -or 6 + 14 * $count -gt $group.Length) { throw 'empty or truncated icon group' }
for ($i = 0; $i -lt $count; $i++) {
$entry = $group.At + 6 + 14 * $i
$id = U16 ($entry + 12)
$length = U32 ($entry + 8)
if (!@($icons | Where-Object { $_.Id -eq $id -and $_.Length -eq $length }).Count) { throw 'icon group refers to missing image' }
}
}
Write-Output "ok embedded app icon: $($icons.Count) images, $($groups.Count) group(s): $Path"
+1
View File
@@ -79,6 +79,7 @@ foreach ($check in @(@('solvecraft.exe', 2), @('solvecraft-cli.exe', 3))) {
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
}
$Stage = Join-Path $TargetDir "windows-package\$Arch"
& (Join-Path $PSScriptRoot 'check-icon.ps1') -Path (Join-Path $Bin 'solvecraft.exe')
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
Copy-Item (Join-Path $Bin 'solvecraft.exe'), (Join-Path $Bin 'solvecraft-cli.exe') $Stage
+11
View File
@@ -61,3 +61,14 @@ Changes:
move both bodies and the result back) hung the app.
- `lib.rs`: compiler warnings no longer fail the build. Test targets are dropped (their sources
are not vendored).
## egui-wgpu 0.36.2 (MIT OR Apache-2.0, Emil Ernerfeldt and contributors)
Original package sources from https://github.com/emilk/egui/tree/0.36.2/crates/egui-wgpu,
with both original licence texts retained. No images or bundled documentation are copied.
SolidCraft changes:
- `src/lib.rs`: use 1x MSAA on the selected OpenGL adapter before creating pipelines,
exposing the effective sample count so host paint callbacks can match it.
- `src/winit.rs`: use that same effective count for depth and resolve attachments.
- Other backends retain requested MSAA, without another adapter or device probe.
+339
View File
@@ -0,0 +1,339 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.95"
name = "egui-wgpu"
version = "0.36.2"
authors = [
"Nils Hasenbanck <[email protected]>",
"embotech <[email protected]>",
"Emil Ernerfeldt <[email protected]>",
]
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"
+75
View File
@@ -0,0 +1,75 @@
[package]
name = "egui-wgpu"
version.workspace = true
description = "Bindings for using egui natively using the wgpu library"
authors = [
"Nils Hasenbanck <[email protected]>",
"embotech <[email protected]>",
"Emil Ernerfeldt <[email protected]>",
]
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 }
+201
View File
@@ -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.
+25
View File
@@ -0,0 +1,25 @@
Copyright (c) 2018-2021 Emil Ernerfeldt <[email protected]>
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.
+267
View File
@@ -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<UserData>, ColorImage)>;
pub type CaptureSender = mpsc::Sender<(ViewportId, Vec<UserData>, 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<UserData>,
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::<u32>() 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,
}
}
}
+162
View File
@@ -0,0 +1,162 @@
// Vertex shader bindings
struct VertexOutput {
@location(0) tex_coord: vec2<f32>,
@location(1) color: vec4<f32>, // gamma 0-1
@builtin(position) position: vec4<f32>,
};
struct Locals {
screen_size: vec2<f32>,
/// 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<uniform> 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>) -> f32 {
let f = 0.06711056 * n.x + 0.00583715 * n.y;
return fract(52.9829189 * fract(f));
}
fn dither_interleaved(rgb: vec3<f32>, levels: f32, frag_coord: vec4<f32>) -> vec3<f32> {
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<f32>) -> vec3<f32> {
let cutoff = srgb < vec3<f32>(0.04045);
let lower = srgb / vec3<f32>(12.92);
let higher = pow((srgb + vec3<f32>(0.055)) / vec3<f32>(1.055), vec3<f32>(2.4));
return select(higher, lower, cutoff);
}
// 0-1 sRGB gamma from 0-1 linear
fn gamma_from_linear_rgb(rgb: vec3<f32>) -> vec3<f32> {
let cutoff = rgb < vec3<f32>(0.0031308);
let lower = rgb * vec3<f32>(12.92);
let higher = vec3<f32>(1.055) * pow(rgb, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055);
return select(higher, lower, cutoff);
}
// 0-1 sRGBA gamma from 0-1 linear
fn gamma_from_linear_rgba(linear_rgba: vec4<f32>) -> vec4<f32> {
return vec4<f32>(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<f32> {
return vec4<f32>(
f32(color & 255u),
f32((color >> 8u) & 255u),
f32((color >> 16u) & 255u),
f32((color >> 24u) & 255u),
) / 255.0;
}
fn position_from_screen(screen_pos: vec2<f32>) -> vec4<f32> {
return vec4<f32>(
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<f32>,
@location(1) a_tex_coord: vec2<f32>,
@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<f32>;
@group(1) @binding(1) var r_tex_sampler: sampler;
fn sample_texture(in: VertexOutput) -> vec4<f32> {
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<i32>(textureDimensions(r_tex_color, 0));
let texture_size_f = vec2<f32>(texture_size);
let pixel_coord = in.tex_coord * texture_size_f - 0.5;
let pixel_fract = fract(pixel_coord);
let pixel_floor = vec2<i32>(floor(pixel_coord));
// Manual texture clamping
let max_coord = texture_size - vec2<i32>(1, 1);
let p00 = clamp(pixel_floor + vec2<i32>(0, 0), vec2<i32>(0, 0), max_coord);
let p10 = clamp(pixel_floor + vec2<i32>(1, 0), vec2<i32>(0, 0), max_coord);
let p01 = clamp(pixel_floor + vec2<i32>(0, 1), vec2<i32>(0, 0), max_coord);
let p11 = clamp(pixel_floor + vec2<i32>(1, 1), vec2<i32>(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<f32> {
// 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<f32>(out_color_gamma_rgb, out_color_gamma.a);
}
let out_color_linear = linear_from_gamma_rgb(out_color_gamma.rgb);
return vec4<f32>(out_color_linear, out_color_gamma.a);
}
@fragment
fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4<f32> {
// 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<f32>(out_color_gamma_rgb, out_color_gamma.a);
}
return out_color_gamma;
}
+585
View File
@@ -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<u32>,
}
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::Adapter>,
/// 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<RwLock<Renderer>>,
/// 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<wgpu::Adapter, WgpuError> {
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<Self, WgpuError> {
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::<Vec<_>>()
.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<dyn Fn(&wgpu::CurrentSurfaceTexture) -> SurfaceErrorAction + Send + Sync>,
}
#[test]
fn wgpu_config_impl_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<WgpuConfiguration>();
}
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<wgpu::TextureFormat, WgpuError> {
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<wgpu::TextureFormat> {
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));
}
}
}
}
+1178
View File
File diff suppressed because it is too large. Load diff
+331
View File
@@ -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<dyn WgpuHasDisplayHandle>`. 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<dyn WgpuHasDisplayHandle>` for [`wgpu::InstanceDescriptor::display`].
fn clone_for_wgpu(&self) -> Box<dyn wgpu::wgt::WgpuHasDisplayHandle>;
/// Clone into a new `Box<dyn EguiDisplayHandle>`.
fn clone_display_handle(&self) -> Box<dyn EguiDisplayHandle>;
}
impl Clone for Box<dyn EguiDisplayHandle> {
fn clone(&self) -> Self {
// We need to deref here, otherwise this causes infinite recursion stack overflow.
(**self).clone_display_handle()
}
}
impl<T> EguiDisplayHandle for T
where
T: wgpu::rwh::HasDisplayHandle + Clone + core::fmt::Debug + Send + Sync + 'static,
{
fn clone_for_wgpu(&self) -> Box<dyn wgpu::wgt::WgpuHasDisplayHandle> {
Box::new(self.clone())
}
fn clone_display_handle(&self) -> Box<dyn EguiDisplayHandle> {
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<WgpuSetupCreateNew> for WgpuSetup {
fn from(create_new: WgpuSetupCreateNew) -> Self {
Self::CreateNew(create_new)
}
}
impl From<WgpuSetupExisting> 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<wgpu::Adapter, String>
+ 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<Box<dyn EguiDisplayHandle>>,
/// 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<NativeAdapterSelectorMethod>,
/// Configuration passed on device request, given an adapter
pub device_descriptor:
Arc<dyn Fn(&wgpu::Adapter) -> 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,
}
+43
View File
@@ -0,0 +1,43 @@
struct VertexOutput {
@builtin(position) position: vec4<f32>,
};
var<private> positions: array<vec2f, 3> = array<vec2f, 3>(
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<f32>;
@fragment
fn fs_main(vertex: VertexOutput) -> @location(0) vec4<f32> {
return textureLoad(r_color, vec2i(vertex.position.xy), 0);
}
+802
View File
@@ -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<CaptureState>,
instance: wgpu::Instance,
render_state: Option<RenderState>,
// Per viewport/window:
depth_texture_view: ViewportIdMap<wgpu::TextureView>,
msaa_texture_view: ViewportIdMap<wgpu::TextureView>,
surfaces: ViewportIdMap<SurfaceState>,
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<RenderState> {
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<winit::window::Window>,
) -> 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<Arc<winit::window::Window>>,
) -> 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<u32>,
) -> 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<usize> {
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::<Metal>()` 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::<wgpu::hal::api::Metal>(),
) {
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<UserData>,
window: &Arc<winit::window::Window>,
) -> 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 <https://github.com/emilk/egui/pull/7928> 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<Event>) {
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?
}
}