Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4491ac605 | ||
|
|
07e263d7e9 | ||
|
|
c53aff7592 | ||
|
|
13aac4296d | ||
|
|
18d487a9bd | ||
|
|
ff9653a353 | ||
|
|
400f200efa | ||
|
|
6dd202d277 | ||
|
|
16bf3ba593 | ||
|
|
8fa52c8e5e | ||
|
|
8601e38d4b | ||
|
|
e171ff07ad | ||
|
|
6ae48ed70f |
No files matched your search
@@ -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**.
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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.
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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, ¶ms).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());
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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`).
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"; };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Vendored
+11
@@ -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.
|
||||
Vendored
+339
@@ -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
@@ -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 }
|
||||
Vendored
+201
@@ -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.
|
||||
Vendored
+25
@@ -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.
|
||||
Vendored
+267
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+162
@@ -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;
|
||||
}
|
||||
Vendored
+585
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1178
File diff suppressed because it is too large.
Load diff
Vendored
+331
@@ -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
@@ -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);
|
||||
}
|
||||
Vendored
+802
@@ -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?
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user