Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eb3f8b53 | ||
|
|
db0a2a007a | ||
|
|
ed102b5393 | ||
|
|
970da30a99 | ||
|
|
113ded5fab | ||
|
|
b4491ac605 | ||
|
|
53b092d287 | ||
|
|
07e263d7e9 | ||
|
|
25ea43e89f | ||
|
|
c53aff7592 | ||
|
|
13aac4296d | ||
|
|
18d487a9bd | ||
|
|
ff9653a353 | ||
|
|
400f200efa | ||
|
|
6dd202d277 | ||
|
|
16bf3ba593 | ||
|
|
8fa52c8e5e | ||
|
|
8601e38d4b | ||
|
|
e171ff07ad | ||
|
|
6ae48ed70f | ||
|
|
d8beaedfa4 | ||
|
|
4af2d06007 | ||
|
|
81ef51c6ce | ||
|
|
1fb4783f67 | ||
|
|
a94bf2562b | ||
|
|
26b7b7336f | ||
|
|
8539ac1b40 | ||
|
|
78aa2aacfb | ||
|
|
92dbd240ae | ||
|
|
71cda4e7ee | ||
|
|
a91596c1b8 | ||
|
|
7f98dd1c47 | ||
|
|
8cce89d78a | ||
|
|
6ce472c29e | ||
|
|
4e39869023 | ||
|
|
3f1b07d35c | ||
|
|
f6e7d754e5 | ||
|
|
6c06168fa7 | ||
|
|
af536a7907 | ||
|
|
ee1b56cac4 | ||
|
|
344e52e207 | ||
|
|
35555c0b9f | ||
|
|
1d3048062d | ||
|
|
da56d180bf | ||
|
|
33f0b9e3cc | ||
|
|
cb5144b6e2 | ||
|
|
cc2ee6a0f9 | ||
|
|
9bfd1158d0 | ||
|
|
58c0ed8a90 | ||
|
|
6dc6372b13 | ||
|
|
adecca41f6 | ||
|
|
36f49811ab | ||
|
|
09bf9cafdf |
No files matched your search
@@ -1,14 +1,19 @@
|
||||
SolveCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
|
||||
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solvecraft/)
|
||||
· [try it in the browser](https://bherbruck.github.io/solvecraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solvecraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solvecraft/guide/mcp.html)).
|
||||
SolidCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
|
||||
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solidcraft/)
|
||||
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
|
||||
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
|
||||
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
|
||||
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
`-portable.zip` (x64, x86 and arm64).
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag SolveCraft onto Applications.
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
|
||||
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
|
||||
@@ -16,10 +21,23 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click SolveCraft in Applications and choose
|
||||
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**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md#known-limitations).
|
||||
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
@@ -37,6 +37,10 @@ jobs:
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Nightly release selection regression
|
||||
run: python3 packaging/tests/nightly_test.py
|
||||
- name: Windows executable icon validation regression
|
||||
run: python3 packaging/tests/windows_icon_test.py
|
||||
- name: Flatpak desktop smoke validation regression
|
||||
run: python3 packaging/tests/flatpak_test.py
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
|
||||
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app
|
||||
# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# repository is private.
|
||||
name: Pages
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solvecraft-web
|
||||
run: trunk build --release --public-url /solvecraft/app/
|
||||
run: trunk build --release --public-url /solidcraft/app/
|
||||
- name: Assemble the site
|
||||
run: |
|
||||
mkdir -p site
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolveCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
@@ -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
|
||||
@@ -357,7 +357,7 @@ jobs:
|
||||
sha256sum --check SHA256SUMS.txt
|
||||
# Stage all packages before publishing. Published tags are never replaced.
|
||||
gh release create "$tag" --draft --prerelease --latest=false \
|
||||
--title "SolveCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes -- *
|
||||
gh release edit "$tag" --draft=false --prerelease --latest=false
|
||||
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
@@ -386,15 +386,15 @@ jobs:
|
||||
while IFS= read -r asset; do
|
||||
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
|
||||
done <<<"$assets"
|
||||
gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \
|
||||
gh release edit "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
gh release create "$tag" --draft --title "SolveCraft v$VERSION" \
|
||||
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
# SolveCraft — instructions for agents
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: 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.
|
||||
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. 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
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
@@ -20,7 +27,7 @@ code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
@@ -44,7 +51,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
|
||||
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
|
||||
@@ -64,7 +71,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
|
||||
+10
-1
@@ -55,5 +55,14 @@ Generated-in-code assets are original and have no file to list:
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| 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/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; degenerate intersection-curve rejection; 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.
|
||||
@@ -1,11 +1,18 @@
|
||||
# SolveCraft — instructions for agents
|
||||
# SolidCraft — instructions for agents
|
||||
|
||||
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
|
||||
Autodesk Fusion: 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.
|
||||
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. 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
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
|
||||
@@ -20,7 +27,7 @@ code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
@@ -44,7 +51,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
|
||||
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no Autodesk iconography, images,
|
||||
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
|
||||
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
|
||||
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
|
||||
@@ -64,7 +71,7 @@ People trust SolveCraft with their designs; a crash loses their work. **This out
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
|
||||
assets, layers). Commit after every feature arc that builds.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
|
||||
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
|
||||
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
|
||||
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
|
||||
|
||||
+34
-38
@@ -1,7 +1,7 @@
|
||||
# Contributing to SolveCraft
|
||||
# Contributing to SolidCraft
|
||||
|
||||
SolveCraft is an open-source parametric 3D CAD app written in pure Rust, in the style of
|
||||
Autodesk Fusion. You sketch on a plane, constrain the sketch, turn profiles into solids, and
|
||||
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
|
||||
You sketch on a plane, constrain the sketch, turn profiles into solids, and
|
||||
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
|
||||
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
|
||||
model with.
|
||||
@@ -12,16 +12,23 @@ needed most.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.90 or newer. For the web
|
||||
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/solvecraft && 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
|
||||
|
||||
@@ -97,7 +93,7 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
|
||||
our own (`solid.fillet`, not Fusion's internal names).
|
||||
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
|
||||
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
|
||||
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolidCraft is
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
@@ -151,14 +147,14 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
|
||||
depend on wall-clock timing.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
|
||||
SolidCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
|
||||
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
|
||||
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
|
||||
install. If you work on geometry and want to check against it, ask in an issue.
|
||||
|
||||
## Sending a change
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solvecraft/issues), labelled
|
||||
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
|
||||
comment on the issue so it gets the `in progress` label and two people don't build the same
|
||||
thing. For anything bigger than a bug fix that has no issue yet, open one first.
|
||||
@@ -189,15 +185,15 @@ CI if it brought changes, and push without force. Report the upstream/PR state y
|
||||
any conflicts resolved and validation results. While a task remains active, follow an open
|
||||
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
|
||||
|
||||
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
owning one area, with a human setting direction and reporting bugs. Their working notes are in
|
||||
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
|
||||
long as the change meets the rules above.
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
See the [`help wanted`](https://github.com/bherbruck/solvecraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/bherbruck/solvecraft/labels/good%20first%20issue) labels.
|
||||
See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels.
|
||||
In order of how much they'd help users:
|
||||
|
||||
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
|
||||
@@ -206,7 +202,7 @@ In order of how much they'd help users:
|
||||
faces, and fillets that consume neighbouring faces.
|
||||
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
|
||||
through without a kernel error. Each failing seed is a reproducible bug.
|
||||
3. **Y-up / Z-up.** SolveCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
|
||||
3. **Y-up / Z-up.** SolidCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
|
||||
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
|
||||
orientation" preference.
|
||||
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
|
||||
@@ -217,4 +213,4 @@ In order of how much they'd help users:
|
||||
## Licence
|
||||
|
||||
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
|
||||
of SolveCraft. SolveCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
|
||||
Generated
+109
-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"
|
||||
@@ -1553,6 +1565,19 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hidapi"
|
||||
version = "2.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818c0e1d27887aaf76fe737042e27a66b796a7b099e6d2e1a72d106c2dff3fa6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
@@ -3093,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"
|
||||
@@ -3256,11 +3290,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3270,7 +3307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3279,7 +3316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3294,7 +3331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3310,7 +3347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3318,7 +3355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3330,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3345,7 +3382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3353,16 +3390,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3373,7 +3411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3388,7 +3426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3574,10 +3612,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",
|
||||
]
|
||||
@@ -3596,13 +3649,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"
|
||||
@@ -3898,6 +3957,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"
|
||||
@@ -4683,6 +4762,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"
|
||||
@@ -4760,7 +4849,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+7
-4
@@ -5,12 +5,12 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
authors = ["SolveCraft contributors"]
|
||||
repository = "https://github.com/bherbruck/solvecraft"
|
||||
rust-version = "1.95"
|
||||
authors = ["SolidCraft contributors"]
|
||||
repository = "https://github.com/bherbruck/solidcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
@@ -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" }
|
||||
@@ -1,26 +1,40 @@
|
||||
# SolveCraft
|
||||
# SolidCraft
|
||||
|
||||
**Parametric 3D CAD; an open-source, [clean-room](CLAUDE.md) reimplementation of Autodesk
|
||||
Fusion 360, built in pure Rust.**
|
||||
**An open-source parametric 3D CAD application built in pure Rust.**
|
||||
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solvecraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
|
||||
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
|
||||
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
|
||||
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
||||
[web app](https://bherbruck.github.io/solvecraft/app/) to try it in the browser.
|
||||
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
|
||||
|
||||
**Status: early but usable for simple parts.** 76 of 76 reference parts built in Fusion
|
||||
rebuild in SolveCraft with the same volume, area and topology ([docs/oracle.md](docs/oracle.md));
|
||||
command parity with Fusion's Design workspace is tracked in [docs/parity.md](docs/parity.md).
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solvecraft/issues),
|
||||
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
|
||||
is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md).
|
||||
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Name and compatibility
|
||||
|
||||
SolidCraft was previously called SolveCraft. The product display name and repository are
|
||||
changing; existing identifiers remain compatible: `.solvecraft` designs, `solvecraft-cli`,
|
||||
Cargo package names, settings/recovery locations and package IDs keep their existing names.
|
||||
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
|
||||
and filenames.
|
||||
|
||||
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
|
||||
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
|
||||
|
||||
## What works
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
@@ -74,7 +88,7 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
Feature errors and Help can open an editable **Create Issue** draft with relevant context
|
||||
and an optional reviewed screenshot; opening GitHub does not submit the report.
|
||||
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
face/edge/vertex counts), export, render PNG snapshots, replay Fusion oracle recipes.
|
||||
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
|
||||
- **MCP server** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
in the running app (see below).
|
||||
|
||||
@@ -92,6 +106,11 @@ cargo xtask ci # fmt, clippy, tests, as
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
page in Preferences has an on/off switch and a speed control for the 3D mouse.
|
||||
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
@@ -134,10 +153,19 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
|
||||
sketches, solid features, an editable timeline, parameters and assemblies. See the
|
||||
[roadmap](ROADMAP.md) for supported operations and current limitations.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
|
||||
and licence rules, and where help is wanted most.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0, at your option. SolveCraft is not affiliated with Autodesk; Fusion is a
|
||||
trademark of Autodesk, Inc.
|
||||
MIT OR Apache-2.0, at your option. Developed under the
|
||||
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
|
||||
documented licences and attribution.
|
||||
|
||||
References to other products describe workflow comparisons and benchmark provenance.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
|
||||
Fusion are trademarks of Autodesk, Inc.
|
||||
+46
-3
@@ -1,6 +1,6 @@
|
||||
# SolveCraft roadmap
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-11**. SolveCraft targets Fusion-style parametric modelling (sketch →
|
||||
Status as of **2026-10-11**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
@@ -54,7 +54,7 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace | deferred | — |
|
||||
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
@@ -67,11 +67,17 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
|
||||
reporting for unexpected results that did not produce an error.
|
||||
- The header exposes **Report a bug**, including an accessible compact control on
|
||||
narrow windows. It opens the same editable report and screenshot review without
|
||||
changing the active model or a pending dimension.
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
@@ -247,6 +253,21 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- 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
|
||||
@@ -271,7 +292,29 @@ 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
|
||||
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- 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.
|
||||
+268
-12
@@ -1,8 +1,8 @@
|
||||
Third-party software in SolveCraft
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolveCraft (the desktop app, solvecraft-cli and the web app) is built from the 453 crates below.
|
||||
SolidCraft (the desktop app, solvecraft-cli and the web app) is built from the 454 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolveCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
accesskit 0.24.1 (MIT OR Apache-2.0)
|
||||
accesskit_atspi_common 0.18.1 (MIT OR Apache-2.0)
|
||||
@@ -147,6 +147,7 @@ hashbrown 0.16.1 (MIT OR Apache-2.0)
|
||||
hashbrown 0.17.1 (MIT OR Apache-2.0)
|
||||
hermit-abi 0.5.3 (MIT OR Apache-2.0)
|
||||
hex 0.4.3 (MIT OR Apache-2.0)
|
||||
hidapi 2.6.7 (MIT)
|
||||
iana-time-zone 0.1.65 (MIT OR Apache-2.0)
|
||||
iana-time-zone-haiku 0.1.2 (MIT OR Apache-2.0)
|
||||
icu_collections 2.3.0 (Unicode-3.0)
|
||||
@@ -327,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)
|
||||
@@ -2229,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
|
||||
@@ -3416,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
|
||||
@@ -4225,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 <emil.ernerfeldt@gmail.com>
|
||||
|
||||
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
|
||||
================================================================================
|
||||
@@ -6019,6 +6049,27 @@ 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.
|
||||
|
||||
================================================================================
|
||||
hidapi 2.6.7
|
||||
================================================================================
|
||||
|
||||
Copyright 2017 The hidapi-rs Developers
|
||||
|
||||
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.
|
||||
|
||||
================================================================================
|
||||
iana-time-zone 0.1.65, iana-time-zone-haiku 0.1.2
|
||||
================================================================================
|
||||
@@ -10185,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
|
||||
@@ -10208,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
|
||||
================================================================================
|
||||
@@ -13017,7 +13274,7 @@ freely, subject to the following restrictions:
|
||||
|
||||
================================================================================
|
||||
Crates whose package carries no licence file (their licence is named in Cargo.toml; the MIT and Apache-2.0
|
||||
texts are as in SolveCraft's own LICENSE-MIT and LICENSE-APACHE)
|
||||
texts are as in SolidCraft's own LICENSE-MIT and LICENSE-APACHE)
|
||||
================================================================================
|
||||
|
||||
accesskit 0.24.1: MIT OR Apache-2.0 (https://github.com/AccessKit/accesskit)
|
||||
@@ -13039,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());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
description = "SolveCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -53,8 +56,17 @@ fn main() -> ExitCode {
|
||||
Some("snapshot") => cmd_snapshot(&args[1..]),
|
||||
Some("exec") => cmd_exec(&args[1..]),
|
||||
Some("commands") => {
|
||||
use std::io::Write as _;
|
||||
let mut s = Session::default();
|
||||
s.execute("engine.commands", &json!({})).map(|v| println!("{}", pretty(&v))).map_err(|e| e.to_string())
|
||||
s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()).and_then(|v| {
|
||||
// A pipeline consumer may stop early (e.g. `commands | head`).
|
||||
// Unlike println!, writing explicitly lets us handle that without a panic.
|
||||
match writeln!(std::io::stdout().lock(), "{}", pretty(&v)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
|
||||
Err(e) => Err(format!("stdout: {e}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
|
||||
let v = read_json(p)?;
|
||||
@@ -219,7 +231,7 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app icon: a blue block with a bore, drawn by the CPU renderer on a transparent background
|
||||
/// The app icon: the SolidCraft logo, drawn in code on a transparent background
|
||||
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
|
||||
fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
|
||||
@@ -237,29 +249,7 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
use solvecraft_engine::render::{Rgb, Scene, SceneLine, SceneMesh, png, render_transparent};
|
||||
let mut s = Session::default();
|
||||
for (cmd, p) in [
|
||||
("solid.box", json!({"length": 20, "width": 20, "height": 20, "center": [0, 0, 0]})),
|
||||
("solid.cylinder", json!({"diameter": 9, "height": 30, "base": [0, 0, -15], "operation": "cut"})),
|
||||
] {
|
||||
s.execute(cmd, &p).map_err(|e| format!("icon: {e}"))?;
|
||||
}
|
||||
let mut cam = solvecraft_engine::view::home_camera(&s);
|
||||
cam.distance *= 0.95;
|
||||
let mut scene = Scene { radius: 60.0, ..Default::default() };
|
||||
let px = size as f64 / 256.0;
|
||||
for body in &s.world_state().bodies {
|
||||
let mesh = body.mesh();
|
||||
scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() });
|
||||
for (i, e) in mesh.edges.iter().enumerate() {
|
||||
if mesh.shaded_edge_visible(i) {
|
||||
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = render_transparent(&mut scene, &cam, size, size);
|
||||
png(c.w, c.h, c.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).
|
||||
|
||||
@@ -12,7 +12,7 @@ pub const REL_TOL: f64 = 1e-3;
|
||||
const APPROXIMATE: [(&str, f64, &str); 1] = [(
|
||||
"49-helical-sweep",
|
||||
5e-3,
|
||||
"Fusion sweeps along a spline fitted through helix points; SolveCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
|
||||
"Fusion sweeps along a spline fitted through helix points; SolidCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
|
||||
)];
|
||||
|
||||
fn check_case(dir: &str) -> Value {
|
||||
@@ -77,7 +77,7 @@ fn check_case(dir: &str) -> Value {
|
||||
})
|
||||
.collect();
|
||||
seam_note = Some(format!(
|
||||
"topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: {})",
|
||||
"topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolidCraft/Fusion: {})",
|
||||
want["total"]["faces"],
|
||||
want["total"]["edges"],
|
||||
want["total"]["vertices"],
|
||||
@@ -120,7 +120,7 @@ fn check_case(dir: &str) -> Value {
|
||||
.collect();
|
||||
if !effective.is_empty() {
|
||||
out["note"] = json!(format!(
|
||||
"known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolveCraft's through-all goes truly through)",
|
||||
"known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolidCraft's through-all goes truly through)",
|
||||
effective.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! The registry remains valid JSON and a closed pipeline reader must not cause a panic.
|
||||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn registry_output_is_complete_json() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let commands = registry.as_array().unwrap();
|
||||
assert!(commands.iter().any(|command| command["id"] == "solid.box"));
|
||||
assert!(commands.iter().any(|command| command["id"] == "engine.commands"));
|
||||
assert!(output.stdout.ends_with(b"\n"));
|
||||
assert!(output.stderr.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_registry_pipe_exits_without_panicking() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
// Close our read end before waiting. No sleeps or timing threshold are needed:
|
||||
// the registry is larger than the pipe buffer, so it cannot all be written first.
|
||||
drop(child.stdout.take());
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(output.status.success(), "status: {}; stderr: {}", output.status, String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-web"
|
||||
description = "SolveCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
description = "SolidCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolveCraft</title>
|
||||
<title>SolidCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
|
||||
@@ -14,6 +14,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solvecraft_canvas"></canvas>
|
||||
<div id="solvecraft_loading">Loading SolveCraft…</div>
|
||||
<div id="solvecraft_loading">Loading SolidCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -406,7 +406,7 @@ mod web {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolveCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
description = "SolveCraft desktop app"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -19,5 +19,12 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[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(())
|
||||
}
|
||||
+75
-23
@@ -12,37 +12,47 @@
|
||||
#![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;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App(SolveApp);
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[cfg(target_os = "windows")]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
|
||||
// designs first; the window closes once they are answered (`quit_requested`).
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.0);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
if self.0.quit_requested {
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(target_os = "windows")]
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
self.solve.ui(ui);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.0.prefs());
|
||||
storage.set_string(PREFS_KEY, self.solve.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.0);
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +67,7 @@ fn services() -> Services {
|
||||
"Designs, STEP, IGES and meshes",
|
||||
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
|
||||
)
|
||||
.add_filter("SolveCraft design", &["solvecraft"])
|
||||
.add_filter("SolidCraft design", &["solvecraft"])
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||
@@ -116,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());
|
||||
@@ -138,16 +166,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SolveCraft")
|
||||
.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,
|
||||
@@ -166,7 +185,7 @@ fn main() -> eframe::Result {
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SolveCraft",
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
@@ -177,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());
|
||||
@@ -205,7 +225,39 @@ fn main() -> eframe::Result {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
#[cfg(target_os = "windows")]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(target_os = "windows")]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
|
||||
//! it publishes the latest axes, and the egui thread applies them once per frame.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_ui_egui::SolveApp;
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
translation: [i16; 3],
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct AxisRule {
|
||||
output: usize,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct DriverMapping {
|
||||
axes: [AxisRule; 6],
|
||||
overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
rotation: [f64; 3],
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ProfileAxis {
|
||||
input: Option<usize>,
|
||||
output: Option<usize>,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ProfilePart {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
fn axis_index(name: &str) -> Option<usize> {
|
||||
match name.trim() {
|
||||
"HIDMultiAxis_X" => Some(0),
|
||||
"HIDMultiAxis_Y" => Some(1),
|
||||
"HIDMultiAxis_Z" => Some(2),
|
||||
"HIDMultiAxis_Rx" => Some(3),
|
||||
"HIDMultiAxis_Ry" => Some(4),
|
||||
"HIDMultiAxis_Rz" => Some(5),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
|
||||
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
|
||||
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
|
||||
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
|
||||
let mut reader = Reader::from_reader(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut mapping = DriverMapping::default();
|
||||
let mut axis: Option<ProfileAxis> = None;
|
||||
let mut part = None;
|
||||
let mut field = Vec::new();
|
||||
let mut found = false;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf).ok()? {
|
||||
Event::Start(start) => match start.local_name().as_ref() {
|
||||
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
|
||||
b"Input" => part = Some(ProfilePart::Input),
|
||||
b"Output" => part = Some(ProfilePart::Output),
|
||||
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
let value = String::from_utf8_lossy(text.as_ref());
|
||||
match field.as_slice() {
|
||||
b"ActionID" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
match part {
|
||||
Some(ProfilePart::Input) => current.input = axis_index(&value),
|
||||
Some(ProfilePart::Output) => current.output = axis_index(&value),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Enabled" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
current.enabled = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Reversed" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.reversed = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Scale" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
b"OverallScale" => {
|
||||
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
|
||||
mapping.overall_scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::End(end) => match end.local_name().as_ref() {
|
||||
b"Axis" => {
|
||||
if let Some(current) = axis.take()
|
||||
&& let (Some(input), Some(output)) = (current.input, current.output)
|
||||
{
|
||||
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
b"Input" | b"Output" => part = None,
|
||||
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
found.then_some(mapping)
|
||||
}
|
||||
|
||||
fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
|
||||
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
|
||||
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
|
||||
return None;
|
||||
}
|
||||
let xml = std::fs::read(&profile).ok()?;
|
||||
parse_driver_mapping(&xml)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
axes: Axes,
|
||||
translation_at: Instant,
|
||||
rotation_at: Instant,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { axes: Axes::default(), translation_at: now, rotation_at: now }
|
||||
}
|
||||
|
||||
fn current(self, now: Instant) -> Axes {
|
||||
Axes {
|
||||
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] },
|
||||
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
|
||||
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
|
||||
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None };
|
||||
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])])
|
||||
}
|
||||
|
||||
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
|
||||
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
|
||||
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
match report.first().copied() {
|
||||
Some(1) => {
|
||||
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.translation = translation;
|
||||
reading.translation_at = now;
|
||||
true
|
||||
}
|
||||
Some(2) => {
|
||||
let Some(rotation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.rotation = rotation;
|
||||
reading.rotation_at = now;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(&report[..size], &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
pub fn start(ctx: &Context) -> Self {
|
||||
let now = Instant::now();
|
||||
let shared = Arc::new(Mutex::new(Reading::new(now)));
|
||||
let worker_state = Arc::clone(&shared);
|
||||
let worker_ctx = ctx.clone();
|
||||
if let Err(error) = std::thread::Builder::new().name("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let now = Instant::now();
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_default();
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
if !app.preferences.space_mouse_enabled || app.home.open {
|
||||
return;
|
||||
}
|
||||
let axes = match self.shared.lock() {
|
||||
Ok(reading) => reading.current(now),
|
||||
Err(_) => return,
|
||||
};
|
||||
if axes == Axes::default() {
|
||||
return;
|
||||
}
|
||||
let speed = f64::from(app.preferences.space_mouse_speed);
|
||||
if !speed.is_finite() {
|
||||
return;
|
||||
}
|
||||
app.begin_external_navigation();
|
||||
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
|
||||
navigate(
|
||||
&mut app.cam,
|
||||
self.driver_mapping.apply(axes),
|
||||
seconds * speed.clamp(0.1, 5.0),
|
||||
height,
|
||||
app.session.active_sketch.is_some(),
|
||||
app.preferences.zoom_reverse,
|
||||
);
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
|
||||
/// per second to pan and about two radians per second to turn, independent of frame rate.
|
||||
fn normalized(value: i16) -> f64 {
|
||||
let v = f64::from(value);
|
||||
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
|
||||
}
|
||||
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile above can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
|
||||
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
|
||||
// navigation mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
|
||||
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert_eq!(reading.current(later), Axes::default());
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
let before = cam;
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
|
||||
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
|
||||
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let before = Camera::default();
|
||||
|
||||
let mut physical_roll = before;
|
||||
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(physical_roll.roll, before.roll);
|
||||
|
||||
let mut physical_spin = before;
|
||||
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(physical_spin.yaw, before.yaw);
|
||||
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-doc"
|
||||
description = "SolveCraft document model: parameters and expressions, sketches and features on a parametric timeline, incremental evaluation"
|
||||
description = "SolidCraft document model: parameters and expressions, sketches and features on a parametric timeline, incremental evaluation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+11
-11
@@ -132,21 +132,21 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
|
||||
|
||||
/// Read a design file's JSON, upgrading older formats.
|
||||
pub fn read(s: &str) -> Result<Document> {
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolveCraft design: {e}")))?;
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
|
||||
let Some(obj) = v.as_object() else {
|
||||
return Err(DocError::Invalid("not a SolveCraft design (expected a JSON object)".into()));
|
||||
return Err(DocError::Invalid("not a SolidCraft design (expected a JSON object)".into()));
|
||||
};
|
||||
let version = match obj.get("format") {
|
||||
None => 1,
|
||||
Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolveCraft design (format `{f}`)")))?,
|
||||
Some(_) => return Err(DocError::Invalid("not a SolveCraft design (format is not a string)".into())),
|
||||
Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolidCraft design (format `{f}`)")))?,
|
||||
Some(_) => return Err(DocError::Invalid("not a SolidCraft design (format is not a string)".into())),
|
||||
};
|
||||
if version == 0 {
|
||||
return Err(DocError::Invalid("not a SolveCraft design (format 0)".into()));
|
||||
return Err(DocError::Invalid("not a SolidCraft design (format 0)".into()));
|
||||
}
|
||||
if version > FORMAT {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"this design was saved by a newer SolveCraft (file format {version}; this version reads formats up to {FORMAT}): update SolveCraft to open it"
|
||||
"this design was saved by a newer SolidCraft (file format {version}; this version reads formats up to {FORMAT}): update SolidCraft to open it"
|
||||
)));
|
||||
}
|
||||
check_numbers(&v, 0)?;
|
||||
@@ -950,7 +950,7 @@ mod tests {
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("free-form offset metadata"), "{error}");
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolveCraft"));
|
||||
assert!(read(&value.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -975,7 +975,7 @@ mod tests {
|
||||
assert!(error.contains("emboss face"), "{error}");
|
||||
value["format"] = Value::from(format_string(FORMAT + 1));
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("newer SolveCraft"), "{error}");
|
||||
assert!(error.contains("newer SolidCraft"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,7 +1038,7 @@ mod tests {
|
||||
value["configs"]["columns"] = serde_json::json!([{"unknown_future_column":"Body"}]);
|
||||
value["configs"]["rules"] = serde_json::json!({"unknown_future_rule": true});
|
||||
let error = read(&value.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("newer SolveCraft") && error.contains("update SolveCraft"), "{error}");
|
||||
assert!(error.contains("newer SolidCraft") && error.contains("update SolidCraft"), "{error}");
|
||||
assert!(!error.contains("unknown variant"), "format refusal precedes decoding new variants");
|
||||
}
|
||||
|
||||
@@ -1090,7 +1090,7 @@ mod tests {
|
||||
saved["format"] = Value::from(format_string(FORMAT + 1));
|
||||
saved["features"][0]["sketch"]["constraints"][0]["type"] = Value::from("future_dimension");
|
||||
let error = read(&saved.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("newer SolveCraft") && !error.contains("unknown variant"), "{error}");
|
||||
assert!(error.contains("newer SolidCraft") && !error.contains("unknown variant"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1202,7 +1202,7 @@ mod tests {
|
||||
assert!(read(&v.to_string()).is_ok(), "a file without a format is format 1");
|
||||
v["format"] = Value::from(format_string(FORMAT + 1));
|
||||
let e = read(&v.to_string()).unwrap_err().to_string();
|
||||
assert!(e.contains("newer SolveCraft"), "{e}");
|
||||
assert!(e.contains("newer SolidCraft"), "{e}");
|
||||
for bad in ["solvecraft/0", "acad/3", "solvecraft/-1", "solvecraft/99999999999999999999"] {
|
||||
v["format"] = Value::from(bad);
|
||||
assert!(read(&v.to_string()).is_err(), "{bad}");
|
||||
|
||||
@@ -369,7 +369,7 @@ mod tests {
|
||||
damaged["features"][1]["profiles"]["associations"] = associations;
|
||||
assert!(validate_raw(&damaged).is_err());
|
||||
damaged["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolveCraft"));
|
||||
assert!(crate::format::read(&damaged.to_string()).unwrap_err().to_string().contains("newer SolidCraft"));
|
||||
}
|
||||
// Absent metadata preserves the historical one-to-one chain representation.
|
||||
let mut legacy = fixture();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-engine"
|
||||
description = "SolveCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||
description = "SolidCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -314,7 +314,7 @@ pub(super) fn paste(
|
||||
let base = entered.as_ref().and_then(|st| st.body(&body.name)).or_else(|| before.as_ref().and_then(|st| st.body(&body.name))).unwrap_or(body);
|
||||
let baseline_component = source.model_body_component(base);
|
||||
let geometry = solvecraft_kernel::transform_matrix(&base.body, offset)?;
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "SolveCraft independent reassigned body")?;
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "SolidCraft independent reassigned body")?;
|
||||
let new_name = unique(&body.name, &mut used);
|
||||
bodies.insert(body.name.clone(), new_name.clone());
|
||||
let feature_name = unique(&format!("{} Base", body.name), &mut used);
|
||||
|
||||
@@ -1738,14 +1738,18 @@ fn construction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cs: Vec<usize> =
|
||||
ids.iter().map(|id| sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))).collect::<Result<_>>()?;
|
||||
// The selection switches together (a slot or polygon as a whole): to construction
|
||||
// unless all of it already is. Projected curves may switch too.
|
||||
// unless all of it already is. Projected curves may switch too. A centerline that
|
||||
// becomes construction stops being a centerline (one line type at a time).
|
||||
let to = want.unwrap_or_else(|| !cs.iter().all(|c| sk.curves.get(*c).is_some_and(|c| c.construction)));
|
||||
let mut n = 0;
|
||||
for c in cs {
|
||||
if let Some(cu) = sk.curves.get_mut(c)
|
||||
&& cu.construction != to
|
||||
&& (cu.construction != to || to && cu.centerline)
|
||||
{
|
||||
cu.construction = to;
|
||||
if to {
|
||||
cu.centerline = false;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
let text = source_text(p, cmd)?;
|
||||
let geom = if svg { solvecraft_io::read_svg(&text) } else { solvecraft_io::read_dxf(&text) }.map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if geom.is_empty() {
|
||||
return Err(bad(cmd, "the drawing has no geometry SolveCraft reads"));
|
||||
return Err(bad(cmd, "the drawing has no geometry SolidCraft reads"));
|
||||
}
|
||||
let k = num(p, "scale").filter(|k| *k > 1e-9 && *k < 1e6).unwrap_or(1.0);
|
||||
let off = p.get("at").and_then(vec2).unwrap_or(Vec2::ZERO);
|
||||
|
||||
@@ -237,6 +237,21 @@ fn construction_toggle_takes_shapes_out_of_profiles_and_back() {
|
||||
assert!(s.execute("sketch.construction", &json!({"curves": ["nope"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centerline_can_become_construction_directly() {
|
||||
let mut s = new_sketch();
|
||||
let l = curve_ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]})));
|
||||
run(&mut s, "sketch.centerline", json!({ "curves": l, "value": true }));
|
||||
let r = run(&mut s, "sketch.construction", json!({ "curves": l, "value": true }));
|
||||
assert_eq!(r["changed"], 1);
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.curves.iter().filter(|c| l.contains(&c.id)).all(|c| c.construction && !c.centerline));
|
||||
// And back to a centerline.
|
||||
run(&mut s, "sketch.centerline", json!({ "curves": l, "value": true }));
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.curves.iter().filter(|c| l.contains(&c.id)).all(|c| c.centerline && !c.construction));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_geometry_can_become_construction() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+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;
|
||||
@@ -11,7 +11,7 @@ pub const THIRD_PARTY: &str = include_str!("../../../THIRD-PARTY-LICENSES.txt");
|
||||
pub fn all() -> String {
|
||||
let rule = "=".repeat(80);
|
||||
format!(
|
||||
"SolveCraft is licensed under the MIT License or the Apache License, Version 2.0, at your option.\n\n{rule}\nNOTICE\n{rule}\n\n{NOTICE}\n{rule}\nLICENSE-MIT\n{rule}\n\n{LICENSE_MIT}\n{rule}\nLICENSE-APACHE\n{rule}\n\n{LICENSE_APACHE}\n{rule}\n{THIRD_PARTY}"
|
||||
"SolidCraft is licensed under the MIT License or the Apache License, Version 2.0, at your option.\n\n{rule}\nNOTICE\n{rule}\n\n{NOTICE}\n{rule}\nLICENSE-MIT\n{rule}\n\n{LICENSE_MIT}\n{rule}\nLICENSE-APACHE\n{rule}\n\n{LICENSE_APACHE}\n{rule}\n{THIRD_PARTY}"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-geom"
|
||||
description = "SolveCraft geometry foundation: vectors, planes, frames, meshes and their measures"
|
||||
description = "SolidCraft geometry foundation: vectors, planes, frames, meshes and their measures"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-io"
|
||||
description = "SolveCraft file formats: designs (JSON), STL, OBJ, STEP and 3MF"
|
||||
description = "SolidCraft file formats: designs (JSON), STL, OBJ, STEP and 3MF"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -202,7 +202,7 @@ pub fn export_mesh(b: &solvecraft_kernel::Body) -> Result<Mesh> {
|
||||
pub fn stl_binary(meshes: &[Mesh], name: &str) -> Vec<u8> {
|
||||
let n: usize = meshes.iter().map(|m| m.triangles.len()).sum();
|
||||
let mut out = Vec::with_capacity(84 + n * 50);
|
||||
let mut header = format!("SolveCraft STL {name}").into_bytes();
|
||||
let mut header = format!("SolidCraft STL {name}").into_bytes();
|
||||
header.resize(80, b' ');
|
||||
out.extend_from_slice(&header);
|
||||
out.extend_from_slice(&u32::try_from(n).unwrap_or(u32::MAX).to_le_bytes());
|
||||
@@ -242,7 +242,7 @@ pub fn stl_ascii(meshes: &[Mesh], name: &str) -> String {
|
||||
|
||||
/// Wavefront OBJ, one object per body.
|
||||
pub fn obj(meshes: &[(String, Mesh)]) -> String {
|
||||
let mut s = String::from("# SolveCraft OBJ export (mm)\n");
|
||||
let mut s = String::from("# SolidCraft OBJ export (mm)\n");
|
||||
let mut base = 1usize;
|
||||
for (name, m) in meshes {
|
||||
s += &format!("o {}\n", name.replace(char::is_whitespace, "_"));
|
||||
|
||||
@@ -211,7 +211,7 @@ pub fn model_xml(objects: &[MeshObject]) -> String {
|
||||
let mut x = String::new();
|
||||
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
let _ = writeln!(x, "<model unit=\"millimeter\" xml:lang=\"en-US\" xmlns=\"{CORE_NS}\">");
|
||||
x.push_str(" <metadata name=\"Application\">SolveCraft</metadata>\n <resources>\n");
|
||||
x.push_str(" <metadata name=\"Application\">SolidCraft</metadata>\n <resources>\n");
|
||||
let colored = objects.iter().any(|o| o.color.is_some() || o.transparency > 0.0 || !o.looks.is_empty());
|
||||
// Base material group id 1 (each object's own colour, then its faces' colours); objects
|
||||
// from 2. A see-through colour carries its alpha (#RRGGBBAA).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-kernel"
|
||||
description = "SolveCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API (implemented with truck)"
|
||||
description = "SolidCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API (implemented with truck)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
+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;
|
||||
@@ -1164,7 +1164,7 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
t.o.add(format!("STYLED_ITEM('color',(#{psa}),#{item})"));
|
||||
}
|
||||
let mut s = String::from(
|
||||
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('IGES import'),'2;1');\nFILE_NAME('','',(''),(''),'SolveCraft IGES','','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n",
|
||||
"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('IGES import'),'2;1');\nFILE_NAME('','',(''),(''),'SolidCraft IGES','','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n",
|
||||
);
|
||||
for (i, l) in t.o.lines.iter().enumerate() {
|
||||
let _ = writeln!(s, "#{}={l};", i + 1);
|
||||
|
||||
@@ -609,7 +609,7 @@ fn layout(w: &W, header: &StepHeader, max_coord: f64) -> String {
|
||||
let line = |out: &mut String, data: &str, sect: char, seq: usize| {
|
||||
let _ = writeln!(out, "{data:<72}{sect}{seq:>7}");
|
||||
};
|
||||
line(&mut out, "SolveCraft IGES export", 'S', 1);
|
||||
line(&mut out, "SolidCraft IGES export", 'S', 1);
|
||||
let stamp = {
|
||||
let t = header.time_stamp.replace(['-', ':', 'T'], "");
|
||||
let t: String = t.chars().filter(char::is_ascii_digit).collect();
|
||||
@@ -618,10 +618,10 @@ fn layout(w: &W, header: &StepHeader, max_coord: f64) -> String {
|
||||
let g = [
|
||||
"1H,".to_string(),
|
||||
"1H;".to_string(),
|
||||
holl("SolveCraft"),
|
||||
holl("SolidCraft"),
|
||||
holl(&header.file_name),
|
||||
holl("SolveCraft"),
|
||||
holl("SolveCraft IGES 5.3"),
|
||||
holl("SolidCraft"),
|
||||
holl("SolidCraft IGES 5.3"),
|
||||
"32".into(),
|
||||
"38".into(),
|
||||
"6".into(),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Regressions against the actual patched truck constructor, run by workspace CI.
|
||||
use truck_meshalgo::prelude::PolylineCurve;
|
||||
use truck_modeling::{BoundedCurve, ParametricCurve, Plane, Point3, Sphere};
|
||||
use truck_shapeops::IntersectionCurveWithParameters;
|
||||
|
||||
fn planes() -> (Plane, Plane) {
|
||||
let o = Point3::new(0.0, 0.0, 0.0);
|
||||
(Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)), Plane::new(o, Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 0.0, 1.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_intersection_polylines_return_none_without_panicking() {
|
||||
let origin = Point3::new(0.0, 0.0, 0.0);
|
||||
for points in [
|
||||
vec![],
|
||||
vec![origin],
|
||||
vec![origin, origin],
|
||||
vec![origin; 3],
|
||||
vec![origin, Point3::new(f64::NAN, 0.0, 0.0)],
|
||||
vec![origin, Point3::new(f64::INFINITY, 0.0, 0.0)],
|
||||
] {
|
||||
let (a, b) = planes();
|
||||
let result = std::panic::catch_unwind(|| IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(points.clone()), 0.01));
|
||||
assert!(result.is_ok(), "constructor panicked for {points:?}");
|
||||
assert!(result.unwrap().is_none(), "collapsed input must not create a curve: {points:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_intersection_retains_the_closure_and_spherical_supports() {
|
||||
let a = Sphere::new(Point3::new(0.0, 0.0, 1.0), 2.0_f64.sqrt());
|
||||
let b = Sphere::new(Point3::new(0.0, 0.0, -1.0), 2.0_f64.sqrt());
|
||||
let poly = PolylineCurve(vec![
|
||||
Point3::new(1.0, 0.0, 0.0),
|
||||
Point3::new(0.0, 1.0, 0.0),
|
||||
Point3::new(-1.0, 0.0, 0.0),
|
||||
Point3::new(0.0, -1.0, 0.0),
|
||||
Point3::new(1.0, 0.0, 0.0),
|
||||
]);
|
||||
let curve = IntersectionCurveWithParameters::try_new(a, b, poly, 0.01).unwrap();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
assert_eq!(curve.subs(lo), curve.subs(hi));
|
||||
for i in 0..=40 {
|
||||
let p = curve.subs(lo + (hi - lo) * i as f64 / 40.0);
|
||||
assert!(p.z.abs() < 1e-8, "{p:?}");
|
||||
assert!(((p.x * p.x + p.y * p.y).sqrt() - 1.0).abs() <= 0.01, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_intersection_keeps_endpoints_on_both_planes() {
|
||||
let (a, b) = planes();
|
||||
let start = Point3::new(1.0, 0.0, 0.0);
|
||||
let end = Point3::new(3.0, 0.0, 0.0);
|
||||
let curve = IntersectionCurveWithParameters::try_new(a, b, PolylineCurve(vec![start, end]), 0.01).unwrap();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
assert_eq!(curve.subs(lo), start);
|
||||
assert_eq!(curve.subs(hi), end);
|
||||
assert_eq!(curve.subs((lo + hi) / 2.0), Point3::new(2.0, 0.0, 0.0));
|
||||
}
|
||||
@@ -30,6 +30,8 @@ mod heal;
|
||||
mod helix;
|
||||
mod iges_in;
|
||||
mod iges_out;
|
||||
#[cfg(test)]
|
||||
mod intersection_curve_tests;
|
||||
mod lip;
|
||||
mod loopblend;
|
||||
mod measure;
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
@@ -1190,7 +1190,7 @@ fn merge_split_faces(ex: &mut p21::Exchange, paint: &mut HashMap<u64, FaceLook>,
|
||||
/// The B-rep entities of one body as truck writes them, parsed, and the surfaces of faces
|
||||
/// merged from pieces.
|
||||
fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V3>, HashMap<u64, FaceLook>)> {
|
||||
let text = crate::step::truck_step(&[b], "SolveCraft")?;
|
||||
let text = crate::step::truck_step(&[b], "SolidCraft")?;
|
||||
let mut ex = p21::parse(&text).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
|
||||
collapse_short_edges(&mut ex);
|
||||
// Face colours by exchange face: truck writes each shell's faces in the body's face order.
|
||||
@@ -1429,10 +1429,10 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m
|
||||
|
||||
let mut s = String::new();
|
||||
s.push_str("ISO-10303-21;\nHEADER;\n");
|
||||
s.push_str("FILE_DESCRIPTION(('SolveCraft model'),'2;1');\n");
|
||||
s.push_str("FILE_DESCRIPTION(('SolidCraft model'),'2;1');\n");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"FILE_NAME({},{},({}),({}),'SolveCraft','SolveCraft','');",
|
||||
"FILE_NAME({},{},({}),({}),'SolidCraft','SolidCraft','');",
|
||||
string(&header.file_name),
|
||||
string(&header.time_stamp),
|
||||
string(&header.author),
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-mcp"
|
||||
description = "SolveCraft MCP server: JSON-RPC 2.0 over stdio, driving a headless session or the running app"
|
||||
description = "SolidCraft MCP server: JSON-RPC 2.0 over stdio, driving a headless session or the running app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -89,7 +89,7 @@ impl Backend for Remote {
|
||||
let reply = match self.roundtrip(&line) {
|
||||
Ok(r) => r,
|
||||
// One retry on a fresh connection (the app may have restarted).
|
||||
Err(_) => self.roundtrip(&line).map_err(|e| format!("SolveCraft at {}: {e}", self.addr))?,
|
||||
Err(_) => self.roundtrip(&line).map_err(|e| format!("SolidCraft at {}: {e}", self.addr))?,
|
||||
};
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
@@ -102,7 +102,7 @@ impl Backend for Remote {
|
||||
true
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
format!("connected to the SolveCraft app at {} (the user watches the viewport)", self.addr)
|
||||
format!("connected to the SolidCraft app at {} (the user watches the viewport)", self.addr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,6 @@ impl Backend for Headless {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"headless in-process SolveCraft session".into()
|
||||
"headless in-process SolidCraft session".into()
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
/// Most messages in one JSON-RPC batch.
|
||||
const MAX_BATCH: usize = 1000;
|
||||
|
||||
const INSTRUCTIONS: &str = "SolveCraft is a parametric 3D CAD app in the style of Autodesk Fusion. Units are millimetres \
|
||||
const INSTRUCTIONS: &str = "SolidCraft is a parametric 3D CAD app in the style of Autodesk Fusion. Units are millimetres \
|
||||
and degrees in expressions (internally radians), Z is up. Model like in Fusion: `sketch.create {plane: XY}`, draw \
|
||||
(`sketch.rectangle.two_point {p0, p1}`, `sketch.circle.center {center, radius}`, `sketch.line {points, closed}`), constrain \
|
||||
and dimension (`sketch.dimension {entities, value}`; values may name parameters), `sketch.finish`, then features \
|
||||
@@ -133,7 +133,7 @@ impl Server {
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
|
||||
"serverInfo": {"name": "solvecraft", "title": "SolveCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"serverInfo": {"name": "solvecraft", "title": "SolidCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-render"
|
||||
description = "SolveCraft view math (orbit camera, projection, picking rays) and a CPU rasterizer for headless snapshots"
|
||||
description = "SolidCraft view math (orbit camera, projection, picking rays) and a CPU rasterizer for headless snapshots"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,3 +12,6 @@ workspace = true
|
||||
solvecraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
+45
-10
@@ -224,6 +224,9 @@ pub struct Camera {
|
||||
pub yaw: f64,
|
||||
/// Elevation above the XY plane, in (−π/2, π/2].
|
||||
pub pitch: f64,
|
||||
/// Rotation about the viewing direction. Zero keeps the world's up axis vertical.
|
||||
#[serde(default)]
|
||||
pub roll: f64,
|
||||
/// Distance from target to eye.
|
||||
pub distance: f64,
|
||||
/// Vertical field of view (radians); 0 = orthographic.
|
||||
@@ -236,7 +239,7 @@ pub struct Camera {
|
||||
impl Default for Camera {
|
||||
fn default() -> Self {
|
||||
let (yaw, pitch) = StandardView::Iso.angles();
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0, up: UpAxis::Z }
|
||||
Camera { target: Vec3::ZERO, yaw, pitch, roll: 0.0, distance: 200.0, fov: 0.0, up: UpAxis::Z }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +269,8 @@ impl Camera {
|
||||
let b = self.back();
|
||||
let right = self.up.from_frame(Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0));
|
||||
let up = b.cross(right).normalized().unwrap_or(self.up.dir());
|
||||
(right, up, b)
|
||||
let (s, c) = self.roll.sin_cos();
|
||||
(right * c + up * s, up * c - right * s, b)
|
||||
}
|
||||
pub fn view(&self) -> Mat4 {
|
||||
let (r, u, b) = self.basis();
|
||||
@@ -349,11 +353,18 @@ impl Camera {
|
||||
self.yaw = (self.yaw + dx * 0.008) % std::f64::consts::TAU;
|
||||
self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim);
|
||||
}
|
||||
/// Rotate the image about the viewing direction.
|
||||
pub fn roll_by(&mut self, radians: f64) {
|
||||
if radians.is_finite() {
|
||||
self.roll = (self.roll + radians) % std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
pub fn set_view(&mut self, v: StandardView) {
|
||||
let lim = std::f64::consts::FRAC_PI_2 - 1e-6;
|
||||
let (y, p) = v.angles();
|
||||
self.yaw = y;
|
||||
self.pitch = p.clamp(-lim, lim);
|
||||
self.roll = 0.0;
|
||||
}
|
||||
/// Frame a bounding box.
|
||||
pub fn fit(&mut self, b: &Aabb3) {
|
||||
@@ -376,15 +387,14 @@ impl Camera {
|
||||
} else {
|
||||
(-b.x).atan2(-b.y)
|
||||
};
|
||||
Camera { yaw, pitch, ..*self }
|
||||
Camera { yaw, pitch, roll: 0.0, ..*self }
|
||||
}
|
||||
/// Orientation as a unit quaternion (camera → world rotation).
|
||||
pub fn orientation(&self) -> Quat {
|
||||
let (r, u, b) = self.basis();
|
||||
Quat::from_basis(r, u, b)
|
||||
}
|
||||
/// Blend between two cameras at `s` in [0, 1]: heading (the shorter way), elevation,
|
||||
/// target, distance and field of view linearly. The ends are returned exactly.
|
||||
/// Blend between two cameras at `s` in [0, 1]: heading and roll take the shorter way.
|
||||
pub fn interpolate(a: &Camera, b: &Camera, s: f64) -> Camera {
|
||||
if !(s > 0.0) {
|
||||
return *a;
|
||||
@@ -392,24 +402,29 @@ impl Camera {
|
||||
if s >= 1.0 {
|
||||
return *b;
|
||||
}
|
||||
// The camera has no roll, so it turns in heading and elevation: both change smoothly
|
||||
// to the very end (the heading the shorter way round). A slerp of orientations would
|
||||
// need a roll-free heading at every step, and near straight up or down that heading
|
||||
// swings fast, so a move to Top spun in its last frames.
|
||||
// Keep heading and elevation smooth near straight up or down. A quaternion slerp
|
||||
// swings the heading sharply there when moving to Top.
|
||||
let dy = (b.yaw - a.yaw + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
let yaw = a.yaw + dy * s;
|
||||
let dr = (b.roll - a.roll + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
|
||||
let pitch = a.pitch + (b.pitch - a.pitch) * s;
|
||||
Camera {
|
||||
target: a.target + (b.target - a.target) * s,
|
||||
yaw,
|
||||
pitch,
|
||||
roll: a.roll + dr * s,
|
||||
distance: a.distance + (b.distance - a.distance) * s,
|
||||
fov: a.fov + (b.fov - a.fov) * s,
|
||||
up: b.up,
|
||||
}
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0
|
||||
self.target.is_finite()
|
||||
&& self.yaw.is_finite()
|
||||
&& self.pitch.is_finite()
|
||||
&& self.roll.is_finite()
|
||||
&& self.distance.is_finite()
|
||||
&& self.distance > 0.0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +459,26 @@ mod tests {
|
||||
assert!(x1 > x0, "dragging right must move the near side right: {x0} -> {x1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roll_rotates_the_picture_and_standard_views_level_it() {
|
||||
let mut c = Camera::default();
|
||||
c.set_view(StandardView::Front);
|
||||
let (_, before_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||||
c.roll_by(std::f64::consts::FRAC_PI_2);
|
||||
let (after_x, after_y, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap();
|
||||
assert!((before_y - 300.0).abs() < 1e-9);
|
||||
assert!((after_x - 400.0).abs() < 1e-9 && after_y > 300.0);
|
||||
let (r, u, b) = c.basis();
|
||||
assert!(r.dot(u).abs() < 1e-12 && r.dot(b).abs() < 1e-12 && u.dot(b).abs() < 1e-12);
|
||||
c.set_view(StandardView::Front);
|
||||
assert_eq!(c.roll, 0.0);
|
||||
let saved = serde_json::to_value(c).unwrap();
|
||||
let mut old = saved.as_object().unwrap().clone();
|
||||
old.remove("roll");
|
||||
let restored: Camera = serde_json::from_value(serde_json::Value::Object(old)).unwrap();
|
||||
assert_eq!(restored.roll, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_animation_interpolates() {
|
||||
let mut a = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 80.0, ..Default::default() };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-sketch"
|
||||
description = "SolveCraft sketches: points, curves, geometric constraints and dimensions, the constraint solver, and profile detection"
|
||||
description = "SolidCraft sketches: points, curves, geometric constraints and dimensions, the constraint solver, and profile detection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-ui-egui"
|
||||
description = "SolveCraft user interface (egui + wgpu): the swappable front end"
|
||||
description = "SolidCraft user interface (egui + wgpu): the swappable front end"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -97,11 +97,19 @@ impl DimensionDraft {
|
||||
|
||||
pub fn cancel() {
|
||||
EDIT.with(|edit| *edit.borrow_mut() = None);
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
}
|
||||
|
||||
/// Infer the measured value through the same command as scripts, without creating a real
|
||||
/// parameter or an undo entry. Only accepting the editor executes the command on the design.
|
||||
pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
|
||||
let (param, expr, draft) = dimension_draft(app, entities, at)?;
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(String, String, DimensionDraft), String> {
|
||||
let sketch = app.session.active_sketch.ok_or("Edit a sketch to add a dimension")?;
|
||||
if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() {
|
||||
return Err("Pick one edge or two endpoints, then place the dimension".into());
|
||||
@@ -126,8 +134,51 @@ pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Resul
|
||||
if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) {
|
||||
return Err("That dimension conflicts with the sketch constraints".into());
|
||||
}
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
|
||||
Ok(())
|
||||
Ok((param, expr, draft))
|
||||
}
|
||||
|
||||
/// Cache inference at a stationary pointer, including incomplete/unsupported picks.
|
||||
#[derive(Clone)]
|
||||
struct Placement {
|
||||
baseline: Arc<solvecraft_engine::doc::Document>,
|
||||
sketch: Option<u64>,
|
||||
component: u64,
|
||||
path: Option<Vec<u64>>,
|
||||
pending: std::collections::BTreeMap<u64, solvecraft_engine::doc::Mat>,
|
||||
entities: Vec<String>,
|
||||
at: Vec2,
|
||||
draft: Option<(String, DimensionDraft)>,
|
||||
}
|
||||
|
||||
fn placement_preview(app: &SolveApp) -> Option<(String, DimensionDraft)> {
|
||||
let tool = app.tool.as_ref().filter(|t| t.kind == crate::tools::Kind::Dimension && !t.picks.is_empty())?;
|
||||
let at = tool.hover.as_ref()?.0;
|
||||
PLACEMENT.with(|preview| {
|
||||
let mut preview = preview.borrow_mut();
|
||||
let current = preview.as_ref().is_some_and(|p| {
|
||||
Arc::ptr_eq(&p.baseline, &app.session.doc)
|
||||
&& p.sketch == app.session.active_sketch
|
||||
&& p.component == app.session.active_component
|
||||
&& p.path == app.session.active_occurrence_path
|
||||
&& p.pending == app.session.pending_moves
|
||||
&& p.entities == tool.picks
|
||||
&& p.at == at
|
||||
});
|
||||
if !current {
|
||||
let draft = dimension_draft(app, tool.picks.clone(), at).ok().map(|(param, _, draft)| (param, draft));
|
||||
*preview = Some(Placement {
|
||||
baseline: app.session.doc.clone(),
|
||||
sketch: app.session.active_sketch,
|
||||
component: app.session.active_component,
|
||||
path: app.session.active_occurrence_path.clone(),
|
||||
pending: app.session.pending_moves.clone(),
|
||||
entities: tool.picks.clone(),
|
||||
at,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
preview.as_ref().and_then(|p| p.draft.clone())
|
||||
})
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -135,6 +186,7 @@ thread_local! {
|
||||
/// A text being dragged: dimension id, undo depth at the start, grab offset (sketch).
|
||||
static DRAG: RefCell<Option<(String, usize, Vec2)>> = const { RefCell::new(None) };
|
||||
static EDIT: RefCell<Option<Edit>> = const { RefCell::new(None) };
|
||||
static PLACEMENT: RefCell<Option<Placement>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Frames the inline editor holds on to the keyboard after opening.
|
||||
@@ -258,7 +310,7 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String {
|
||||
/// Draw the active sketch's dimensions and remember where they are for picking.
|
||||
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
let mut drawn = Vec::new();
|
||||
let draft = EDIT.with(|edit| {
|
||||
let editor_draft = EDIT.with(|edit| {
|
||||
let mut edit = edit.borrow_mut();
|
||||
if edit.as_ref().and_then(|e| e.draft.as_ref()).is_some_and(|d| !d.valid(app)) {
|
||||
*edit = None;
|
||||
@@ -282,6 +334,8 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
None
|
||||
}
|
||||
});
|
||||
let placing = !editing();
|
||||
let draft = editor_draft.or_else(|| placing.then(|| placement_preview(app)).flatten());
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else {
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
@@ -372,13 +426,13 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
.collect();
|
||||
painter.add(Shape::line(pts, stroke));
|
||||
}
|
||||
if is_draft {
|
||||
if is_draft && !placing {
|
||||
EDIT.with(|edit| {
|
||||
if let Some(e) = edit.borrow_mut().as_mut() {
|
||||
e.at = Some(tc);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
} else if !is_draft {
|
||||
drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text });
|
||||
}
|
||||
}
|
||||
@@ -502,6 +556,71 @@ pub fn drawn() -> Vec<(String, Pos2)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn placement_previews_radial_and_angular_dimensions_and_invalidates_on_document_change() {
|
||||
cancel();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[30,30],"radius":5})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[20,10]],"infer":false,"connect":false})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[10,20]],"infer":false,"connect":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
for (picks, at, angular) in [(vec![[35, 30]], [40, 40], false), (vec![[15, 10], [10, 15]], [18, 18], true)] {
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
for pick in picks {
|
||||
h.step(&json!({"click":{"sketch":pick}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"move":{"sketch":at}})).unwrap();
|
||||
let (param, draft) = placement_preview(&h.app).unwrap();
|
||||
let constraint =
|
||||
draft.preview.sketch(draft.sketch).unwrap().sketch.constraints.iter().find(|c| c.param.as_deref() == Some(¶m)).unwrap();
|
||||
assert_eq!(constraint.kind.is_angle(), angular);
|
||||
assert!(!editing());
|
||||
let (_, cached) = placement_preview(&h.app).unwrap();
|
||||
assert!(Arc::ptr_eq(&draft.preview_doc, &cached.preview_doc), "stationary pointer reuses inference");
|
||||
h.app.run("sketch.point", json!({"point":[50,50]})).unwrap();
|
||||
let (_, refreshed) = placement_preview(&h.app).unwrap();
|
||||
assert!(!Arc::ptr_eq(&draft.preview_doc, &refreshed.preview_doc));
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_follows_real_pointer_without_editing_or_mutating_design() {
|
||||
cancel();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
h.step(&json!({"click":{"sketch":[20,10]}})).unwrap();
|
||||
for at in [[20., 20.], [20., -5.]] {
|
||||
h.step(&json!({"move":{"sketch":at}})).unwrap();
|
||||
h.settle();
|
||||
let (param, draft) = placement_preview(&h.app).expect("a line has a dimension preview");
|
||||
assert!((draft.at - Vec2::new(at[0], at[1])).len() < 1e-5);
|
||||
let sk = &draft.preview.sketch(draft.sketch).unwrap().sketch;
|
||||
let c = sk.constraints.iter().find(|c| c.param.as_deref() == Some(¶m)).unwrap();
|
||||
assert!(matches!(c.kind, ConstraintKind::Length {value,..} if (value-20.).abs()<1e-6));
|
||||
assert!(!editing());
|
||||
assert!(drawn().is_empty(), "preview must not become a selectable committed dimension");
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
h.step(&json!({"click":{"sketch":[20,-5]}})).unwrap();
|
||||
assert!(editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(placement_preview(&h.app).is_none());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_show_the_value_or_fx() {
|
||||
let k = ConstraintKind::Length { l: 0, value: 25.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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -11,14 +11,14 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const COMMIT: &str = env!("SOLVECRAFT_COMMIT");
|
||||
pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
|
||||
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
|
||||
pub const DOCS_URL: &str = "https://bherbruck.github.io/solvecraft/";
|
||||
pub const DOCS_URL: &str = "https://bherbruck.github.io/solidcraft/";
|
||||
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
|
||||
use solvecraft_engine::licences::{LICENSE_APACHE, LICENSE_MIT, NOTICE, THIRD_PARTY};
|
||||
/// Fonts: what this build carries.
|
||||
pub const FONTS: &str = "The interface uses egui's built-in fonts (Ubuntu Light and Hack, under the Ubuntu Font Licence and \
|
||||
the MIT/Bitstream Vera licence; Noto Emoji and the emoji icon font, under the OFL and MIT). Sketch text uses Liberation Sans \
|
||||
(SIL OFL 1.1). The Crafting Apps' optional font pack, craft-fonts (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans \
|
||||
CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolveCraft builds.";
|
||||
CJK SC, Noto Sans Arabic; SIL OFL 1.1), is not bundled in SolidCraft builds.";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HelpState {
|
||||
@@ -32,7 +32,7 @@ pub struct HelpState {
|
||||
pub fn diagnostics(app: &SolveApp) -> String {
|
||||
let renderer = if app.viewport.gpu.is_some() { "GPU" } else { "CPU" };
|
||||
format!(
|
||||
"SolveCraft {VERSION} ({COMMIT}, built {BUILD_DATE})\nOS: {} {}\nRenderer: {renderer}{}\nWindow: {}\nDesign: {} ({} features, {} bodies)",
|
||||
"SolidCraft {VERSION} ({COMMIT}, built {BUILD_DATE})\nOS: {} {}\nRenderer: {renderer}{}\nWindow: {}\nDesign: {} ({} features, {} bodies)",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
app.help.gpu.as_ref().map(|g| format!(", {g}")).unwrap_or_default(),
|
||||
@@ -71,7 +71,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
for (id, label) in [
|
||||
("docs", "Documentation"),
|
||||
("about", "About SolveCraft"),
|
||||
("about", "About SolidCraft"),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("report", "Report an Issue…"),
|
||||
] {
|
||||
@@ -100,12 +100,12 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let mut open = true;
|
||||
crate::frame::window(ctx, "About SolveCraft", crate::frame::Width::Normal)
|
||||
crate::frame::window(ctx, "About SolidCraft", crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_about"))
|
||||
.open(&mut open)
|
||||
.default_size(vec2(440.0, 560.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label(RichText::new("SolveCraft").size(22.0).strong());
|
||||
ui.label(RichText::new("SolidCraft").size(22.0).strong());
|
||||
ui.label(format!("Version {VERSION} · commit {COMMIT} · built {BUILD_DATE}"));
|
||||
ui.label(RichText::new("Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.").color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
|
||||
@@ -343,7 +343,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(36, 24))).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("SolveCraft").size(26.0).strong().color(t.text));
|
||||
ui.label(RichText::new("SolidCraft").size(26.0).strong().color(t.text));
|
||||
ui.add_space(12.0);
|
||||
ui.label(RichText::new("Start").size(16.0).color(t.text_dim));
|
||||
});
|
||||
|
||||
@@ -33,7 +33,8 @@ impl Catalog {
|
||||
|
||||
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
|
||||
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
|
||||
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE];
|
||||
static GERMAN: Catalog = Catalog::new("de", "Deutsch", include_str!("locales/de.json"));
|
||||
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE, &GERMAN];
|
||||
|
||||
pub fn is_supported(id: &str) -> bool {
|
||||
LOCALES.iter().any(|catalog| catalog.id == id)
|
||||
|
||||
@@ -192,6 +192,17 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.poly(&[(4.0, 7.5), (12.0, 12.0), (12.0, 21.0), (4.0, 16.5)], a, ink);
|
||||
pen.poly(&[(12.0, 12.0), (20.0, 7.5), (20.0, 16.5), (12.0, 21.0)], Color32::TRANSPARENT, ink);
|
||||
}
|
||||
// Original bug glyph: rounded body, antennae and three pairs of legs.
|
||||
"bug_report" => {
|
||||
pen.circle(12.0, 13.5, 5.0, Color32::TRANSPARENT, ink);
|
||||
pen.line(&[(12.0, 9.0), (12.0, 18.0)], ink);
|
||||
pen.line(&[(9.5, 8.5), (8.0, 5.0)], ink);
|
||||
pen.line(&[(14.5, 8.5), (16.0, 5.0)], ink);
|
||||
for y in [10.0, 13.5, 17.0] {
|
||||
pen.line(&[(3.0, y), (7.0, y)], ink);
|
||||
pen.line(&[(17.0, y), (21.0, y)], ink);
|
||||
}
|
||||
}
|
||||
// Help: a question mark in a ring.
|
||||
"help" => {
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
@@ -229,6 +240,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a);
|
||||
}
|
||||
}
|
||||
"configuration_table" => {
|
||||
pen.poly(&[(3.0, 4.0), (21.0, 4.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
|
||||
pen.line(&[(3.0, 9.0), (21.0, 9.0)], a);
|
||||
pen.line(&[(3.0, 14.0), (21.0, 14.0)], ink);
|
||||
pen.line(&[(9.0, 4.0), (9.0, 20.0)], ink);
|
||||
}
|
||||
"params" => {
|
||||
pen.line(&[(8.0, 20.0), (9.0, 8.0), (10.0, 5.0), (13.0, 4.0)], ink);
|
||||
pen.line(&[(6.0, 11.0), (12.0, 11.0)], ink);
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{SolveApp, dialogs, help};
|
||||
|
||||
const MAX_BODY: usize = 24_000;
|
||||
const MAX_IMAGE_PIXELS: usize = 16_000_000;
|
||||
const ISSUE_URL: &str = "https://github.com/bherbruck/solvecraft/issues/new";
|
||||
const ISSUE_URL: &str = "https://github.com/bherbruck/solidcraft/issues/new";
|
||||
|
||||
thread_local! {
|
||||
static CAD_INPUT_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
@@ -480,7 +480,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if ui.add_enabled(draft.image.is_some() && app.services.pick_save.is_some(), egui::Button::new("Save screenshot…")).clicked()
|
||||
&& let Some(image) = draft.image.as_ref()
|
||||
&& let Some(path) =
|
||||
app.services.pick_save.as_ref().and_then(|pick| pick(&format!("solvecraft-issue-{}.png", app.shot_token), &["png"]))
|
||||
app.services.pick_save.as_ref().and_then(|pick| pick(&format!("solidcraft-issue-{}.png", app.shot_token), &["png"]))
|
||||
{
|
||||
let result = crate::control::save_screenshot(image, Some(&path));
|
||||
draft.note = if result.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn command_for(app: &SolveApp, key: &str) -> Option<&'static str> {
|
||||
pub fn bind(app: &mut SolveApp, id: &str, key: &str, replace: bool) -> Result<(), String> {
|
||||
let spec = solvecraft_engine::find_command(id).ok_or_else(|| format!("no command `{id}`"))?;
|
||||
if RESERVED.contains(&key) {
|
||||
return Err(format!("{key} is used by SolveCraft itself"));
|
||||
return Err(format!("{key} is used by SolidCraft itself"));
|
||||
}
|
||||
if !key.is_empty() {
|
||||
let others: Vec<&'static str> = commands_for(app, key).into_iter().filter(|o| *o != id).collect();
|
||||
@@ -224,7 +224,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
Some((_, "reset_all")) => reset(app, None),
|
||||
Some((_, "export")) => {
|
||||
let text = crate::toolbar_custom::export(app).to_string();
|
||||
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolveCraft shortcuts.json", &["json"])) {
|
||||
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolidCraft shortcuts.json", &["json"])) {
|
||||
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-16
@@ -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;
|
||||
@@ -518,22 +519,6 @@ impl SolveApp {
|
||||
let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. }));
|
||||
return self.start(if edges { "solid.fillet" } else { "solid.extrude" });
|
||||
}
|
||||
// Fix/Unfix: the selected sketch points and curves change at once, otherwise pick them.
|
||||
if id == "sketch.constraint.fix" {
|
||||
let ents: Vec<String> = self
|
||||
.session
|
||||
.selection
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !ents.is_empty() {
|
||||
let _ = self.run(id, json!({ "entities": ents }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Construction toggle: selected sketch curves switch at once, otherwise pick them.
|
||||
// Construction (X): convert the selection, or switch construction mode; no pick tool.
|
||||
if id == "sketch.construction" {
|
||||
@@ -547,6 +532,13 @@ impl SolveApp {
|
||||
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
|
||||
}
|
||||
if let Some(mut t) = tools::Tool::for_command(id) {
|
||||
if let Some(params) = tools::constraint_preselection(id, &self.session.selection) {
|
||||
if self.run(id, params).is_ok() {
|
||||
let _ = self.session.execute("select.clear", &json!({}));
|
||||
self.set_status("Constraint applied", false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Curve-pair constraints can consume an exact preselection. Leave ambiguous
|
||||
// selections to the ordinary pick tool; never silently constrain a subset.
|
||||
if matches!(
|
||||
@@ -856,6 +848,15 @@ impl SolveApp {
|
||||
self.cam_anim = None;
|
||||
}
|
||||
|
||||
/// Hand the view to an external navigation device without a pending mouse orbit or spin
|
||||
/// immediately taking it back on the next frame.
|
||||
pub fn begin_external_navigation(&mut self) {
|
||||
self.cancel_view_animation();
|
||||
self.viewport.orbit_snap = None;
|
||||
self.viewport.orbit_free = None;
|
||||
self.viewport.spin = false;
|
||||
}
|
||||
|
||||
fn step_view_animation(&mut self, ctx: &egui::Context) {
|
||||
let Some(anim) = self.cam_anim else { return };
|
||||
let (cam, done) = anim.sample(self.now);
|
||||
@@ -937,6 +938,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;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"browser.body_name": "Körper {number}",
|
||||
"browser.component_name": "Komponente {number}",
|
||||
"browser.parameters": "Parameter ({count})",
|
||||
"browser.precision": "Genauigkeit: {precision}",
|
||||
"browser.sketch_name": "Skizze {number}",
|
||||
"browser.units": "Einheiten: {units}",
|
||||
"dialog.custom_axis": "↖ Eigene Achse",
|
||||
"dialog.edit_title": "{title} bearbeiten",
|
||||
"dialog.select": "↖ Auswählen",
|
||||
"dialog.selected_count": "↖ {count} ausgewählt",
|
||||
"document.save_changes": "Änderungen in {name} speichern?",
|
||||
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
|
||||
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
|
||||
"sketch.draw_curves": "{linetype}-Kurven zeichnen",
|
||||
"sketch.make_selection": "Auswahl als {linetype} markieren",
|
||||
"ui.1_degree_of_freedom": "1 Freiheitsgrad",
|
||||
"ui.3d_sketch": "3D-Skizze",
|
||||
"ui.about": "Über",
|
||||
"ui.activate": "Aktivieren",
|
||||
"ui.active_component": "Aktive Komponente",
|
||||
"ui.agent_cursor": "Agent-Cursor",
|
||||
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
|
||||
"ui.all": "Alle",
|
||||
"ui.angle": "Winkel",
|
||||
"ui.angle_decimals": "Winkel-Dezimalstellen",
|
||||
"ui.animate_view_changes": "Ansichtsänderungen animieren",
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Animationsgeschwindigkeit",
|
||||
"ui.apply": "Anwenden",
|
||||
"ui.arc": "Kreisbogen",
|
||||
"ui.assemble": "Zusammenbau",
|
||||
"ui.autosave_every": "Autospeichern alle",
|
||||
"ui.axes": "Achsen",
|
||||
"ui.axis": "Achse",
|
||||
"ui.bodies": "Körper",
|
||||
"ui.box": "Block",
|
||||
"ui.break": "Unterbrechen",
|
||||
"ui.cancel": "Abbrechen",
|
||||
"ui.canvases": "Kanvas",
|
||||
"ui.centerline": "Mittellinie",
|
||||
"ui.chamfer": "Abschrägung",
|
||||
"ui.change_workspace": "Arbeitsbereich wechseln",
|
||||
"ui.chord_length": "Sehnenlänge",
|
||||
"ui.circle": "Kreis",
|
||||
"ui.circular_pattern": "Radiales Muster",
|
||||
"ui.clear_recent": "Zuletzt verwendet leeren",
|
||||
"ui.clear_the_selection": "Auswahl aufheben",
|
||||
"ui.close": "Schließen",
|
||||
"ui.coil": "Schraube",
|
||||
"ui.coincident": "Koinzident",
|
||||
"ui.collapse": "Einklappen",
|
||||
"ui.collinear": "Kollinear",
|
||||
"ui.combine": "Kombinieren",
|
||||
"ui.coming_later": "bald verfügbar",
|
||||
"ui.concentric": "Konzentrisch",
|
||||
"ui.conic_curve": "Kegelschnitt",
|
||||
"ui.constant": "Konstant",
|
||||
"ui.constraints_conflict": "Bedingungen konfliktreich",
|
||||
"ui.construct": "Konstruktion",
|
||||
"ui.construction": "Konstruktionslinie",
|
||||
"ui.copy": "Kopieren",
|
||||
"ui.counterbore": "Tiefenbohrung",
|
||||
"ui.countersink": "Senkung",
|
||||
"ui.create": "Erstellen",
|
||||
"ui.create_sketch": "Skizze erstellen",
|
||||
"ui.current": "aktuell",
|
||||
"ui.curvature": "Krümmung",
|
||||
"ui.cut": "Ausschneiden",
|
||||
"ui.cylinder": "Zylinder",
|
||||
"ui.dark": "Dunkel",
|
||||
"ui.default_modelling_orientation": "Standard-Modellierungsorientierung",
|
||||
"ui.degrees_of_freedom": "Freiheitsgrade",
|
||||
"ui.delete": "Löschen",
|
||||
"ui.depth": "Tiefe",
|
||||
"ui.design": "Design",
|
||||
"ui.diameter": "Durchmesser",
|
||||
"ui.dimension": "Maß",
|
||||
"ui.direction": "Richtung",
|
||||
"ui.distance": "Abstand",
|
||||
"ui.distance_1": "Abstand 1",
|
||||
"ui.distance_2": "Abstand 2",
|
||||
"ui.document_settings": "Dokumenteneinstellungen",
|
||||
"ui.don_t_save": "Nicht speichern",
|
||||
"ui.draft": "Abziehen",
|
||||
"ui.drawing": "Zeichnung",
|
||||
"ui.drill_point": "Bohrpunkt",
|
||||
"ui.edit": "Bearbeiten",
|
||||
"ui.ellipse": "Ellipse",
|
||||
"ui.end_radius": "Endradius",
|
||||
"ui.equal": "Gleich",
|
||||
"ui.expand": "Aufklappen",
|
||||
"ui.export_ellipsis": "Exportieren...",
|
||||
"ui.extend": "Verlängern",
|
||||
"ui.extent_type": "Ausdehnungstyp",
|
||||
"ui.extrude": "Extrudieren",
|
||||
"ui.features": "Funktionen",
|
||||
"ui.file": "Datei",
|
||||
"ui.fillet": "Vollrundung",
|
||||
"ui.finish_sketch": "Skizze beenden",
|
||||
"ui.fix_unfix": "Fixieren/Lösen",
|
||||
"ui.flat": "Flach",
|
||||
"ui.flip": "Umkehren",
|
||||
"ui.front": "Vorne",
|
||||
"ui.fully_constrained": "Vollständig bestimmt",
|
||||
"ui.general": "Allgemein",
|
||||
"ui.generative_design": "Generatives Design",
|
||||
"ui.graphics": "Grafik",
|
||||
"ui.heading_assemble": "ZUSAMMENBAU",
|
||||
"ui.heading_box": "BLOCK",
|
||||
"ui.heading_chamfer": "ABSCHRÄGUNG",
|
||||
"ui.heading_circular_pattern": "RADIALES MUSTER",
|
||||
"ui.heading_coil": "SCHRAUBE",
|
||||
"ui.heading_combine": "KOMBINIEREN",
|
||||
"ui.heading_construct": "KONSTRUKTION",
|
||||
"ui.heading_create": "ERSTELLEN",
|
||||
"ui.heading_create_sketch": "SKIZZE ERSTELLEN",
|
||||
"ui.heading_cylinder": "ZYLINDER",
|
||||
"ui.heading_design": "DESIGN",
|
||||
"ui.heading_draft": "ABZIEHEN",
|
||||
"ui.heading_edit": "BEARBEITEN",
|
||||
"ui.heading_extrude": "EXTRUDIEREN",
|
||||
"ui.heading_fillet": "VOLLRUNDUNG",
|
||||
"ui.heading_finish_sketch": "SKIZZE BEENDEN",
|
||||
"ui.heading_front": "VORNE",
|
||||
"ui.heading_hole": "BOHRUNG",
|
||||
"ui.heading_home": "START",
|
||||
"ui.heading_insert": "EINFÜGEN",
|
||||
"ui.heading_inspect": "PRÜFEN",
|
||||
"ui.heading_loft": "LOFT",
|
||||
"ui.heading_measure": "Messen",
|
||||
"ui.heading_mesh": "MESH",
|
||||
"ui.heading_mirror": "SPIEGELN",
|
||||
"ui.heading_modify": "ÄNDERN",
|
||||
"ui.heading_move": "VERSCHIEBEN",
|
||||
"ui.heading_offset_plane": "ABSTANDSEBENE",
|
||||
"ui.heading_ok": "OK",
|
||||
"ui.heading_parameters": "PARAMETER",
|
||||
"ui.heading_pattern_on_path": "PFADMUSTER",
|
||||
"ui.heading_pipe": "ROHR",
|
||||
"ui.heading_plane_at_angle": "EBENE IM WINKEL",
|
||||
"ui.heading_plastic": "PLASTIK",
|
||||
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
|
||||
"ui.heading_revolve": "DREHEN",
|
||||
"ui.heading_right": "RECHTS",
|
||||
"ui.heading_scale": "SKALIEREN",
|
||||
"ui.heading_section_analysis": "QUERSCHNITTSANALYSE",
|
||||
"ui.heading_select": "AUSWÄHLEN",
|
||||
"ui.heading_sheet_metal": "BLECH",
|
||||
"ui.heading_shell": "HÜLLE",
|
||||
"ui.heading_sketch": "SKIZZE",
|
||||
"ui.heading_sketch_palette": "SKIZZENPALETTE",
|
||||
"ui.heading_solid": "KÖRPER",
|
||||
"ui.heading_sphere": "SPHÄRE",
|
||||
"ui.heading_split_body": "KÖRPER TRENNEN",
|
||||
"ui.heading_surface": "FLÄCHE",
|
||||
"ui.heading_sweep": "FÜHREN",
|
||||
"ui.heading_thread": "GEWINDE",
|
||||
"ui.heading_top": "OBEN",
|
||||
"ui.heading_torus": "TORUS",
|
||||
"ui.help": "Hilfe",
|
||||
"ui.hide": "Ausblenden",
|
||||
"ui.hole": "Bohrung",
|
||||
"ui.hole_tap_type": "Gewindetyp",
|
||||
"ui.hole_type": "Bohrungstyp",
|
||||
"ui.home": "Start",
|
||||
"ui.horizontal_vertical": "Horizontal/Vertikal",
|
||||
"ui.insert": "Einfügen",
|
||||
"ui.inspect": "Prüfen",
|
||||
"ui.intersect": "Schnitt",
|
||||
"ui.join": "Verbinden",
|
||||
"ui.joints": "Gelenke",
|
||||
"ui.keyboard_shortcuts": "Tastenkürzel",
|
||||
"ui.language": "Sprache",
|
||||
"ui.length_decimals": "Längen-Dezimalstellen",
|
||||
"ui.light": "Hell",
|
||||
"ui.line": "Linie",
|
||||
"ui.linetype": "Linientyp",
|
||||
"ui.loft": "Loft",
|
||||
"ui.look_at": "Ansehen",
|
||||
"ui.manufacture": "Fertigung",
|
||||
"ui.measure": "Messen",
|
||||
"ui.midpoint": "Mittelpunkt",
|
||||
"ui.mirror": "Spiegeln",
|
||||
"ui.modify": "Ändern",
|
||||
"ui.move": "Verschieben",
|
||||
"ui.multiple": "Mehrfach",
|
||||
"ui.named_views": "Benannte Ansichten",
|
||||
"ui.navigation": "Navigation",
|
||||
"ui.new_body": "Neuer Körper",
|
||||
"ui.new_design": "Neues Design",
|
||||
"ui.normal": "Normal",
|
||||
"ui.object_type": "Objekttyp",
|
||||
"ui.objects": "Objekte",
|
||||
"ui.offer_to_stand_up_y_up_files": "Y-hoch-Dateien aufrecht anbieten",
|
||||
"ui.offset": "Abstand",
|
||||
"ui.offset_plane": "Abstandsebene",
|
||||
"ui.one_side": "Einseitig",
|
||||
"ui.open": "Öffnen",
|
||||
"ui.open_ellipsis": "Öffnen...",
|
||||
"ui.open_recent": "Zuletzt verwendet",
|
||||
"ui.operation": "Operation",
|
||||
"ui.options": "Optionen",
|
||||
"ui.orbit_around": "Umkreisen um",
|
||||
"ui.origin": "Ursprung",
|
||||
"ui.pan_zoom_orbit": "Verschieben, zoomen, umkreisen",
|
||||
"ui.parallel": "Parallel",
|
||||
"ui.parameters": "Parameter",
|
||||
"ui.paste": "Einfügen",
|
||||
"ui.path": "Pfad",
|
||||
"ui.pattern_on_path": "Pfadmuster",
|
||||
"ui.perpendicular": "Senkrecht",
|
||||
"ui.pipe": "Rohr",
|
||||
"ui.placement": "Platzierung",
|
||||
"ui.plane": "Ebene",
|
||||
"ui.plane_at_angle": "Ebene im Winkel",
|
||||
"ui.point": "Punkt",
|
||||
"ui.point_under_the_cursor": "Punkt unter dem Cursor",
|
||||
"ui.points": "Punkte",
|
||||
"ui.polygon": "Polygon",
|
||||
"ui.preferences": "Einstellungen",
|
||||
"ui.profile": "Profil",
|
||||
"ui.profile_plane": "Profilebene",
|
||||
"ui.profiles": "Profile",
|
||||
"ui.project": "Projizieren",
|
||||
"ui.quit": "Beenden",
|
||||
"ui.radius": "Radius",
|
||||
"ui.radius_type": "Radius-Typ",
|
||||
"ui.rectangle": "Rechteck",
|
||||
"ui.rectangular_pattern": "Rechteckmuster",
|
||||
"ui.redo": "Wiederholen",
|
||||
"ui.render": "Rendern",
|
||||
"ui.reset_the_settings_on_this_page": "Einstellungen auf dieser Seite zurücksetzen",
|
||||
"ui.restore_defaults": "Standardeinstellungen wiederherstellen",
|
||||
"ui.reverse_zoom_direction": "Zoomrichtung umkehren",
|
||||
"ui.revolve": "Drehen",
|
||||
"ui.right": "Rechts",
|
||||
"ui.save": "Speichern",
|
||||
"ui.save_as": "Speichern unter",
|
||||
"ui.save_as_ellipsis": "Speichern unter...",
|
||||
"ui.scale": "Skalieren",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
"ui.select": "Auswählen",
|
||||
"ui.selection": "Auswahl",
|
||||
"ui.shell": "Hülle",
|
||||
"ui.show": "Einblenden",
|
||||
"ui.show_constraints": "Bedingungen anzeigen",
|
||||
"ui.show_dimensions": "Maße anzeigen",
|
||||
"ui.show_points": "Punkte anzeigen",
|
||||
"ui.show_profile": "Profil anzeigen",
|
||||
"ui.show_projected_geometries": "Projizierte Geometrien anzeigen",
|
||||
"ui.simple": "Einfach",
|
||||
"ui.simulation": "Simulation",
|
||||
"ui.single": "Einzel",
|
||||
"ui.sketch": "Skizze",
|
||||
"ui.sketch_dimension": "Skizzenmaß",
|
||||
"ui.sketch_grid": "Skizzenraster",
|
||||
"ui.sketch_scale": "Skizzenskalierung",
|
||||
"ui.sketches": "Skizzen",
|
||||
"ui.slice": "Scheibe",
|
||||
"ui.slot": "Langloch",
|
||||
"ui.smooth": "Glatt",
|
||||
"ui.snap": "Einrasten",
|
||||
"ui.sphere": "Sphäre",
|
||||
"ui.spline": "Spline",
|
||||
"ui.split_body": "Körper trennen",
|
||||
"ui.start": "Start",
|
||||
"ui.start_radius": "Startradius",
|
||||
"ui.sweep": "Führen",
|
||||
"ui.symmetric": "Symmetrisch",
|
||||
"ui.symmetry": "Symmetrie",
|
||||
"ui.system": "System",
|
||||
"ui.tangent": "Tangential",
|
||||
"ui.taper_angle": "Kegelwinkel",
|
||||
"ui.tapped": "Gewindebohrung",
|
||||
"ui.text": "Text",
|
||||
"ui.theme": "Design",
|
||||
"ui.thickness": "Dicke",
|
||||
"ui.thread": "Gewinde",
|
||||
"ui.tip_angle": "Spitzenwinkel",
|
||||
"ui.to_object": "Bis Objekt",
|
||||
"ui.top": "Oben",
|
||||
"ui.torus": "Torus",
|
||||
"ui.trim": "Kürzen",
|
||||
"ui.two_sides": "Beidseitig",
|
||||
"ui.ui_scale": "UI-Skalierung",
|
||||
"ui.undo": "Rückgängig",
|
||||
"ui.units": "Einheiten",
|
||||
"ui.units_of_new_designs": "Einheiten für neue Designs",
|
||||
"ui.untitled": "Ohne Titel",
|
||||
"ui.variable": "Variabel",
|
||||
"ui.view_centre": "Ansichtszentrum",
|
||||
"ui.visual_style": "Ansichtsstil",
|
||||
"ui.workspace": "Arbeitsbereich",
|
||||
"ui.zoom_to_the_cursor": "Zum Cursor zoomen"
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"sketch.make_selection": "選択を{linetype}にする",
|
||||
"ui.1_degree_of_freedom": "自由度 1",
|
||||
"ui.3d_sketch": "3D スケッチ",
|
||||
"ui.about": "SolveCraft について",
|
||||
"ui.about": "SolidCraft について",
|
||||
"ui.activate": "アクティブ化",
|
||||
"ui.active_component": "アクティブなコンポーネント",
|
||||
"ui.agent_cursor": "AIエージェントカーソル",
|
||||
|
||||
@@ -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,10 +35,16 @@ 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).
|
||||
pub orbit_cursor: bool,
|
||||
/// Whether a connected 3D mouse moves the view.
|
||||
pub space_mouse_enabled: bool,
|
||||
/// Speed multiplier for 3D mouse navigation.
|
||||
pub space_mouse_speed: f32,
|
||||
/// Anti-aliasing samples (applies on restart).
|
||||
pub msaa: u32,
|
||||
/// Decimals shown for lengths and angles.
|
||||
@@ -52,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,
|
||||
@@ -66,8 +74,11 @@ 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,
|
||||
space_mouse_speed: 1.0,
|
||||
msaa: 4,
|
||||
length_decimals: 2,
|
||||
angle_decimals: 1,
|
||||
@@ -75,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(),
|
||||
@@ -172,9 +184,13 @@ 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;
|
||||
p.look_at_anim = d.look_at_anim;
|
||||
p.look_at_speed = d.look_at_speed;
|
||||
}
|
||||
@@ -555,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();
|
||||
@@ -562,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");
|
||||
@@ -577,6 +600,16 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
egui::Slider::new(&mut app.preferences.look_at_speed, 0.5..=3.0).step_by(0.25).custom_formatter(|v, _| format!("{v:.2}×")),
|
||||
);
|
||||
ui.end_row();
|
||||
ui.label("3D mouse (Windows)");
|
||||
ui.checkbox(&mut app.preferences.space_mouse_enabled, "Navigate with SpaceNavigator / SpaceMouse");
|
||||
ui.end_row();
|
||||
ui.label("3D mouse speed");
|
||||
ui.add_enabled(
|
||||
app.preferences.space_mouse_enabled,
|
||||
egui::Slider::new(&mut app.preferences.space_mouse_speed, 0.25..=3.0).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×")),
|
||||
);
|
||||
ui.end_row();
|
||||
note(ui, "Move and rotate the view together; sketches use pan and zoom only.");
|
||||
}
|
||||
PAGE_DESIGN => {
|
||||
ui.label("Default modelling orientation");
|
||||
@@ -616,7 +649,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, "Anti-aliasing applies the next time SolveCraft starts");
|
||||
note(ui, "Anti-aliasing applies the next time SolidCraft starts");
|
||||
ui.label("Renderer");
|
||||
ui.label(match (&app.viewport.gpu, &app.help.gpu) {
|
||||
(Some(_), Some(g)) => format!("GPU: {g}"),
|
||||
@@ -735,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());
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
.min_width(420.0)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label("SolveCraft closed before these designs were saved. Their last autosave can be opened.");
|
||||
ui.label("SolidCraft closed before these designs were saved. Their last autosave can be opened.");
|
||||
ui.add_space(6.0);
|
||||
for e in app.recoverable.iter().take(20) {
|
||||
ui.label(RichText::new(&e.name).strong());
|
||||
|
||||
@@ -535,7 +535,7 @@ fn approx(got: f64, want: &Value, what: &str) -> Result<(), String> {
|
||||
/// Checks: `bodies` (count), `volume` / `area` (total, number or [value, rel tol]), `faces`,
|
||||
/// `features` (count), `errors` (timeline features in error, default 0), `dialog` (null: none
|
||||
/// open, or a word its kind must contain), `tool` (null or command id), `sketching` (bool),
|
||||
/// `selection` (count), `glyphs` ({constraint id: count}, none overlapping), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
|
||||
/// `selection` (count), `glyphs` ({constraint id: count}, none overlapping one another or a dimension value), `param` ({name: value}), `sketch_status` (solved|…), `dof`, `query`
|
||||
/// ({command, params?, path, value}: a command's result).
|
||||
pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
let doc = h.call("document.inspect", json!({"measure": true}))["result"].clone();
|
||||
@@ -695,6 +695,10 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
|
||||
if let Some((b, _)) = g.iter().skip(i + 1).find(|(_, q)| q.distance(*p) < 14.0) {
|
||||
return Err(format!("glyphs of {a} and {b} overlap at {p:?}"));
|
||||
}
|
||||
let r = egui::Rect::from_center_size(*p, egui::vec2(14.0, 14.0));
|
||||
if let Some((d, _)) = crate::dim_view::text_rects().into_iter().find(|(_, t)| t.intersects(r)) {
|
||||
return Err(format!("glyph of {a} at {p:?} overlaps the value of dimension {d}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
"sketching" => {
|
||||
|
||||
@@ -1000,7 +1000,13 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let picked = PICKED.with(|p| p.borrow().clone());
|
||||
// Where each glyph goes: beside its entity (to its left, away from the line), every entity
|
||||
// of a two-entity constraint getting one; glyphs that would overlap step along.
|
||||
// of a two-entity constraint getting one; glyphs that would overlap step along. A glyph
|
||||
// that would sit on a dimension's value goes to the other side of its line.
|
||||
let dim_texts: Vec<egui::Rect> = crate::dim_view::text_rects().into_iter().map(|(_, r)| r.expand(2.0)).collect();
|
||||
let on_dim = |p: Pos2| {
|
||||
let r = egui::Rect::from_center_size(p, egui::vec2(GLYPH, GLYPH));
|
||||
dim_texts.iter().any(|d| d.intersects(r))
|
||||
};
|
||||
let mut placed: Vec<(String, Pos2, Pos2)> = Vec::new();
|
||||
for c in &ss.sketch.constraints {
|
||||
if c.kind.is_dimension() {
|
||||
@@ -1017,13 +1023,22 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
|
||||
None => egui::vec2(0.7, -0.7),
|
||||
};
|
||||
let step = if dir.is_some() { egui::vec2(side.y, -side.x) } else { egui::vec2(1.0, 0.0) };
|
||||
let mut sp = base + side * (GLYPH * 0.9);
|
||||
for _ in 0..12 {
|
||||
if !placed.iter().any(|(_, q, _)| q.distance(sp) < GLYPH + 1.0) {
|
||||
break;
|
||||
let free = |sp: Pos2| !placed.iter().any(|(_, q, _)| q.distance(sp) < GLYPH + 1.0);
|
||||
let start = |s: egui::Vec2| base + s * (GLYPH * 0.9);
|
||||
let side = if on_dim(start(side)) && !on_dim(start(-side)) { -side } else { side };
|
||||
// Step along until clear of other glyphs, preferring a spot clear of dimension
|
||||
// values too.
|
||||
let settle = |avoid_dims: bool| {
|
||||
let mut sp = start(side);
|
||||
for _ in 0..12 {
|
||||
if free(sp) && !(avoid_dims && on_dim(sp)) {
|
||||
return Some(sp);
|
||||
}
|
||||
sp += step * (GLYPH + 2.0);
|
||||
}
|
||||
sp += step * (GLYPH + 2.0);
|
||||
}
|
||||
None
|
||||
};
|
||||
let sp = settle(true).or_else(|| settle(false)).unwrap_or_else(|| start(side) + step * (12.0 * (GLYPH + 2.0)));
|
||||
placed.push((c.id.clone(), sp, base));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+305
-29
@@ -36,6 +36,13 @@ pub fn promoted(tab: &str, panel: &str) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
fn report_rect(bar: Rect, captions: f32, tabs_start: f32) -> (Rect, bool) {
|
||||
let right = bar.right() - captions - 90.0;
|
||||
let labeled = right - tabs_start >= 340.0;
|
||||
let width = if labeled { 124.0 } else { 28.0 };
|
||||
(Rect::from_min_size(pos2(right - width, bar.top() + 4.0), vec2(width, 26.0)), labeled)
|
||||
}
|
||||
|
||||
pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
egui::Panel::top("sc_appbar").exact_size(34.0).show_separator_line(false).frame(egui::Frame::NONE.fill(t.app_bar)).show(ui, |ui| {
|
||||
@@ -45,14 +52,62 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::titlebar::drag_area(ui, r);
|
||||
}
|
||||
let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
|
||||
// Brand mark: a small solid block drawn in code.
|
||||
let logo = Rect::from_center_size(pos2(left + 20.0, r.center().y), vec2(20.0, 20.0));
|
||||
icons::paint(ui.painter(), logo, "box", Color32::from_rgb(220, 226, 236), Color32::from_rgb(90, 160, 240), Color32::WHITE);
|
||||
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text);
|
||||
let mut x = left + 128.0;
|
||||
// Keep the current header controls and product name when merging the new mark.
|
||||
let compact_actions = r.right() - left < 640.0;
|
||||
// Brand mark: the SolidCraft logo, drawn in code from the SVG's own coordinates
|
||||
// (viewBox 357 x 412). Same 20 px height and centre as the old block.
|
||||
{
|
||||
let c = pos2(left + 20.0, r.center().y);
|
||||
let s = 20.0 / 412.217;
|
||||
let m =
|
||||
|pts: &[(f32, f32)]| -> Vec<egui::Pos2> { pts.iter().map(|&(x, y)| pos2(c.x + (x - 178.5) * s, c.y + (y - 206.1) * s)).collect() };
|
||||
let p = ui.painter();
|
||||
// egui only fills convex shapes, so each face is a few overlapping convex pieces.
|
||||
let face = |col: Color32, pts: &[(f32, f32)]| {
|
||||
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
|
||||
};
|
||||
let (light, mid, dark) = (Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197));
|
||||
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
|
||||
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||||
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
|
||||
face(mid, &[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)]);
|
||||
face(mid, &[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)]);
|
||||
face(dark, &[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)]);
|
||||
face(dark, &[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
|
||||
face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
|
||||
face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
|
||||
// White edge lines.
|
||||
let edge = Stroke::new(10.0 * s, Color32::WHITE);
|
||||
let line = |pts: &[(f32, f32)]| {
|
||||
p.add(egui::Shape::line(m(pts), edge));
|
||||
};
|
||||
line(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)]);
|
||||
line(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)]);
|
||||
line(&[(177.0, 206.1), (263.8, 156.0)]);
|
||||
p.add(egui::Shape::closed_line(
|
||||
m(&[
|
||||
(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),
|
||||
]),
|
||||
edge,
|
||||
));
|
||||
}
|
||||
if !compact_actions {
|
||||
ui.painter().text(pos2(left + 36.0, r.center().y), Align2::LEFT_CENTER, "SolidCraft", FontId::proportional(14.0), t.app_bar_text);
|
||||
}
|
||||
let mut x = left + if compact_actions { 60.0 } else { 128.0 };
|
||||
let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool {
|
||||
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
|
||||
x += 30.0;
|
||||
let width = if compact_actions { 22.0 } else { 26.0 };
|
||||
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(width, 26.0));
|
||||
x += if compact_actions { 22.0 } else { 30.0 };
|
||||
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
|
||||
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
|
||||
if resp.hovered() {
|
||||
@@ -83,7 +138,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.home.show(rev);
|
||||
}
|
||||
let theme_tip = if app.ui.dark { app.tr("ui.light") } else { app.tr("ui.dark") };
|
||||
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
|
||||
if !compact_actions && click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
|
||||
app.ui.dark = !app.ui.dark;
|
||||
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
|
||||
}
|
||||
@@ -109,7 +164,34 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Document tabs.
|
||||
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
|
||||
crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 90.0);
|
||||
let tabs_start = x + 16.0;
|
||||
let (report, report_label) = report_rect(r, captions, tabs_start);
|
||||
let tabs_end = report.left() - 8.0;
|
||||
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
|
||||
// extend into the persistent report and window controls.
|
||||
let tabs_rect = Rect::from_min_max(pos2(tabs_start.min(tabs_end), r.top()), pos2(tabs_end, r.bottom()));
|
||||
let mut tabs_ui = ui.new_child(egui::UiBuilder::new().max_rect(tabs_rect));
|
||||
tabs_ui.set_clip_rect(tabs_rect.intersect(ui.clip_rect()));
|
||||
crate::documents::tabs(app, &mut tabs_ui, r, tabs_start, tabs_end);
|
||||
let response = ui.interact(report, ui.id().with("sc_report_button"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Report a bug"));
|
||||
crate::scenario::publish_handle("titlebar:report", report.center());
|
||||
ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar });
|
||||
ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(ui.painter(), icon, "bug_report", t.app_bar_text, t.app_bar_text, t.app_bar);
|
||||
if report_label {
|
||||
ui.painter().text(
|
||||
pos2(report.left() + 29.0, report.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
"Report a bug",
|
||||
FontId::proportional(13.0),
|
||||
t.app_bar_text,
|
||||
);
|
||||
}
|
||||
if response.on_hover_text("Report a bug — review a screenshot and open a GitHub draft").clicked() {
|
||||
crate::help::run(app, &ui.ctx().clone(), "report");
|
||||
}
|
||||
// Preferences, at the top right like Fusion's profile menu (also Ctrl+,).
|
||||
let gear = Rect::from_center_size(pos2(r.right() - captions - 70.0, r.center().y), vec2(24.0, 24.0));
|
||||
let gresp = ui.interact(gear, ui.id().with("sc_prefs_button"), Sense::click());
|
||||
@@ -332,6 +414,14 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
// Panels.
|
||||
let tab = app.ui.tab.clone();
|
||||
// The command whose tool or dialog is open right now, shown pressed in the toolbar.
|
||||
let running: Option<String> = match (&app.tool, &app.dialog) {
|
||||
(Some(tool), _) => Some(tool.cmd.to_string()),
|
||||
// Create Sketch never lands in last_command (it is not repeatable).
|
||||
(None, Some(d)) if matches!(d.kind, crate::dialogs::Kind::Sketch) => Some("sketch.create".to_string()),
|
||||
(None, Some(_)) => app.last_command.as_ref().map(|(id, _)| id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let mut px = r.left() + 112.0;
|
||||
let top = r.top() + 26.0;
|
||||
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
|
||||
@@ -381,7 +471,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if resp.secondary_clicked() {
|
||||
menu = Some((resp.interact_pointer_pos().unwrap_or(br.left_bottom()), c.id));
|
||||
}
|
||||
if resp.hovered() && info.enabled {
|
||||
if running.as_deref() == Some(c.id) {
|
||||
painter.rect_filled(br, 4.0, t.border.gamma_multiply(0.7));
|
||||
} else if resp.hovered() && info.enabled {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
let (ink, fill, acc) = if info.enabled {
|
||||
@@ -389,9 +481,19 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else {
|
||||
(t.border, t.panel_header, t.border)
|
||||
};
|
||||
let icon = if c.id == "sketch.finish" { "finish" } else { c.icon };
|
||||
let icon = match c.id {
|
||||
"sketch.finish" => "finish",
|
||||
"config.table" => "configuration_table",
|
||||
_ => c.icon,
|
||||
};
|
||||
icons::paint(&painter, br.shrink(6.0), icon, ink, fill, acc);
|
||||
let tr_label = app.tr_label(c.label);
|
||||
let label = match c.id {
|
||||
"parameters.change" if *panel == "CONFIGURE" => "Parameters",
|
||||
"config.table" if *panel == "CONFIGURE" => "Configurations",
|
||||
_ => c.label,
|
||||
};
|
||||
let tr_label = app.tr_label(label);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, tr_label));
|
||||
let tip = match &info.disabled_reason {
|
||||
Some(why) => format!("{}\n{why}", tr_label),
|
||||
None => format!("{}{}", tr_label, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
|
||||
@@ -440,29 +542,41 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else if cmds.is_empty() {
|
||||
ui.label(egui::RichText::new("Not available yet").color(t.text_dim));
|
||||
}
|
||||
let row_w = dropdown_width(app, ui, &cmds);
|
||||
// Up and Down move the keyboard focus through the enabled rows (from none
|
||||
// focused: the first or the last), instead of egui's own spatial move.
|
||||
let (down, up) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp)));
|
||||
if down || up {
|
||||
let enabled: Vec<&str> = cmds.iter().filter(|c| c.info(&app.session).enabled).map(|c| c.id).collect();
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let at = enabled.iter().position(|id| focused == Some(dropdown_row_id(ui, id)));
|
||||
let n = enabled.len();
|
||||
let next = match (at, down) {
|
||||
(None, true) => Some(0),
|
||||
(None, false) => n.checked_sub(1),
|
||||
(Some(i), true) => Some((i + 1) % n.max(1)),
|
||||
(Some(i), false) => Some((i + n.max(1) - 1) % n.max(1)),
|
||||
};
|
||||
if let Some(id) = next.and_then(|i| enabled.get(i)) {
|
||||
ui.ctx().memory_mut(|m| {
|
||||
m.request_focus(dropdown_row_id(ui, id));
|
||||
m.move_focus(egui::FocusDirection::None);
|
||||
});
|
||||
}
|
||||
}
|
||||
for c in &cmds {
|
||||
let info = c.info(&app.session);
|
||||
ui.horizontal(|ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
|
||||
let b = ui.add_enabled(
|
||||
info.enabled,
|
||||
egui::Button::new(app.tr_label(c.label)).frame(false).shortcut_text(c.shortcut.unwrap_or("")),
|
||||
);
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
|
||||
if b.secondary_clicked() {
|
||||
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
|
||||
}
|
||||
if b.clicked() {
|
||||
let id = c.id;
|
||||
app.start(id);
|
||||
match dropdown_row(app, ui, c, row_w) {
|
||||
RowAction::None => {}
|
||||
RowAction::Menu(at) => menu = Some((at, c.id)),
|
||||
RowAction::Start => {
|
||||
app.start(c.id);
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if area.response.clicked_elsewhere() && !lresp.clicked() {
|
||||
if (area.response.clicked_elsewhere() && !lresp.clicked()) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
|
||||
}
|
||||
}
|
||||
@@ -506,6 +620,72 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What a click on a panel drop-down row asks for.
|
||||
enum RowAction {
|
||||
None,
|
||||
Start,
|
||||
Menu(egui::Pos2),
|
||||
}
|
||||
|
||||
const ROW_H: f32 = 24.0;
|
||||
|
||||
/// The id of a drop-down row (for keyboard focus).
|
||||
fn dropdown_row_id(ui: &egui::Ui, id: &str) -> egui::Id {
|
||||
ui.id().with(("dropdown_row", id))
|
||||
}
|
||||
|
||||
/// How wide a panel drop-down's rows are: the widest label and shortcut, at least 200.
|
||||
fn dropdown_width(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solvecraft_engine::CommandSpec]) -> f32 {
|
||||
let t = Tokens::get();
|
||||
let font = FontId::proportional(13.0);
|
||||
cmds.iter()
|
||||
.map(|c| {
|
||||
let label = ui.painter().layout_no_wrap(app.tr_label(c.label).to_string(), font.clone(), t.text).size().x;
|
||||
let key = c.shortcut.map(|k| ui.painter().layout_no_wrap(k.to_string(), font.clone(), t.text).size().x + 24.0).unwrap_or(0.0);
|
||||
6.0 + 18.0 + 8.0 + label + key + 30.0
|
||||
})
|
||||
.fold(200.0, f32::max)
|
||||
}
|
||||
|
||||
/// One command row of a panel drop-down: icon, label and shortcut, with the hover background
|
||||
/// under the pointer or keyboard focus (like every menu).
|
||||
fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solvecraft_engine::CommandSpec, width: f32) -> RowAction {
|
||||
let t = Tokens::get();
|
||||
let info = c.info(&app.session);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
let sense = if info.enabled { Sense::click() } else { Sense::hover() };
|
||||
let resp = ui.interact(rect, dropdown_row_id(ui, c.id), sense);
|
||||
let label = app.tr_label(c.label).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
|
||||
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
|
||||
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
|
||||
let hot = info.enabled && (resp.hovered() || resp.has_focus());
|
||||
let p = ui.painter();
|
||||
if hot {
|
||||
p.rect_filled(rect, 3.0, t.hover);
|
||||
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
|
||||
}
|
||||
let (ink, fill, acc, text) =
|
||||
if info.enabled { (t.icon, t.icon_fill, t.accent, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) };
|
||||
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(p, ir, c.icon, ink, fill, acc);
|
||||
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
|
||||
if let Some(k) = c.shortcut {
|
||||
p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, k, FontId::proportional(13.0), t.text_dim);
|
||||
}
|
||||
let resp = match &info.disabled_reason {
|
||||
Some(why) if !info.enabled => resp.on_hover_text(why),
|
||||
_ => resp,
|
||||
};
|
||||
if resp.secondary_clicked() {
|
||||
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
|
||||
}
|
||||
if resp.clicked() && info.enabled {
|
||||
return RowAction::Start;
|
||||
}
|
||||
RowAction::None
|
||||
}
|
||||
|
||||
/// A toolbar button being dragged (its command id).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ToolbarDrag(&'static str);
|
||||
@@ -612,3 +792,99 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_report_tests {
|
||||
use super::*;
|
||||
|
||||
fn captured_frame(h: &mut crate::scenario::Harness) {
|
||||
let token = h.app.shot_token;
|
||||
crate::issue_report::receive(&mut h.app, token, std::sync::Arc::new(egui::ColorImage::new([2, 2], vec![Color32::GREEN; 4])));
|
||||
h.frames(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_report_reserves_controls_at_normal_and_double_scale_widths() {
|
||||
for physical_width in [900.0, 1400.0] {
|
||||
for scale in [1.0, 2.0] {
|
||||
let width = physical_width / scale;
|
||||
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 34.0));
|
||||
for captions in [0.0, crate::titlebar::WIDTH] {
|
||||
let (report, labeled) = report_rect(bar, captions, 354.0);
|
||||
assert!(bar.contains_rect(report));
|
||||
let prefs = Rect::from_center_size(pos2(width - captions - 70.0, 17.0), vec2(24.0, 24.0));
|
||||
assert!(!report.intersects(prefs));
|
||||
assert!(report.right() < width - captions - 82.0);
|
||||
assert_eq!(labeled, width - captions - 90.0 - 354.0 >= 340.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_report_actual_click_opens_capture_without_changing_healthy_design() {
|
||||
for width in [450.0, 700.0, 900.0, 1400.0] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.custom_titlebar = true;
|
||||
h.app.run("solid.box", json!({"length":35,"width":12,"height":4})).unwrap();
|
||||
h.call("ui.resize", json!({"width":width,"height":720}));
|
||||
h.frames(3);
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision);
|
||||
let point = h.widget("Report a bug").expect("compact and full buttons retain an accessible name");
|
||||
h.step(&json!({"click":point})).unwrap();
|
||||
assert!(h.app.issue_report.draft.is_some(), "width {width}: header click must reach the report");
|
||||
assert!(crate::issue_report::pending(&h.app));
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision));
|
||||
captured_frame(&mut h);
|
||||
// Existing review content scrolls vertically when the window is narrow.
|
||||
if width < 700.0 {
|
||||
let [x, y] = h.widget("Close draft").unwrap();
|
||||
h.call("ui.scroll", json!({"x":x,"y":y - 60.0,"delta":-1200}));
|
||||
// Let the scroll animation finish before locating the click target.
|
||||
h.frames(60);
|
||||
}
|
||||
h.step(&json!({"widget":"Close draft"})).unwrap();
|
||||
assert!(h.app.issue_report.draft.is_none(), "width {width}: review closes through its actual button");
|
||||
h.step(&json!({"click":{"handle":"appbar:home"}})).unwrap();
|
||||
assert!(h.app.home.open, "width {width}: Home remains reachable beside Report");
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_report_preserves_new_dimension_draft_and_single_commit() {
|
||||
crate::dim_view::cancel();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 80.0;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(10.0, 0.0, 0.0);
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
for point in [[0, 0, 0], [20, 0, 0], [10, 5, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"text":"30 mm"})).unwrap();
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision);
|
||||
h.step(&json!({"widget":"Report a bug"})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.app.issue_report.draft.is_some() && crate::dim_view::editing());
|
||||
captured_frame(&mut h);
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
|
||||
h.step(&json!({"text":"Describe this dimension problem."})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.revision));
|
||||
h.step(&json!({"widget":"Close draft"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.undo.len(), before.1 + 1);
|
||||
assert_eq!(h.app.session.doc.param("d1").unwrap().expr, "30 mm");
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before.0);
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ pub fn export(app: &SolveApp) -> Value {
|
||||
|
||||
pub fn import(app: &mut SolveApp, v: &Value) -> Result<(), String> {
|
||||
if v.get("format").and_then(Value::as_str) != Some("solvecraft-shortcuts/1") {
|
||||
return Err("not a SolveCraft shortcuts file".into());
|
||||
return Err("not a SolidCraft shortcuts file".into());
|
||||
}
|
||||
if let Some(s) = v.get("shortcuts") {
|
||||
app.keymap.load(s);
|
||||
|
||||
@@ -323,6 +323,61 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
|
||||
Ok(result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
|
||||
}
|
||||
|
||||
/// Only consume a complete, unambiguous selection; command validation owns geometry.
|
||||
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
|
||||
use solvecraft_engine::Sel::{SketchCurve, SketchPoint};
|
||||
let selected_ids = |curves_only: bool, minimum: usize| -> Option<Vec<&str>> {
|
||||
if !(minimum..=200).contains(&selection.len()) {
|
||||
return None;
|
||||
}
|
||||
let ids: Vec<&str> = selection
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
SketchCurve { id } => Some(id.as_str()),
|
||||
SketchPoint { id } if !curves_only => Some(id.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let unique: std::collections::BTreeSet<_> = ids.iter().copied().collect();
|
||||
(unique.len() == ids.len()).then_some(ids)
|
||||
};
|
||||
match (command, selection) {
|
||||
("sketch.constraint.fix", _) => selected_ids(false, 1).map(|ids| json!({"entities":ids})),
|
||||
("sketch.constraint.polygon", _) => selected_ids(true, 3).map(|ids| json!({"lines":ids})),
|
||||
("sketch.constraint.curvature", [SketchCurve { id: a }, SketchCurve { id: b }]) if a != b => Some(json!({"a":a,"b":b})),
|
||||
("sketch.constraint.horizontal_vertical", [SketchCurve { id }]) => Some(json!({"line":id})),
|
||||
("sketch.constraint.horizontal_vertical", [SketchPoint { id: a }, SketchPoint { id: b }]) if a != b => Some(json!({"points":[a,b]})),
|
||||
("sketch.constraint.coincident", [a, b]) => {
|
||||
let entity = |s: &solvecraft_engine::Sel| match s {
|
||||
SketchPoint { id } | SketchCurve { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let (a, b) = (entity(a)?, entity(b)?);
|
||||
(a != b).then(|| json!({"a":a,"b":b}))
|
||||
}
|
||||
(
|
||||
"sketch.constraint.midpoint",
|
||||
[SketchPoint { id: point }, SketchCurve { id: line }] | [SketchCurve { id: line }, SketchPoint { id: point }],
|
||||
) => Some(json!({"point":point,"line":line})),
|
||||
("sketch.constraint.symmetry", [a, b, c]) => {
|
||||
let mut points = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
for entity in [a, b, c] {
|
||||
match entity {
|
||||
SketchPoint { id } => points.push(id),
|
||||
SketchCurve { id } => lines.push(id),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
match (points.as_slice(), lines.as_slice()) {
|
||||
([a, b], [line]) if a != b => Some(json!({"a":a,"b":b,"line":line})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
|
||||
+205
-65
@@ -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.
|
||||
@@ -671,7 +673,7 @@ fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
pos.x.to_bits().hash(&mut h);
|
||||
pos.y.to_bits().hash(&mut h);
|
||||
for v in [app.cam.yaw, app.cam.pitch, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
|
||||
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
app.session.revision.hash(&mut h);
|
||||
@@ -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;
|
||||
@@ -2241,7 +2287,7 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
sketch_scene_key(app, proj).hash(&mut h);
|
||||
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
|
||||
for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {
|
||||
v.to_bits().hash(&mut h);
|
||||
}
|
||||
(rect.width() as u32, rect.height() as u32).hash(&mut h);
|
||||
@@ -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();
|
||||
|
||||
@@ -100,6 +100,7 @@ pub fn panels(tab: &str) -> Option<&'static [&'static str]> {
|
||||
/// to the registry.
|
||||
pub fn layout<'a>(tab: &str, panel: &str, specs: &[&'a CommandSpec]) -> Option<(Vec<&'a CommandSpec>, usize)> {
|
||||
let (ids, promoted, own_tab): (&[&str], usize, &str) = match (tab, panel) {
|
||||
("SOLID", "CONFIGURE") => (&["parameters.change", "config.table"], 2, tab),
|
||||
("SOLID" | "SHEET METAL" | "PLASTIC", "ASSEMBLE") => (ASSEMBLE, 3, tab),
|
||||
("SHEET METAL", "CREATE") => (SHEET_CREATE, 3, tab),
|
||||
("SHEET METAL", "MODIFY") => (SHEET_MODIFY, 2, tab),
|
||||
@@ -146,6 +147,38 @@ mod tests {
|
||||
layout(tab, panel, &specs).map(|(v, _)| v.iter().map(|c| c.id).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_header_clicks_open_parameters_and_configurations_separately() {
|
||||
use serde_json::json;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let commands = ids("SOLID", "CONFIGURE");
|
||||
assert_eq!(&commands[..2], &["parameters.change", "config.table"]);
|
||||
assert!(commands.contains(&"config.configure"), "Configure remains available in the dropdown");
|
||||
h.step(&json!({"widget":"Parameters"})).unwrap();
|
||||
assert!(h.app.params.is_some(), "the ordinary Parameters button opens the editor");
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert!(h.widget("User Parameters").is_some());
|
||||
assert!(h.widget("Model Parameters").is_some());
|
||||
// Exercise the editor's Add button, not a script-only parameter command.
|
||||
let parameters = h.app.params.as_mut().unwrap();
|
||||
parameters.new_name = "width".into();
|
||||
parameters.new_expr = "25 mm".into();
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":"Add"})).unwrap();
|
||||
let values = h.app.session.execute("parameters.list", &json!({})).unwrap();
|
||||
assert!(values["parameters"].as_array().unwrap().iter().any(|p| p["name"] == "width" && p["value"] == 25.0));
|
||||
h.step(&json!({"click":{"handle":"params:close"}})).unwrap();
|
||||
assert!(h.app.params.is_none());
|
||||
h.step(&json!({"widget":"Configurations"})).unwrap();
|
||||
assert!(h.app.params.is_none());
|
||||
assert!(matches!(
|
||||
h.app.dialog.as_ref().map(|d| &d.kind),
|
||||
Some(crate::dialogs::Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. }))
|
||||
));
|
||||
assert!(h.widget("Add Column").is_some());
|
||||
assert!(h.widget("Add Row").is_some());
|
||||
}
|
||||
|
||||
/// Fusion's layout: joints on every tab's ASSEMBLE panel, sheet metal and plastic tabs
|
||||
/// reach the shared solid commands, the plastic rules sit on SETUP.
|
||||
#[test]
|
||||
|
||||
@@ -146,3 +146,106 @@ fn rejected_preselection_preserves_document_and_error() {
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curvature_polygon_and_fix_consume_clicked_operands_atomically() {
|
||||
for command in ["fix", "curvature", "polygon"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
let picks = match command {
|
||||
"curvature" => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.spline.control_point", json!({"points":["l1.end",[40,12],[50,16],[60,25]]})).unwrap();
|
||||
vec![[20., 10.], [45., 14.875]]
|
||||
}
|
||||
"polygon" => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[40,10],[40,30],[10,30]],"closed":true,"infer":false})).unwrap();
|
||||
vec![[25., 10.], [40., 20.], [25., 30.], [10., 20.]]
|
||||
}
|
||||
_ => {
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[40,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[20,25],"id":"p"})).unwrap();
|
||||
vec![[25., 10.], [20., 25.]]
|
||||
}
|
||||
};
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 80.0;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(30.0, 15.0, 0.0);
|
||||
h.settle();
|
||||
for (i, p) in picks.iter().enumerate() {
|
||||
h.step(&json!({"click":{"sketch":p},"shift":i>0})).unwrap();
|
||||
}
|
||||
assert_eq!(h.app.session.selection.len(), picks.len(), "{command}: {:?}", h.app.session.selection);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status);
|
||||
assert!(h.app.tool.is_none());
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let st = h.app.session.world_state();
|
||||
let sk = &st.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert!(sk.constraints.iter().any(|c| match command {
|
||||
"fix" => matches!(c.kind, ConstraintKind::Fix { .. }),
|
||||
"curvature" => matches!(c.kind, ConstraintKind::Smooth { .. }),
|
||||
_ => matches!(c.kind, ConstraintKind::Equal { .. }),
|
||||
}));
|
||||
match command {
|
||||
"fix" => {
|
||||
assert!(sk.curve_locked(sk.curve_index("l1").unwrap()));
|
||||
assert!(sk.point_locked(sk.resolve_point("p").unwrap()));
|
||||
}
|
||||
"curvature" => {
|
||||
let spline = sk.curves.iter().find(|c| c.id != "l1").unwrap();
|
||||
let pts: Vec<_> = spline.kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
|
||||
let line = sk.polyline(sk.curve_index("l1").unwrap());
|
||||
let direction = (line[1] - line[0]).normalized().unwrap();
|
||||
assert!((pts[1] - pts[0]).cross(direction).abs() < 1e-5);
|
||||
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3);
|
||||
}
|
||||
_ => {
|
||||
let lengths: Vec<_> = ["l1", "l2", "l3", "l4"]
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let pts = sk.polyline(sk.curve_index(id).unwrap());
|
||||
pts[0].dist(pts[1])
|
||||
})
|
||||
.collect();
|
||||
assert!(lengths[0] > 1.0);
|
||||
assert!(lengths.iter().all(|l| (l - lengths[0]).abs() < 1e-6));
|
||||
}
|
||||
}
|
||||
assert!(st.sketch(h.app.session.active_sketch.unwrap()).unwrap().report.ok());
|
||||
let after = h.app.session.doc.to_json();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_constraints_preserve_mixed_selections_and_rejected_geometry() {
|
||||
for command in ["fix", "curvature", "polygon"] {
|
||||
let mut h = lines();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let mixed = vec![Sel::SketchCurve { id: "l1".into() }, Sel::Body { name: "missing".into() }];
|
||||
h.app.session.selection = mixed.clone();
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(h.app.tool.is_some());
|
||||
assert_eq!(h.app.session.selection, mixed);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
let mut h = lines();
|
||||
let selection = vec![Sel::SketchCurve { id: "l1".into() }, Sel::SketchCurve { id: "missing".into() }];
|
||||
h.app.session.selection = selection.clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.constraint.curvature"})).unwrap();
|
||||
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,6 +4,97 @@ use serde_json::json;
|
||||
use solvecraft_engine::sketch::ConstraintKind;
|
||||
use solvecraft_ui_egui::scenario::Harness;
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_uses_preselected_line_and_preserves_rejection() {
|
||||
for fixed in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[10,10],[40,13]],"infer":false}}},
|
||||
{"call":"ui.view","params":{"view":"top"}}
|
||||
]))
|
||||
.unwrap();
|
||||
if fixed {
|
||||
h.app.session.execute("sketch.constraint.fix", &json!({"entity":"l1"})).unwrap();
|
||||
}
|
||||
h.run(&json!([{"click":{"sketch":[25,11.5]}}])).unwrap();
|
||||
assert_eq!(h.app.session.selection.len(), 1);
|
||||
let selection = h.app.session.selection.clone();
|
||||
let before = serde_json::to_value(&h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap();
|
||||
assert_eq!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error), fixed);
|
||||
if fixed {
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
} else {
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap();
|
||||
let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap();
|
||||
assert!((a.y - b.y).abs() < 1e-6);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
assert!(h.app.tool.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_leaves_ambiguous_preselection_to_pick_tool() {
|
||||
use solvecraft_engine::Sel;
|
||||
for selection in [
|
||||
vec![Sel::SketchPoint { id: "origin".into() }],
|
||||
vec![Sel::SketchPoint { id: "origin".into() }, Sel::SketchCurve { id: "c1".into() }],
|
||||
vec![Sel::SketchPoint { id: "origin".into() }, Sel::SketchPoint { id: "c1.center".into() }, Sel::SketchCurve { id: "c1".into() }],
|
||||
] {
|
||||
let mut h = Harness::new();
|
||||
h.app.session.execute("sketch.create", &json!({"plane":"XY"})).unwrap();
|
||||
h.app.session.execute("sketch.circle.center", &json!({"center":[20,3],"radius":5})).unwrap();
|
||||
h.app.session.selection = selection.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap();
|
||||
assert!(h.app.tool.as_ref().unwrap().picks.is_empty());
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_uses_preselected_points() {
|
||||
for center in [[20.0, 3.0], [3.0, 20.0]] {
|
||||
for reverse in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
let (first, second) = if reverse { (center, [0.0, 0.0]) } else { ([0.0, 0.0], center) };
|
||||
h.run(&json!([
|
||||
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
|
||||
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":center,"radius":5}}},
|
||||
{"call":"ui.view","params":{"view":"top"}},
|
||||
{"click":{"sketch":first}},
|
||||
{"click":{"sketch":second},"shift":true}
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(h.app.session.selection.len(), 2);
|
||||
let before = serde_json::to_value(&h.app.session.doc).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.run(&json!([{"start":"sketch.constraint.horizontal_vertical"}])).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
let sk = &h.app.session.model.state().sketches[0].sketch;
|
||||
let p = sk.point(sk.resolve_point("c1.center").unwrap()).unwrap();
|
||||
assert!((if center[0] > center[1] { p.y } else { p.x }).abs() < 1e-6);
|
||||
assert!(h.app.tool.is_none());
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let after = serde_json::to_value(&h.app.session.doc).unwrap();
|
||||
h.app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), before);
|
||||
h.app.session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc).unwrap(), after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_aligns_origin_and_circle_center() {
|
||||
for (center, horizontal) in [([20.0, 3.0], true), ([3.0, 20.0], false)] {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Sel, sketch::ConstraintKind};
|
||||
use solvecraft_ui_egui::scenario::Harness;
|
||||
|
||||
fn setup() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false,"connect":false})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[20,20],"id":"a"})).unwrap();
|
||||
h.app.run("sketch.point", json!({"point":[25,30],"id":"b"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_point_constraint_preselection_applies_and_undoes_once() {
|
||||
for (command, picks) in [
|
||||
("coincident", vec![[20, 20], [25, 30]]),
|
||||
("coincident", vec![[20, 20], [20, 10]]),
|
||||
("coincident", vec![[20, 10], [20, 20]]),
|
||||
("midpoint", vec![[20, 20], [20, 10]]),
|
||||
("midpoint", vec![[20, 10], [20, 20]]),
|
||||
("symmetry", vec![[20, 10], [20, 20], [25, 30]]),
|
||||
("symmetry", vec![[20, 20], [20, 10], [25, 30]]),
|
||||
] {
|
||||
let mut h = setup();
|
||||
for (index, pick) in picks.iter().enumerate() {
|
||||
h.step(&json!({"click":{"sketch":pick},"shift":index>0})).unwrap();
|
||||
}
|
||||
assert_eq!(h.app.session.selection.len(), picks.len(), "{command}");
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(!h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "{command}: {:?}", h.app.status);
|
||||
assert!(h.app.tool.is_none());
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert!(sk.constraints.iter().any(|c| match command {
|
||||
"coincident" => matches!(c.kind, ConstraintKind::Coincident { .. } | ConstraintKind::PointOnCurve { .. }),
|
||||
"midpoint" => matches!(c.kind, ConstraintKind::Midpoint { .. }),
|
||||
_ => matches!(c.kind, ConstraintKind::Symmetric { .. }),
|
||||
}));
|
||||
let point = |id| sk.point(sk.resolve_point(id).unwrap()).unwrap();
|
||||
let a = point("a");
|
||||
let b = point("b");
|
||||
let start = point("l1.start");
|
||||
let end = point("l1.end");
|
||||
let axis = (end - start).normalized().unwrap();
|
||||
match command {
|
||||
"midpoint" => assert!(a.dist((start + end) * 0.5) < 1e-6),
|
||||
"symmetry" => {
|
||||
assert!(((a + b) * 0.5 - start).cross(axis).abs() < 1e-6);
|
||||
assert!((b - a).dot(axis).abs() < 1e-6);
|
||||
}
|
||||
_ if picks.contains(&[20, 10]) => assert!((a - start).cross(axis).abs() < 1e-6),
|
||||
_ => assert!(a.dist(b) < 1e-6),
|
||||
}
|
||||
let after = h.app.session.doc.to_json();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_and_ambiguous_preselection_preserve_document_and_selection() {
|
||||
let mut h = setup();
|
||||
h.app.run("sketch.constraint.fix", json!({"entities":["a","b"]})).unwrap();
|
||||
h.app.session.selection = vec![Sel::SketchPoint { id: "a".into() }, Sel::SketchPoint { id: "b".into() }];
|
||||
let selection = h.app.session.selection.clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.constraint.coincident"})).unwrap();
|
||||
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error));
|
||||
assert_eq!(h.app.session.selection, selection);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
for command in ["coincident", "midpoint", "symmetry"] {
|
||||
h.app.session.selection = vec![Sel::SketchPoint { id: "a".into() }];
|
||||
h.step(&json!({"start":format!("sketch.constraint.{command}")})).unwrap();
|
||||
assert!(h.app.tool.as_ref().unwrap().picks.is_empty());
|
||||
assert_eq!(h.app.session.selection.len(), 1);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [10, 10]}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.dimension", "params": {"entities": ["l1"], "value": 10, "text_at": [5, -0.3]}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.dimension", "params": {"entities": ["l2"], "value": 10, "text_at": [10.3, 5]}}},
|
||||
{"call": "ui.view", "params": {"view": "top"}},
|
||||
{"call": "ui.view", "params": {"view": "fit"}},
|
||||
{"expect": {"glyphs": {"k1": 1, "k2": 1, "k3": 1, "k4": 1}}}
|
||||
]
|
||||
@@ -1,9 +1,9 @@
|
||||
[
|
||||
{"note": "Help ▸ About ▸ Licences shows SolveCraft's licences and every third-party crate's, built into the program."},
|
||||
{"note": "Help ▸ About ▸ Licences shows SolidCraft's licences and every third-party crate's, built into the program."},
|
||||
{"call": "ui.help", "params": {"item": "about"}},
|
||||
{"widget": "Licences"},
|
||||
{"expect": {"shown": "Third-party licences"}},
|
||||
{"widget": "Third-party licences"},
|
||||
{"expect": {"shown": "Copy all licences"}},
|
||||
{"expect": {"shown": "Third-party software in SolveCraft"}}
|
||||
{"expect": {"shown": "Third-party software in SolidCraft"}}
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
[
|
||||
{
|
||||
"note": "#288: a panel drop-down highlights the row under the pointer with the hover background, and the row with keyboard focus. Drop-down rows publish `dropdown:<command id>`, the highlighted one also `dropdown_hot:<command id>`."
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "panel:SOLID:CREATE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box", "dropdown:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box", "dropdown_hot:solid.sphere"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.box"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.cylinder"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": {
|
||||
"handle": "dropdown:solid.sphere"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.sphere"],
|
||||
"hidden_handles": ["dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"move": [700, 600]
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown:solid.box"],
|
||||
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.box"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "The keyboard: Down focuses the first row, Down again the next; the highlight follows."
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"shown_handles": ["dropdown_hot:solid.extrude"],
|
||||
"hidden_handles": ["dropdown_hot:sketch.create"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"hidden_handles": ["dropdown:solid.box"]
|
||||
}
|
||||
}
|
||||
]
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
[book]
|
||||
title = "SolveCraft"
|
||||
title = "SolidCraft"
|
||||
description = "An open-source parametric 3D CAD application in pure Rust: user guide and developer guide."
|
||||
authors = ["SolveCraft contributors"]
|
||||
authors = ["SolidCraft contributors"]
|
||||
language = "en"
|
||||
src = "src"
|
||||
|
||||
@@ -10,9 +10,9 @@ build-dir = "../../target/book"
|
||||
create-missing = false
|
||||
|
||||
[output.html]
|
||||
git-repository-url = "https://github.com/bherbruck/solvecraft"
|
||||
edit-url-template = "https://github.com/bherbruck/solvecraft/edit/main/docs/book/{path}"
|
||||
site-url = "/solvecraft/"
|
||||
git-repository-url = "https://github.com/bherbruck/solidcraft"
|
||||
edit-url-template = "https://github.com/bherbruck/solidcraft/edit/main/docs/book/{path}"
|
||||
site-url = "/solidcraft/"
|
||||
no-section-label = false
|
||||
|
||||
[output.html.search]
|
||||
@@ -23,7 +23,7 @@ enable = true
|
||||
# links; these send them to the right place.
|
||||
"/dev/mcp.html" = "../guide/mcp.html"
|
||||
"/dev/docs/oracle.html" = "../oracle.html"
|
||||
"/dev/README.html" = "https://github.com/bherbruck/solvecraft/blob/main/README.md"
|
||||
"/dev/ATTRIBUTION.html" = "https://github.com/bherbruck/solvecraft/blob/main/ATTRIBUTION.md"
|
||||
"/dev/AGENTS.html" = "https://github.com/bherbruck/solvecraft/blob/main/AGENTS.md"
|
||||
"/dev/ROADMAP.html" = "https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md"
|
||||
"/dev/README.html" = "https://github.com/bherbruck/solidcraft/blob/main/README.md"
|
||||
"/dev/ATTRIBUTION.html" = "https://github.com/bherbruck/solidcraft/blob/main/ATTRIBUTION.md"
|
||||
"/dev/AGENTS.html" = "https://github.com/bherbruck/solidcraft/blob/main/AGENTS.md"
|
||||
"/dev/ROADMAP.html" = "https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md"
|
||||
@@ -14,7 +14,7 @@
|
||||
- [Plastic parts](guide/plastic.md)
|
||||
- [Import and export](guide/files.md)
|
||||
- [Keyboard shortcuts](guide/shortcuts.md)
|
||||
- [Driving SolveCraft with an AI agent (MCP)](guide/mcp.md)
|
||||
- [Driving SolidCraft with an AI agent (MCP)](guide/mcp.md)
|
||||
|
||||
# Developer guide
|
||||
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user