Compare commits

...
Author SHA1 Message Date
bherbruckandClaude Opus 5.5 a4eb3f8b53 Toolbar drop-downs highlight the row under the pointer and the keyboard focus (#288)
A panel drop-down's command rows are full-width rows that take the theme's hover background
(icon, label and shortcut on it) when hovered or focused. Up/Down move the focus through the
enabled rows, Enter starts the command, Escape closes the drop-down. Rows publish
`dropdown:<id>` and the highlighted one `dropdown_hot:<id>`; scenario s46 checks pointer and
keyboard highlight.

Fixes #288

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 13:30:53 +00:00
lillith db0a2a007a Apply remaining sketch constraints to complete preselection 2026-10-12 08:48:05 -04:00
lillith ed102b5393 fix: reject degenerate boolean intersection polylines 2026-10-12 08:36:40 -04:00
bherbruck 970da30a99 Merge pull request #207 from crono2250/fix/spacemouse-axis-mapping-198
Fix SpaceMouse axis mapping and apply 3DxWare settings
2026-10-12 08:14:51 -04:00
bherbruck 113ded5fab Merge pull request #222 from ich777/lang-german
feat: add German (de) UI localization
2026-10-12 07:51:46 -04:00
bherbruck b4491ac605 Remove premature craft family affiliation claims 2026-10-12 07:14:49 -04:00
ich777 53b092d287 feat: add German (de) UI localization
Adds German (Deutsch) as a third UI language, following the procedure outlined in docs/localization.md
2026-10-12 08:10:14 +02:00
bherbruck 07e263d7e9 Prepare SolidCraft 0.3.2 patch release 2026-10-11 22:54:31 -04:00
kenos 25ea43e89f fix: apply 3DxWare SpaceMouse axis settings 2026-10-12 11:52:29 +09:00
bherbruck c53aff7592 Fix artificial Shell wall splits on stepped bodies 2026-10-11 22:32:53 -04:00
bherbruck 13aac4296d Keep prepared assembly geometry through selection changes 2026-10-11 21:59:33 -04:00
bherbruck 18d487a9bd Merge pull request #202 from 0xlillith/feat/173-immediate-wheel-zoom
feat(ui): allow disabling wheel zoom smoothing
2026-10-11 20:36:41 -04:00
bherbruck ff9653a353 Merge current main into wheel zoom smoothing option 2026-10-11 20:36:16 -04:00
bherbruck 400f200efa Speed up cold document geometry rebuilds 2026-10-11 20:22:19 -04:00
bherbruck 6dd202d277 Fix desktop launch, app icons and keyboard parameter editing 2026-10-11 19:09:47 -04:00
lillith 16bf3ba593 feat(ui): allow disabling wheel zoom smoothing 2026-10-12 01:07:23 +02:00
bherbruck 8fa52c8e5e Merge pull request #197 from 0xlillith/fix/185-free-middle-orbit
fix(ui): restore free Shift-middle orbit
2026-10-11 18:54:11 -04:00
lillith 8601e38d4b fix(ui): restore free Shift-middle orbit 2026-10-12 00:30:05 +02:00
bherbruck e171ff07ad Merge pull request #196 from 0xlillith/fix/solidcraft-ci-followup
docs: clarify SolidCraft clone and compatible build names
2026-10-11 18:17:47 -04:00
lillith 6ae48ed70f docs: clarify SolidCraft clone and compatible build names 2026-10-12 00:15:20 +02:00
bherbruck d8beaedfa4 Open Parameters directly and prepare SolidCraft 0.3.0 2026-10-11 17:49:47 -04:00
bherbruck 4af2d06007 Integrate PRs #159, #166 and #174 for 0.3.0 2026-10-11 17:23:30 -04:00
bherbruck 81ef51c6ce Integrate current project documentation 2026-10-11 17:23:13 -04:00
bherbruck 1fb4783f67 Integrate current project documentation 2026-10-11 17:23:13 -04:00
bherbruck a94bf2562b Integrate #174 CLI closed-pipe handling 2026-10-11 17:23:13 -04:00
bherbruck 26b7b7336f Integrate current project documentation 2026-10-11 17:23:12 -04:00
bherbruck 8539ac1b40 Resolve #166 minimum Rust version and renamed docs 2026-10-11 17:23:12 -04:00
bherbruck 78aa2aacfb Resolve #159 against current sketch preselection 2026-10-11 17:23:12 -04:00
bherbruck 92dbd240ae Keep contributor introduction consistent with project description 2026-10-11 17:16:14 -04:00
bherbruck 71cda4e7ee Use plain open-source CAD wording 2026-10-11 17:15:59 -04:00
bherbruck a91596c1b8 Mention familiar Fusion workflows later in README 2026-10-11 17:13:55 -04:00
bherbruck 7f98dd1c47 Describe SolidCraft as independent parametric CAD 2026-10-11 17:12:16 -04:00
bherbruck 8cce89d78a Merge pull request #189: add Windows SpaceMouse navigation 2026-10-11 17:04:47 -04:00
bherbruck 6ce472c29e Integrate current main before app icon merge 2026-10-11 16:48:45 -04:00
bherbruck 4e39869023 Merge pull request #179 from tbreckle/fix/177-centerline-to-construction
fix(sketch): a centerline can be switched straight to construction
2026-10-11 16:46:04 -04:00
bherbruck 3f1b07d35c Merge pull request #181 from Koschtetnix/feat/tool-select-highlight
feat: highlight active tool or dialog in toolbar
2026-10-11 16:45:52 -04:00
bherbruck f6e7d754e5 Merge pull request #192: update app and package icons 2026-10-11 16:44:56 -04:00
bherbruck 6c06168fa7 Merge pull request #184 from tbreckle/fix/175-glyphs-clear-of-dimensions
fix(sketch): constraint glyphs move off dimension values
2026-10-11 16:44:49 -04:00
bherbruck af536a7907 Rename displayed product to SolidCraft and add header bug reporting 2026-10-11 16:16:48 -04:00
kenos ee1b56cac4 feat: add Windows SpaceNavigator navigation 2026-10-12 05:07:44 +09:00
NAX LAB 344e52e207 App Icon 2026-10-11 15:50:09 -04:00
NAX LAB 35555c0b9f Merge branch 'bherbruck:main' into main 2026-10-11 15:41:26 -04:00
bherbruck 1d3048062d Merge pull request #178 from 0xlillith/fix/sketch-dimension-placement-preview
fix: preview sketch dimensions before the placement click
2026-10-11 15:33:15 -04:00
bherbruck da56d180bf Merge pull request #180 from 0xlillith/fix/sketch-point-constraint-preselection
fix: apply Coincident, Midpoint and point Symmetry to preselection
2026-10-11 15:32:20 -04:00
NAX LAB 33f0b9e3cc Updated Logo 2026-10-11 15:28:45 -04:00
tbreckleandClaude Opus 5.5 cb5144b6e2 fix(sketch): constraint glyphs move off dimension values
A horizontal or vertical glyph sat beside its line on a fixed side, so a
dimension placed on that side drew its value box on top of the glyph.
A glyph that would overlap a dimension's value now goes to the other side
of its line, or steps along it when both sides are taken.

The scenario `glyphs` check also fails when a glyph overlaps a dimension
value; constraint_glyphs_clear_of_dimensions covers it.

Fixes #175

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:14:02 +02:00
lillith cc2ee6a0f9 fix: apply point constraints to sketch preselection 2026-10-11 20:52:48 +02:00
Koschtetnix 9bfd1158d0 feat: highlight active tool or dialog in toolbar 2026-10-11 20:50:43 +02:00
lillith 58c0ed8a90 fix: preview sketch dimensions before placement 2026-10-11 20:45:04 +02:00
tbreckle 6dc6372b13 fix(sketch): a centerline can be switched straight to construction
sketch.construction set `construction` but left `centerline` on, so the
curve stayed a centerline (drawn dash-dot, still marked as an axis) until
it was first switched back to normal. Making a curve construction now
clears its centerline flag, mirroring sketch.centerline.
2026-10-11 20:40:21 +02:00
lillith adecca41f6 fix: handle closed stdout pipes in CLI command listing 2026-10-11 20:17:23 +02:00
lillith 36f49811ab chore: align advertised Rust version with egui requirements 2026-10-11 19:37:06 +02:00
lillith 09bf9cafdf fix: apply horizontal and vertical constraints to preselection 2026-10-11 18:51:34 +02:00
156 changed files with 9577 additions and 658 deletions

No files matched your search

+27 -9
View File
@@ -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).
+4
View File
@@ -37,6 +37,10 @@ jobs:
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Nightly release selection regression
run: python3 packaging/tests/nightly_test.py
- name: Windows executable icon validation regression
run: python3 packaging/tests/windows_icon_test.py
- name: Flatpak desktop smoke validation regression
run: python3 packaging/tests/flatpak_test.py
- name: PowerShell
shell: pwsh
run: |
+3 -3
View File
@@ -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
+6 -6
View File
@@ -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"
+17 -10
View File
@@ -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
View File
@@ -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.
+17 -10
View File
@@ -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
View File
@@ -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
View File
@@ -971,8 +971,6 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
dependencies = [
"ahash",
"bytemuck",
@@ -1041,6 +1039,20 @@ dependencies = [
"serde",
]
[[package]]
name = "embed-resource"
version = "3.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml",
"vswhom",
"winreg",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -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
View File
@@ -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" }
+43 -15
View File
@@ -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.
![SolveCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
![SolidCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif)
*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
View File
@@ -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
View File
@@ -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)
+126
View File
@@ -0,0 +1,126 @@
//! Original SolidCraft mark, shared by desktop, packaging and CLI.
/// This block is the icon image data
pub fn rgba(size: usize) -> Result<Vec<u8>, String> {
if !(1..=2048).contains(&size) {
return Err("icon size must be 1..=2048".into());
}
type P = (f32, f32);
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
let ss = if size > 512 { 2 } else { 4 };
let n = size * ss;
let mut buf = vec![[0u8; 4]; n * n];
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
// Fill a convex polygon given in SVG units.
let mut fill = |pts: &[P], col: [u8; 3]| {
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
let cy = y as f32 + 0.5;
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for i in 0..v.len() {
let (a, b) = (v[i], v[(i + 1) % v.len()]);
if (a.1 <= cy) != (b.1 <= cy) {
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
lo = lo.min(x);
hi = hi.max(x);
}
}
if lo > hi {
continue;
}
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
}
}
};
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
let white = [255, 255, 255];
let mut stroke = |pts: &[P], closed: bool| {
let m = pts.len();
for i in 0..(if closed { m } else { m - 1 }) {
let (a, b) = (pts[i], pts[(i + 1) % m]);
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
}
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
let disc: Vec<P> = (0..16)
.map(|k| {
let t = k as f32 * std::f32::consts::TAU / 16.0;
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
})
.collect();
fill(&disc, white);
}
};
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
stroke(
&[
(178.5, 5.8),
(5.0, 105.9),
(5.0, 206.1),
(91.7, 256.2),
(91.7, 356.4),
(178.5, 406.4),
(352.0, 306.3),
(352.0, 206.1),
(265.2, 156.0),
(265.2, 55.9),
],
true,
);
// Average each ss x ss block down to one pixel (straight alpha = covered share).
let mut rgba = Vec::with_capacity(size * size * 4);
for y in 0..size {
for x in 0..size {
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
for j in 0..ss {
for i in 0..ss {
let p = buf[(y * ss + j) * n + x * ss + i];
if p[3] != 0 {
cnt += 1;
for k in 0..3 {
sum[k] += u32::from(p[k]);
}
}
}
}
if cnt == 0 {
rgba.extend_from_slice(&[0, 0, 0, 0]);
} else {
let a = 255 * cnt / (ss * ss) as u32;
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
}
}
}
Ok(rgba)
}
#[cfg(test)]
mod tests {
#[test]
fn shared_mark_has_transparency_and_original_colors_at_window_and_package_sizes() {
for size in [16, 24, 32, 48, 64, 128, 256] {
let pixels = super::rgba(size).expect("valid size");
assert_eq!(pixels.len(), size * size * 4);
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0));
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[2] > p[0]));
}
assert!(super::rgba(0).is_err());
assert!(super::rgba(2049).is_err());
}
}
+1 -1
View File
@@ -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
+15 -25
View File
@@ -25,6 +25,9 @@ mod seams;
use std::process::ExitCode;
use serde_json::{Value, json};
#[path = "../../app_icon.rs"]
mod app_icon;
use solvecraft_engine::Session;
use solvecraft_engine::render::{StandardView, render_png};
@@ -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).
+3 -3
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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&hellip;</div>
<div id="solvecraft_loading">Loading SolidCraft&hellip;</div>
</body>
</html>
+1 -1
View File
@@ -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>"))
}
}
}
+8 -1
View File
@@ -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"
+61
View File
@@ -0,0 +1,61 @@
#![forbid(unsafe_code)]
#[path = "../app_icon.rs"]
mod app_icon;
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=../app_icon.rs");
println!("cargo:rerun-if-changed=build.rs");
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
let sizes = [16usize, 24, 32, 48, 64, 128, 256];
let mut images = Vec::new();
for size in sizes {
let rgba = app_icon::rgba(size)?;
// ICO DIB: bottom-up BGRA plus a padded one-bit transparency mask.
let mask_stride = size.div_ceil(32) * 4;
let mut dib = Vec::new();
dib.extend_from_slice(&40u32.to_le_bytes());
dib.extend_from_slice(&(size as i32).to_le_bytes());
dib.extend_from_slice(&((size * 2) as i32).to_le_bytes());
dib.extend_from_slice(&1u16.to_le_bytes());
dib.extend_from_slice(&32u16.to_le_bytes());
dib.extend_from_slice(&[0; 24]);
for row in rgba.chunks_exact(size * 4).rev() {
for p in row.as_chunks::<4>().0.iter() {
dib.extend_from_slice(&[p[2], p[1], p[0], p[3]]);
}
}
for row in rgba.chunks_exact(size * 4).rev() {
let mut mask = vec![0u8; mask_stride];
for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
if p[3] == 0 {
mask[x / 8] |= 0x80 >> (x % 8);
}
}
dib.extend_from_slice(&mask);
}
images.push((size, dib));
}
let mut ico = vec![0, 0, 1, 0];
ico.extend_from_slice(&(images.len() as u16).to_le_bytes());
let mut offset = 6 + 16 * images.len();
for (size, data) in &images {
let dim = if *size == 256 { 0 } else { *size as u8 };
ico.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
ico.extend_from_slice(&(data.len() as u32).to_le_bytes());
ico.extend_from_slice(&(offset as u32).to_le_bytes());
offset += data.len();
}
for (_, data) in images {
ico.extend_from_slice(&data);
}
let icon = out.join("solidcraft.ico");
std::fs::write(&icon, ico)?;
let rc = out.join("solidcraft.rc");
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
// Missing resource tools are a build error, rather than silently shipping a generic icon.
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
+75 -23
View File
@@ -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);
}
}
}
}
+489
View File
@@ -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 -1
View File
@@ -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
+121
View File
@@ -457,3 +457,124 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
}
}
}
#[test]
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
let mut doc = Document::new("public stepped tab case");
doc.set_param("length", "40", Some("mm"), None).unwrap();
doc.set_param("wall", "2", Some("mm"), None).unwrap();
for (name, kind) in [
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
] {
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
}
doc.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
None,
)
.unwrap();
let tool = doc
.add_feature(
serde_json::from_value(
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
)
.unwrap(),
Some("Pocket tool"),
)
.unwrap();
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
let cut = doc
.add_feature(
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
Some("Pocket"),
)
.unwrap();
let mut model = Model::new();
model.evaluate(&doc);
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
let before = model.state_before(cut);
// A real per-face paint control preserves the former slab construction;
// no historical names or geometry are fabricated.
let old_stock = before
.body("Case")
.unwrap()
.body
.clone()
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
feature: doc.feature(cut).unwrap().clone(),
before,
depth: 1,
mats: vec![],
})));
let at = Vec3::new(0., 15., 1.);
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
let mut old_state = crate::ModelState::default();
old_state.bodies.push(old_body);
let plane = solvecraft_geom::Plane::YZ;
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
let current_state = model.state();
let current = current_state.body("Case").unwrap();
let stem = crate::naming::strip_piece(&former);
let current_names = crate::naming::face_names(current);
let merged = current_names.iter().position(|name| name == stem).unwrap();
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
assert!(!current_names.contains(&former));
let mut sketch = Sketch::new();
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
sketch.links[0].face_name = Some(former);
let original = sketch.clone();
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
assert_eq!(captured.len(), 4);
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
let material = doc
.add_feature(
FeatureKind::Extrude {
sketch: sk,
profiles: crate::ProfileSel::All,
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
operation: crate::Operation::NewBody,
targets: vec![],
},
Some("Saved band stock"),
)
.unwrap();
for (length, wall) in [(40., 2.), (50., 3.)] {
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
model.evaluate(&doc);
let reopened = crate::format::read(&doc.to_json()).unwrap();
let mut cold = Model::new();
cold.evaluate(&reopened);
for current in [&model, &cold] {
let result = current.results.iter().find(|r| r.id == sk).unwrap();
assert!(result.error.is_none(), "{result:?}");
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
let state = current.state();
let saved = state.sketch(sk).unwrap();
assert!(saved.sketch.links[0].lost);
assert_eq!(saved.sketch.points, original.points);
assert_eq!(saved.sketch.curves, original.curves);
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
assert!(
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
solvecraft_kernel::measure(&stock.body).unwrap().volume,
stock.mesh().bounds(),
original.points
);
let case = state.body("Case").unwrap();
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
}
}
}
+93 -3
View File
@@ -3436,9 +3436,18 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
let name = doc.world_body_name(st, &b.name, comp, &path);
// A body moved in from another component keeps its place (its offset).
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
let mut placed = ModelBody::new(name, body, b.feature);
// Identical geometry also shares its display mesh cell with the definition.
// A changed placement keeps a distinct mesh, at that placement's coordinates.
let mut placed = if crate::is_identity(&bm) {
let mut placed = b.clone();
placed.name = name;
placed
} else {
let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone());
ModelBody::new(name, body, b.feature)
};
// World states keep their previous display-only evaluation-key semantics.
placed.key = 0;
placed.source_name = Some(b.definition_name().to_string());
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
placed.component = comp;
@@ -3481,6 +3490,87 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
out
}
#[cfg(test)]
mod world_placement_cache_tests {
use super::*;
#[test]
fn placed_real_feature_retains_names_from_its_original_feature_context() {
let mut doc = Document::new("authored names");
let component = doc.add_component(Some("Part"), 0).unwrap();
let feature = doc
.add_feature(
FeatureKind::Box {
corner: crate::point_expr(Vec3::ZERO),
length: "2 mm".into(),
width: "3 mm".into(),
height: "4 mm".into(),
operation: crate::Operation::NewBody,
},
Some("AuthoredBox"),
)
.unwrap();
doc.feature_mut(feature).unwrap().component = component;
let matrix = crate::rigid(Vec3::new(15., -2., 8.), Vec3::ZERO, Vec3::X, 0.7);
doc.occurrences.iter_mut().find(|o| o.component == component).unwrap().transform = matrix;
let mut model = Model::default();
model.evaluate(&doc);
let state = model.state();
let source = &state.bodies[0];
assert!(source.names.origin.is_some());
assert!(source.mesh.get().is_none());
let world = world_state(&doc, &state);
assert!(source.mesh.get().is_some(), "source naming is prepared before adoption, as before");
let names = crate::naming::face_names(&world.bodies[0]);
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(source)));
assert!(names.iter().all(|name| name.starts_with(&format!("F{feature}:"))));
let local = source.mesh();
let placed = world.bodies[0].mesh();
assert_eq!(local.tri_face, placed.tri_face);
let mapped: Vec<_> = local.positions.iter().map(|p| crate::apply_point(&matrix, *p)).collect();
assert!(mapped.iter().all(|p| placed.positions.iter().any(|q| p.dist(*q) < 1e-8)));
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
}
#[test]
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
let mut doc = Document::new("placements");
let component = doc.add_component(Some("Part"), 0).unwrap();
doc.body_components.insert("Block".into(), component);
let matrix = crate::rigid(Vec3::new(10., 20., 0.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2);
let second = doc.add_occurrence(component, 0, matrix).unwrap();
let mut state = ModelState::default();
state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1));
state.bodies[0].key = 1234;
let source = &state.bodies[0];
let world = world_state(&doc, &state);
assert_eq!(world.bodies.len(), 2);
let first = world.body("Block").unwrap();
let second_name = doc.world_body_name(&state, "Block", component, &[second]);
let placed = world.body(&second_name).unwrap();
assert!(source.mesh.get().is_some(), "persistent source names stay prepared before adoption");
assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell");
assert_eq!(first.key, 0);
assert_eq!(placed.key, 0);
assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates");
assert_eq!(first.source_name.as_deref(), Some("Block"));
assert_eq!(placed.source_name.as_deref(), Some("Block"));
assert_eq!(placed.source_transform, matrix);
assert_eq!(doc.model_body_component(placed), component);
let bounds = placed.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
let names = crate::naming::face_names(source);
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(first)));
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(placed)));
let original_mesh = source.mesh();
assert!(Arc::ptr_eq(&original_mesh, &first.mesh()));
let again = world_state(&doc, &state);
assert!(Arc::ptr_eq(&original_mesh, &again.body("Block").unwrap().mesh()));
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(again.body(&second_name).unwrap())));
}
}
#[cfg(test)]
mod chain_tests {
use super::*;
+11 -11
View File
@@ -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}");
+1 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+6 -2
View File
@@ -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;
}
}
+1 -1
View File
@@ -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);
+15
View File
@@ -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
View File
@@ -150,6 +150,28 @@ pub struct Preview {
pub after: Arc<ModelState>,
}
/// Prepared world geometry depends on design/model identity and placement context, not
/// selection or the view revision. Retained Arcs prevent pointer reuse after a design closes.
#[derive(Clone)]
struct WorldCache {
doc: Arc<Document>,
model: Arc<solvecraft_doc::ModelState>,
pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
world: Arc<solvecraft_doc::ModelState>,
}
impl WorldCache {
fn matches(&self, session: &Session, model: &Arc<solvecraft_doc::ModelState>) -> bool {
Arc::ptr_eq(&self.doc, &session.doc)
&& Arc::ptr_eq(&self.model, model)
&& self.pending_moves == session.pending_moves
&& self.active_component == session.active_component
&& self.active_occurrence_path == session.active_occurrence_path
}
}
/// An editing session on one design.
pub struct Session {
pub doc: Arc<Document>,
@@ -173,7 +195,7 @@ pub struct Session {
pub active_occurrence_path: Option<Vec<u64>>,
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
world_cache: std::sync::Mutex<Option<WorldCache>>,
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
pub clipboard: Vec<solvecraft_doc::Feature>,
@@ -308,10 +330,10 @@ impl Session {
return st;
}
if let Ok(c) = self.world_cache.lock()
&& let Some((rev, w)) = c.as_ref()
&& *rev == self.revision
&& let Some(cached) = c.as_ref()
&& cached.matches(self, &st)
{
return w.clone();
return cached.world.clone();
}
let mut w = if self.pending_moves.is_empty() {
solvecraft_doc::world_state(&self.doc, &st)
@@ -350,7 +372,14 @@ impl Session {
}
let w = Arc::new(w);
if let Ok(mut c) = self.world_cache.lock() {
*c = Some((self.revision, w.clone()));
*c = Some(WorldCache {
doc: self.doc.clone(),
model: st,
pending_moves: self.pending_moves.clone(),
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
world: w.clone(),
});
}
w
}
@@ -503,7 +532,7 @@ impl Session {
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
pending_moves: self.pending_moves.clone(),
world_cache: Default::default(),
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
occurrence_visibility_cache: Default::default(),
clipboard: self.clipboard.clone(),
section: None,
@@ -519,12 +548,6 @@ impl Session {
pub fn invalidate_view(&mut self, previous_revision: u64) {
let old = self.revision;
self.revision = old.max(previous_revision) + 1;
if let Ok(mut cache) = self.world_cache.lock()
&& let Some((revision, _)) = cache.as_mut()
&& *revision == old
{
*revision = self.revision;
}
}
/// Commit commands evaluated on a scratch copy without losing this session's file,
@@ -651,3 +674,5 @@ mod split_face_tests;
mod tests;
#[cfg(test)]
mod thread_tests;
#[cfg(test)]
mod world_placement_cache_tests;
+1 -1
View File
@@ -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, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
#[test]
fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joint_frames() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
run(&mut session, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false}));
let pending = session.pending_moves.clone();
let path = vec![second];
let placement = session.occurrence_placement(&path).unwrap();
let name = placement.bodies[0].world.clone();
let world = session.world_state();
assert!(Arc::ptr_eq(&world, &session.world_state()));
let source_state = session.model.state();
let source = source_state.body("Block").unwrap();
let placed = world.body(&name).unwrap();
assert_eq!(placed.source_name.as_deref(), Some("Block"));
assert_eq!(placed.component, component);
let bounds = placed.mesh().bounds();
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
assert_eq!(session.doc.model_body_look(placed), session.doc.model_body_look(source));
let source_names = crate::doc::naming::face_names(source);
assert_eq!(source_names, crate::doc::naming::face_names(placed));
let pick = Vec3::new(8.5, 21., 4.);
let top = placed.body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap().index;
run(&mut session, "select.set", json!({"items":[{"type":"face","body":name,"index":top,"point":[pick.x,pick.y,pick.z]}]}));
assert!(matches!(&session.selection[0], crate::Sel::Face { body, index, .. } if body == &name && *index == top));
assert!(Arc::ptr_eq(&world, &session.world_state()), "selection changes must not discard prepared placements");
let scratch = session.scratch();
assert_eq!(scratch.pending_moves, pending);
let scratch_world = scratch.world_state();
assert!(Arc::ptr_eq(&world, &scratch_world));
let scratch_body = scratch_world.body(&name).unwrap();
assert!(scratch_body.mesh().bounds().min.dist(bounds.min) < 1e-9);
assert_eq!(crate::doc::naming::face_names(scratch_body), source_names);
run(&mut session, "component.capture_position", json!({}));
run(&mut session, "joint.origin", json!({"name":"Top joint","occurrence":second,"face":[pick.x,pick.y,pick.z]}));
let joint = session.doc.assembly.origins[0].origin.clone();
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let before_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&before_joint, Vec3::ZERO).dist(pick) < 1e-9);
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":true}));
let moved = session.world_state();
assert!(!Arc::ptr_eq(&world, &moved), "a changed transform must invalidate placed meshes");
assert!(moved.body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert!(world.body(&name).unwrap().mesh().bounds().min.dist(bounds.min) < 1e-9, "prior snapshots must remain immutable");
assert_eq!(crate::doc::naming::face_names(moved.body(&name).unwrap()), source_names);
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
let after_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
assert!(crate::doc::apply_point(&after_joint, Vec3::ZERO).dist(pick + Vec3::new(5., 0., 0.)) < 1e-9);
let reopened = Session::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
assert!(reopened.world_state().body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
assert_eq!(reopened.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
}
#[test]
fn selection_visibility_section_and_view_revisions_reuse_world_geometry() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let world = session.world_state();
let revision = session.revision;
run(&mut session, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
assert!(session.revision > revision);
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "select.clear", json!({}));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "browser.visibility", json!({"items":["Block"],"visible":false}));
assert!(!session.body_visible("Block"));
assert!(Arc::ptr_eq(&world, &session.world_state()));
run(&mut session, "inspect.section", json!({"plane":{"origin":[0,0,2],"normal":[0,0,1]}}));
assert!(session.section.is_some());
assert!(Arc::ptr_eq(&world, &session.world_state()));
session.invalidate_view(session.revision + 10);
assert!(Arc::ptr_eq(&world, &session.world_state()));
assert!(Arc::ptr_eq(&world, &session.scratch().world_state()));
}
#[test]
fn world_cache_requires_exact_design_model_pending_and_author_context() {
let mut session = Session::default();
let created = run(&mut session, "component.create", json!({"name":"Part"}));
let component = created["component"].as_u64().unwrap();
let first = created["occurrence"].as_u64().unwrap();
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let sketch = run(&mut session, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
run(&mut session, "sketch.finish", json!({}));
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
let initial = session.world_state();
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"capture":false}));
let pending = session.world_state();
assert!(!Arc::ptr_eq(&initial, &pending));
let name = session.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
assert!(pending.body(&name).unwrap().mesh().bounds().min.dist(Vec3::new(10., 20., 0.)) < 1e-9);
run(&mut session, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
let authored = session.world_state();
assert!(!Arc::ptr_eq(&pending, &authored));
assert!(authored.sketch(sketch).unwrap().plane.origin.dist(Vec3::new(10., 20., 0.)) < 1e-9);
let mut scratch = session.scratch();
assert!(Arc::ptr_eq(&authored, &scratch.world_state()));
scratch.active_occurrence_path = Some(vec![first]);
let scratch_world = scratch.world_state();
assert!(!Arc::ptr_eq(&authored, &scratch_world));
assert!(scratch_world.sketch(sketch).unwrap().plane.origin.dist(Vec3::ZERO) < 1e-9);
assert!(Arc::ptr_eq(&authored, &session.world_state()), "scratch context must not replace the source cache");
run(&mut session, "appearance.assign", json!({"bodies":["Block"],"color":"#ff0000"}));
let painted = session.world_state();
assert!(!Arc::ptr_eq(&authored, &painted));
assert_ne!(session.doc.model_body_look(painted.body("Block").unwrap()), None);
run(&mut session, "edit.undo", json!({}));
let undone = session.world_state();
assert!(!Arc::ptr_eq(&painted, &undone));
run(&mut session, "edit.redo", json!({}));
let redone = session.world_state();
assert!(!Arc::ptr_eq(&undone, &redone));
assert!(session.doc.model_body_look(redone.body("Block").unwrap()).is_some());
let before_edit = session.world_state();
run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Other","operation":"new"}));
let edited = session.world_state();
assert!(!Arc::ptr_eq(&before_edit, &edited));
assert!(edited.body("Other").is_some());
session.doc = Arc::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
let replaced = session.world_state();
assert!(!Arc::ptr_eq(&edited, &replaced), "equal contents in a different document cannot share the cache key");
session.model = crate::doc::Model::default();
session.model.evaluate(&session.doc);
assert!(!Arc::ptr_eq(&replaced, &session.world_state()), "a new evaluated model must invalidate even when the document Arc is unchanged");
}
#[test]
fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() {
let mut session = Session::default();
run(&mut session, "component.create", json!({"name":"Part"}));
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
let source = session.world_state();
let source_json = session.doc.to_json();
let mut scratch = session.scratch();
run(&mut scratch, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Draft","operation":"new"}));
let warmed = scratch.world_state();
assert!(!Arc::ptr_eq(&source, &warmed));
assert_eq!(session.doc.to_json(), source_json);
assert!(Arc::ptr_eq(&source, &session.world_state()));
assert!(source.body("Draft").is_none());
let feature = warmed.body("Draft").unwrap().feature;
session.accept_computed(scratch);
assert!(Arc::ptr_eq(&warmed, &session.world_state()), "adoption/view revision must retain the worker's prepared geometry");
session.invalidate_view(session.revision + 20);
assert!(Arc::ptr_eq(&warmed, &session.world_state()));
run(&mut session, "timeline.rollback", json!({"position":1}));
let rolled = session.world_state();
assert!(!Arc::ptr_eq(&warmed, &rolled));
assert!(rolled.body("Block").is_some());
assert!(rolled.body("Draft").is_none());
run(&mut session, "timeline.rollback", json!({}));
let restored = session.world_state();
assert!(!Arc::ptr_eq(&rolled, &restored));
assert!(restored.body("Draft").is_some());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":true}));
let suppressed = session.world_state();
assert!(!Arc::ptr_eq(&restored, &suppressed));
assert!(suppressed.body("Draft").is_none());
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false}));
assert!(session.world_state().body("Draft").is_some());
}
+1 -1
View File
@@ -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
+87 -1
View File
@@ -244,8 +244,24 @@ impl Seg2 {
}
}
}
/// Parameter of the point of the segment closest to `p` (sampling, then Newton).
/// Parameter of the closest point: exact projection for lines,
/// sampling followed by Newton for the remaining curves.
pub fn closest_param(&self, p: Vec2) -> f64 {
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite();
match *self {
Seg2::Line { a, b } if finite(a) && finite(b) && finite(p) => {
let d = b - a;
if a == b {
return 0.0;
}
let denominator = d.len2();
let t = (p - a).dot(d) / denominator;
if denominator > 0.0 && denominator.is_finite() && t.is_finite() {
return t.clamp(0.0, 1.0);
}
}
_ => {}
}
let n = 64;
let mut t =
(0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0);
@@ -540,6 +556,76 @@ impl Region2 {
mod tests {
use super::*;
#[test]
fn closest_line_projection_and_degeneracy() {
let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) };
for (p, t) in [(Vec2::new(-9.0, 20.0), 0.0), (Vec2::new(2.0, -5.0), 0.5), (Vec2::new(15.0, 4.0), 1.0)] {
assert_eq!(line.closest_param(p), t);
assert_eq!(line.reversed().closest_param(p), 1.0 - t);
}
let a = Vec2::new(1e100, -1e100);
let b = a + Vec2::new(1e90, 2e90);
let large = Seg2::Line { a, b };
assert!((large.closest_param(a.lerp(b, 0.5)) - 0.5).abs() < 1e-6);
assert_eq!(Seg2::Line { a, b: a }.closest_param(Vec2::ZERO), 0.0);
let tiny = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-12, 0.0) };
assert_eq!(tiny.closest_param(Vec2::new(0.5e-12, 1.0)), 0.5);
for line in [line, line.reversed(), tiny] {
for k in 0..53 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let closest = line.point_at(line.closest_param(p)).dist(p);
let dense = (0..=8192).map(|i| line.point_at(i as f64 / 8192.0).dist(p)).fold(f64::INFINITY, f64::min);
assert!(closest <= dense + 1e-12);
}
}
}
fn sampled_closest(s: &Seg2, p: Vec2) -> f64 {
let mut t = (0..=64).map(|i| i as f64 / 64.0).min_by(|x, y| s.point_at(*x).dist(p).total_cmp(&s.point_at(*y).dist(p))).unwrap_or(0.0);
for _ in 0..30 {
let (q, d) = (s.point_at(t), s.derivative(t));
let (d2, h) = ((s.derivative((t + 1e-6).min(1.0)) - s.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p);
let df = d.dot(d) + h.dot(d2);
if df.abs() < 1e-300 {
break;
}
let nt = (t - h.dot(d) / df).clamp(0.0, 1.0);
if (nt - t).abs() < 1e-14 {
t = nt;
break;
}
t = nt;
}
t
}
#[test]
fn closest_projection_preserves_clearance_tolerance_and_generic_fallback() {
use std::f64::consts::TAU;
let curves = [
Seg2::Line { a: Vec2::new(-4.0, 7.0), b: Vec2::new(3.0, -2.0) },
Seg2::Arc { center: Vec2::new(3.0, -2.0), radius: 6.0, start: 5.9, sweep: 1.3 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.2, sweep: -TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: TAU },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 0.0 },
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1e-12 },
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(2.0, 7.0), p2: Vec2::new(6.0, -3.0), p3: Vec2::new(8.0, 4.0) },
];
for (i, s) in curves.iter().enumerate() {
for k in 0..101 {
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
let old = sampled_closest(s, p);
let new = s.closest_param(p);
if i > 0 {
assert_eq!(new.to_bits(), old.to_bits());
}
assert!(s.point_at(new).dist(p) <= s.point_at(old).dist(p) + 1e-10);
let on_curve = s.point_at(k as f64 / 100.0);
assert!(s.point_at(s.closest_param(on_curve)).dist(on_curve) < 1e-9);
}
}
}
#[test]
fn triangulated_area() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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, "_"));
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -33,6 +33,8 @@ pub struct Body {
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
/// Exact construction/import source identities, in this body's face order.
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
pub(crate) regularization_protected: bool,
}
#[derive(Clone, Debug, PartialEq)]
@@ -120,6 +122,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
/// `None` when nothing needs splitting or the parameters don't match the surface.
#[allow(clippy::type_complexity)]
fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> {
refined_with_cache(pm, surf, tol, true)
}
#[allow(clippy::type_complexity)]
fn refined_with_cache(pm: &PolygonMesh, surf: &mt::Surface, tol: f64, memoize: bool) -> Option<Vec<[(Vec3, Vec3); 3]>> {
use mt::{ParametricSurface, ParametricSurface3D};
let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals());
if uvs.is_empty() {
@@ -148,6 +155,15 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
// Edges on the face's boundary are shared with the next face: never split them.
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
// Only exact finite values identify reusable evaluations. In particular,
// the rounded UV keys used for boundary incidence cannot identify a point
// whose derivatives or midpoint may be reused.
let vertex_key = |v: &V| {
let values = [v.0.x, v.0.y, v.0.z, v.1.0, v.1.1, v.2.x, v.2.y, v.2.z];
values.iter().all(|x| x.is_finite()).then(|| values.map(f64::to_bits))
};
let cache_limit = cap * 3;
let mut singular_cache = std::collections::HashMap::<(u64, u64), bool>::new();
let mut changed = false;
for _ in 0..4 {
let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new();
@@ -156,34 +172,60 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
*uses.entry(e).or_default() += 1;
}
}
let at = |a: &V, b: &V| -> Option<V> {
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
return None;
let mut edge_cache = std::collections::HashMap::<([u64; 8], [u64; 8]), Option<V>>::new();
let mut at = |a: &V, b: &V| -> Option<V> {
let key = if memoize { vertex_key(a).zip(vertex_key(b)).map(|(a, b)| if a < b { (a, b) } else { (b, a) }) } else { None };
if let Some(key) = key
&& let Some(value) = edge_cache.get(&key)
{
return *value;
}
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
let singular = |x: &V| {
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300
let mut calculate = || -> Option<V> {
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
return None;
}
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
let mut singular = |x: &V| {
let key = (x.1.0.to_bits(), x.1.1.to_bits());
let cacheable = memoize && x.1.0.is_finite() && x.1.1.is_finite();
if cacheable && let Some(value) = singular_cache.get(&key) {
return *value;
}
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
let c = du.cross(dv);
let value = c.x * c.x + c.y * c.y + c.z * c.z
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300;
if cacheable && singular_cache.len() < cache_limit {
singular_cache.insert(key, value);
}
value
};
if singular(a) || singular(b) {
return None;
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
};
if singular(a) || singular(b) {
return None;
let value = calculate();
if let Some(key) = key
&& edge_cache.len() < cache_limit
&& value.is_none_or(|v| vertex_key(&v).is_some())
{
edge_cache.insert(key, value);
}
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
let q = from_p3(surf.subs(u, v));
// Within tolerance, or so far off that the parameters wrapped (a seam).
let sag = q.dist((a.0 + b.0) * 0.5);
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
return None;
}
let n = surf.normal(u, v);
let mut n = Vec3::new(n.x, n.y, n.z);
// Keep the mesh's normal side.
if n.dot(a.2 + b.2) < 0.0 {
n = -n;
}
Some((q, (u, v), n))
value
};
let mut next = Vec::with_capacity(tris.len());
let mut any = false;
@@ -320,6 +362,7 @@ impl Body {
&& self.paint == other.paint
&& self.splits == other.splits
&& self.face_sources == other.face_sources
&& self.regularization_protected == other.regularization_protected
}
pub(crate) fn new(solid: Solid) -> Result<Body> {
@@ -358,9 +401,15 @@ impl Body {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
})
}
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
self.regularization_protected |= input.regularization_protected;
self
}
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
self.face_sources.as_deref().map(Vec::as_slice)
}
@@ -588,6 +637,7 @@ impl Body {
/// The same body with the lines its faces were split along on purpose.
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
self.splits = s.filter(|l| !l.is_empty());
self.regularization_protected |= self.splits.is_some();
self
}
@@ -612,6 +662,7 @@ impl Body {
})
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
.map(std::sync::Arc::new);
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
self
}
@@ -658,11 +709,15 @@ impl Body {
let flip = !face.orientation();
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
// Curved faces: split triangles that cut through the surface.
if refine
&& let Some(sf) = solid_faces.get(fi)
&& !matches!(sf.surface(), mt::Surface::Plane(_))
&& let Some(tris) = refined(&pm, &sf.surface(), tol)
{
let refinement = if refine {
solid_faces.get(fi).and_then(|sf| {
let surface = sf.surface();
if matches!(surface, mt::Surface::Plane(_)) { None } else { refined(&pm, &surface, tol) }
})
} else {
None
};
if let Some(tris) = refinement {
let fid = u32::try_from(fi).unwrap_or(u32::MAX);
for t in tris {
let mut idx = [0u32; 3];
@@ -1019,7 +1074,7 @@ impl Body {
})
.collect()
});
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)))
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(self))
})
.collect()
})
@@ -1029,3 +1084,11 @@ impl Body {
#[cfg(test)]
#[path = "body_lump_tests.rs"]
mod lump_tests;
#[cfg(test)]
#[path = "refine_cache_tests.rs"]
mod refine_cache_tests;
#[cfg(test)]
#[path = "regularization_protection_tests.rs"]
mod regularization_protection_tests;
+1 -1
View File
@@ -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);
+4 -4
View File
@@ -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));
}
+2
View File
@@ -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;
+2
View File
@@ -109,6 +109,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
})
}
@@ -201,6 +202,7 @@ impl Body {
paint: self.paint.clone(),
splits: self.splits.clone(),
face_sources: None,
regularization_protected: self.regularization_protected,
})
}
}
+9 -3
View File
@@ -221,6 +221,7 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
)),
};
}
r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b)));
if matches!(op, BoolOp::Cut | BoolOp::Union) {
r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, op == BoolOp::Cut)).transpose());
}
@@ -538,7 +539,11 @@ pub(crate) fn join_pieces(pieces: Vec<Body>) -> Result<Option<Body>> {
_ => {
let shells: Vec<mt::Shell> = pieces.iter().flat_map(|p| p.solid.boundaries().clone()).collect();
let solid = guard("pieces", || Solid::try_new(shells).map_err(|e| KernelError::Failed(e.to_string())))?;
Body::new(solid).map(Some)
let protected = pieces.iter().any(|piece| piece.regularization_protected);
Body::new(solid).map(|mut body| {
body.regularization_protected = protected;
Some(body)
})
}
}
}
@@ -558,8 +563,9 @@ pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
);
// An affine placement retains face order and appearance metadata.
let sources = body.transformed_sources(m)?;
Body::new(placed_topology(&body.solid, mat)?)
.map(|placed| placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources))
Body::new(placed_topology(&body.solid, mat)?).map(|placed| {
placed.with_color(body.color()).with_paint(body.paint().cloned()).with_face_sources(sources).inherit_regularization_protection(body)
})
})
}
+49 -3
View File
@@ -117,6 +117,36 @@ fn merged_face(faces: &[&mt::Face], members: &[usize], plane: Plane, eligible: &
impl Body {
pub(crate) fn regularize_certified_planar_faces(self) -> Result<Self> {
self.regularize_planar_faces(false)
}
/// Only newly produced Cut walls call this path. The temporary planes carry
/// construction support, never persistent ancestor/source-role authority.
pub(crate) fn regularize_generated_planar_faces(self) -> Result<Self> {
let body = self.regularize_planar_faces(true)?;
let sources = body.face_sources().map(|sources| {
sources
.iter()
.map(|source| {
let mut source = source.clone();
if let Some(super::FaceSource::ExtrudeSide { plane, coalesced, .. }) = &mut source {
if plane.is_some() && !*coalesced {
source = None;
} else {
*plane = None;
}
}
source
})
.collect()
});
Ok(body.with_face_sources(sources.map(Arc::new)))
}
fn regularize_planar_faces(self, generated: bool) -> Result<Self> {
if self.regularization_protected {
return Ok(self);
}
let Some(sources) = self.face_sources() else { return Ok(self) };
if sources.len() > 16_384 || self.paint().is_some_and(|p| !p.faces.is_empty()) {
return Ok(self);
@@ -148,7 +178,16 @@ impl Body {
.zip(sources)
.map(|(f, s)| {
s.as_ref()
.and_then(super::planar_sources::certified_plane)
.and_then(|source| {
if generated {
match source {
super::FaceSource::ExtrudeSide { segment: solvecraft_geom::Seg2::Line { .. }, plane: Some(p), .. } => Some(*p),
_ => None,
}
} else {
super::planar_sources::certified_plane(source)
}
})
.filter(|p| super::planar_sources::actual_plane(f).is_some_and(|actual| super::planar_sources::same_support(p, &actual)))
})
.collect();
@@ -179,7 +218,7 @@ impl Body {
};
let Some(left) = identity.checked_sub(cost) else { return Ok(None) };
identity = left;
if sa != sb {
if !generated && sa != sb {
continue;
}
let Some(cost) = split_points.checked_mul(6).and_then(|n| n.checked_add(1)) else { return Ok(None) };
@@ -235,7 +274,14 @@ impl Body {
let Some(left) = metadata.checked_sub(cost) else { return Ok(None) };
metadata = left;
}
output_sources.push(source.cloned());
let mut source = source.cloned();
if generated
&& replacements.contains_key(&leader)
&& let Some(super::FaceSource::ExtrudeSide { coalesced, .. }) = &mut source
{
*coalesced = true;
}
output_sources.push(source);
offset += 1;
}
shells.push(mt::Shell::from(output));
+4 -2
View File
@@ -114,13 +114,15 @@ impl Body {
/// Retain exact shell roles, and construction roles for Cut, only on unchanged
/// supports without a competing coplanar tool. No imported or fitted role is inferred.
pub(crate) fn with_preserved_boolean_planar_sources(self, input: &Body, tool: &Body, constructed: bool) -> Result<Self> {
let self_ = self.inherit_regularization_protection(input).inherit_regularization_protection(tool);
if !input.face_sources().is_some_and(|sources| sources.iter().flatten().any(|s| boolean_source(s, constructed))) {
return Ok(self);
return Ok(self_);
}
self.map_planar_sources(input, None, Some(tool), &[], constructed)
self_.map_planar_sources(input, None, Some(tool), &[], constructed)
}
fn map_planar_sources(mut self, input: &Body, shell: Option<f64>, blocker: Option<&Body>, open: &[Vec3], constructed: bool) -> Result<Self> {
self.regularization_protected |= input.regularization_protected;
let Some(sources) = input.face_sources() else { return Ok(self) };
if input.solid.face_iter().count() > 16_384
|| self.solid.face_iter().count() > 16_384
+9 -1
View File
@@ -342,7 +342,15 @@ pub fn round_all_edges(b: &Body, r: f64) -> Result<Body> {
/// by the thickness (open faces outward, past the body) keeping the body's topology, which
/// gives the cavity; the shell is the body minus the cavity.
fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result<Body> {
let healed = Body::new(crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep()))?;
// Face paint and authored splits are construction boundaries. Retain their
// eligibility veto through the temporary body used for the cavity Cut.
// Paint indices stay in source order by avoiding healing in that case.
let solid = if b.paint().is_some_and(|paint| !paint.faces.is_empty()) {
b.deep_copy()
} else {
crate::heal::heal_keep(b.deep_copy(), b.size(), b.split_keep())
};
let healed = Body::new(solid)?.with_paint(b.paint().cloned()).with_split_lines(b.split_lines().cloned()).inherit_regularization_protection(b);
let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
// Faces to open: the face of the triangle nearest each point.
+95 -2
View File
@@ -848,6 +848,16 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
}
let mut faces: Vec<FaceSpec> = Vec::new();
let mut sources = Vec::new();
let frame_is_orthonormal = plane.origin.is_finite()
&& plane.x.is_finite()
&& plane.y.is_finite()
&& d.is_finite()
&& (plane.x.len2() - 1.).abs() <= 64. * f64::EPSILON
&& (plane.y.len2() - 1.).abs() <= 64. * f64::EPSILON
&& (d.len2() - 1.).abs() <= 64. * f64::EPSILON
&& plane.x.dot(plane.y).abs() <= 64. * f64::EPSILON
&& plane.x.dot(d).abs() <= 64. * f64::EPSILON
&& plane.y.dot(d).abs() <= 64. * f64::EPSILON;
for (first, end, sg) in runs {
let (z0, z1) = (levels[first], levels[end]);
let (a, b) = (sg.start(), sg.end());
@@ -868,7 +878,28 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
_ => SurfSpec::Plane,
};
faces.push(FaceSpec { loops: vec![lp3], surface, probe: Some((at(sg.point_at(0.5), (z0 + z1) / 2.0), outward)) });
sources.push((end > first + 1).then_some(crate::FaceSource::ExtrudeSide { segment: sg, plane: None, coalesced: true }));
// Axis-aligned generated lines have a common oriented support frame
// independent of axial stations or along-line endpoint interpolation.
let generated_plane = if coalesce
&& frame_is_orthonormal
&& let Seg2::Line { a, b } = sg
{
let (origin, x) = if a.y == b.y && a.x != b.x {
(at(Vec2::new(0., a.y), 0.), plane.x * (b.x - a.x).signum())
} else if a.x == b.x && a.y != b.y {
(at(Vec2::new(a.x, 0.), 0.), plane.y * (b.y - a.y).signum())
} else {
(Vec3::ZERO, Vec3::ZERO)
};
(origin.is_finite() && x.len2() > 0.).then_some(Plane { origin, x, y: d })
} else {
None
};
sources.push((generated_plane.is_some() || end > first + 1).then_some(crate::FaceSource::ExtrudeSide {
segment: sg,
plane: generated_plane,
coalesced: end > first + 1,
}));
}
for (z, up, rs) in &caps {
for r in rs {
@@ -885,7 +916,11 @@ fn sew_connected_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec<R
sources.push(None);
}
}
Some(crate::sew::sew_touching_with_sources(&faces, &sources))
Some(crate::sew::sew_touching_with_sources(&faces, &sources).and_then(
|body| {
if coalesce { body.regularize_generated_planar_faces() } else { Ok(body) }
},
))
}
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
@@ -1109,6 +1144,8 @@ fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>>
&slabs,
tol,
op == BoolOp::Cut
&& !a.regularization_protected
&& !b.regularization_protected
&& a.split_keep().is_empty()
&& b.split_keep().is_empty()
&& a.paint().is_none_or(|p| p.faces.is_empty())
@@ -1216,6 +1253,62 @@ mod planar_tests {
assert_eq!(crate::step_export(&[&tool], "tool").unwrap(), before_tool);
assert_eq!(crate::step_export(&[&roof], "roof").unwrap(), before_roof);
}
fn stepped_tab_stock() -> Body {
let stock = crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap();
let tab = crate::box_solid(Vec3::new(-12., -25., 8.), Vec3::new(-4., -15., 16.)).unwrap();
crate::boolean(&stock, &tab, BoolOp::Union).unwrap().unwrap()
}
fn coplanar_joins(body: &Body) -> usize {
let faces: Vec<_> = body.solid.face_iter().collect();
let mut adjacency = std::collections::HashMap::<_, Vec<usize>>::new();
for (i, face) in faces.iter().enumerate() {
for edge in face.edge_iter() {
adjacency.entry(edge.id()).or_default().push(i);
}
}
adjacency
.values()
.filter(|faces| {
let [a, b] = faces.as_slice() else { return false };
let (Some(pa), Some(pb)) = (body.planar_face(*a), body.planar_face(*b)) else { return false };
let (na, nb) = (pa.normal(), pb.normal());
na.dot(nb) > 1. - 1e-12 && (pb.origin - pa.origin).dot(na).abs() < 1e-10
})
.count()
}
#[test]
fn shell_of_joined_stepped_tabs_has_no_artificial_planar_stations() {
let stock = stepped_tab_stock();
let snapshot = crate::step_export(&[&stock], "unchanged stock").unwrap();
assert_eq!(coplanar_joins(&stock), 0);
let shell = crate::shell(&stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap();
assert_eq!(coplanar_joins(&shell), 0, "Shell must not add internal edges across continuous planar walls");
assert!(shell.validity().is_empty());
let mesh = shell.tessellate(1e-5).unwrap();
let expected = 40. * 30. * 24. + 8. * 10. * 8. - (38. * 28. * 23. + 6. * 10. * 6.);
assert!((mesh.measure().volume - expected).abs() < 1e-6);
for (point, material) in [
(Vec3::new(0., 0., 0.5), true),
(Vec3::new(0., 0., 10.), false),
(Vec3::new(0., -14.5, 10.), true),
(Vec3::new(19.5, 0., 10.), true),
(Vec3::new(-8., -24.5, 12.), true),
(Vec3::new(-8., -20., 12.), false),
(Vec3::new(0., -18., 9.), false),
] {
assert_eq!(mesh.contains(point), material, "wrong material at {point:?}");
}
let step = crate::step_export(&[&shell], "stepped tab shell").unwrap();
let reopened = crate::step_import(&step).unwrap();
let body = &reopened.bodies[0].body;
assert_eq!(body.face_count(), shell.face_count());
assert_eq!(coplanar_joins(body), 0);
assert!((body.tessellate(1e-5).unwrap().measure().volume - expected).abs() < 1e-6);
assert_eq!(crate::step_export(&[&stock], "unchanged stock").unwrap(), snapshot);
}
#[test]
fn contiguous_analytic_walls_remove_faces_but_retain_vertical_stations() {
for curved in [false, true] {
+53
View File
@@ -0,0 +1,53 @@
use super::*;
fn bits(v: Vec3) -> [u64; 3] {
[v.x.to_bits(), v.y.to_bits(), v.z.to_bits()]
}
fn check_body(body: &Body) -> usize {
let meshes = mesh_shells(&body.solid, 0.5);
let mut changed = 0;
for (face, meshed) in body.solid.face_iter().zip(meshes.iter().flat_map(|shell| shell.face_iter())) {
let Some(mesh) = meshed.surface() else { continue };
let surface = face.surface();
let cached = refined_with_cache(&mesh, &surface, 0.002, true);
let original = refined_with_cache(&mesh, &surface, 0.002, false);
match (cached, original) {
(None, None) => {}
(Some(cached), Some(original)) => {
changed += 1;
assert_eq!(cached.len(), original.len(), "triangle count/winding must not change");
for (cached, original) in cached.into_iter().zip(original) {
for ((point, normal), (old_point, old_normal)) in cached.into_iter().zip(original) {
assert_eq!(bits(point), bits(old_point), "every refined coordinate must remain bit-identical");
assert_eq!(bits(normal), bits(old_normal), "every refined normal must remain bit-identical");
}
}
}
_ => panic!("memoization changed the refine/no-refine decision"),
}
}
changed
}
#[test]
fn exact_refinement_cache_preserves_periodic_seams_and_normals() {
let cylinder = crate::cylinder(Vec3::ZERO, Vec3::Z, 15., 8.).unwrap();
let torus = crate::torus(Vec3::ZERO, 18., 4.).unwrap();
assert!(check_body(&cylinder) > 0, "the fixture must actually refine");
assert!(check_body(&torus) > 0, "the periodic fixture must actually refine");
}
#[test]
fn exact_refinement_cache_preserves_singular_poles_and_placed_surfaces() {
let sphere = crate::sphere(Vec3::ZERO, 12.).unwrap();
assert!(check_body(&sphere) > 0, "the polar fixture must actually refine");
let placed = crate::transform(&sphere, Vec3::new(120., -85., 40.), Vec3::ZERO, Vec3::new(1., 2., 3.), 0.371).unwrap();
assert!(check_body(&placed) > 0);
}
#[test]
fn exact_refinement_cache_keeps_unnecessary_planar_refinement_absent() {
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
assert_eq!(check_body(&body), 0);
}
@@ -0,0 +1,65 @@
use super::*;
use solvecraft_geom::Plane;
fn stamped_stock() -> Body {
crate::box_solid(Vec3::new(-20., -15., 0.), Vec3::new(20., 15., 24.)).unwrap().with_box_certificate(7).unwrap()
}
fn cup(stock: &Body) -> Body {
crate::shell(stock, &[Vec3::new(0., 0., 24.)], 1.).unwrap().with_shell_certificate(9).unwrap()
}
#[test]
fn stamped_shell_keeps_authored_painted_and_split_boundaries() {
let stock = stamped_stock();
let ordinary = cup(&stock);
assert!(!ordinary.regularization_protected);
let painted = stock.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
let side = stock
.solid
.face_iter()
.enumerate()
.find(|(i, _)| stock.planar_face(*i).is_some_and(|p| p.normal().dot(Vec3::X) > 0.999 && p.origin.x == 20.))
.unwrap()
.0;
let split = crate::split_faces(&stock, &[side], &crate::SplitTool::Plane(Plane { origin: Vec3::new(0., 0., 8.), ..Plane::XY })).unwrap();
assert!(split.split_lines().is_some());
for protected in [painted, split] {
let result = cup(&protected);
assert!(result.regularization_protected);
assert!(result.face_count() > ordinary.face_count(), "authored boundaries must survive both Shell regularizers");
assert!(result.validity().is_empty());
let expected = 40. * 30. * 24. - 38. * 28. * 23.;
let mesh = result.tessellate(1e-5).unwrap();
assert!((mesh.measure().volume - expected).abs() < 1e-6);
assert!(mesh.contains(Vec3::new(19.5, 0., 12.)));
assert!(!mesh.contains(Vec3::new(0., 0., 12.)));
let count = result.face_count();
assert_eq!(result.clone().regularize_certified_planar_faces().unwrap().face_count(), count);
assert_eq!(result.clone().regularize_generated_planar_faces().unwrap().face_count(), count);
let placed = crate::transform(&result, Vec3::new(30., -40., 7.), Vec3::ZERO, Vec3::Z, 0.3).unwrap();
assert!(placed.regularization_protected);
for lump in placed.lumps().unwrap() {
assert!(lump.regularization_protected);
}
let cleared = result.clone().with_paint(None).with_split_lines(None);
assert!(cleared.regularization_protected, "cleared appearance must not erase inherited boundary protection");
assert_eq!(cleared.regularize_certified_planar_faces().unwrap().face_count(), count);
}
}
#[test]
fn protection_is_identity_and_survives_tool_operand_and_compounds() {
let a = stamped_stock();
let protected = a.clone().with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [0., 1., 0.], opacity: 1. }] }));
let cleared = protected.with_paint(None);
assert!(!a.same(&cleared));
let tool = crate::box_solid(Vec3::new(-5., -5., 23.), Vec3::new(5., 5., 30.)).unwrap().inherit_regularization_protection(&cleared);
let cut = crate::boolean(&a, &tool, crate::BoolOp::Cut).unwrap().unwrap();
assert!(cut.regularization_protected);
let remote = crate::box_solid(Vec3::new(100., 0., 0.), Vec3::new(110., 10., 10.)).unwrap();
let joined = crate::ops::join_pieces(vec![cleared, remote]).unwrap().unwrap();
assert!(joined.regularization_protected);
assert_eq!(joined.lumps().unwrap().len(), 2);
assert!(joined.lumps().unwrap().iter().all(|body| body.regularization_protected));
}
+1
View File
@@ -886,6 +886,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
paint: None,
splits: body.splits.clone(),
face_sources: None,
regularization_protected: true,
},
imp,
))
+3 -3
View File
@@ -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),
+2
View File
@@ -30,6 +30,7 @@ fn surface_body(faces: Vec<mt::Face>) -> Body {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
}
}
@@ -47,6 +48,7 @@ fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
paint: None,
splits: None,
face_sources: None,
regularization_protected: false,
})
}
}
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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()
}
}
+2 -2
View File
@@ -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()),
}))
}
+4 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+59 -5
View File
@@ -1,9 +1,10 @@
//! Context menus, one look everywhere.
//!
//! The viewport's right-click opens a radial marking menu (eight commands around the cursor,
//! Repeat at the top) with a list underneath of what fits the selection or the thing under the
//! cursor: faces, edges, bodies, sketch entities or empty space. Holding the right button opens
//! it too; releasing over a direction picks that command (a flick). The browser's right-click
//! The viewport's right-click opens a list of commands for the selection or the thing under the
//! cursor: faces, edges, bodies, sketch entities or empty space. The optional radial marking
//! menu adds eight commands around the cursor, with Repeat at the top. With that preference on,
//! holding the right button opens it too; releasing over a direction picks a command (a flick).
//! The browser's right-click
//! opens the same list styling for a body, sketch or component.
//!
//! Items are commands (by id, with parameters filled from the target) or `ui.*` actions on view
@@ -1172,7 +1173,7 @@ pub fn open(app: &mut SolveApp, at: Pos2) {
/// Open a menu for a target (the radial ring only for the viewport).
pub fn open_for(app: &mut SolveApp, at: Pos2, target: Target) {
app.menu.key_row = None;
app.menu.radial = target == Target::Viewport;
app.menu.radial = target == Target::Viewport && app.preferences.radial_context_menu;
app.menu.target = target;
app.menu.flick = false;
app.viewport.context_menu = Some(at);
@@ -1301,6 +1302,12 @@ fn slot_rect(c: Pos2, k: usize, size: Vec2) -> Rect {
/// Press-and-hold of the right button in the viewport opens the menu; releasing over a
/// direction picks it.
fn track_hold(app: &mut SolveApp, ctx: &egui::Context) {
if !app.preferences.radial_context_menu {
app.menu.press = None;
app.menu.flick = false;
app.menu.radial = false;
return;
}
let (pressed, down, released, pos, time) = ctx.input(|i| {
(
i.pointer.button_pressed(egui::PointerButton::Secondary),
@@ -1648,6 +1655,53 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) {
mod tests {
use super::*;
#[test]
fn viewport_right_click_keeps_list_menu_and_radial_is_opt_in() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.settle();
let point = h.at(&json!({"world":[0,0,0]})).unwrap();
h.step(&json!({"click":point,"button":"right"})).unwrap();
assert!(h.app.viewport.context_menu.is_some(), "ordinary right click must still open a menu");
let list = describe(&h.app);
assert!(list["radial"].as_array().unwrap().is_empty());
assert!(list["items"].as_array().unwrap().iter().any(|item| item["id"] == "ui.origin" && item["enabled"] == true));
h.step(&json!({"click":{"handle":"menu:ui.origin"}})).unwrap();
assert!(!h.app.ui.show_origin && h.app.viewport.context_menu.is_none(), "the ordinary menu must execute Hide Origin and close");
h.call("ui.prefs", json!({"radial_context_menu":true}));
h.step(&json!({"click":point,"button":"right"})).unwrap();
assert_eq!(describe(&h.app)["radial"].as_array().unwrap().len(), DIRS);
open_for(&mut h.app, pos2(point[0] as f32, point[1] as f32), Target::Origin);
assert!(!h.app.menu.radial, "browser menus remain lists when radial menus are enabled");
}
#[test]
fn disabled_radial_menu_does_not_capture_right_button_hold() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
let ctx = egui::Context::default();
let point = pos2(600.0, 400.0);
app.viewport.mouse = Some(point);
for (time, events) in [
(
0.0,
vec![
egui::Event::PointerMoved(point),
egui::Event::PointerButton {
pos: point,
button: egui::PointerButton::Secondary,
pressed: true,
modifiers: egui::Modifiers::NONE,
},
],
),
(1.0, vec![]),
] {
let mut output = ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |_| track_hold(&mut app, &ctx));
output.textures_delta.clear();
}
assert!(app.viewport.context_menu.is_none() && app.menu.press.is_none() && !app.menu.flick);
}
#[test]
fn sectors_go_clockwise_from_the_top() {
assert_eq!(sector(vec2(0.0, -50.0)), Some(0));
+124 -5
View File
@@ -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(&param)).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(&param)).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 };
+335
View File
@@ -0,0 +1,335 @@
//! Focus-scoped completion keyboard handling before editors and CAD shortcuts see input.
use egui::{Key, RichText};
use crate::params_dialog::{suggestions, word_before};
use crate::theme::Tokens;
#[derive(Clone)]
struct Popup {
editor: egui::Id,
frame: u64,
word: String,
cursor: usize,
selected: usize,
count: usize,
accept: bool,
dismissed: bool,
}
fn popup_id() -> egui::Id {
egui::Id::new("expression_completion_keyboard")
}
/// Run before any TextEdit or viewport shortcuts. Only the focused, recently drawn popup
/// owns these keys. Raw events are removed too: several expression editors inspect raw Tab.
pub(crate) fn begin_frame(ctx: &egui::Context) {
let Some(mut popup) = ctx.data(|d| d.get_temp::<Popup>(popup_id())) else { return };
let escape_blurred = ctx.input(|i| i.key_pressed(Key::Escape)) && ctx.memory(|m| m.focused().is_none() && m.had_focus_last_frame(popup.editor));
let focused = ctx.memory(|m| m.has_focus(popup.editor)) || escape_blurred;
if popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() || !focused || popup.dismissed || popup.count == 0 {
if !focused || popup.frame.saturating_add(1) < ctx.cumulative_frame_nr() {
ctx.data_mut(|d| d.remove::<Popup>(popup_id()));
}
return;
}
// Let the editor process a newly typed/deleted prefix before deciding what Enter means.
// A prior frame's completion must never replace text changed in this input batch.
if ctx.input(|i| {
i.raw.events.iter().any(|e| {
matches!(e, egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Key { key: Key::Backspace | Key::Delete, pressed: true, .. })
})
}) {
return;
}
let mut consumed = false;
ctx.input_mut(|input| {
let mut owned = Vec::new();
for event in &input.raw.events {
if let egui::Event::Key { key, pressed: true, modifiers, .. } = event
&& *modifiers == egui::Modifiers::NONE
{
match key {
Key::ArrowDown => popup.selected = (popup.selected + 1) % popup.count,
Key::ArrowUp => popup.selected = (popup.selected + popup.count - 1) % popup.count,
Key::Enter | Key::Tab => popup.accept = true,
Key::Escape => {
popup.dismissed = true;
popup.accept = false;
}
_ => continue,
}
owned.push(*key);
consumed = true;
}
}
let keep = |event: &egui::Event| {
!matches!(event, egui::Event::Key { key, modifiers, .. }
if owned.contains(key) && *modifiers == egui::Modifiers::NONE)
};
input.events.retain(keep);
input.raw.events.retain(keep);
});
if consumed {
// egui schedules navigation (and Escape blur) at begin_pass, before this hook.
ctx.memory_mut(|m| {
m.move_focus(egui::FocusDirection::None);
m.request_focus(popup.editor);
});
}
ctx.data_mut(|d| d.insert_temp(popup_id(), popup));
}
pub(crate) fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> bool {
let ctx = ui.ctx().clone();
if !ctx.memory(|m| m.has_focus(resp.id)) {
return false;
}
let state = egui::TextEdit::load_state(&ctx, resp.id);
let cursor = state.as_ref().and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0).unwrap_or_else(|| text.chars().count());
let (start, word) = word_before(text, cursor);
let list = suggestions(&ctx, &word);
let previous = ctx.data(|d| d.get_temp::<Popup>(popup_id()));
let mut popup = previous.filter(|p| p.editor == resp.id && p.word == word && p.cursor == cursor).unwrap_or(Popup {
editor: resp.id,
frame: 0,
word,
cursor,
selected: 0,
count: list.len(),
accept: false,
dismissed: false,
});
popup.frame = ctx.cumulative_frame_nr();
popup.count = list.len();
popup.selected = popup.selected.min(list.len().saturating_sub(1));
if list.is_empty() || popup.dismissed {
ctx.data_mut(|d| d.insert_temp(popup_id(), popup));
return false;
}
let mut pick = if popup.accept { list.get(popup.selected).map(|s| s.text.clone()) } else { None };
popup.accept = false;
let t = Tokens::get();
crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show(
&ctx,
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(resp.rect.width().max(180.0));
for (i, suggestion) in list.iter().enumerate() {
let colour = match suggestion.kind.as_str() {
"parameter" => t.text,
"function" => t.accent,
_ => t.text_dim,
};
let r = ui
.add(egui::Button::new(RichText::new(&suggestion.text).color(colour)).selected(i == popup.selected))
.on_hover_text(&suggestion.detail);
if i == popup.selected {
ui.label(RichText::new(&suggestion.detail).small().color(t.text_dim));
}
if r.clicked() {
pick = Some(suggestion.text.clone());
}
}
ui.label(RichText::new("↑ ↓ choose · Enter / Tab complete · Esc dismiss").small().color(t.text_dim));
});
},
);
ctx.data_mut(|d| d.insert_temp(popup_id(), popup));
let Some(pick) = pick else { return false };
let chars: Vec<char> = text.chars().collect();
let before: String = chars.get(..start).map(|s| s.iter().collect()).unwrap_or_default();
let after: String = chars.get(cursor.min(chars.len())..).map(|s| s.iter().collect()).unwrap_or_default();
*text = format!("{before}{pick}{after}");
let mut state = state.unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(start + pick.chars().count()))));
state.store(&ctx, resp.id);
resp.request_focus();
// Do not leave the accepted popup owning Enter on the next frame.
ctx.data_mut(|d| d.remove::<Popup>(popup_id()));
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{SolveApp, params_dialog};
use serde_json::json;
struct Editor {
ctx: egui::Context,
app: SolveApp,
text: String,
id: egui::Id,
accepted: bool,
leaked: Vec<Key>,
}
impl Editor {
fn new(text: &str) -> Self {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
for (name, value) in [("width", 40), ("width_extra", 50), ("wing", 20), ("wire", 10)] {
app.run("parameters.add", json!({"name":name,"expression":format!("{value} mm")})).unwrap();
}
params_dialog::publish(&mut app, &ctx);
let id = egui::Id::new("completion_test_editor");
ctx.memory_mut(|m| m.request_focus(id));
let mut editor = Self { ctx, app, text: text.into(), id, accepted: false, leaked: Vec::new() };
editor.frame(Vec::new());
let mut state = egui::TextEdit::load_state(&editor.ctx, id).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(text.chars().count()))));
state.store(&editor.ctx, id);
editor.frame(Vec::new());
editor
}
fn frame(&mut self, events: Vec<egui::Event>) {
self.accepted = false;
self.leaked.clear();
let _ = self.ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))),
events,
..Default::default()
},
|ui| {
begin_frame(ui.ctx());
params_dialog::publish(&mut self.app, ui.ctx());
egui::CentralPanel::default().show(ui, |ui| {
let response = ui.add(egui::TextEdit::singleline(&mut self.text).id(self.id));
self.accepted = complete(ui, &response, &mut self.text);
for key in [Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Tab, Key::Escape] {
if ui.input(|i| {
i.key_pressed(key)
|| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key:k, pressed:true, .. } if *k == key))
}) {
self.leaked.push(key);
}
}
});
},
);
}
fn key(&mut self, key: Key) {
self.frame(vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }]);
}
fn cursor(&self) -> usize {
egui::TextEdit::load_state(&self.ctx, self.id).unwrap().cursor.char_range().unwrap().primary.index.0
}
fn selected(&self) -> usize {
self.ctx.data(|d| d.get_temp::<Popup>(popup_id())).unwrap().selected
}
}
#[test]
fn arrows_cycle_without_moving_caret_enter_accepts_selected_without_leaking() {
let mut e = Editor::new("2 * wi");
let list = suggestions(&e.ctx, "wi");
assert!(list.len() >= 3);
let cursor = e.cursor();
e.key(Key::ArrowDown);
assert_eq!(e.selected(), 1);
assert_eq!(e.cursor(), cursor);
assert!(e.leaked.is_empty());
e.key(Key::ArrowUp);
assert_eq!(e.selected(), 0);
e.key(Key::ArrowUp);
assert_eq!(e.selected(), list.len() - 1);
e.key(Key::Enter);
assert_eq!(e.text, format!("2 * {}", list.last().unwrap().text));
assert!(e.accepted);
assert!(e.leaked.is_empty());
assert_eq!(e.cursor(), e.text.chars().count());
assert!(e.ctx.memory(|m| m.has_focus(e.id)));
}
#[test]
fn tab_accepts_selection_filtering_resets_it_and_escape_dismisses() {
let mut e = Editor::new("wi");
e.key(Key::ArrowDown);
let expected = suggestions(&e.ctx, "wi").get(1).unwrap().text.clone();
e.key(Key::Tab);
assert_eq!(e.text, expected);
assert!(e.accepted && e.leaked.is_empty());
let mut e = Editor::new("wi");
e.key(Key::ArrowDown);
e.frame(vec![egui::Event::Text("d".into())]);
assert_eq!(e.text, "wid");
assert_eq!(e.selected(), 0);
e.key(Key::Escape);
assert!(e.leaked.is_empty());
assert_eq!(e.text, "wid");
e.key(Key::Enter);
assert!(!e.accepted);
assert!(e.leaked.contains(&Key::Enter));
}
#[test]
fn absent_popup_preserves_editor_and_application_keys() {
let mut e = Editor::new("123");
e.key(Key::ArrowDown);
assert!(e.leaked.contains(&Key::ArrowDown));
e.key(Key::Escape);
assert!(e.leaked.contains(&Key::Escape));
assert!(!e.ctx.memory(|m| m.has_focus(e.id)));
e.ctx.memory_mut(|m| m.request_focus(e.id));
e.frame(Vec::new());
let mut state = egui::TextEdit::load_state(&e.ctx, e.id).unwrap();
state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(e.text.chars().count()))));
state.store(&e.ctx, e.id);
e.frame(vec![egui::Event::Text("4".into())]);
assert_eq!(e.text, "1234");
}
#[test]
fn exact_tokens_submit_without_completing_longer_names() {
for text in ["width", "2 mm"] {
let mut e = Editor::new(text);
e.key(Key::Enter);
assert_eq!(e.text, text);
assert!(!e.accepted);
assert!(e.leaked.contains(&Key::Enter));
}
}
#[test]
fn typing_and_enter_in_one_batch_does_not_accept_stale_prefix() {
let mut e = Editor::new("wi");
e.frame(vec![
egui::Event::Text("dth".into()),
egui::Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE },
]);
assert_eq!(e.text, "width");
assert!(!e.accepted);
assert!(e.leaked.contains(&Key::Enter));
}
#[test]
fn modified_navigation_remains_available_to_the_editor() {
let mut e = Editor::new("wi");
e.frame(vec![egui::Event::Key {
key: Key::ArrowUp,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { ctrl: true, ..Default::default() },
}]);
assert!(e.leaked.contains(&Key::ArrowUp));
assert!(!e.accepted);
let mut e = Editor::new("wi");
e.frame(vec![egui::Event::Key {
key: Key::Tab,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { shift: true, ..Default::default() },
}]);
assert!(e.leaked.contains(&Key::Tab));
assert_eq!(e.text, "wi");
assert!(!e.accepted);
}
}
+18 -1
View File
@@ -102,6 +102,8 @@ fn start(app: &mut SolveApp) {
#[cfg(not(target_arch = "wasm32"))]
fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
let profile = std::env::var_os("SOLVECRAFT_PROFILE_OPEN").is_some();
let began = std::time::Instant::now();
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
@@ -113,16 +115,31 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can
"file.insert_step"
};
let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?;
let geometry_ms = began.elapsed().as_secs_f64() * 1000.0;
let view_began = std::time::Instant::now();
phase.store(1, Ordering::Relaxed);
let world = session.world_state();
let placement_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let mesh_began = std::time::Instant::now();
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
for b in &session.world_state().bodies {
for b in &world.bodies {
if cancelled.load(Ordering::Relaxed) {
return Err("file operation cancelled".into());
}
let _ = b.mesh();
}
let tessellation_ms = mesh_began.elapsed().as_secs_f64() * 1000.0;
phase.store(2, Ordering::Relaxed);
let view_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let thumbnail_began = std::time::Instant::now();
let image = if insert { None } else { crate::home::remember(&session) };
if profile {
eprintln!(
"SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}",
thumbnail_began.elapsed().as_secs_f64() * 1000.0,
began.elapsed().as_secs_f64() * 1000.0
);
}
Ok(Made { session, result, image })
}
+6 -6
View File
@@ -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);
+1 -1
View File
@@ -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));
});
+2 -1
View File
@@ -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)
+17
View File
@@ -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);
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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
View File
@@ -61,6 +61,7 @@ mod status_tests {
assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last());
}
}
mod expression_complete;
#[cfg(test)]
mod extrude_edit_tests;
mod file_load;
@@ -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;
+296
View File
@@ -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"
}
+1 -1
View File
@@ -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エージェントカーソル",
+65 -74
View File
@@ -11,7 +11,7 @@
use std::sync::Arc;
use egui::{Color32, RichText};
use egui::RichText;
use serde_json::{Value, json};
use crate::SolveApp;
@@ -275,7 +275,9 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
if bad {
te = te.text_color(t.error);
}
let resp = ui.add_sized([170.0, 20.0], te);
let id = ui.make_persistent_id(("pexpr", &r.name));
let focused = ui.memory(|m| m.has_focus(id));
let resp = ui.add_sized([170.0, 20.0], te.id(id));
crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center());
let completed = complete(ui, &resp, &mut e);
if resp.changed() || completed {
@@ -284,10 +286,10 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
if resp.has_focus() && matches!(live, Some(Ok(_))) {
d.preview = Some((r.name.clone(), e.clone()));
}
if resp.lost_focus() {
if !completed && (resp.lost_focus() || enter_for(ui, focused)) {
d.preview = None;
if e != r.expression {
let res = app.run("parameters.change", json!({"name": r.name, "expression": e}));
let res = check(app, &e, &r.unit).and_then(|_| app.run("parameters.change", json!({"name": r.name, "expression": e})));
d.error = res.err();
if d.error.is_none() {
d.editing.remove(&r.name);
@@ -336,41 +338,79 @@ fn unit_label(u: &str) -> &str {
if u.is_empty() { "none" } else { u }
}
/// Only a fresh Enter in this field submits; held keys and unrelated focus do not.
fn enter_for(ui: &mut egui::Ui, focused: bool) -> bool {
focused
&& ui.input_mut(|i| {
let fresh = i.raw.events.iter().any(|event| {
matches!(event,
egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, modifiers, .. } if *modifiers == egui::Modifiers::NONE)
});
if fresh {
i.consume_key(egui::Modifiers::NONE, egui::Key::Enter);
}
fresh
})
}
fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog) {
let t = Tokens::get();
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut d.new_name).hint_text("new parameter").desired_width(120.0));
egui::ComboBox::from_id_salt("pnew_unit").width(56.0).selected_text(unit_label(UNITS.get(d.new_unit).copied().unwrap_or("mm"))).show_ui(
ui,
|ui| {
let name_id = ui.make_persistent_id("pnew_name");
let expr_id = ui.make_persistent_id("pnew_expr");
let comment_id = ui.make_persistent_id("pnew_comment");
let focused_id = ui.memory(|m| [name_id, expr_id, comment_id].into_iter().find(|id| m.has_focus(*id)));
let name = ui.add(egui::TextEdit::singleline(&mut d.new_name).id(name_id).hint_text("new parameter").desired_width(120.0));
crate::scenario::publish_handle("param:new:name", name.rect.center());
let unit_response = egui::ComboBox::from_id_salt("pnew_unit")
.width(56.0)
.selected_text(unit_label(UNITS.get(d.new_unit).copied().unwrap_or("mm")))
.show_ui(ui, |ui| {
for (i, u) in UNITS.iter().enumerate() {
ui.selectable_value(&mut d.new_unit, i, unit_label(u));
}
},
);
});
crate::scenario::publish_handle("param:new:unit", unit_response.response.rect.center());
let unit = UNITS.get(d.new_unit).copied().unwrap_or("mm");
let live = check(app, &d.new_expr, unit);
let mut te = egui::TextEdit::singleline(&mut d.new_expr).desired_width(170.0);
if live.is_err() {
let bad = check(app, &d.new_expr, unit).is_err();
let mut te = egui::TextEdit::singleline(&mut d.new_expr).id(expr_id).desired_width(170.0);
if bad {
te = te.text_color(t.error);
}
let resp = ui.add(te);
complete(ui, &resp, &mut d.new_expr);
crate::scenario::publish_handle("param:new:expression", resp.rect.center());
let completed = complete(ui, &resp, &mut d.new_expr);
let live = check(app, &d.new_expr, unit);
match &live {
Ok(v) => ui.label(RichText::new(v).color(t.text_dim)),
Err(m) => ui.label(RichText::new(m).color(t.error)),
};
ui.add(egui::TextEdit::singleline(&mut d.new_comment).hint_text("comment").desired_width(120.0));
let comment = ui.add(egui::TextEdit::singleline(&mut d.new_comment).id(comment_id).hint_text("comment").desired_width(120.0));
crate::scenario::publish_handle("param:new:comment", comment.rect.center());
let submit = !completed && enter_for(ui, focused_id.is_some());
let ok = !d.new_name.trim().is_empty() && live.is_ok();
if ui.add_enabled(ok, egui::Button::new("Add")).clicked() {
if ui.add_enabled(ok, egui::Button::new("Add")).clicked() || submit {
if let Err(error) = &live {
d.error = Some(error.clone());
if let Some(id) = focused_id {
ui.memory_mut(|m| m.request_focus(id));
}
return;
}
let p = json!({"name": d.new_name.trim(), "expression": d.new_expr, "unit": unit, "comment": d.new_comment});
match app.run("parameters.add", p) {
Ok(_) => {
d.new_name.clear();
d.new_comment.clear();
d.error = None;
name.request_focus();
}
Err(e) => {
d.error = Some(e);
if let Some(id) = focused_id {
ui.memory_mut(|m| m.request_focus(id));
}
}
Err(e) => d.error = Some(e),
}
}
});
@@ -421,7 +461,7 @@ pub fn publish(app: &mut SolveApp, ctx: &egui::Context) {
}
/// The identifier being typed before `cursor` (chars) and where it starts.
fn word_before(text: &str, cursor: usize) -> (usize, String) {
pub(crate) fn word_before(text: &str, cursor: usize) -> (usize, String) {
let chars: Vec<char> = text.chars().collect();
let end = cursor.min(chars.len());
let mut start = end;
@@ -437,67 +477,18 @@ pub fn suggestions(ctx: &egui::Context, word: &str) -> Vec<Suggestion> {
return Vec::new();
}
let all = ctx.data(|d| d.get_temp::<Cache>(cache_id())).map(|c| c.all).unwrap_or_default();
// A complete token is ready to submit, even when longer names share its prefix.
if all.iter().any(|s| s.text == word) {
return Vec::new();
}
let w = word.to_ascii_lowercase();
all.iter().filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && s.text.trim_end_matches('(') != word).take(8).cloned().collect()
}
/// Offer completions under an expression box while it has focus; Tab or a click takes the first
/// (or the clicked) one. Returns true when the text changed.
/// Offer keyboard and pointer completions under a focused expression box.
/// Returns true when a suggestion was accepted; callers must not submit that frame.
pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> bool {
let ctx = ui.ctx().clone();
let state = egui::TextEdit::load_state(&ctx, resp.id);
let cursor = state.as_ref().and_then(|s| s.cursor.char_range()).map(|r| r.primary.index.0).unwrap_or_else(|| text.chars().count());
let (start, word) = word_before(text, cursor);
// Raw events: egui's focus navigation consumes Tab before widgets see it.
let tab = ui.input(|i| i.raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Tab, pressed: true, .. })));
// Tab moves focus away before we see it: accept on that frame and take focus back.
// Keyboard focus in egui's memory (not `has_focus`, which also needs the OS window focused).
let focused = ctx.memory(|m| m.has_focus(resp.id));
let active = focused || (resp.lost_focus() && tab);
if !active {
return false;
}
let list = suggestions(&ctx, &word);
if list.is_empty() {
return false;
}
let mut pick: Option<String> = if tab { list.first().map(|s| s.text.clone()) } else { None };
let t = Tokens::get();
crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show(
&ctx,
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(resp.rect.width().max(180.0));
for (i, s) in list.iter().enumerate() {
let colour = match s.kind.as_str() {
"parameter" => t.text,
"function" => t.accent,
_ => t.text_dim,
};
let label = RichText::new(&s.text).color(colour);
let r = ui.add(egui::Button::new(label).frame(false).fill(Color32::TRANSPARENT)).on_hover_text(&s.detail);
if i == 0 {
ui.label(RichText::new(&s.detail).small().color(t.text_dim));
}
if r.clicked() {
pick = Some(s.text.clone());
}
}
ui.label(RichText::new("Tab to complete").small().color(t.text_dim));
});
},
);
let Some(p) = pick else { return false };
let chars: Vec<char> = text.chars().collect();
let before: String = chars.get(..start).map(|s| s.iter().collect()).unwrap_or_default();
let after: String = chars.get(cursor.min(chars.len())..).map(|s| s.iter().collect()).unwrap_or_default();
*text = format!("{before}{p}{after}");
let at = start + p.chars().count();
let mut st = state.unwrap_or_default();
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at))));
st.store(&ctx, resp.id);
resp.request_focus();
true
crate::expression_complete::complete(ui, resp, text)
}
#[cfg(test)]
+76 -1
View File
@@ -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());
+1 -1
View File
@@ -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());
+5 -1
View File
@@ -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" => {
+22 -7
View File
@@ -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));
}
}
+56 -5
View File
@@ -263,9 +263,9 @@ pub fn set_dark(dark: bool) {
DARK.store(dark, Ordering::Relaxed);
}
pub fn apply(ctx: &egui::Context) {
let t = Tokens::get();
let mut v = if is_dark() { egui::Visuals::dark() } else { egui::Visuals::light() };
fn visuals(dark: bool) -> egui::Visuals {
let t = if dark { Tokens::dark() } else { Tokens::light() };
let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() };
v.panel_fill = t.panel;
v.window_fill = t.panel;
v.window_stroke = Stroke::new(1.0, t.border);
@@ -282,11 +282,62 @@ pub fn apply(ctx: &egui::Context) {
v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text);
v.widgets.hovered.weak_bg_fill = t.hover;
v.widgets.hovered.bg_fill = t.hover;
// egui uses the open-state fill for a movable window's active title bar.
v.widgets.open.weak_bg_fill = t.panel_header;
v.window_corner_radius = egui::CornerRadius::same(6);
ctx.set_theme(if is_dark() { egui::Theme::Dark } else { egui::Theme::Light });
ctx.set_visuals(v);
v
}
pub fn apply(ctx: &egui::Context) {
let dark = is_dark();
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
ctx.set_visuals(visuals(dark));
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 3.0);
});
}
#[cfg(test)]
mod tests {
#[test]
fn active_movable_window_header_uses_theme_panel_header() {
for dark in [false, true] {
let ctx = egui::Context::default();
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
ctx.set_visuals(super::visuals(dark));
let mut output = egui::FullOutput::default();
for _ in 0..3 {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
..Default::default()
};
output = ctx.run_ui(raw, |ui| {
crate::frame::window(ui.ctx(), "Parameters", crate::frame::Width::Normal).fade_in(false).show(ui.ctx(), |ui| {
ui.label("Parameter values");
});
});
output.textures_delta.clear();
}
let title = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.text() == "Parameters" => Some(text.pos + text.galley.rect.center().to_vec2()),
_ => None,
})
.expect("window title must be drawn");
let header = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) if rect.fill.a() > 0 && rect.rect.contains(title) => Some(rect),
_ => None,
})
.min_by(|a, b| a.rect.area().total_cmp(&b.rect.area()))
.expect("window header must be drawn behind its title");
let expected = if dark { super::Tokens::dark() } else { super::Tokens::light() };
assert_eq!(header.fill, expected.panel_header, "active title bar should follow the {dark} palette");
}
}
}
+305 -29
View File
@@ -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);
}
}
+1 -1
View File
@@ -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);
+55
View File
@@ -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
View File
@@ -62,6 +62,8 @@ pub struct ViewportState {
pub orbit_pivot: Option<Vec3>,
/// Continuous orbit: the view turns about the vertical until the next click or key.
pub spin: bool,
/// After immediate zoom, ignore egui's outstanding smoothing until the gesture ends.
wheel_zoom_raw_until_idle: bool,
/// A primary press on the axis gizmo: the pointer position used by its last orbit step.
pub axis_drag: Option<Pos2>,
/// Unsnapped camera while Shift-orbit previews a standard or diagonal view.
@@ -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();
+33
View File
@@ -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);
}
+108
View File
@@ -0,0 +1,108 @@
//! Enter submits only the focused Parameters editor row.
use egui::{Event, Key, Modifiers};
use serde_json::json;
use solvecraft_ui_egui::scenario::Harness;
fn open() -> Harness {
let mut h = Harness::new();
h.step(&json!({"widget":"Parameters"})).unwrap();
assert!(h.app.params.is_some());
h
}
fn fill(h: &mut Harness, field: &str, text: &str) {
h.run(&json!([
{"click":{"handle":format!("param:new:{field}")}},
{"key":"A","cmd":true}, {"text":text}
]))
.unwrap();
}
fn enter(h: &mut Harness, repeat: bool) {
h.app.synthetic.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE });
h.frame();
h.app.synthetic.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.frames(3);
}
#[test]
fn parameter_enter_adds_once_and_focuses_next_name() {
let mut h = open();
fill(&mut h, "name", "width");
fill(&mut h, "expression", "25 mm");
let undo = h.app.session.undo.len();
enter(&mut h, false);
assert_eq!(h.app.session.undo.len(), undo + 1);
assert!(h.app.params.as_ref().unwrap().new_name.is_empty());
assert!(h.app.params.as_ref().unwrap().error.is_none());
let saved = h.app.session.doc.to_json();
enter(&mut h, true);
assert_eq!(h.app.session.doc.to_json(), saved);
assert_eq!(h.app.session.undo.len(), undo + 1);
// Name has focus after successful creation; no mouse needed for the next name.
h.run(&json!([{"text":"height"}])).unwrap();
assert_eq!(h.app.params.as_ref().unwrap().new_name, "height");
enter(&mut h, false);
assert_eq!(h.app.session.undo.len(), undo + 2);
let result = h.app.session.execute("parameters.list", &json!({})).unwrap();
assert!(result["parameters"].as_array().unwrap().iter().any(|p| p["name"] == "height"));
}
#[test]
fn parameter_enter_invalid_preserves_draft_and_other_rows() {
let mut h = open();
h.app.session.execute("parameters.add", &json!({"name":"existing","expression":"10 mm"})).unwrap();
h.frames(3);
fill(&mut h, "name", "bad");
fill(&mut h, "expression", "missing + 1 mm");
let saved = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
enter(&mut h, false);
assert_eq!(h.app.session.doc.to_json(), saved);
assert_eq!(h.app.session.undo.len(), undo);
let d = h.app.params.as_ref().unwrap();
assert_eq!(d.new_name, "bad");
assert_eq!(d.new_expr, "missing + 1 mm");
assert!(d.error.is_some());
fill(&mut h, "expression", "15 mm");
fill(&mut h, "name", "existing");
enter(&mut h, false);
assert_eq!(h.app.session.doc.to_json(), saved);
assert!(h.app.params.as_ref().unwrap().error.is_some());
}
#[test]
fn parameter_enter_edits_expression_without_adding_draft() {
let mut h = open();
h.app.session.execute("parameters.add", &json!({"name":"width","expression":"10 mm"})).unwrap();
h.frames(3);
fill(&mut h, "name", "unsubmitted");
let undo = h.app.session.undo.len();
h.run(&json!([{"click":{"handle":"param:width"}}, {"key":"A","cmd":true}, {"text":"20 mm"}])).unwrap();
enter(&mut h, false);
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.params.as_ref().unwrap().new_name, "unsubmitted");
let result = h.app.session.execute("parameters.list", &json!({})).unwrap();
let ps = result["parameters"].as_array().unwrap();
assert!(ps.iter().any(|p| p["name"] == "width" && p["expression"] == "20 mm"));
assert!(!ps.iter().any(|p| p["name"] == "unsubmitted"));
let saved = h.app.session.doc.to_json();
h.run(&json!([{"click":{"handle":"param:width"}}, {"key":"A","cmd":true}, {"text":"missing + 2 mm"}])).unwrap();
enter(&mut h, false);
assert_eq!(h.app.session.doc.to_json(), saved);
assert!(h.app.params.as_ref().unwrap().error.is_some());
assert_eq!(h.app.params.as_ref().unwrap().editing.get("width").map(String::as_str), Some("missing + 2 mm"));
}
#[test]
fn parameter_enter_comment_uses_selected_unit_and_unrelated_focus_does_not_submit() {
let mut h = open();
fill(&mut h, "name", "length");
h.run(&json!([{"click":{"handle":"param:new:unit"}}, {"widget":"cm"}])).unwrap();
fill(&mut h, "expression", "2 cm");
fill(&mut h, "comment", "Overall length");
let undo = h.app.session.undo.len();
h.run(&json!([{"click":{"handle":"params:title"}}])).unwrap();
enter(&mut h, false);
assert_eq!(h.app.session.undo.len(), undo);
h.run(&json!([{"click":{"handle":"param:new:comment"}}])).unwrap();
enter(&mut h, false);
let result = h.app.session.execute("parameters.list", &json!({})).unwrap();
assert!(result["parameters"].as_array().unwrap().iter().any(|p| p["name"] == "length" && p["unit"] == "cm" && p["comment"] == "Overall length"));
assert_eq!(h.app.session.undo.len(), undo + 1);
}
+91
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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