mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8beaedfa4 | |||
| 4af2d06007 | |||
| 81ef51c6ce | |||
| 1fb4783f67 | |||
| a94bf2562b | |||
| 26b7b7336f | |||
| 8539ac1b40 | |||
| 78aa2aacfb | |||
| 92dbd240ae | |||
| 71cda4e7ee | |||
| a91596c1b8 | |||
| 7f98dd1c47 | |||
| 8cce89d78a | |||
| 6ce472c29e | |||
| 4e39869023 | |||
| 3f1b07d35c | |||
| f6e7d754e5 | |||
| 6c06168fa7 | |||
| af536a7907 | |||
| ee1b56cac4 | |||
| 344e52e207 | |||
| 35555c0b9f | |||
| 1d3048062d | |||
| da56d180bf | |||
| 33f0b9e3cc | |||
| ef8f6ca6f9 | |||
| cb5144b6e2 | |||
| 1aedbf6631 | |||
| 6f872c7789 | |||
| 9bcd8ffd56 | |||
| cc2ee6a0f9 | |||
| 9bfd1158d0 | |||
| 58c0ed8a90 | |||
| 6dc6372b13 | |||
| 1adb2cd69f | |||
| 9d7be3c292 | |||
| adecca41f6 | |||
| ac7d5d14cf | |||
| a7512e5e08 | |||
| 36f49811ab | |||
| 4c6afc6ba4 | |||
| f2f8a0df57 | |||
| 09bf9cafdf | |||
| d9d4dd262d | |||
| 2eee967d40 | |||
| 6140f89a42 | |||
| 66f351e93b | |||
| 5b54275626 | |||
| 7abfe69603 | |||
| 1a62c4513f | |||
| 2f78d3ef81 | |||
| 78f80143d5 | |||
| b2cf19c9fb | |||
| b5fb86f245 | |||
| a03e4b9413 | |||
| 0b5e15e2b9 | |||
| 5affff307e | |||
| ab05b24dee | |||
| 8a38a7354c | |||
| a094fa46b5 | |||
| 0167c358bb | |||
| 463441a119 | |||
| ce46faa19d | |||
| b93698c42e | |||
| 4e595deff2 | |||
| 49ca7ef878 | |||
| 5c4785f55c | |||
| 4c466466a2 | |||
| 4c4a0449a4 | |||
| 43a02625a2 | |||
| 6cbcf5c823 | |||
| b02c5c01f9 | |||
| 180c655ded | |||
| 93ca39c68e | |||
| 442c1f134b | |||
| d47b5d3a29 | |||
| 736730c398 | |||
| 32900813c5 | |||
| 839966c108 | |||
| 4667c3a600 | |||
| 6d4c031090 | |||
| 680c82365e | |||
| 79753040d5 | |||
| 413a1fb727 | |||
| 420ee723ee | |||
| 7e4dd4fd66 | |||
| 5a2174899f | |||
| f9d61590f3 | |||
| c11fc79288 | |||
| d9f7473ceb | |||
| 4b35484052 | |||
| b93d4905ac | |||
| 184e301c46 | |||
| 3dc0a6024e | |||
| 8970254672 | |||
| 21e0e99c86 | |||
| cc22672041 | |||
| adb1e5fec5 | |||
| 3573b96c7a | |||
| c67ad22129 | |||
| 2406957039 | |||
| a6bb8fc924 | |||
| 5073f5dd72 | |||
| 848e7aec0e | |||
| 75e9472a47 | |||
| c1d819f235 | |||
| 0398a813c8 | |||
| 07932d0f42 | |||
| d71bdb78ec | |||
| 3afd6ae447 | |||
| 8c1b1478ce | |||
| 22672585c9 | |||
| fc8b71956c | |||
| 7c667eda53 | |||
| 5cc65fe46b | |||
| 2683b8dfed | |||
| 347230f5d3 | |||
| 6b46915507 |
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolveCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolveCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
@@ -0,0 +1,17 @@
|
||||
## Change
|
||||
|
||||
Describe the problem and resulting behavior. Link the issue when applicable.
|
||||
|
||||
## Validation
|
||||
|
||||
What did you run, and what were the results? Include expected and measured values for geometry changes.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
|
||||
|
||||
## Integration status
|
||||
|
||||
Record the upstream commit and PR status checked before handoff, any conflicts resolved, and validation after integration. Keep up with upstream merges and monitor this PR while the task is active; rebase before pushing and never force-push.
|
||||
@@ -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,10 @@ 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
|
||||
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).
|
||||
|
||||
|
||||
@@ -28,13 +28,15 @@ jobs:
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml jq
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: '"$RUNNER_TEMP/actionlint" -color'
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Nightly release selection regression
|
||||
run: python3 packaging/tests/nightly_test.py
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
|
||||
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app
|
||||
# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
|
||||
# repository is private.
|
||||
name: Pages
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solvecraft-web
|
||||
run: trunk build --release --public-url /solvecraft/app/
|
||||
run: trunk build --release --public-url /solidcraft/app/
|
||||
- name: Assemble the site
|
||||
run: |
|
||||
mkdir -p site
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolveCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
@@ -357,7 +357,7 @@ jobs:
|
||||
sha256sum --check SHA256SUMS.txt
|
||||
# Stage all packages before publishing. Published tags are never replaced.
|
||||
gh release create "$tag" --draft --prerelease --latest=false \
|
||||
--title "SolveCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes -- *
|
||||
gh release edit "$tag" --draft=false --prerelease --latest=false
|
||||
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
@@ -386,15 +386,15 @@ jobs:
|
||||
while IFS= read -r asset; do
|
||||
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
|
||||
done <<<"$assets"
|
||||
gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \
|
||||
gh release edit "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
gh release create "$tag" --draft --title "SolveCraft v$VERSION" \
|
||||
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# 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
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
|
||||
## 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`
|
||||
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
|
||||
@@ -20,7 +28,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 +52,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,13 +72,36 @@ 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.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Showing and following through on changes
|
||||
|
||||
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
|
||||
issues and PRs must include actual descriptive screenshots or a short recording from a
|
||||
small reproduction. This includes kernel, geometry, assembly and backend changes whose
|
||||
effects can be shown in the app. Coding agents should run the app and capture this evidence
|
||||
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
|
||||
or result, preferably before and after, with brief reproduction steps, expected/actual
|
||||
behavior and annotations that point out the relevant detail.
|
||||
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
|
||||
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
|
||||
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
|
||||
- Use a small public-safe example and check for private designs, paths, account details or
|
||||
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
|
||||
recording; do not commit generated screenshots or videos just to provide review evidence.
|
||||
- Throughout the work and before handoff, check current upstream commits and the relevant
|
||||
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
|
||||
discarding others' work, and re-run validation when integration changes the result. Use
|
||||
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
|
||||
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
|
||||
+4
-3
@@ -10,7 +10,7 @@ third-party material under an open licence. Screenshots of Autodesk software are
|
||||
|
||||
Generated-in-code assets are original and have no file to list:
|
||||
- the UI icon set (`crates/ui-egui/src/icons.rs`);
|
||||
- the view cube, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
- the axis view, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
|
||||
`crates/ui-egui/src/timeline.rs`).
|
||||
|
||||
## Files
|
||||
@@ -18,7 +18,8 @@ Generated-in-code assets are original and have no file to list:
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
@@ -46,7 +47,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| SolveCraft file | Original contribution | Licence |
|
||||
|---|---|---|
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `view_cube_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the 14-target cube | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# 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
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
|
||||
## 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`
|
||||
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
|
||||
@@ -20,7 +28,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 +52,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 +72,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.
|
||||
|
||||
+74
-13
@@ -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,11 +12,11 @@ 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 solvecraft
|
||||
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)
|
||||
@@ -28,6 +28,46 @@ HTTPS or localhost, because it uses the Origin Private File System.
|
||||
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
### NixOS: `nix develop`
|
||||
|
||||
NixOS has no `/usr/lib`, so nothing is on `LD_LIBRARY_PATH` and a plain `cargo run` finds none of
|
||||
the libraries the app loads at run time: the Wayland ones winit opens the window through, the EGL
|
||||
loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-desktop-portal
|
||||
through (a dialog then never appears and the pick returns no path):
|
||||
|
||||
```
|
||||
ERROR solvecraft: winit EventLoopError: ... The wayland library could not be loaded
|
||||
ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to create surface for any enabled backend: {}
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## How the code is laid out
|
||||
|
||||
The [README](README.md#architecture) has the layer table. The short version:
|
||||
@@ -57,7 +97,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).
|
||||
@@ -111,14 +151,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.
|
||||
@@ -128,15 +168,36 @@ install. If you work on geometry and want to check against it, ask in an issue.
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
|
||||
is required for changes or bugs that can be demonstrated in the app, including kernel,
|
||||
geometry, assembly and backend work. Coding agents should run a small reproduction and
|
||||
capture the evidence themselves; please don't ask a human for evidence the agent can produce.
|
||||
Show the actual failure or result, preferably before and after, with brief steps and
|
||||
expected/actual behavior. An arrow or annotation can help identify the detail that matters.
|
||||
|
||||
Flowcharts and Mermaid diagrams cannot replace evidence of visible behavior. Diagrams are
|
||||
useful for architectural reasoning when actual screenshots cannot illustrate the point.
|
||||
Changes with no useful observable behavior do not need screenshot or video evidence. Use a
|
||||
small example without private designs or sensitive details; attach the media or link to an
|
||||
accessible recording rather than committing generated review artifacts.
|
||||
|
||||
Keep checking upstream commits and the relevant PR's merge status throughout the work and
|
||||
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
|
||||
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
|
||||
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
|
||||
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 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).
|
||||
@@ -145,7 +206,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
|
||||
@@ -156,4 +217,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
+379
-13
@@ -452,6 +452,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -731,6 +740,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -771,6 +789,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -801,6 +829,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
@@ -819,6 +857,17 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.3"
|
||||
@@ -958,6 +1007,7 @@ dependencies = [
|
||||
"serde",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -1199,6 +1249,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
@@ -1254,6 +1313,16 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -1484,6 +1553,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"
|
||||
@@ -1508,6 +1590,110 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -1729,6 +1915,12 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
@@ -2477,6 +2669,15 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -2905,6 +3106,17 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3057,10 +3269,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"hidapi",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
@@ -3071,7 +3284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3080,11 +3293,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-kernel",
|
||||
"solvecraft-sketch",
|
||||
@@ -3094,7 +3308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3110,7 +3324,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3118,7 +3332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3130,7 +3344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3145,7 +3359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3153,16 +3367,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3173,7 +3388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3182,12 +3397,13 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"ttf-parser",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3222,6 +3438,12 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -3260,6 +3482,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3336,6 +3569,16 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.2"
|
||||
@@ -3567,6 +3810,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -3602,6 +3851,24 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
@@ -3882,6 +4149,22 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webbrowser"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
|
||||
dependencies = [
|
||||
"jni",
|
||||
"log",
|
||||
"ndk-context",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"url",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -4421,6 +4704,12 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -4486,11 +4775,34 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
@@ -4617,6 +4929,60 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "3.0.0"
|
||||
|
||||
+5
-4
@@ -5,12 +5,12 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.6"
|
||||
version = "0.3.0"
|
||||
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
|
||||
@@ -29,6 +29,7 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
sha2 = "0.10"
|
||||
|
||||
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
|
||||
truck-modeling = "0.6"
|
||||
|
||||
@@ -1,43 +1,75 @@
|
||||
# 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.
|
||||
|
||||

|
||||

|
||||
|
||||
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
|
||||
*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),
|
||||
**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 the origin planes, offset planes or planar faces: lines, rectangles (2-point,
|
||||
3-point, centre), circles (centre, 2-point, 3-point), arcs (3-point, centre), polygons, slots
|
||||
and points; construction geometry.
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
|
||||
projection, trimming, fillets and chamfers, including corners at the origin.
|
||||
New face attachments retain supported construction identities and chosen occurrences;
|
||||
missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md).
|
||||
Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation.
|
||||
Supported convex line-and-arc offsets retain their identity when an inset consumes corner
|
||||
arcs, and linked extrusions follow the current boundary. Breaking the association freezes
|
||||
the boundary and keeps existing extrusions usable.
|
||||
Curve drawing and picking adapt together to zoom and display scale, including small circles
|
||||
at Fit and sketches in placed components.
|
||||
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
|
||||
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
|
||||
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
|
||||
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
|
||||
rejected; fully constrained geometry turns black.
|
||||
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
|
||||
step. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
- **Features** on a parametric timeline: extrude (one side, flipped, symmetric, two-sided, start
|
||||
offset; new body, join, cut, intersect), revolve, fillet and chamfer (straight convex edges),
|
||||
box, cylinder, sphere, torus, combine, move. The timeline rebuilds incrementally from the first
|
||||
changed feature; it can be rolled back, and features can be suppressed, renamed, edited and
|
||||
deleted.
|
||||
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
|
||||
Newly captured complete adaptive boundaries retain their outer and hole source identities
|
||||
when spline span counts change. Partial or ambiguous boundaries do not qualify.
|
||||
- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
|
||||
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
|
||||
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
|
||||
and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md).
|
||||
New revolves preserve symmetric and two-sided angular placement across reversed axes;
|
||||
older designs retain their saved convention when reopened or edited.
|
||||
- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`,
|
||||
`angle / 2`), ordered rules, feature suppression and per-row body materials/appearances.
|
||||
- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets,
|
||||
interference and material-aware mass properties. Recorded body transfers preserve modelling
|
||||
history and world placement; 3D sketch wires retain their coordinate frames through edits.
|
||||
- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns,
|
||||
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
|
||||
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
|
||||
separately from through-hole outlines in the flat preview and DXF.
|
||||
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
|
||||
@@ -48,10 +80,12 @@ and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
|
||||
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
|
||||
control channel so agents and tests can drive it.
|
||||
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).
|
||||
|
||||
@@ -69,6 +103,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).
|
||||
|
||||
@@ -111,10 +150,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.
|
||||
|
||||
+193
-19
@@ -1,15 +1,15 @@
|
||||
# SolveCraft roadmap
|
||||
# SolidCraft roadmap
|
||||
|
||||
Status as of **2026-10-10**. 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
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
|
||||
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
|
||||
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
|
||||
| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
|
||||
| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
@@ -46,23 +46,85 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
|
||||
| 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), 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); appearances, preferences | in progress | 6 |
|
||||
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), 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. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| 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. 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, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
|
||||
| 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: installers, signing, docs, performance | planned | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
| 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 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
|
||||
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.
|
||||
- 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
|
||||
|
||||
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
|
||||
durable supported profile/face references, endpoint dimensioning and contextual reporting.
|
||||
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
|
||||
authored splits and actual creases remain protected. Wireframe retains physical edges.
|
||||
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
|
||||
prisms with separated circular holes; unsupported shapes retain the existing strict check.
|
||||
- Common-bore Union retries check additional boundary curves before recutting the bore,
|
||||
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
|
||||
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
|
||||
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
|
||||
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
|
||||
web app and both Flatpak architectures were verified. The replacement README demo uses
|
||||
the original electronics enclosure story and animated pointer in the actual English
|
||||
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
|
||||
certificates. Sketches placed directly on the enclosure wall and inner floor follow
|
||||
width and wall changes, including after the USB cut and cold reopening.
|
||||
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
|
||||
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
|
||||
face, with exact full-height supports and boundary stitching. Preserve authored face
|
||||
splits and face appearances. Permanent regressions cover material, STEP, reopening and
|
||||
unaffected bodies across the extrusion end plane.
|
||||
- Feature dialogs use the available screen height for their fields, with OK and Cancel
|
||||
outside the scrolling region, including enlarged display scales.
|
||||
- Design format 13 records shell-face attachments. Older designs remain readable;
|
||||
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
|
||||
attachment rather than guessing a plane.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
|
||||
@@ -81,28 +143,140 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges.
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
|
||||
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
curved wall qualification rejects false transverse axes instead of filling holes.
|
||||
Bodies touching along one whole face
|
||||
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
|
||||
their neighbours are planes or cylinders. Other coincident curved configurations can still
|
||||
fail. Fully internal voids are not supported. Spheres are built from six pole-free patches.
|
||||
fail. Bounded containment classification preserves fully internal cavities and nested
|
||||
material islands independently of shell order, with STEP round-trip and material-membership
|
||||
regressions. Near-contact curved shells use the general boolean path rather than a
|
||||
containment shortcut. Spheres are built from six pole-free patches.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
|
||||
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
|
||||
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
|
||||
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
|
||||
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
|
||||
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
|
||||
offset surfaces and pcurve-only edges are not read yet,
|
||||
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
|
||||
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
|
||||
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
|
||||
references resolve by name first and warn when the entity split or vanished. Kernel-reported
|
||||
face history (exact through coincident surfaces) is not wired in yet.
|
||||
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
|
||||
source certificates with checked target and author occurrence paths. A missing or changed
|
||||
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
|
||||
retaining current geometry and dimension identities. Unsupported modified faces need an
|
||||
explicit fixed plane. Legacy attachments retain their original resolver behavior.
|
||||
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
|
||||
and Revolve To Object on a curved face cut the tool back to the target's surface.
|
||||
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
|
||||
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
|
||||
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
|
||||
touch (no clean crossing) enter that profile as polylines.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs. Singular,
|
||||
self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
Newly captured adaptive profile references with complete source coverage retain their outer
|
||||
and hole identities through span-count changes, including parameter edits after extrusion.
|
||||
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
|
||||
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
|
||||
reselection. Format 12 stores the new bounded whole-boundary references.
|
||||
Projected analytic boundaries follow nested loop identities through resizing, including
|
||||
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
|
||||
can lose or regain corner arcs during an inset while preserving reserved curve identities
|
||||
and linked profile selections. Format 12 saves those associations; compatible older closed
|
||||
offsets upgrade structurally without changing their curve IDs. Breaking an association
|
||||
freezes its current boundary and materializes downstream profile references. Child offsets
|
||||
of a boundary with retired arcs still require breaking its association first; mixed or
|
||||
nonconvex junctions and retained references to disappearing individual arcs remain limited.
|
||||
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
|
||||
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
|
||||
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
|
||||
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
|
||||
use the sketch's actual target, author or uniquely placed third-component frame.
|
||||
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
|
||||
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
|
||||
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
|
||||
use the sketch-normal direction for each selected profile, including To Object and curved
|
||||
target trimming, independently of the selected axis sign.
|
||||
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
|
||||
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
|
||||
radial walls. New features infer a unique eligible cylinder when the face field is empty;
|
||||
ambiguous targets require an explicit face. Saved target
|
||||
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
|
||||
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
|
||||
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
the origin; clicked UI regressions verify geometry, closed profiles and undo.
|
||||
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
|
||||
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
|
||||
invalidate the display cache without changing CAD geometry or timeline history. Work
|
||||
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
|
||||
The Sketch Palette scrolls its options on short windows and enlarged displays while
|
||||
keeping constraint status and Finish Sketch visible.
|
||||
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
|
||||
existing geometry before assigning positions, retaining saved curve IDs and constraints
|
||||
across enumeration changes and resize, including concentric circles. Ambiguous identities
|
||||
retain the last geometry and report a lost reference; permutation matching is bounded.
|
||||
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
|
||||
selecting visited values for replacement. Expression completion retains forward-Tab
|
||||
priority; real keyboard regressions cover rectangles, lines and single-value circles.
|
||||
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
|
||||
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
|
||||
rejected values keep the editable field focused.
|
||||
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
|
||||
checked occurrence paths. Active nested occurrences author planar sketches and solids in
|
||||
their selected frame. Pending moves compose through parents; dialog cancellation restores
|
||||
its selected move. Source materials and body/face appearances follow transformed instances.
|
||||
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
|
||||
independent feature/input histories, styles and internal motion records, with bounded geometry
|
||||
checks and explicit external-link notices. Paste New selects the copy while preserving the active
|
||||
authoring context. Imported periodic STEP seams survive placement and reexport with shared
|
||||
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
|
||||
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
|
||||
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.
|
||||
- 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
|
||||
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
|
||||
Component styles and certified semantic face styles are available; unsupported lineage refuses
|
||||
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling.
|
||||
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
|
||||
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
check, not a new Fusion oracle result. Under-resolved fits return an error.
|
||||
- Contact driving checks the trajectory between clear endpoints, including linked joints,
|
||||
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## Kernel
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
|
||||
+1202
-61
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -64,6 +64,8 @@ struct Job {
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let from = flag(args, "--from", 0u64);
|
||||
let count = flag(args, "--count", 100u64);
|
||||
let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?;
|
||||
let mut seeds = (count > 0).then_some(from..=last).into_iter().flatten().peekable();
|
||||
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
|
||||
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
|
||||
let steps = flag(args, "--steps", 8usize);
|
||||
@@ -74,14 +76,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
if let Some(d) = &out_dir {
|
||||
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
|
||||
}
|
||||
let mut next = from;
|
||||
let mut running: Vec<Job> = Vec::new();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
let t0 = Instant::now();
|
||||
while next < from + count || !running.is_empty() {
|
||||
while running.len() < jobs && next < from + count {
|
||||
while seeds.peek().is_some() || !running.is_empty() {
|
||||
while running.len() < jobs {
|
||||
let Some(seed) = seeds.next() else { break };
|
||||
let mut cmd = Command::new(&exe);
|
||||
cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]);
|
||||
cmd.args(["fuzz-one", &seed.to_string(), "--steps", &steps.to_string()]);
|
||||
if minimise {
|
||||
cmd.arg("--minimise");
|
||||
}
|
||||
@@ -96,8 +98,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
});
|
||||
}
|
||||
running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() });
|
||||
next += 1;
|
||||
running.push(Job { seed, child, started: Instant::now(), err: rx, last: String::new() });
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let mut i = 0;
|
||||
|
||||
+134
-41
@@ -53,8 +53,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)?;
|
||||
@@ -97,7 +106,9 @@ fn pretty(v: &Value) -> String {
|
||||
}
|
||||
|
||||
pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
let s = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
// Through the engine's file access, which refuses anything that is not a regular file and
|
||||
// reads no more than the cap: a character device or a FIFO would otherwise never end.
|
||||
let s = solvecraft_engine::io::vfs::read_to_string(path, solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -152,28 +163,31 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
Some(b) => load(b)?,
|
||||
None => Session::default(),
|
||||
};
|
||||
if args.iter().any(|a| a == "--results") {
|
||||
// Print every command's result (scripts only).
|
||||
let results = if args.iter().any(|a| a == "--results") {
|
||||
// Collect every command's result (scripts only), then process requested files too.
|
||||
let v = read_json(path.ok_or("--results needs a script")?)?;
|
||||
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&Value::Array(r)));
|
||||
return Ok(());
|
||||
}
|
||||
match (base, path) {
|
||||
(Some(_), Some(p)) => {
|
||||
let v = read_json(p)?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
Some(Value::Array(r))
|
||||
} else {
|
||||
match (base, path) {
|
||||
(Some(_), Some(p)) => {
|
||||
let v = read_json(p)?;
|
||||
s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
}
|
||||
(None, Some(p)) => s = load(p)?,
|
||||
_ => {}
|
||||
}
|
||||
(None, Some(p)) => s = load(p)?,
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
};
|
||||
for out in flags(args, "--out") {
|
||||
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(p) = flag(args, "--save") {
|
||||
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if !args.iter().any(|a| a == "--quiet") {
|
||||
if let Some(results) = results {
|
||||
println!("{}", pretty(&results));
|
||||
} else if !args.iter().any(|a| a == "--quiet") {
|
||||
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&m));
|
||||
}
|
||||
@@ -214,7 +228,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")?;
|
||||
@@ -231,30 +245,113 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This block is the icon image data
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
use solvecraft_engine::render::{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.seams.get(i).copied().unwrap_or(false) {
|
||||
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
|
||||
use solvecraft_engine::render::png;
|
||||
type P = (f32, f32);
|
||||
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
|
||||
let ss = if size > 512 { 2 } else { 4 };
|
||||
let n = size * ss;
|
||||
let mut buf = vec![[0u8; 4]; n * n];
|
||||
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
|
||||
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
|
||||
// Fill a convex polygon given in SVG units.
|
||||
let mut fill = |pts: &[P], col: [u8; 3]| {
|
||||
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
|
||||
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
|
||||
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
let cy = y as f32 + 0.5;
|
||||
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||||
for i in 0..v.len() {
|
||||
let (a, b) = (v[i], v[(i + 1) % v.len()]);
|
||||
if (a.1 <= cy) != (b.1 <= cy) {
|
||||
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
continue;
|
||||
}
|
||||
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
|
||||
}
|
||||
}
|
||||
};
|
||||
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
|
||||
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
|
||||
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
|
||||
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
|
||||
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
|
||||
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
|
||||
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
|
||||
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
|
||||
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
|
||||
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
|
||||
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
|
||||
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
|
||||
let white = [255, 255, 255];
|
||||
let mut stroke = |pts: &[P], closed: bool| {
|
||||
let m = pts.len();
|
||||
for i in 0..(if closed { m } else { m - 1 }) {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % m]);
|
||||
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
|
||||
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
|
||||
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
|
||||
}
|
||||
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
|
||||
let disc: Vec<P> = (0..16)
|
||||
.map(|k| {
|
||||
let t = k as f32 * std::f32::consts::TAU / 16.0;
|
||||
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
|
||||
})
|
||||
.collect();
|
||||
fill(&disc, white);
|
||||
}
|
||||
};
|
||||
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
|
||||
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
|
||||
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
|
||||
stroke(
|
||||
&[
|
||||
(178.5, 5.8),
|
||||
(5.0, 105.9),
|
||||
(5.0, 206.1),
|
||||
(91.7, 256.2),
|
||||
(91.7, 356.4),
|
||||
(178.5, 406.4),
|
||||
(352.0, 306.3),
|
||||
(352.0, 206.1),
|
||||
(265.2, 156.0),
|
||||
(265.2, 55.9),
|
||||
],
|
||||
true,
|
||||
);
|
||||
// Average each ss x ss block down to one pixel (straight alpha = covered share).
|
||||
let mut rgba = Vec::with_capacity(size * size * 4);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
|
||||
for j in 0..ss {
|
||||
for i in 0..ss {
|
||||
let p = buf[(y * ss + j) * n + x * ss + i];
|
||||
if p[3] != 0 {
|
||||
cnt += 1;
|
||||
for k in 0..3 {
|
||||
sum[k] += u32::from(p[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cnt == 0 {
|
||||
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
||||
} else {
|
||||
let a = 255 * cnt / (ss * ss) as u32;
|
||||
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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())
|
||||
png(size, size, rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
}
|
||||
|
||||
/// A Windows icon file holding PNG images (Vista and later read these).
|
||||
@@ -359,13 +456,9 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let len = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
|
||||
let step = solvecraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
||||
if len > limit as u64 {
|
||||
return Err(format!("{path}: file too large"));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let bytes = solvecraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
||||
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
|
||||
let t = std::time::Instant::now();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -150,6 +150,30 @@ fn try_bytes(bytes: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
|
||||
/// cycle, which resolve through the model and back into the document.
|
||||
fn generated() -> Vec<(String, Vec<u8>)> {
|
||||
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
|
||||
let plane = |id: u64, name: &str, side: Value| {
|
||||
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
|
||||
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
|
||||
};
|
||||
let design = |name: &str, features: Value| {
|
||||
json!({"format": "solvecraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
|
||||
};
|
||||
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
|
||||
// by the point count and abort on the allocation.
|
||||
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
|
||||
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
|
||||
"plane": {"type": "origin", "name": "XY"},
|
||||
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
|
||||
vec![
|
||||
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
|
||||
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
|
||||
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_design_files_never_panic() {
|
||||
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
|
||||
@@ -201,6 +225,7 @@ fn damaged_and_hostile_design_files_never_panic() {
|
||||
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
|
||||
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
|
||||
}
|
||||
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
|
||||
let next = std::sync::atomic::AtomicUsize::new(0);
|
||||
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
|
||||
let failures: Vec<String> = std::thread::scope(|scope| {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Seed ranges must not crash or wrap, including at the last representable seed.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_seed_range_at_maximum_is_valid() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_seed_runs_once_without_wrapping() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("1 seeds,"));
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Result reporting must not skip requested files or execute a script twice.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
fn script(&self) -> PathBuf {
|
||||
let path = self.0.join("box.json");
|
||||
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
}
|
||||
|
||||
fn json_output(output: Output) -> Value {
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn body_count(path: &Path) -> Value {
|
||||
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_still_save_and_export_the_script_once() {
|
||||
let dir = Scratch::new();
|
||||
let saved = dir.0.join("box.solvecraft");
|
||||
let stl = dir.0.join("box.stl");
|
||||
let obj = dir.0.join("box.obj");
|
||||
let result = json_output(
|
||||
cli().arg("run").arg(dir.script()).arg("--results").arg("--save").arg(&saved).arg("--out").arg(&stl).arg("--out").arg(&obj).output().unwrap(),
|
||||
);
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
assert_eq!(body_count(&saved), 1);
|
||||
for exported in [&stl, &obj] {
|
||||
assert!(std::fs::metadata(exported).unwrap().len() > 0);
|
||||
assert_eq!(body_count(exported), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script();
|
||||
let base = dir.0.join("base.solvecraft");
|
||||
let saved = dir.0.join("combined.solvecraft");
|
||||
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
|
||||
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
|
||||
assert_eq!(result.as_array().unwrap().len(), 1);
|
||||
assert_eq!(body_count(&saved), 2);
|
||||
assert_eq!(body_count(&base), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_propagate_output_errors() {
|
||||
let dir = Scratch::new();
|
||||
for flag in ["--save", "--out"] {
|
||||
let path = dir.0.join("missing").join(if flag == "--save" { "box.solvecraft" } else { "box.stl" });
|
||||
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(!output.stderr.is_empty());
|
||||
assert!(output.stdout.is_empty());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_without_script_still_fail() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solvecraft");
|
||||
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
|
||||
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-web"
|
||||
description = "SolveCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
description = "SolidCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolveCraft</title>
|
||||
<title>SolidCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
|
||||
@@ -14,6 +14,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solvecraft_canvas"></canvas>
|
||||
<div id="solvecraft_loading">Loading SolveCraft…</div>
|
||||
<div id="solvecraft_loading">Loading SolidCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -349,7 +349,7 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
|
||||
if files.entries.borrow().iter().any(|e| e.name == to) {
|
||||
return Err(format!("there is already a file named {to}"));
|
||||
}
|
||||
let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?;
|
||||
let bytes = vfs::read(&format!("{OPFS}{from}"), solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
|
||||
delete(files, from);
|
||||
flush(files);
|
||||
|
||||
@@ -234,7 +234,7 @@ mod web {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"download" => match vfs::read(&path) {
|
||||
"download" => match vfs::read(&path, solvecraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
Ok(b) => {
|
||||
if let Err(e) = files::download(&name, &b) {
|
||||
self.app.set_status(e, true);
|
||||
@@ -406,7 +406,7 @@ mod web {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolveCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
description = "SolveCraft desktop app"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -13,11 +13,14 @@ workspace = true
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
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"] }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::MAX_LINE;
|
||||
use solvecraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once. The channel has no authentication beyond being bound to
|
||||
/// loopback, so a flood must not be able to spawn threads without end.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -21,19 +31,69 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
let mut refused = stream;
|
||||
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
|
||||
continue;
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, tx, ctx);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
/// gigabytes with no newline would otherwise make this thread allocate until the process is
|
||||
/// killed, so the line is refused as soon as it passes `max`.
|
||||
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
let (done, used) = {
|
||||
let available = r.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok((!line.is_empty()).then_some(line));
|
||||
}
|
||||
match available.iter().position(|b| *b == b'\n') {
|
||||
Some(i) => {
|
||||
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
|
||||
(true, i + 1)
|
||||
}
|
||||
None => {
|
||||
line.extend_from_slice(available);
|
||||
(false, available.len())
|
||||
}
|
||||
}
|
||||
};
|
||||
r.consume(used);
|
||||
if done {
|
||||
return Ok(Some(line));
|
||||
}
|
||||
if line.len() > max {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -53,10 +113,58 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
Err(e) => {
|
||||
bad += 1;
|
||||
json!({"ok": false, "error": format!("bad JSON: {e}")})
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
if bad >= MAX_BAD_LINES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A request longer than the cap is refused instead of allocated.
|
||||
#[test]
|
||||
fn lines_past_the_cap_are_refused() {
|
||||
let flood = vec![b'A'; MAX_LINE + 2];
|
||||
let mut r = BufReader::new(flood.as_slice());
|
||||
let e = read_line(&mut r, MAX_LINE).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(e.to_string().contains("request too large"), "{e}");
|
||||
}
|
||||
|
||||
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
|
||||
#[test]
|
||||
fn lines_split_on_newlines() {
|
||||
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
|
||||
let mut r = BufReader::new(text.as_slice());
|
||||
let mut got = Vec::new();
|
||||
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
|
||||
got.push(String::from_utf8(line).unwrap());
|
||||
}
|
||||
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A line that spans several reads of the underlying stream still arrives whole.
|
||||
#[test]
|
||||
fn lines_longer_than_one_buffer_arrive_whole() {
|
||||
let long = "x".repeat(200_000);
|
||||
let text = format!("{long}\n{long}\n").into_bytes();
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
|
||||
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
|
||||
// The same line against a smaller cap is refused.
|
||||
let mut r = BufReader::with_capacity(64, text.as_slice());
|
||||
assert!(read_line(&mut r, 1000).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+28
-13
@@ -13,36 +13,44 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
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 +65,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"])
|
||||
@@ -139,7 +147,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SolveCraft")
|
||||
.with_title("SolidCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
@@ -166,9 +174,10 @@ fn main() -> eframe::Result {
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SolveCraft",
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
@@ -204,7 +213,13 @@ 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,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! 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 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);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
translation: [i16; 3],
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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 !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, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
|
||||
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, axes: Axes, seconds: f64, height: f64, sketch: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = axes.translation.map(normalized);
|
||||
let [rx, ry, rz] = axes.rotation.map(normalized);
|
||||
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
|
||||
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, 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, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -16,4 +16,5 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Presentation/projection state for an exact selected planar source placement.
|
||||
use crate::{DocError, Document, FaceAnchor, ModelBody, ModelState, Result};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anchor: &FaceAnchor, author: u64) -> Result<Cow<'a, ModelState>> {
|
||||
let frame = anchor.author_frame(doc, author)?;
|
||||
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
|
||||
let relative = anchor.source_to_author(doc, author, raw)?;
|
||||
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
|
||||
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solvecraft_kernel::transform_matrix(&raw.body, relative)? };
|
||||
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
|
||||
body.component = raw.component;
|
||||
body.transferred = raw.transferred;
|
||||
body.source_name = raw.source_name.clone();
|
||||
body.source_transform = crate::mat_mul(&relative, &raw.source_transform);
|
||||
body.imported = raw.imported.clone();
|
||||
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
|
||||
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
|
||||
*slot = body;
|
||||
Ok(Cow::Owned(output))
|
||||
}
|
||||
@@ -45,6 +45,15 @@ pub struct FaceLook {
|
||||
/// The face's persistent name (found first).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// Strict semantic assignments never transfer to a nearby replacement face.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub strict_name: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<crate::config::FaceCertificate>,
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
/// The look a body was imported with, carried through later features (which build new kernel
|
||||
@@ -130,6 +139,24 @@ pub fn material_look(material: &str) -> Option<Look> {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Shared definition appearance for a computed occurrence body.
|
||||
pub fn model_body_look(&self, body: &crate::ModelBody) -> Option<Look> {
|
||||
let name = body.definition_name();
|
||||
self.appearances
|
||||
.bodies
|
||||
.get(name)
|
||||
.cloned()
|
||||
.or_else(|| self.appearances.components.get(&self.model_body_component(body)).cloned())
|
||||
.or_else(|| self.materials.get(name).and_then(|material| material_look(material)))
|
||||
}
|
||||
/// Effective body opacity, including paint carried from an imported body.
|
||||
pub fn model_body_opacity(&self, body: &crate::ModelBody) -> f64 {
|
||||
self.model_body_look(body)
|
||||
.map(|look| look.opacity)
|
||||
.or_else(|| body.body.paint().map(|paint| paint.opacity as f64))
|
||||
.or_else(|| body.imported.as_ref().and_then(|paint| paint.opacity.map(f64::from)))
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
/// A body's look (without face appearances): its own, else its component's, else its
|
||||
/// material's.
|
||||
pub fn body_look(&self, name: &str, feature: u64) -> Option<Look> {
|
||||
@@ -167,8 +194,27 @@ impl Document {
|
||||
None => Vec::new(),
|
||||
},
|
||||
};
|
||||
for f in self.appearances.faces.iter().filter(|f| f.body == b.name) {
|
||||
let (p, look) = (f.point, f.look.clone());
|
||||
for f in self.appearances.faces.iter().filter(|f| f.body == b.definition_name()) {
|
||||
let (p, look) = (crate::apply_point(&b.source_transform, f.point), f.look.clone());
|
||||
if f.strict_name {
|
||||
let Some(name) = f.name.as_ref() else { continue };
|
||||
let Some(origin_feature) = name.strip_prefix('F').and_then(|name| name.split_once(':')).and_then(|(id, _)| id.parse().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: f.body.clone(),
|
||||
origin_feature,
|
||||
name: crate::naming::strip_piece(name).into(),
|
||||
point: f.point,
|
||||
certificate: f.certificate.clone(),
|
||||
};
|
||||
let Ok(indices) = self.configuration_face_indices(b, &target) else { continue };
|
||||
for index in indices {
|
||||
out.retain(|(old, _)| *old != index);
|
||||
out.push((index, look.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// By name (every piece of a split face), else by the point.
|
||||
if let Some(n) = &f.name {
|
||||
let stem = crate::naming::strip_piece(n);
|
||||
@@ -181,7 +227,9 @@ impl Document {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(i) = face_index_at(b, p) {
|
||||
if !f.strict_name
|
||||
&& let Some(i) = face_index_at(b, p)
|
||||
{
|
||||
out.retain(|(j, _)| *j != i);
|
||||
out.push((i, look));
|
||||
}
|
||||
|
||||
@@ -328,7 +328,8 @@ impl FeatureKind {
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
@@ -385,24 +386,61 @@ impl Document {
|
||||
/// further instance), its component, the occurrence path, and the transform from the frame
|
||||
/// its geometry is in to the world.
|
||||
pub fn body_frames(&self, st: &crate::ModelState) -> Vec<(String, u64, Vec<u64>, Mat)> {
|
||||
self.body_frames_with_source(st).into_iter().map(|(world, _, component, path, matrix)| (world, component, path, matrix)).collect()
|
||||
}
|
||||
|
||||
/// Body frames with explicit definition names; callers must not infer them from labels.
|
||||
pub fn body_frames_with_source(&self, st: &crate::ModelState) -> Vec<(String, String, u64, Vec<u64>, Mat)> {
|
||||
let mut out = Vec::new();
|
||||
for (comp, path, m) in self.placements() {
|
||||
let primary = path == self.first_path(comp);
|
||||
let label = || -> String {
|
||||
path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect::<Vec<_>>().join("/")
|
||||
};
|
||||
for b in st.bodies.iter().filter(|b| self.body_component(&b.name, b.feature) == comp) {
|
||||
let name = if primary { b.name.clone() } else { format!("{} ({})", b.name, label()) };
|
||||
let w = match self.body_offsets.get(&b.name) {
|
||||
for b in st.bodies.iter().filter(|b| self.model_body_component(b) == comp) {
|
||||
let name = self.world_body_name(st, &b.name, comp, &path);
|
||||
let w = match self.model_body_offset(b) {
|
||||
Some(o) => mat_mul(&m, o),
|
||||
None => m,
|
||||
};
|
||||
out.push((name, comp, path.clone(), w));
|
||||
out.push((name, b.definition_name().to_string(), comp, path.clone(), w));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Computed placement name. Source names retain priority; ambiguous labels use full IDs.
|
||||
/// Resolves label ambiguity with bounded dynamic counts instead of expanding all placements.
|
||||
pub fn world_body_name(&self, st: &crate::ModelState, source: &str, component: u64, path: &[u64]) -> String {
|
||||
if path == self.first_path(component) {
|
||||
return source.to_string();
|
||||
}
|
||||
let names: Vec<&str> = path.iter().filter_map(|id| self.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.as_str()).collect();
|
||||
let label = names.join("/");
|
||||
let candidate = format!("{source} ({label})");
|
||||
let mut counts = std::collections::BTreeMap::from([(0, 1usize)]);
|
||||
for name in &names {
|
||||
let mut next = std::collections::BTreeMap::<u64, usize>::new();
|
||||
for o in self.occurrences.iter().filter(|o| o.name == *name) {
|
||||
if let Some(count) = counts.get(&o.parent) {
|
||||
let n = next.entry(o.component).or_default();
|
||||
*n = (*n + count).min(2);
|
||||
}
|
||||
}
|
||||
counts = next;
|
||||
}
|
||||
let ambiguous = counts.get(&component).copied().unwrap_or(0) > 1;
|
||||
// Slash and reserved-marker names could alias concatenated labels or fallback keys.
|
||||
let reserved =
|
||||
self.occurrences.iter().any(|o| o.name.contains('/') || o.name.contains("@occ[")) || st.bodies.iter().any(|b| b.name.contains("@occ["));
|
||||
if !ambiguous && !reserved && st.body(&candidate).is_none() {
|
||||
return candidate;
|
||||
}
|
||||
let ids = path.iter().map(u64::to_string).collect::<Vec<_>>().join("/");
|
||||
let encoded = serde_json::to_string(source).unwrap_or_else(|_| source.to_string());
|
||||
let mut key = format!("{encoded}@occ[{ids}]");
|
||||
while st.body(&key).is_some() {
|
||||
key.push('#');
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// The occurrence path of a component's first placement.
|
||||
pub fn first_path(&self, component: u64) -> Vec<u64> {
|
||||
let mut path = Vec::new();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Recorded ownership changes are evaluated at their timeline position.
|
||||
use crate::{DocError, Document, Mat, ModelBody, ModelState, Result};
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn valid_transfer_matrix(m: &Mat) -> bool {
|
||||
let axis = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (axis(0), axis(1), axis(2));
|
||||
m.iter().flatten().all(|v| v.is_finite())
|
||||
&& m[3][..3].iter().all(|v| v.abs() <= 1e9)
|
||||
&& [x, y, z].iter().all(|v| (v.dot(*v) - 1.0).abs() < 1e-6)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6
|
||||
&& x.cross(y).dot(z) > 0.0
|
||||
&& [m[0][3], m[1][3], m[2][3]].iter().all(|v| v.abs() < 1e-9)
|
||||
&& (m[3][3] - 1.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// Validate the stored boundary before allocation, loading, or evaluation.
|
||||
pub fn validate_body_transfer(doc: &Document, bodies: &[String], from: u64, to: u64, transform: &Mat) -> Result<()> {
|
||||
if bodies.is_empty()
|
||||
|| bodies.len() > 1000
|
||||
|| bodies.iter().any(|name| name.trim().is_empty() || name.len() > 512 || name.chars().any(char::is_control))
|
||||
|| bodies.iter().collect::<BTreeSet<_>>().len() != bodies.len()
|
||||
|| !valid_transfer_matrix(transform)
|
||||
|| [from, to].iter().any(|id| *id != 0 && !doc.components.iter().any(|c| c.id == *id))
|
||||
{
|
||||
return Err(DocError::Invalid("invalid body transfer boundary".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], from: u64, to: u64, transform: Mat) -> Result<()> {
|
||||
validate_body_transfer(doc, bodies, from, to, &transform)?;
|
||||
let mut next = st.clone();
|
||||
for name in bodies {
|
||||
let old = st.body(name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
|
||||
if doc.model_body_component(old) != from {
|
||||
return Err(DocError::Invalid(format!("body `{name}` no longer belongs to transfer source component {from}")));
|
||||
}
|
||||
let shape = if crate::is_identity(&transform) { old.body.clone() } else { solvecraft_kernel::transform_matrix(&old.body, transform)? };
|
||||
let mut body = ModelBody::new(old.name.clone(), shape, old.feature);
|
||||
body.component = to;
|
||||
body.transferred = true;
|
||||
body.source_name = old.source_name.clone();
|
||||
body.source_transform = crate::mat_mul(&transform, &old.source_transform);
|
||||
body.imported = old.imported.clone();
|
||||
body.names = crate::naming::NameCell::known(crate::naming::face_names(old));
|
||||
let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
|
||||
*slot = body;
|
||||
}
|
||||
crate::body_transfer_sheet::remap_sheet_metadata(&mut next, bodies, &transform)?;
|
||||
*st = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_transfer_constructor_preserves_document_and_allocator() {
|
||||
let mut doc = Document::default();
|
||||
let before = doc.to_json();
|
||||
let mut scaled = crate::IDENTITY;
|
||||
scaled[0][0] = 2.0;
|
||||
for (bodies, from, transform) in [
|
||||
(vec!["Block".into()], 42, crate::IDENTITY),
|
||||
(vec!["Block".into(), "Block".into()], 0, crate::IDENTITY),
|
||||
(vec![String::new()], 0, crate::IDENTITY),
|
||||
(vec![" ".into()], 0, crate::IDENTITY),
|
||||
(vec!["bad\nname".into()], 0, crate::IDENTITY),
|
||||
(vec!["Block".into()], 0, scaled),
|
||||
] {
|
||||
assert!(doc.add_feature(crate::FeatureKind::BodyTransfer { bodies, from, to: 0, transform }, None).is_err());
|
||||
assert_eq!(doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! World-frame metadata at a timeline body ownership boundary.
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
use crate::{DocError, Mat, ModelState, Result, apply_point, apply_vector};
|
||||
|
||||
pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transform: &Mat) -> Result<()> {
|
||||
if names.len() > 1000 || st.sheets.len().saturating_add(st.threads.len()) > 100_000 || !transform.iter().flatten().all(|v| v.is_finite()) {
|
||||
return Err(DocError::Invalid("body transfer metadata exceeds its finite processing budget".into()));
|
||||
}
|
||||
let names: std::collections::BTreeSet<&str> = names.iter().map(String::as_str).collect();
|
||||
if !st.sheets.iter().any(|s| names.contains(s.body.as_str())) && !st.threads.iter().any(|t| names.contains(t.body.as_str())) {
|
||||
return Ok(());
|
||||
}
|
||||
if !crate::valid_transfer_matrix(transform) {
|
||||
return Err(DocError::Invalid("sheet and thread transfer requires a rigid placement".into()));
|
||||
}
|
||||
let frames: Vec<_> = st
|
||||
.sheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| names.contains(s.body.as_str()))
|
||||
.map(|(i, s)| {
|
||||
let frame = Plane::new(apply_point(transform, s.frame.origin), apply_vector(transform, s.frame.x), apply_vector(transform, s.frame.y))
|
||||
.ok_or_else(|| DocError::Invalid("invalid transferred sheet frame".into()))?;
|
||||
Ok((i, frame))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let threads: Vec<_> = st
|
||||
.threads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| names.contains(t.body.as_str()))
|
||||
.map(|(i, t)| {
|
||||
let point = apply_point(transform, t.axis_point);
|
||||
let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
|
||||
if !point.is_finite() {
|
||||
return Err(DocError::Invalid("invalid transferred thread placement".into()));
|
||||
}
|
||||
Ok((i, point, axis))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (i, frame) in frames {
|
||||
if let Some(s) = st.sheets.get_mut(i) {
|
||||
s.frame = frame;
|
||||
}
|
||||
}
|
||||
for (i, point, axis) in threads {
|
||||
if let Some(t) = st.threads.get_mut(i) {
|
||||
t.axis_point = point;
|
||||
t.axis = axis;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -49,7 +49,10 @@ impl FeatureKind {
|
||||
FeatureKind::Extrude { sketch, .. }
|
||||
| FeatureKind::Revolve { sketch, .. }
|
||||
| FeatureKind::Emboss { sketch, .. }
|
||||
| FeatureKind::SheetBase { sketch, .. }
|
||||
| FeatureKind::SheetContour { sketch, .. }
|
||||
| FeatureKind::Rib { sketch, .. } => sk(sketch),
|
||||
FeatureKind::Patch { sketch: Some(s), .. } => sk(s),
|
||||
FeatureKind::Sweep { sketch, path_sketch, .. } => {
|
||||
sk(sketch);
|
||||
sk(path_sketch);
|
||||
@@ -74,3 +77,387 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
|
||||
pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> String {
|
||||
// Imported names belong to the complete canonical body prefix, even when that prefix
|
||||
// happens to contain text resembling a feature token.
|
||||
if let Some((body, suffix)) = name.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
{
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while at < name.len() {
|
||||
let rest = &name[at..];
|
||||
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) {
|
||||
let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1;
|
||||
if end > 1
|
||||
&& matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.'))
|
||||
&& let Ok(id) = rest[1..end].parse::<u64>()
|
||||
&& let Some(new) = ids.get(&id)
|
||||
{
|
||||
out.push_str(&format!("F{new}"));
|
||||
at += end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(ch) = rest.chars().next() else { break };
|
||||
out.push(ch);
|
||||
at += ch.len_utf8();
|
||||
}
|
||||
// Import faces have a canonical body prefix rather than a feature token.
|
||||
if let Some((body, suffix)) = out.rsplit_once(":face:")
|
||||
&& let Some(new) = bodies.get(body)
|
||||
{
|
||||
return format!("{new}:face:{suffix}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mapped_name(n: &mut String, names: &BTreeMap<String, String>) {
|
||||
if let Some(new) = names.get(n) {
|
||||
*n = new.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Simultaneous generated-parameter substitution: map values may themselves be old keys.
|
||||
pub fn clone_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
|
||||
let mut result = source.to_string();
|
||||
let mut temporary = Vec::new();
|
||||
for (index, (old, new)) in parameters.iter().enumerate() {
|
||||
let mut token = format!("__clone_reference_{index}");
|
||||
while source.contains(&token) || parameters.values().any(|n| n == &token) {
|
||||
token.push('_');
|
||||
}
|
||||
result = crate::expr::rename_reference(&result, old, &token);
|
||||
temporary.push((token, new));
|
||||
}
|
||||
for (token, new) in temporary {
|
||||
result = crate::expr::rename_reference(&result, &token, new);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn remap_geo(
|
||||
g: &mut crate::construct::GeoRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) -> crate::Result<()> {
|
||||
use crate::construct::GeoRef;
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match g {
|
||||
GeoRef::SketchPoint { sketch, .. } | GeoRef::SketchCurve { sketch, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
}
|
||||
GeoRef::Face { name: Some(n), .. } | GeoRef::Edge { name: Some(n), .. } => *n = remap_topology(n, ids, bodies),
|
||||
GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names),
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_plane(
|
||||
p: &mut PlaneRef,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?,
|
||||
PlaneRef::Construction { name } => mapped_name(name, names),
|
||||
PlaneRef::Face { name, anchor, .. } => {
|
||||
if let Some(name) = name {
|
||||
*name = remap_topology(name, ids, bodies);
|
||||
}
|
||||
if let Some(anchor) = anchor {
|
||||
anchor.remap_target(ids, bodies)?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for r in p.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_link(
|
||||
source: &mut solvecraft_sketch::LinkSource,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
depth: usize,
|
||||
) {
|
||||
use solvecraft_sketch::LinkSource;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
match source {
|
||||
LinkSource::Edge { body, .. }
|
||||
| LinkSource::Face { body, .. }
|
||||
| LinkSource::Body { body }
|
||||
| LinkSource::Vertex { body, .. }
|
||||
| LinkSource::Iso { body, .. }
|
||||
| LinkSource::Spun { body, .. } => mapped_name(body, bodies),
|
||||
LinkSource::SketchCurve { sketch, .. } | LinkSource::SketchPoint { sketch, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
}
|
||||
LinkSource::OnSurface { sketch, body, .. } => {
|
||||
if let Some(new) = ids.get(sketch) {
|
||||
*sketch = *new;
|
||||
}
|
||||
mapped_name(body, bodies);
|
||||
}
|
||||
LinkSource::Intersection { a, b } => {
|
||||
remap_link(a, ids, names, bodies, depth + 1);
|
||||
remap_link(b, ids, names, bodies, depth + 1);
|
||||
}
|
||||
LinkSource::Plane { name } => mapped_name(name, names),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Feature {
|
||||
/// Clone identities through typed reference fields. Sketch-local entity IDs stay local.
|
||||
pub fn remap_clone(
|
||||
&mut self,
|
||||
ids: &BTreeMap<u64, u64>,
|
||||
names: &BTreeMap<String, String>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
parameters: &BTreeMap<String, String>,
|
||||
) -> crate::Result<()> {
|
||||
use FeatureKind as K;
|
||||
match &self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
checked_plane(plane, 0)?;
|
||||
for link in &sketch.links {
|
||||
checked_link(&link.source, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
|
||||
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
|
||||
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs() {
|
||||
checked_geo(r, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.kind.remap_refs(ids, names);
|
||||
for n in &mut self.body_names {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.participants {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
for n in &mut self.param_names {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
for n in self.face_names.iter_mut().chain(&mut self.edge_names) {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Emboss { face: Some(face), .. } => mapped_name(&mut face.body, bodies),
|
||||
K::Split { tool: Some(tool), .. } | K::SplitFace { tool: Some(tool), .. } | K::SurfaceTrim { tool: Some(tool), .. } => {
|
||||
mapped_name(&mut tool.body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Extrude { targets, .. }
|
||||
| K::Revolve { targets, .. }
|
||||
| K::Loft { targets, .. }
|
||||
| K::Sweep { targets, .. }
|
||||
| K::Pipe { targets, .. }
|
||||
| K::Emboss { targets, .. } => {
|
||||
for n in targets {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Combine { target, tools, .. } => {
|
||||
mapped_name(target, bodies);
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Pattern { bodies: list, .. }
|
||||
| K::Mirror { bodies: list, .. }
|
||||
| K::Scale { bodies: list, .. }
|
||||
| K::BoundingSolid { bodies: list, .. }
|
||||
| K::Align { bodies: list, .. }
|
||||
| K::Remove { bodies: list }
|
||||
| K::Stitch { bodies: list, .. }
|
||||
| K::Move { bodies: list, .. }
|
||||
| K::BodyTransfer { bodies: list, .. } => {
|
||||
for n in list {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Thicken { bodies: list, targets, .. } => {
|
||||
for n in list.iter_mut().chain(targets) {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::BoundaryFill { tools, .. } => {
|
||||
for n in tools {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Fillet { body, .. }
|
||||
| K::Chamfer { body, .. }
|
||||
| K::Shell { body, .. }
|
||||
| K::Draft { body, .. }
|
||||
| K::SplitFace { body, .. }
|
||||
| K::OffsetFace { body, .. }
|
||||
| K::DeleteFace { body, .. }
|
||||
| K::ReplaceFace { body, .. }
|
||||
| K::SheetFlange { body, .. }
|
||||
| K::SheetHem { body, .. }
|
||||
| K::SheetFold { body, .. }
|
||||
| K::SheetUnfold { body, .. }
|
||||
| K::Boss { body, .. }
|
||||
| K::Lip { body, .. }
|
||||
| K::SnapFit { body, .. }
|
||||
| K::Rest { body, .. }
|
||||
| K::Patch { body, .. } => {
|
||||
if let Some(n) = body {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
|
||||
mapped_name(body, bodies)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
K::Sketch { plane, sketch } => {
|
||||
remap_plane(plane, ids, names, bodies, 0)?;
|
||||
for c in &mut sketch.constraints {
|
||||
if let Some(n) = &mut c.param {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
if let Some(n) = &mut link.face_name {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
}
|
||||
}
|
||||
K::ConstructionPlane { plane }
|
||||
| K::Split { plane, .. }
|
||||
| K::SplitFace { plane, .. }
|
||||
| K::Mirror { plane, .. }
|
||||
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
|
||||
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
|
||||
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
|
||||
K::ConstructionAxis { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
K::ConstructionPoint { def } => {
|
||||
for r in def.refs_mut() {
|
||||
remap_geo(r, ids, names, bodies, 0)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_geo(g: &crate::construct::GeoRef, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
if let crate::construct::GeoRef::Plane { plane } = g {
|
||||
checked_plane(plane, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?,
|
||||
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
}
|
||||
PlaneRef::Perpendicular { plane, line } => {
|
||||
checked_geo(plane, depth + 1)?;
|
||||
checked_geo(line, depth + 1)?;
|
||||
}
|
||||
PlaneRef::ThreePoints { a, b, c } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
checked_geo(c, depth + 1)?;
|
||||
}
|
||||
PlaneRef::Tangent { face, .. } => checked_geo(face, depth + 1)?,
|
||||
PlaneRef::AlongPath { path, .. } => checked_geo(path, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn checked_link(source: &solvecraft_sketch::LinkSource, depth: usize) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(crate::DocError::Invalid("copy link nesting exceeds 64".into()));
|
||||
}
|
||||
if let solvecraft_sketch::LinkSource::Intersection { a, b } = source {
|
||||
checked_link(a, depth + 1)?;
|
||||
checked_link(b, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clone_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn imported_names_and_topology_tokens_have_separate_identity() {
|
||||
let ids = BTreeMap::from([(1, 2), (10, 20)]);
|
||||
let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]);
|
||||
assert_eq!(remap_topology("F1:Housing:face:0", &ids, &bodies), "F1:Housing (copy):face:0");
|
||||
assert_eq!(remap_topology("F1:+x|F10:end@F1.2", &ids, &bodies), "F2:+x|F20:end@F2.2");
|
||||
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
|
||||
}
|
||||
#[test]
|
||||
fn parameter_remap_is_simultaneous_and_token_scoped() {
|
||||
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
|
||||
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
|
||||
}
|
||||
}
|
||||
|
||||
+278
-25
@@ -1,11 +1,24 @@
|
||||
//! Configurations: a table whose rows are variants of the design. Each column is a parameter
|
||||
//! (its expression per row) or a feature (suppressed or not per row); activating a row sets
|
||||
//! those values in the design, which then rebuilds as usual.
|
||||
//! expression, feature suppression, body material or body appearance. Activating a row sets
|
||||
//! those values in the design atomically, which then rebuilds as usual.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
#[path = "config_rules.rs"]
|
||||
mod rules;
|
||||
pub use rules::*;
|
||||
#[path = "config_styles.rs"]
|
||||
mod styles;
|
||||
pub use styles::*;
|
||||
#[path = "config_certificates.rs"]
|
||||
mod certificates;
|
||||
pub use certificates::*;
|
||||
#[path = "config_clone.rs"]
|
||||
mod clone;
|
||||
pub use clone::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Column {
|
||||
@@ -13,12 +26,20 @@ pub enum Column {
|
||||
Param(String),
|
||||
/// A feature's suppression (by feature id).
|
||||
Suppress(u64),
|
||||
/// A body's physical material (library name, or Default to clear).
|
||||
Material(String),
|
||||
/// A body's appearance (library name, serialized Look, or empty to clear).
|
||||
Appearance(String),
|
||||
/// Shared component definition's direct appearance.
|
||||
ComponentAppearance(u64),
|
||||
/// A certified semantic face lineage in a shared definition.
|
||||
FaceAppearance(ConfigFaceTarget),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ConfigRow {
|
||||
pub name: String,
|
||||
/// One cell per column: an expression, or "true"/"false" for suppression.
|
||||
/// One cell per column: expression, suppression, material name or appearance look.
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -28,67 +49,235 @@ pub struct ConfigTable {
|
||||
pub rows: Vec<ConfigRow>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub active: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<ConfigRule>,
|
||||
}
|
||||
|
||||
pub const MAX_COLUMNS: usize = 500;
|
||||
pub const MAX_ROWS: usize = 2000;
|
||||
|
||||
impl ConfigTable {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.columns.is_empty() && self.rows.is_empty()
|
||||
pub fn suppression_cell(value: &str) -> Result<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" => Ok(true),
|
||||
"false" | "0" | "no" => Ok(false),
|
||||
_ => Err(DocError::Invalid("suppression must be true or false".into())),
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigTable {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.columns.is_empty() && self.rows.is_empty() && self.rules.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an appearance cell. Empty/Default follows the body's material; named library looks
|
||||
/// and JSON Look objects support both convenient editing and lossless custom colours.
|
||||
pub fn appearance_cell(value: &str) -> Result<Option<crate::appearance::Look>> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("default") {
|
||||
return Ok(None);
|
||||
}
|
||||
if value.len() > 4096 {
|
||||
return Err(DocError::Invalid("appearance value too long".into()));
|
||||
}
|
||||
if let Some(look) = crate::appearance::library(value) {
|
||||
return Ok(Some(look));
|
||||
}
|
||||
let look: crate::appearance::Look =
|
||||
serde_json::from_str(value).map_err(|_| DocError::Invalid(format!("unknown configuration appearance `{value}`")))?;
|
||||
if look.name.chars().count() > 128 || !look.opacity.is_finite() || !(0.0..=1.0).contains(&look.opacity) {
|
||||
return Err(DocError::Invalid("appearance requires a name up to 128 characters and opacity in 0..1".into()));
|
||||
}
|
||||
Ok(Some(look))
|
||||
}
|
||||
|
||||
/// Parse a physical material cell, returning None for the default material.
|
||||
pub fn material_cell(value: &str) -> Result<Option<String>> {
|
||||
let name = crate::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(value.trim()))
|
||||
.map(|(n, _)| *n)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown configuration material `{value}`")))?;
|
||||
Ok((name != "Default").then(|| name.to_string()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A column's current value in the design.
|
||||
pub fn config_current(&self, c: &Column) -> Option<String> {
|
||||
match c {
|
||||
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, _)| e),
|
||||
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
|
||||
/// Freeze design-unit-sensitive configuration literals when changing display units.
|
||||
/// Explicit units and nonliteral expressions retain their source text.
|
||||
pub fn preserve_configuration_units(&mut self, previous_unit: &str, kept_parameters: &[String]) {
|
||||
let feature_lengths: Vec<String> = self
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|feature| {
|
||||
feature
|
||||
.param_names
|
||||
.iter()
|
||||
.zip(feature.kind.inputs())
|
||||
.filter(|(_, (_, _, kind))| *kind == crate::expr::Kind::Length)
|
||||
.map(|(name, _)| name.clone())
|
||||
})
|
||||
.collect();
|
||||
let lengths: std::collections::BTreeSet<String> = feature_lengths.into_iter().chain(kept_parameters.iter().cloned()).collect();
|
||||
for row in &mut self.configs.rows {
|
||||
for (column, cell) in self.configs.columns.iter().zip(&mut row.cells) {
|
||||
if matches!(column,Column::Param(name) if lengths.contains(name)) && cell.trim().parse::<f64>().is_ok() {
|
||||
*cell = format!("{} {previous_unit}", cell.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
for rule in &mut self.configs.rules {
|
||||
rule.preserve_length_units(&lengths, previous_unit);
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's display key: `param:<name>` or `suppress:<feature name>`.
|
||||
/// A column's current value in the design.
|
||||
pub fn config_current(&self, c: &Column) -> Option<String> {
|
||||
match c {
|
||||
Column::Param(n) => self.all_param_exprs().into_iter().find(|(m, _, _)| m == n).map(|(_, e, kind)| {
|
||||
// Feature literals are stored in base units, unlike newly typed bare
|
||||
// row cells. Capture their unit explicitly before editing in an inch design.
|
||||
if self.param(n).is_none() && kind == crate::expr::Kind::Length && self.units != "mm" && e.trim().parse::<f64>().is_ok() {
|
||||
format!("{} mm", e.trim())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
}),
|
||||
Column::Suppress(id) => self.feature(*id).map(|f| f.suppressed.to_string()),
|
||||
Column::Material(body) => Some(self.materials.get(body).cloned().unwrap_or_else(|| "Default".into())),
|
||||
Column::Appearance(body) => Some(self.appearances.bodies.get(body).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default()),
|
||||
Column::ComponentAppearance(id) => {
|
||||
Some(self.appearances.components.get(id).and_then(|look| serde_json::to_string(look).ok()).unwrap_or_default())
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
Some(self.config_face_look(target).and_then(|look| serde_json::to_string(&look).ok()).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A column's display key, prefixed by its kind to distinguish targets with equal names.
|
||||
pub fn config_key(&self, c: &Column) -> String {
|
||||
match c {
|
||||
Column::Param(n) => format!("param:{n}"),
|
||||
Column::Suppress(id) => format!("suppress:{}", self.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| id.to_string())),
|
||||
Column::Material(body) => format!("material:{body}"),
|
||||
Column::Appearance(body) => format!("appearance:{body}"),
|
||||
Column::ComponentAppearance(id) => format!("component_appearance:{id}"),
|
||||
Column::FaceAppearance(target) => format!("face_appearance:{}", target.key()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the design to a row's values (all or nothing).
|
||||
pub fn apply_configuration(&mut self, row: &str) -> Result<()> {
|
||||
self.apply_configuration_with_trace(row).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn apply_configuration_with_trace(&mut self, row: &str) -> Result<Vec<RuleTrace>> {
|
||||
let r = self.configs.rows.iter().find(|r| r.name == row).cloned().ok_or_else(|| DocError::Unknown(format!("configuration `{row}`")))?;
|
||||
if self.configs.columns.len() > MAX_COLUMNS || self.configs.rows.len() > MAX_ROWS || r.cells.len() != self.configs.columns.len() {
|
||||
return Err(DocError::Invalid("configuration table exceeds limits or has incomplete rows".into()));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
let changes: Vec<(String, String)> = self
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.zip(&r.cells)
|
||||
.filter_map(|(c, v)| match c {
|
||||
Column::Param(name) => Some((name.clone(), v.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
next.change_params_atomic(&changes).map_err(|e| DocError::Invalid(format!("configuration `{row}`: {e}")))?;
|
||||
for (c, v) in self.configs.columns.iter().zip(&r.cells) {
|
||||
if v.len() > 4096 {
|
||||
return Err(DocError::Invalid("configuration value too long".into()));
|
||||
}
|
||||
match c {
|
||||
Column::Param(n) => {
|
||||
if next.config_current(c).as_deref() != Some(v.as_str()) {
|
||||
next.change_param(n, v, None, None).map_err(|e| DocError::Invalid(format!("configuration `{row}`, parameter `{n}`: {e}")))?;
|
||||
}
|
||||
}
|
||||
Column::Param(_) => {}
|
||||
Column::Suppress(id) => {
|
||||
let on = match v.trim() {
|
||||
"true" | "1" | "yes" => true,
|
||||
"false" | "0" | "no" => false,
|
||||
o => return Err(DocError::Invalid(format!("configuration `{row}`: suppression must be true or false, not `{o}`"))),
|
||||
};
|
||||
let on = suppression_cell(v)?;
|
||||
let f = next.feature_mut(*id).ok_or_else(|| DocError::Unknown(format!("configuration `{row}`: feature {id} is gone")))?;
|
||||
f.suppressed = on;
|
||||
}
|
||||
Column::Material(body) => match material_cell(v)? {
|
||||
Some(material) => {
|
||||
next.materials.insert(body.clone(), material);
|
||||
}
|
||||
None => {
|
||||
next.materials.remove(body);
|
||||
}
|
||||
},
|
||||
Column::Appearance(body) => match appearance_cell(v)? {
|
||||
Some(look) => {
|
||||
next.appearances.bodies.insert(body.clone(), look);
|
||||
}
|
||||
None => {
|
||||
next.appearances.bodies.remove(body);
|
||||
}
|
||||
},
|
||||
Column::ComponentAppearance(id) => {
|
||||
if *id == 0 || !next.components.iter().any(|component| component.id == *id) {
|
||||
return Err(DocError::Unknown(format!("component {id} is gone")));
|
||||
}
|
||||
match appearance_cell(v)? {
|
||||
Some(look) => {
|
||||
next.appearances.components.insert(*id, look);
|
||||
}
|
||||
None => {
|
||||
next.appearances.components.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
next.certify_config_face(target)?;
|
||||
next.appearances.faces.retain(|face| !target.matches(face));
|
||||
if let Some(look) = appearance_cell(v)? {
|
||||
next.appearances.faces.push(crate::appearance::FaceLook {
|
||||
body: target.body.clone(),
|
||||
point: target.point,
|
||||
look,
|
||||
name: Some(target.name.clone()),
|
||||
strict_name: true,
|
||||
certificate: target.certificate.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
next.configs.active = Some(row.to_string());
|
||||
let trace = next.evaluate_configuration_rules()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
Ok(trace)
|
||||
}
|
||||
|
||||
/// The active row's values all hold in the design.
|
||||
pub fn configuration_matches(&self) -> bool {
|
||||
let Some(r) = self.configs.active.as_ref().and_then(|a| self.configs.rows.iter().find(|r| r.name == *a)) else { return false };
|
||||
if r.cells.len() != self.configs.columns.len() {
|
||||
return false;
|
||||
}
|
||||
if !self.configs.rules.is_empty() {
|
||||
let mut expected = self.clone();
|
||||
if expected.apply_configuration(&r.name).is_err() {
|
||||
return false;
|
||||
}
|
||||
return self.configs.columns.iter().all(|column| self.config_current(column) == expected.config_current(column));
|
||||
}
|
||||
self.configs.columns.iter().zip(&r.cells).all(|(c, v)| match c {
|
||||
Column::Suppress(_) => self.config_current(c).as_deref() == Some(v.trim()),
|
||||
Column::Param(_) => self.config_current(c).is_some_and(|x| x.replace(' ', "") == v.replace(' ', "")),
|
||||
Column::Suppress(id) => suppression_cell(v).is_ok_and(|on| self.feature(*id).is_some_and(|f| f.suppressed == on)),
|
||||
Column::Param(name) => {
|
||||
let kind = self
|
||||
.features
|
||||
.iter()
|
||||
.find_map(|f| f.param_names.iter().position(|n| n == name).and_then(|k| f.kind.inputs().get(k).map(|input| input.2)));
|
||||
let expected = kind.map_or_else(|| v.trim().to_string(), |kind| self.with_design_unit(v, kind));
|
||||
self.config_current(c)
|
||||
.is_some_and(|current| current.chars().filter(|c| !c.is_whitespace()).eq(expected.chars().filter(|c| !c.is_whitespace())))
|
||||
}
|
||||
Column::Material(body) => material_cell(v).is_ok_and(|m| self.materials.get(body) == m.as_ref()),
|
||||
Column::Appearance(body) => appearance_cell(v).is_ok_and(|look| self.appearances.bodies.get(body) == look.as_ref()),
|
||||
Column::ComponentAppearance(id) => appearance_cell(v).is_ok_and(|look| self.appearances.components.get(id) == look.as_ref()),
|
||||
Column::FaceAppearance(target) => appearance_cell(v).is_ok_and(|look| self.config_face_look(target) == look),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -97,6 +286,53 @@ impl Document {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_use_final_graph_and_missing_targets_never_reappear() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("a", "1 mm", None, None).unwrap();
|
||||
d.set_param("b", "a", None, None).unwrap();
|
||||
d.configs.columns = vec![Column::Param("a".into()), Column::Param("b".into())];
|
||||
d.configs.rows.push(ConfigRow { name: "Reordered".into(), cells: vec!["b".into(), "2 mm".into()] });
|
||||
d.apply_configuration("Reordered").unwrap();
|
||||
assert!(d.configuration_matches());
|
||||
assert_eq!(d.param_values().0["a"].v, 2.0);
|
||||
d.configs.rows.push(ConfigRow { name: "Cycle".into(), cells: vec!["b".into(), "a".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Cycle").is_err());
|
||||
assert_eq!(d, before);
|
||||
d.params.retain(|p| p.name != "a");
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Reordered").is_err());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_changes_reject_new_errors_in_unchanged_dependents() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("length", "2 mm", None, None).unwrap();
|
||||
d.set_param("twice", "length * 2", None, None).unwrap();
|
||||
let before = d.clone();
|
||||
assert!(d.change_params_atomic(&[("length".into(), "1 / 0".into())]).is_err());
|
||||
assert_eq!(d, before);
|
||||
assert!(d.change_params_atomic(&[("length".into(), "3 mm".into()), ("length".into(), "4 mm".into())]).is_err());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_erroneous_configured_parameter_is_a_noop_but_changed_error_rejects() {
|
||||
let mut d = Document::new("Existing broken design");
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
d.params[0].expr = "1/".into();
|
||||
d.configs.columns = vec![Column::Param("width".into())];
|
||||
d.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["1/".into()] });
|
||||
d.apply_configuration("Default").unwrap();
|
||||
assert!(d.configuration_matches());
|
||||
d.configs.rows.push(ConfigRow { name: "Different error".into(), cells: vec!["missing + 1".into()] });
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Different error").is_err());
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_parameters() {
|
||||
let mut d = Document::new("A");
|
||||
@@ -114,4 +350,21 @@ mod tests {
|
||||
assert!(d.apply_configuration("Bad").is_err());
|
||||
assert_eq!(d, before, "all or nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_appearance_rolls_back_material_and_parameters() {
|
||||
let mut d = Document::new("A");
|
||||
d.set_param("width", "10 mm", None, None).unwrap();
|
||||
d.configs.columns = vec![Column::Param("width".into()), Column::Material("Body".into()), Column::Appearance("Body".into())];
|
||||
d.configs.rows.push(ConfigRow {
|
||||
name: "Broken".into(),
|
||||
cells: vec!["20 mm".into(), "Steel".into(), r#"{"name":"Invalid","color":[1,2,3],"opacity":2}"#.into()],
|
||||
});
|
||||
let before = d.clone();
|
||||
assert!(d.apply_configuration("Broken").is_err());
|
||||
assert_eq!(d, before);
|
||||
assert!(appearance_cell(r#"{"name":"Invalid","color":[1,2,3],"opacity":1e999}"#).is_err());
|
||||
assert!(appearance_cell(&"a".repeat(4097)).is_err());
|
||||
assert!(appearance_cell("default").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
//! Certificates are producer identities, never a nearest-surface selection.
|
||||
use crate::{DocError, Document, FeatureKind, ModelBody, Result, SolvedSketch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Seg2;
|
||||
|
||||
fn valid_signature(outer: &[String], holes: &[Vec<String>]) -> bool {
|
||||
let valid = |ids: &[String]| {
|
||||
!ids.is_empty() && ids.len() <= 2000 && ids.iter().all(|id| !id.is_empty() && id.len() <= 128) && ids.windows(2).all(|pair| pair[0] < pair[1])
|
||||
};
|
||||
valid(outer)
|
||||
&& holes.len() <= 128
|
||||
&& holes.iter().all(|ids| valid(ids))
|
||||
&& outer.len() + holes.iter().map(Vec::len).sum::<usize>() <= 2000
|
||||
&& holes.windows(2).all(|pair| pair[0] < pair[1])
|
||||
&& holes.iter().flatten().all(|id| outer.binary_search(id).is_err())
|
||||
&& holes.iter().flatten().collect::<std::collections::BTreeSet<_>>().len() == holes.iter().map(Vec::len).sum::<usize>()
|
||||
}
|
||||
fn valid_signature_json(outer: Option<&serde_json::Value>, holes: Option<&serde_json::Value>) -> bool {
|
||||
let valid = |value: &serde_json::Value| {
|
||||
value.as_array().is_some_and(|ids| {
|
||||
!ids.is_empty()
|
||||
&& ids.len() <= 2000
|
||||
&& ids.iter().all(|id| id.as_str().is_some_and(|id| !id.is_empty() && id.len() <= 128))
|
||||
&& ids.windows(2).all(|pair| pair[0].as_str() < pair[1].as_str())
|
||||
})
|
||||
};
|
||||
let Some(outer) = outer.filter(|value| valid(value)).and_then(serde_json::Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let Some(holes) = holes.and_then(serde_json::Value::as_array).filter(|holes| holes.len() <= 128) else {
|
||||
return false;
|
||||
};
|
||||
if !holes.iter().all(valid) {
|
||||
return false;
|
||||
}
|
||||
let total = outer.len() + holes.iter().filter_map(serde_json::Value::as_array).map(Vec::len).sum::<usize>();
|
||||
let ids: Vec<_> = outer.iter().filter_map(serde_json::Value::as_str).collect();
|
||||
total <= 2000
|
||||
&& holes
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
== total - outer.len()
|
||||
&& holes
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.all(|id| ids.binary_search(&id).is_err())
|
||||
&& holes.windows(2).all(|pair| {
|
||||
let a: Vec<_> = pair[0].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
|
||||
let b: Vec<_> = pair[1].as_array().into_iter().flatten().filter_map(serde_json::Value::as_str).collect();
|
||||
a < b
|
||||
})
|
||||
}
|
||||
pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hash = Sha256::new();
|
||||
for ids in std::iter::once(outer).chain(holes.iter().map(Vec::as_slice)) {
|
||||
hash.update((ids.len() as u64).to_le_bytes());
|
||||
for id in ids {
|
||||
hash.update((id.len() as u64).to_le_bytes());
|
||||
hash.update(id.as_bytes());
|
||||
}
|
||||
}
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
|
||||
if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() > 4096 || region.holes.len() > 128 {
|
||||
return None;
|
||||
}
|
||||
let signature = |outline: &solvecraft_geom::Loop2| {
|
||||
let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::<Option<Vec<_>>>()?;
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
Some(ids)
|
||||
};
|
||||
let outer = signature(®ion.outer)?;
|
||||
let mut holes = region.holes.iter().map(signature).collect::<Option<Vec<_>>>()?;
|
||||
holes.sort();
|
||||
valid_signature(&outer, &holes).then_some((outer, holes))
|
||||
}
|
||||
|
||||
/// Charge all source-curve comparisons before matching; over-budget tools stay usable but uncertified.
|
||||
type CapBindings = Vec<Vec<Option<solvecraft_kernel::CapSignature>>>;
|
||||
pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_kernel::Body], budget: usize) -> Option<CapBindings> {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return None;
|
||||
}
|
||||
let mut faces = 0usize;
|
||||
let mut comparisons = 0usize;
|
||||
for body in bodies {
|
||||
for source in body.face_sources().into_iter().flatten() {
|
||||
faces = faces.checked_add(1)?;
|
||||
if faces > 50_000 {
|
||||
return None;
|
||||
}
|
||||
let segments = match source {
|
||||
Some(FaceSource::ExtrudeSide { .. }) => 1,
|
||||
Some(FaceSource::ExtrudeCap { region, .. }) => {
|
||||
region.outer.segs.len().checked_add(region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>())?
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
comparisons = comparisons.checked_add(segments.checked_mul(sketch.sketch.curves.len())?)?;
|
||||
if comparisons > budget {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
let mut bindings = Vec::with_capacity(bodies.len());
|
||||
for body in bodies {
|
||||
let mut cached: Option<(&solvecraft_geom::Region2, Option<solvecraft_kernel::CapSignature>)> = None;
|
||||
let mut values = Vec::new();
|
||||
for source in body.face_sources().into_iter().flatten() {
|
||||
let signature = if let Some(FaceSource::ExtrudeCap { region, high, .. }) = source {
|
||||
let signature = match &cached {
|
||||
Some((previous, signature)) if *previous == region => signature.clone(),
|
||||
_ => exact_region_signature(sketch, region),
|
||||
};
|
||||
cached = Some((region, signature.clone()));
|
||||
if let Some((outer, holes)) = &signature {
|
||||
*counts.entry((*high, outer.clone(), holes.clone())).or_insert(0usize) += 1;
|
||||
}
|
||||
signature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
values.push(signature);
|
||||
}
|
||||
bindings.push(values);
|
||||
}
|
||||
for (body, values) in bodies.iter().zip(&mut bindings) {
|
||||
for (source, value) in body.face_sources().into_iter().flatten().zip(values) {
|
||||
if let (Some(FaceSource::ExtrudeCap { high, .. }), Some((outer, holes))) = (source, value.as_ref())
|
||||
&& counts.get(&(*high, outer.clone(), holes.clone())) != Some(&1)
|
||||
{
|
||||
*value = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(bindings)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceCertificate {
|
||||
Box { axis: u8, high: bool },
|
||||
ExtrudeCap { sketch: u64, high: bool, outer: Vec<String>, holes: Vec<Vec<String>> },
|
||||
ExtrudeSide { sketch: u64, curve: String },
|
||||
Imported { payload_sha256: String, item: u64, face: u64 },
|
||||
ShellPlanar { source_producer: u64, source: Box<FaceCertificate>, inner: bool },
|
||||
}
|
||||
|
||||
impl FaceCertificate {
|
||||
pub fn remap_features(&mut self, features: &std::collections::BTreeMap<u64, u64>) {
|
||||
if let Self::ShellPlanar { source_producer, source, .. } = self {
|
||||
if let Some(mapped) = features.get(source_producer) {
|
||||
*source_producer = *mapped;
|
||||
}
|
||||
if !matches!(source.as_ref(), Self::ShellPlanar { .. }) {
|
||||
source.remap_features(features);
|
||||
}
|
||||
}
|
||||
if let Self::ExtrudeSide { sketch, .. } | Self::ExtrudeCap { sketch, .. } = self
|
||||
&& let Some(mapped) = features.get(sketch)
|
||||
{
|
||||
*sketch = *mapped;
|
||||
}
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = match self {
|
||||
Self::Box { axis, .. } => *axis <= 2,
|
||||
Self::ExtrudeCap { sketch, outer, holes, .. } => *sketch != 0 && valid_signature(outer, holes),
|
||||
Self::ExtrudeSide { sketch, curve } => *sketch != 0 && !curve.is_empty() && curve.len() <= 128,
|
||||
Self::Imported { payload_sha256, item, face } => {
|
||||
*item != 0
|
||||
&& *face != 0
|
||||
&& payload_sha256.len() == 64
|
||||
&& payload_sha256.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
Self::ShellPlanar { source_producer, source, .. } => {
|
||||
*source_producer != 0 && !matches!(source.as_ref(), Self::ShellPlanar { .. }) && source.validate().is_ok()
|
||||
}
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("invalid face source certificate".into())) }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_certificate_json(value: &serde_json::Value) -> Result<()> {
|
||||
if value.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face certificate must be an object".into()))?;
|
||||
let nonzero = |field: &str| fields.get(field).and_then(serde_json::Value::as_u64).is_some_and(|id| id != 0);
|
||||
let valid = match fields.get("kind").and_then(serde_json::Value::as_str) {
|
||||
Some("box") => {
|
||||
fields.len() == 3
|
||||
&& fields.keys().all(|key| ["kind", "axis", "high"].contains(&key.as_str()))
|
||||
&& fields.get("axis").and_then(serde_json::Value::as_u64).is_some_and(|axis| axis <= 2)
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
}
|
||||
Some("extrude_cap") => {
|
||||
fields.len() == 5
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "high", "outer", "holes"].contains(&key.as_str()))
|
||||
&& nonzero("sketch")
|
||||
&& fields.get("high").and_then(serde_json::Value::as_bool).is_some()
|
||||
&& valid_signature_json(fields.get("outer"), fields.get("holes"))
|
||||
}
|
||||
Some("extrude_side") => {
|
||||
fields.len() == 3
|
||||
&& fields.keys().all(|key| ["kind", "sketch", "curve"].contains(&key.as_str()))
|
||||
&& nonzero("sketch")
|
||||
&& fields.get("curve").and_then(serde_json::Value::as_str).is_some_and(|curve| !curve.is_empty() && curve.len() <= 128)
|
||||
}
|
||||
Some("imported") => {
|
||||
fields.len() == 4
|
||||
&& fields.keys().all(|key| ["kind", "payload_sha256", "item", "face"].contains(&key.as_str()))
|
||||
&& nonzero("item")
|
||||
&& nonzero("face")
|
||||
&& fields
|
||||
.get("payload_sha256")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|digest| digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
|
||||
}
|
||||
Some("shell_planar") => {
|
||||
fields.len() == 4
|
||||
&& fields.keys().all(|key| ["kind", "source_producer", "source", "inner"].contains(&key.as_str()))
|
||||
&& nonzero("source_producer")
|
||||
&& fields.get("inner").and_then(serde_json::Value::as_bool).is_some()
|
||||
&& fields.get("source").is_some_and(|source| {
|
||||
source.as_object().is_some_and(|source| source.get("kind").and_then(serde_json::Value::as_str) != Some("shell_planar"))
|
||||
&& validate_certificate_json(source).is_ok()
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if valid { Ok(()) } else { Err(DocError::Invalid("malformed or oversized face source certificate".into())) }
|
||||
}
|
||||
|
||||
/// Canonical diagnostic role; producer identity remains in the typed certificate.
|
||||
pub(crate) fn shell_certificate_role(source: &FaceCertificate, inner: bool) -> Result<String> {
|
||||
source.validate()?;
|
||||
let role = match source {
|
||||
FaceCertificate::Box { axis, high } => {
|
||||
let axis = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid shell source axis".into()))?;
|
||||
format!("box:{}{axis}", if *high { "+" } else { "-" })
|
||||
}
|
||||
FaceCertificate::ExtrudeSide { curve, .. } => format!("side:{curve}"),
|
||||
FaceCertificate::ExtrudeCap { high, outer, holes, .. } => format!("cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes)),
|
||||
FaceCertificate::Imported { payload_sha256, item, face } => format!("import:{payload_sha256}:{item}:{face}"),
|
||||
FaceCertificate::ShellPlanar { .. } => return Err(DocError::Invalid("nested shell source certificates are unsupported".into())),
|
||||
};
|
||||
Ok(format!("shell:{}:{role}", if inner { "inner" } else { "outer" }))
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_certificate(source: &solvecraft_kernel::FaceSource) -> Option<(u64, FaceCertificate)> {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
match source {
|
||||
FaceSource::Box { producer: Some(producer), axis, high, .. } => Some((*producer, FaceCertificate::Box { axis: *axis, high: *high })),
|
||||
FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }))
|
||||
}
|
||||
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. } => {
|
||||
Some((*producer, FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() }))
|
||||
}
|
||||
FaceSource::Imported { producer: Some(producer), payload_sha256: Some(digest), item, face } => {
|
||||
Some((*producer, FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvecraft_kernel::FaceSource, producer: u64) -> bool {
|
||||
use solvecraft_kernel::FaceSource;
|
||||
match (certificate, source) {
|
||||
(FaceCertificate::Box { axis, high }, FaceSource::Box { producer: Some(current), axis: current_axis, high: current_high, .. }) => {
|
||||
producer == *current && axis == current_axis && high == current_high
|
||||
}
|
||||
(
|
||||
FaceCertificate::ExtrudeSide { sketch, curve },
|
||||
FaceSource::CertifiedExtrudeSide { producer: current, sketch: current_sketch, curve: current_curve, .. },
|
||||
) => producer == *current && sketch == current_sketch && curve == current_curve,
|
||||
(
|
||||
FaceCertificate::ExtrudeCap { sketch, high, outer, holes },
|
||||
FaceSource::CertifiedExtrudeCap {
|
||||
producer: current,
|
||||
sketch: current_sketch,
|
||||
high: current_high,
|
||||
outer: current_outer,
|
||||
holes: current_holes,
|
||||
..
|
||||
},
|
||||
) => producer == *current && sketch == current_sketch && high == current_high && outer == current_outer && holes == current_holes,
|
||||
(
|
||||
FaceCertificate::Imported { payload_sha256, item, face },
|
||||
FaceSource::Imported { producer: Some(current), payload_sha256: Some(digest), item: current_item, face: current_face },
|
||||
) => producer == *current && payload_sha256 == digest && item == current_item && face == current_face,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a generated segment to one exact analytic sketch curve. Coincident
|
||||
/// candidates are ambiguous and never resolved by distance or candidate order.
|
||||
pub(crate) fn exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return None;
|
||||
}
|
||||
let mut found = None;
|
||||
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
|
||||
if curve.construction
|
||||
|| !matches!(
|
||||
curve.kind,
|
||||
solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. }
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
|
||||
sketch
|
||||
.sketch
|
||||
.point(c)
|
||||
.is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated))
|
||||
} else {
|
||||
sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated))
|
||||
};
|
||||
if matches {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(curve.id.clone());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
match (original, generated) {
|
||||
(Seg2::Line { a, b }, Seg2::Line { a: c, b: d }) => {
|
||||
let direction = *b - *a;
|
||||
let scale = direction.len().max((*d - *c).len()).max(1.);
|
||||
let interval = |point: solvecraft_geom::Vec2| {
|
||||
let t = (point - *a).dot(direction) / direction.len2();
|
||||
(-1e-10..=1. + 1e-10).contains(&t)
|
||||
};
|
||||
direction.len() > 1e-12
|
||||
&& direction.cross(*c - *a).abs() <= 1e-10 * scale * scale
|
||||
&& direction.cross(*d - *a).abs() <= 1e-10 * scale * scale
|
||||
&& interval(*c)
|
||||
&& interval(*d)
|
||||
}
|
||||
(Seg2::Arc { center: a, radius: ar, start: os, sweep: ow }, Seg2::Arc { center: b, radius: br, start: gs, sweep: gw }) => {
|
||||
let tau = std::f64::consts::TAU;
|
||||
let tolerance = 1e-10;
|
||||
let length = ow.abs();
|
||||
let generated_length = gw.abs();
|
||||
if a.dist(*b) > 1e-10 * ar.max(*br).max(1.)
|
||||
|| (ar - br).abs() > 1e-10 * ar.max(*br).max(1.)
|
||||
|| length > tau + tolerance
|
||||
|| generated_length > length + tolerance
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if length >= tau - tolerance {
|
||||
return true;
|
||||
}
|
||||
let original_low = if *ow >= 0. { *os } else { os + ow };
|
||||
let generated_low = if *gw >= 0. { *gs } else { gs + gw };
|
||||
let offset = (generated_low - original_low).rem_euclid(tau);
|
||||
let offset = if offset > tau - tolerance { 0. } else { offset };
|
||||
offset + generated_length <= length + tolerance
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn configuration_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
|
||||
let names = crate::naming::face_names(body);
|
||||
let mut name = names
|
||||
.get(index)
|
||||
.map(|name| crate::naming::strip_piece(name).to_string())
|
||||
.ok_or_else(|| DocError::Unknown("face source name missing".into()))?;
|
||||
let origin = match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
|
||||
Some(solvecraft_kernel::FaceSource::Imported { producer: Some(producer), .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, .. })
|
||||
| Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), .. })
|
||||
| Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), .. }) => *producer,
|
||||
_ => name
|
||||
.strip_prefix('F')
|
||||
.and_then(|name| name.split_once(':'))
|
||||
.and_then(|(id, _)| id.parse().ok())
|
||||
.ok_or_else(|| DocError::Invalid("face source producer missing".into()))?,
|
||||
};
|
||||
let certificate = self.configuration_face_certificate(body, index)?;
|
||||
if let Some(FaceCertificate::ExtrudeSide { curve, .. }) = &certificate {
|
||||
name = format!("F{origin}:side:{curve}");
|
||||
} else if let Some(FaceCertificate::ShellPlanar { source, inner, .. }) = &certificate {
|
||||
name = format!("F{origin}:{}", shell_certificate_role(source, *inner)?);
|
||||
} else if !name.starts_with(&format!("F{origin}:")) {
|
||||
name = format!("F{origin}:face:{index}");
|
||||
}
|
||||
let inverse = crate::mat_inverse(&body.source_transform).ok_or_else(|| DocError::Invalid("invalid definition placement".into()))?;
|
||||
Ok(crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name,
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate,
|
||||
})
|
||||
}
|
||||
pub fn configuration_face_certificate(&self, body: &ModelBody, index: usize) -> Result<Option<FaceCertificate>> {
|
||||
let Some(source) = body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source, inner, .. } = source {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Shell { .. })) {
|
||||
return Err(DocError::Invalid("shell source producer missing".into()));
|
||||
}
|
||||
let (source_producer, source) =
|
||||
flat_source_certificate(source).ok_or_else(|| DocError::Invalid("shell has no flat certified source".into()))?;
|
||||
if !self.flat_certificate_producer_matches(source_producer, &source) {
|
||||
return Err(DocError::Invalid("shell original source producer missing".into()));
|
||||
}
|
||||
let certificate = FaceCertificate::ShellPlanar { source_producer, source: Box::new(source), inner: *inner };
|
||||
certificate.validate()?;
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
if let solvecraft_kernel::FaceSource::Imported { item, face, payload_sha256: Some(digest), producer: Some(producer) } = source {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Import { .. })) {
|
||||
return Err(DocError::Invalid("import source producer missing".into()));
|
||||
}
|
||||
let certificate = FaceCertificate::Imported { payload_sha256: digest.clone(), item: *item, face: *face };
|
||||
certificate.validate()?;
|
||||
return Ok(Some(certificate));
|
||||
}
|
||||
let certificate = match source {
|
||||
solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch, curve, .. } => {
|
||||
if !self.feature(*producer).is_some_and(|feature| matches!(feature.kind, FeatureKind::Extrude { .. })) {
|
||||
return Err(DocError::Invalid("side source producer missing".into()));
|
||||
}
|
||||
FaceCertificate::ExtrudeSide { sketch: *sketch, curve: curve.clone() }
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
certificate.validate()?;
|
||||
Ok(Some(certificate))
|
||||
}
|
||||
pub fn strong_face_target(&self, body: &ModelBody, index: usize, point: solvecraft_geom::Vec3) -> Result<crate::config::ConfigFaceTarget> {
|
||||
let mut target = self.configuration_face_target(body, index, point)?;
|
||||
match body.body.face_sources().and_then(|sources| sources.get(index)).and_then(Option::as_ref) {
|
||||
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis, high, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
let axis_name = ["x", "y", "z"].get(*axis as usize).ok_or_else(|| DocError::Invalid("invalid box face axis".into()))?;
|
||||
target.name = format!("F{producer}:{}{axis_name}", if *high { "+" } else { "-" });
|
||||
target.certificate = Some(FaceCertificate::Box { axis: *axis, high: *high });
|
||||
}
|
||||
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, .. }) => {
|
||||
target.origin_feature = *producer;
|
||||
target.name = format!("F{producer}:cap:{}:{}", if *high { "high" } else { "low" }, cap_key(outer, holes));
|
||||
target.certificate = Some(FaceCertificate::ExtrudeCap { sketch: *sketch, high: *high, outer: outer.clone(), holes: holes.clone() });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.certify_config_face(&target)?;
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn payload_digest(payload: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(payload.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_shell_tests.rs"]
|
||||
mod shell_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn certificate_budgets_apply_before_saved_target_and_paint_decode() {
|
||||
for certificate in [
|
||||
json!({"kind":"extrude_side","sketch":1,"curve":"x".repeat(1_000_000)}),
|
||||
json!({"kind":"imported","payload_sha256":"0".repeat(64),"item":0,"face":1}),
|
||||
json!({"kind":"imported","payload_sha256":"g".repeat(64),"item":1,"face":1}),
|
||||
json!({"kind":"extrude_side","sketch":1,"curve":"c1","extra":[[[[[]]]]]}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&certificate).is_err());
|
||||
let target = json!({"configs":{"columns":[{"face_appearance":{"body":"B","origin_feature":1,"name":"F1:side:c1","point":[0,0,0],"certificate":certificate}}]}});
|
||||
assert!(crate::config::validate_styles_json(&target).is_err());
|
||||
let paint = json!({"appearances":{"faces":[{"certificate":certificate}]}});
|
||||
assert!(crate::config::validate_styles_json(&paint).is_err());
|
||||
}
|
||||
assert!(validate_certificate_json(&json!({"kind":"imported","payload_sha256":"a".repeat(64),"item":1,"face":2})).is_ok());
|
||||
assert!(validate_certificate_json(&json!({"kind":"extrude_side","sketch":1,"curve":"c1"})).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn analytic_arc_interval_refuses_unsampled_gaps_and_accepts_wrap_reversal() {
|
||||
let radians = |degrees: f64| degrees.to_radians();
|
||||
let arc = |start, sweep| Seg2::Arc { center: solvecraft_geom::Vec2::default(), radius: 10., start: radians(start), sweep: radians(sweep) };
|
||||
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., 360.)), "five point probes missed the missing ten-degree gap");
|
||||
assert!(!exact_segment_contains(&arc(10., 350.), &arc(20., -360.)));
|
||||
assert!(exact_segment_contains(&arc(350., 40.), &arc(355., 20.)));
|
||||
assert!(exact_segment_contains(&arc(350., 40.), &arc(15., -20.)));
|
||||
assert!(exact_segment_contains(&arc(30., -40.), &arc(15., -20.)));
|
||||
assert!(!exact_segment_contains(&arc(350., 40.), &arc(345., 20.)));
|
||||
assert!(exact_segment_contains(&arc(0., 360.), &arc(20., -360.)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Configuration graph remapping for independent component copies.
|
||||
use super::{Column, ConfigTable};
|
||||
use crate::{DocError, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ConfigCloneMaps {
|
||||
pub features: BTreeMap<u64, u64>,
|
||||
pub components: BTreeMap<u64, u64>,
|
||||
pub bodies: BTreeMap<String, String>,
|
||||
pub parameters: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Simultaneous token-reference substitution, including maps whose values are other keys.
|
||||
pub(super) fn remap_expression(source: &str, parameters: &BTreeMap<String, String>) -> String {
|
||||
let refs = crate::expr::references(source);
|
||||
let mut result = source.to_string();
|
||||
let mut replacements = Vec::new();
|
||||
for (index, reference) in refs.iter().enumerate() {
|
||||
if let Some(mapped) = parameters.get(reference) {
|
||||
let mut temporary = format!("_config_clone_{index}");
|
||||
while refs.contains(&temporary) || parameters.contains_key(&temporary) || parameters.values().any(|value| value == &temporary) {
|
||||
temporary.push('_');
|
||||
}
|
||||
result = crate::expr::rename_reference(&result, reference, &temporary);
|
||||
replacements.push((temporary, mapped));
|
||||
}
|
||||
}
|
||||
for (temporary, mapped) in replacements {
|
||||
result = crate::expr::rename_reference(&result, &temporary, mapped);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl ConfigTable {
|
||||
/// Append mapped columns/cells/rules atomically; source targets and active row remain intact.
|
||||
pub fn clone_targets(&mut self, maps: &ConfigCloneMaps) -> Result<()> {
|
||||
if maps.features.values().chain(maps.components.values()).any(|id| *id == 0)
|
||||
|| maps.parameters.values().any(|name| crate::Document::param_name_problem(name).is_some())
|
||||
|| maps.bodies.values().any(|name| name.is_empty() || name.len() > 128)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid configuration clone identity mapping".into()));
|
||||
}
|
||||
if self.columns.len() > super::MAX_COLUMNS
|
||||
|| self.rows.len() > super::MAX_ROWS
|
||||
|| self.rules.len() > super::MAX_RULES
|
||||
|| self.rows.iter().any(|row| row.cells.len() != self.columns.len())
|
||||
{
|
||||
return Err(DocError::Invalid("cannot clone an incomplete configuration table".into()));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
for (index, column) in self.columns.iter().enumerate() {
|
||||
if let Column::FaceAppearance(target) = column
|
||||
&& maps.bodies.contains_key(&target.body)
|
||||
&& !maps.features.contains_key(&target.origin_feature)
|
||||
{
|
||||
return Err(DocError::Invalid(format!(
|
||||
"cannot copy configured face `{}`: imported foreign body has no certified cloned lineage",
|
||||
target.name
|
||||
)));
|
||||
}
|
||||
let mapped = match column {
|
||||
Column::Param(name) => maps.parameters.get(name).cloned().map(Column::Param),
|
||||
Column::Suppress(id) => maps.features.get(id).copied().map(Column::Suppress),
|
||||
Column::Material(name) => maps.bodies.get(name).cloned().map(Column::Material),
|
||||
Column::Appearance(name) => maps.bodies.get(name).cloned().map(Column::Appearance),
|
||||
Column::ComponentAppearance(id) => maps.components.get(id).copied().map(Column::ComponentAppearance),
|
||||
Column::FaceAppearance(target) => {
|
||||
maps.bodies.get(&target.body).zip(maps.features.get(&target.origin_feature)).map(|(body, origin)| {
|
||||
let mut target = target.clone();
|
||||
target.body = body.clone();
|
||||
target.name = target
|
||||
.name
|
||||
.strip_prefix(&format!("F{}:", target.origin_feature))
|
||||
.map(|role| format!("F{origin}:{role}"))
|
||||
.unwrap_or_else(|| target.name.clone());
|
||||
target.origin_feature = *origin;
|
||||
if let Some(certificate) = &mut target.certificate {
|
||||
certificate.remap_features(&maps.features);
|
||||
}
|
||||
Column::FaceAppearance(target)
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(mapped) = mapped {
|
||||
if mapped == *column {
|
||||
continue;
|
||||
}
|
||||
if next.columns.iter().any(|column| {
|
||||
*column == mapped
|
||||
|| matches!((column,&mapped),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name)
|
||||
}) {
|
||||
return Err(DocError::Invalid("configuration copy target collides with an existing column".into()));
|
||||
}
|
||||
if next.columns.len() >= super::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("configuration copy exceeds column limit".into()));
|
||||
}
|
||||
next.columns.push(mapped);
|
||||
for (source, destination) in self.rows.iter().zip(&mut next.rows) {
|
||||
let value = source.cells.get(index).cloned().unwrap_or_default();
|
||||
let value = if matches!(column, Column::Param(_)) { remap_expression(&value, &maps.parameters) } else { value };
|
||||
if value.len() > 4096 {
|
||||
return Err(DocError::Invalid("configuration copied value exceeds length limit".into()));
|
||||
}
|
||||
destination.cells.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for rule in &self.rules {
|
||||
if let Some(mut copied) = rule.clone_for_targets(maps) {
|
||||
if next.rules.len() >= super::MAX_RULES {
|
||||
return Err(DocError::Invalid("configuration copy exceeds rule limit".into()));
|
||||
}
|
||||
let stem: String = rule.id.chars().take(40).collect();
|
||||
copied.id = (1..=super::MAX_RULES + 1)
|
||||
.map(|i| format!("{stem}_copy{i}"))
|
||||
.find(|id| !next.rules.iter().any(|rule| rule.id == *id))
|
||||
.ok_or_else(|| DocError::Invalid("no configuration rule ID available".into()))?;
|
||||
copied.name = format!("{} (copy)", rule.name.chars().take(120).collect::<String>());
|
||||
next.rules.push(copied);
|
||||
}
|
||||
}
|
||||
super::validate_rules_json(&serde_json::json!({"configs":{"rules":next.rules}}))?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{ConfigFaceTarget, ConfigRow, ConfigRule, RuleCondition, RuleEffect};
|
||||
#[test]
|
||||
fn clone_preserves_source_variations_and_remaps_only_typed_targets_and_tokens() {
|
||||
let mut table = ConfigTable {
|
||||
columns: vec![
|
||||
Column::Param("d1".into()),
|
||||
Column::Param("d2".into()),
|
||||
Column::Appearance("Old".into()),
|
||||
Column::ComponentAppearance(2),
|
||||
Column::FaceAppearance(ConfigFaceTarget {
|
||||
body: "Old".into(),
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::new(1., 1., 2.),
|
||||
certificate: None,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow {
|
||||
name: "Default".into(),
|
||||
cells: vec!["d1 * 2 + d2".into(), "4 mm".into(), "d1".into(), String::new(), String::new()],
|
||||
}],
|
||||
active: Some("Default".into()),
|
||||
rules: vec![ConfigRule {
|
||||
id: "Rule1".into(),
|
||||
name: "Rule1".into(),
|
||||
enabled: true,
|
||||
length_unit: Some("mm".into()),
|
||||
condition: RuleCondition::Suppression { feature: 3, value: false },
|
||||
effect: RuleEffect::DriveParameter { parameter: "d1".into(), expression: "d2 * 2 + d1".into() },
|
||||
}],
|
||||
};
|
||||
let original = table.clone();
|
||||
let maps = ConfigCloneMaps {
|
||||
features: BTreeMap::from([(3, 7)]),
|
||||
components: BTreeMap::from([(2, 8)]),
|
||||
bodies: BTreeMap::from([("Old".into(), "New".into())]),
|
||||
parameters: BTreeMap::from([("d1".into(), "copy_d1".into()), ("d2".into(), "copy_d2".into())]),
|
||||
};
|
||||
table.clone_targets(&maps).unwrap();
|
||||
assert_eq!(&table.columns[..5], &original.columns);
|
||||
assert_eq!(&table.rows[0].cells[..5], &original.rows[0].cells);
|
||||
assert_eq!(table.columns[5], Column::Param("copy_d1".into()));
|
||||
assert_eq!(table.rows[0].cells[5], "copy_d1 * 2 + copy_d2");
|
||||
assert_eq!(table.rows[0].cells[7], "d1"); // appearance strings are never parameter expressions
|
||||
assert_eq!(table.columns[8], Column::ComponentAppearance(8));
|
||||
let Column::FaceAppearance(face) = &table.columns[9] else { panic!() };
|
||||
assert_eq!(face.name, "F7:+z");
|
||||
assert_eq!(face.body, "New");
|
||||
assert_eq!(table.rules[0], original.rules[0]);
|
||||
assert!(
|
||||
matches!(&table.rules[1].effect,RuleEffect::DriveParameter{parameter,expression} if parameter=="copy_d1" && expression=="copy_d2 * 2 + copy_d1")
|
||||
);
|
||||
assert!(matches!(table.rules[1].condition, RuleCondition::Suppression { feature: 7, .. }));
|
||||
assert_eq!(table.active, original.active);
|
||||
let chained = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
|
||||
assert_eq!(remap_expression("d1 * 2 + d2", &chained), "d2 * 2 + d3");
|
||||
let collision = ConfigCloneMaps { parameters: chained, ..Default::default() };
|
||||
let mut original_copy = original.clone();
|
||||
assert!(original_copy.clone_targets(&collision).is_err());
|
||||
assert_eq!(original_copy, original);
|
||||
}
|
||||
#[test]
|
||||
fn foreign_strict_lineage_failure_is_atomic() {
|
||||
let mut table = ConfigTable {
|
||||
columns: vec![
|
||||
Column::Param("width".into()),
|
||||
Column::FaceAppearance(ConfigFaceTarget {
|
||||
body: "Foreign".into(),
|
||||
origin_feature: 3,
|
||||
name: "F3:+z".into(),
|
||||
point: solvecraft_geom::Vec3::ZERO,
|
||||
certificate: None,
|
||||
}),
|
||||
],
|
||||
rows: vec![ConfigRow { name: "Default".into(), cells: vec!["2 mm".into(), String::new()] }],
|
||||
..Default::default()
|
||||
};
|
||||
let original = table.clone();
|
||||
let maps = ConfigCloneMaps {
|
||||
bodies: BTreeMap::from([("Foreign".into(), "Copied".into())]),
|
||||
parameters: BTreeMap::from([("width".into(), "copied_width".into())]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(table.clone_targets(&maps).unwrap_err().to_string().contains("certified"));
|
||||
assert_eq!(table, original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
//! Bounded typed configuration rules. The saved row remains the editable source;
|
||||
//! ordered rules derive an effective design without overwriting its cells.
|
||||
use super::Column;
|
||||
use crate::{DocError, Document, Result, expr};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub const MAX_RULES: usize = 128;
|
||||
const MAX_NODES: usize = 64;
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigRule {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "enabled")]
|
||||
pub enabled: bool,
|
||||
/// Unit context for feature-length expressions, fixed when authoring a rule.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub length_unit: Option<String>,
|
||||
pub condition: RuleCondition,
|
||||
pub effect: RuleEffect,
|
||||
}
|
||||
fn enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum RuleCondition {
|
||||
Always,
|
||||
All { conditions: Vec<RuleCondition> },
|
||||
Any { conditions: Vec<RuleCondition> },
|
||||
Not { condition: Box<RuleCondition> },
|
||||
Parameter { parameter: String, operator: RuleOperator, expression: String },
|
||||
Suppression { feature: u64, value: bool },
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuleOperator {
|
||||
Eq,
|
||||
Ne,
|
||||
Lt,
|
||||
Le,
|
||||
Gt,
|
||||
Ge,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum RuleEffect {
|
||||
DriveParameter { parameter: String, expression: String },
|
||||
DriveSuppression { feature: u64, value: bool },
|
||||
ParameterRange { parameter: String, minimum: String, maximum: String },
|
||||
AllowParameterValues { parameter: String, expressions: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RuleTrace {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub matched: bool,
|
||||
pub effect: RuleEffect,
|
||||
}
|
||||
|
||||
/// Check saved rule structure before recursive serde decoding. Absence is backward
|
||||
/// compatible; malformed present data is refused. Runtime evaluation checks it again.
|
||||
pub fn validate_rules_json(document: &Value) -> Result<()> {
|
||||
let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) };
|
||||
let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?;
|
||||
if rules.len() > MAX_RULES {
|
||||
return Err(invalid("too many rules"));
|
||||
}
|
||||
for rule in rules {
|
||||
let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?;
|
||||
for (key, limit) in [("id", 64), ("name", 128)] {
|
||||
let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?;
|
||||
if value.trim().is_empty() || value.chars().count() > limit {
|
||||
return Err(invalid("rule id or name is empty or too long"));
|
||||
}
|
||||
}
|
||||
if let Some(unit) = rule.get("length_unit") {
|
||||
let unit =
|
||||
unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?;
|
||||
let _ = unit;
|
||||
}
|
||||
let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)];
|
||||
let mut count = 0;
|
||||
while let Some((condition, depth)) = pending.pop() {
|
||||
count += 1;
|
||||
if depth > MAX_DEPTH || count > MAX_NODES {
|
||||
return Err(invalid("condition nesting or node limit exceeded"));
|
||||
}
|
||||
let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?;
|
||||
match kind {
|
||||
"all" | "any" => {
|
||||
let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?;
|
||||
if children.is_empty() || children.len() > MAX_NODES {
|
||||
return Err(invalid("condition group is empty or too large"));
|
||||
}
|
||||
pending.extend(children.iter().map(|child| (child, depth + 1)));
|
||||
}
|
||||
"not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)),
|
||||
"parameter" => {
|
||||
check_parameter(condition.get("parameter"))?;
|
||||
check_expression(condition.get("expression"))?;
|
||||
}
|
||||
"always" | "suppression" => {}
|
||||
_ => return Err(invalid("unknown condition type")),
|
||||
}
|
||||
}
|
||||
let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?;
|
||||
if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") {
|
||||
check_parameter(effect.get("parameter"))?;
|
||||
}
|
||||
match effect.get("type").and_then(Value::as_str) {
|
||||
Some("drive_parameter") => check_expression(effect.get("expression"))?,
|
||||
Some("parameter_range") => {
|
||||
check_expression(effect.get("minimum"))?;
|
||||
check_expression(effect.get("maximum"))?;
|
||||
}
|
||||
Some("allow_parameter_values") => {
|
||||
let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?;
|
||||
if expressions.is_empty() || expressions.len() > 128 {
|
||||
return Err(invalid("allowed values are empty or too many"));
|
||||
}
|
||||
for expression in expressions {
|
||||
check_expression(Some(expression))?;
|
||||
}
|
||||
}
|
||||
Some("drive_suppression") => {}
|
||||
_ => return Err(invalid("unknown effect type")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn check_expression(value: Option<&Value>) -> Result<()> {
|
||||
let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?;
|
||||
if text.trim().is_empty() || text.len() > 4096 {
|
||||
return Err(invalid("expression is empty or too long"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn check_parameter(value: Option<&Value>) -> Result<()> {
|
||||
let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?;
|
||||
if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() {
|
||||
return Err(invalid("invalid parameter name"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn invalid(message: &str) -> DocError {
|
||||
DocError::Invalid(format!("configuration rules: {message}"))
|
||||
}
|
||||
|
||||
fn operands(
|
||||
doc: &Document,
|
||||
values: &BTreeMap<String, expr::Value>,
|
||||
parameter: &str,
|
||||
expression: &str,
|
||||
length_unit: Option<&str>,
|
||||
) -> Result<(f64, f64)> {
|
||||
let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?;
|
||||
let unit = doc
|
||||
.param(parameter)
|
||||
.map(|p| p.unit.as_str())
|
||||
.or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() }))
|
||||
.unwrap_or("");
|
||||
let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0));
|
||||
let rhs = expr::eval_with_defaults(
|
||||
expression,
|
||||
&|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))),
|
||||
defaults,
|
||||
)?;
|
||||
let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?;
|
||||
let right = rhs.to_kind_in(kind, defaults)?;
|
||||
if !lhs.v.is_finite() || !right.is_finite() {
|
||||
return Err(invalid("comparison requires finite values"));
|
||||
}
|
||||
Ok((lhs.v, right))
|
||||
}
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
|
||||
}
|
||||
fn validate_drive_graph(graph: &BTreeMap<String, Vec<String>>, parameter: &str, expression: &str) -> Result<()> {
|
||||
let mut pending = expr::references(expression);
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some(name) = pending.pop() {
|
||||
if name == parameter {
|
||||
return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle")));
|
||||
}
|
||||
if visited.insert(name.clone())
|
||||
&& let Some(references) = graph.get(&name)
|
||||
{
|
||||
pending.extend(references.iter().cloned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn condition(doc: &Document, values: &BTreeMap<String, expr::Value>, value: &RuleCondition, length_unit: Option<&str>) -> Result<bool> {
|
||||
match value {
|
||||
RuleCondition::Always => Ok(true),
|
||||
RuleCondition::All { conditions } => {
|
||||
for c in conditions {
|
||||
if !condition(doc, values, c, length_unit)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
RuleCondition::Any { conditions } => {
|
||||
for c in conditions {
|
||||
if condition(doc, values, c, length_unit)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?),
|
||||
RuleCondition::Suppression { feature, value } => {
|
||||
Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value)
|
||||
}
|
||||
RuleCondition::Parameter { parameter, operator, expression } => {
|
||||
let (a, b) = operands(doc, values, parameter, expression, length_unit)?;
|
||||
Ok(match operator {
|
||||
RuleOperator::Eq => close(a, b),
|
||||
RuleOperator::Ne => !close(a, b),
|
||||
RuleOperator::Lt => a < b,
|
||||
RuleOperator::Le => a <= b || close(a, b),
|
||||
RuleOperator::Gt => a > b,
|
||||
RuleOperator::Ge => a >= b || close(a, b),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Apply ordered drives, then enforce all matched restrictions on the final state.
|
||||
/// Running on a scratch document makes a failing rule fully atomic for callers.
|
||||
pub(super) fn evaluate_configuration_rules(&mut self) -> Result<Vec<RuleTrace>> {
|
||||
validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?;
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut trace = Vec::new();
|
||||
let mut restrictions = Vec::new();
|
||||
let (mut values, _) = self.param_values();
|
||||
let parameter_names: BTreeSet<String> = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect();
|
||||
let mut dependency_graph: BTreeMap<String, Vec<String>> =
|
||||
self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect();
|
||||
for rule in self.configs.rules.clone() {
|
||||
if !ids.insert(rule.id.clone()) {
|
||||
return Err(invalid("duplicate rule id"));
|
||||
}
|
||||
if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) {
|
||||
return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name)));
|
||||
}
|
||||
// Targets must remain configured, including disabled rules, so dormant rules
|
||||
// cannot silently become invalid and surprise a subsequent activation.
|
||||
let target = match &rule.effect {
|
||||
RuleEffect::DriveParameter { parameter, .. }
|
||||
| RuleEffect::ParameterRange { parameter, .. }
|
||||
| RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()),
|
||||
RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature),
|
||||
};
|
||||
if !self.configs.columns.contains(&target) {
|
||||
return Err(invalid(&format!("rule `{}` target is not configured", rule.name)));
|
||||
}
|
||||
let matched = rule.enabled
|
||||
&& condition(self, &values, &rule.condition, rule.length_unit.as_deref())
|
||||
.map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
|
||||
trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() });
|
||||
if !matched {
|
||||
continue;
|
||||
}
|
||||
match rule.effect {
|
||||
RuleEffect::DriveParameter { parameter, mut expression } => {
|
||||
validate_drive_graph(&dependency_graph, ¶meter, &expression)?;
|
||||
dependency_graph.insert(parameter.clone(), expr::references(&expression));
|
||||
if self.param(¶meter).is_none()
|
||||
&& rule.length_unit.as_deref().is_some_and(|unit| unit != self.units)
|
||||
&& values.get(¶meter).is_some_and(|value| value.kind() == Some(expr::Kind::Length))
|
||||
{
|
||||
let (_, value) = operands(self, &values, ¶meter, &expression, rule.length_unit.as_deref())?;
|
||||
expression = format!("{value} mm");
|
||||
}
|
||||
self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
|
||||
values = self.param_values().0;
|
||||
}
|
||||
RuleEffect::DriveSuppression { feature, value } => {
|
||||
self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value
|
||||
}
|
||||
effect => restrictions.push((rule.name, effect, rule.length_unit)),
|
||||
}
|
||||
}
|
||||
for (name, effect, length_unit) in restrictions {
|
||||
let valid = match effect {
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
let (value, min) = operands(self, &values, ¶meter, &minimum, length_unit.as_deref())?;
|
||||
let (_, max) = operands(self, &values, ¶meter, &maximum, length_unit.as_deref())?;
|
||||
min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max))
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
let mut valid = false;
|
||||
for expression in expressions {
|
||||
let (a, b) = operands(self, &values, ¶meter, &expression, length_unit.as_deref())?;
|
||||
valid |= close(a, b);
|
||||
}
|
||||
valid
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
if !valid {
|
||||
return Err(invalid(&format!("rule `{name}` restricts the final value")));
|
||||
}
|
||||
}
|
||||
Ok(trace)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigRule {
|
||||
pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet<String>, unit: &str) {
|
||||
if self.length_unit.is_none() {
|
||||
self.length_unit = Some(unit.to_string());
|
||||
}
|
||||
fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet<String>, unit: &str) {
|
||||
if parameters.contains(parameter) && expression.trim().parse::<f64>().is_ok() {
|
||||
*expression = format!("{} {unit}", expression.trim());
|
||||
}
|
||||
}
|
||||
fn walk(condition: &mut RuleCondition, parameters: &BTreeSet<String>, unit: &str) {
|
||||
match condition {
|
||||
RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit),
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
for condition in conditions {
|
||||
walk(condition, parameters, unit);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => walk(condition, parameters, unit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
walk(&mut self.condition, parameters, unit);
|
||||
match &mut self.effect {
|
||||
RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit),
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
annotate(parameter, minimum, parameters, unit);
|
||||
annotate(parameter, maximum, parameters, unit);
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
for expression in expressions {
|
||||
annotate(parameter, expression, parameters, unit);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameter references include targets and condition/effect expressions.
|
||||
pub fn parameter_references(&self) -> Vec<String> {
|
||||
let mut copy = self.clone();
|
||||
let mut references = Vec::new();
|
||||
copy.visit_parameters(&mut |text, expression| {
|
||||
if expression {
|
||||
references.extend(expr::references(text));
|
||||
} else {
|
||||
references.push(text.clone());
|
||||
}
|
||||
});
|
||||
references
|
||||
}
|
||||
pub fn rename_parameter(&mut self, old: &str, new: &str) {
|
||||
self.visit_parameters(&mut |text, expression| {
|
||||
if expression {
|
||||
*text = expr::rename_reference(text, old, new);
|
||||
} else if text == old {
|
||||
*text = new.to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
pub(super) fn clone_for_targets(&self, maps: &super::ConfigCloneMaps) -> Option<Self> {
|
||||
let mapped = match &self.effect {
|
||||
RuleEffect::DriveParameter { parameter, .. }
|
||||
| RuleEffect::ParameterRange { parameter, .. }
|
||||
| RuleEffect::AllowParameterValues { parameter, .. } => maps.parameters.contains_key(parameter),
|
||||
RuleEffect::DriveSuppression { feature, .. } => maps.features.contains_key(feature),
|
||||
};
|
||||
if !mapped {
|
||||
return None;
|
||||
}
|
||||
let mut copy = self.clone();
|
||||
copy.visit_parameters(&mut |text, expression| {
|
||||
*text = if expression {
|
||||
super::clone::remap_expression(text, &maps.parameters)
|
||||
} else {
|
||||
maps.parameters.get(text).cloned().unwrap_or_else(|| text.clone())
|
||||
};
|
||||
});
|
||||
fn remap(condition: &mut RuleCondition, maps: &super::ConfigCloneMaps) {
|
||||
match condition {
|
||||
RuleCondition::Suppression { feature, .. } => {
|
||||
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
||||
}
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
for condition in conditions {
|
||||
remap(condition, maps);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => remap(condition, maps),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
remap(&mut copy.condition, maps);
|
||||
if let RuleEffect::DriveSuppression { feature, .. } = &mut copy.effect {
|
||||
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
||||
}
|
||||
Some(copy)
|
||||
}
|
||||
fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) {
|
||||
fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) {
|
||||
match c {
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
||||
for c in conditions {
|
||||
walk(c, visit);
|
||||
}
|
||||
}
|
||||
RuleCondition::Not { condition } => walk(condition, visit),
|
||||
RuleCondition::Parameter { parameter, expression, .. } => {
|
||||
visit(parameter, false);
|
||||
visit(expression, true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
walk(&mut self.condition, visit);
|
||||
match &mut self.effect {
|
||||
RuleEffect::DriveParameter { parameter, expression } => {
|
||||
visit(parameter, false);
|
||||
visit(expression, true);
|
||||
}
|
||||
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
||||
visit(parameter, false);
|
||||
visit(minimum, true);
|
||||
visit(maximum, true);
|
||||
}
|
||||
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
||||
visit(parameter, false);
|
||||
for e in expressions {
|
||||
visit(e, true);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn references_features(&self, ids: &[u64]) -> bool {
|
||||
fn condition(c: &RuleCondition, ids: &[u64]) -> bool {
|
||||
match c {
|
||||
RuleCondition::Suppression { feature, .. } => ids.contains(feature),
|
||||
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)),
|
||||
RuleCondition::Not { condition: c } => condition(c, ids),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigRow;
|
||||
fn design() -> Document {
|
||||
let mut doc = Document::new("Rules");
|
||||
doc.set_param("width", "10 mm", None, None).unwrap();
|
||||
doc.set_param("depth", "5 mm", None, None).unwrap();
|
||||
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
||||
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc
|
||||
}
|
||||
fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
|
||||
ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect }
|
||||
}
|
||||
#[test]
|
||||
fn ordered_rules_derive_values_without_overwriting_rows() {
|
||||
let mut doc = design();
|
||||
doc.configs.rules = vec![
|
||||
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }),
|
||||
rule(
|
||||
"second",
|
||||
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() },
|
||||
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
|
||||
),
|
||||
];
|
||||
let source = doc.configs.rows.clone();
|
||||
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
||||
assert!(trace.iter().all(|entry| entry.matched));
|
||||
assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9);
|
||||
assert_eq!(doc.configs.rows, source);
|
||||
assert!(doc.configuration_matches());
|
||||
doc.configs.rules.swap(0, 1);
|
||||
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
||||
assert!(!trace[0].matched);
|
||||
assert_eq!(doc.param_values().0["depth"].v, 5.0);
|
||||
}
|
||||
#[test]
|
||||
fn restrictions_intersect_and_fail_atomically_with_named_rule() {
|
||||
let mut doc = design();
|
||||
doc.configs.rules = vec![
|
||||
rule(
|
||||
"range",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() },
|
||||
),
|
||||
rule(
|
||||
"choices",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] },
|
||||
),
|
||||
];
|
||||
doc.apply_configuration("Default").unwrap();
|
||||
doc.configs.rows[0].cells[0] = "15 mm".into();
|
||||
let before = doc.clone();
|
||||
assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices"));
|
||||
assert_eq!(doc, before);
|
||||
doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() };
|
||||
assert!(doc.apply_configuration("Default").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rule_references_follow_rename_and_prevent_delete() {
|
||||
let mut doc = design();
|
||||
doc.configs.rules.push(rule(
|
||||
"derive",
|
||||
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() },
|
||||
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
|
||||
));
|
||||
assert!(doc.remove_param("width").is_err());
|
||||
doc.rename_param("width", "span").unwrap();
|
||||
let refs = doc.configs.rules[0].parameter_references();
|
||||
assert!(refs.contains(&"span".into()));
|
||||
assert!(!refs.contains(&"width".into()));
|
||||
doc.apply_configuration("Default").unwrap();
|
||||
assert_eq!(doc.param_values().0["depth"].v, 5.0);
|
||||
}
|
||||
#[test]
|
||||
fn angle_and_unitless_comparisons_keep_their_dimensions() {
|
||||
let mut doc = design();
|
||||
doc.set_param("angle", "45 deg", Some("deg"), None).unwrap();
|
||||
doc.set_param("count", "1", Some(""), None).unwrap();
|
||||
doc.configs.columns.push(Column::Param("count".into()));
|
||||
doc.configs.rows[0].cells.push("1".into());
|
||||
doc.configs.rules.push(rule(
|
||||
"angle-drive",
|
||||
RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() },
|
||||
RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() },
|
||||
));
|
||||
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
||||
assert!(trace[0].matched);
|
||||
assert_eq!(doc.param_values().0["count"].v, 2.0);
|
||||
doc.configs.rules[0].condition =
|
||||
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() };
|
||||
let before = doc.clone();
|
||||
assert!(doc.apply_configuration("Default").is_err());
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_rule_budgets_are_checked_before_deserialization() {
|
||||
let valid = serde_json::to_value(rule(
|
||||
"r",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
|
||||
))
|
||||
.unwrap();
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok());
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err());
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err());
|
||||
let mut condition = serde_json::json!({"type":"always"});
|
||||
for _ in 0..8 {
|
||||
condition = serde_json::json!({"type":"not","condition":condition});
|
||||
}
|
||||
let mut deep = valid.clone();
|
||||
deep["condition"] = condition;
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err());
|
||||
let mut wide = valid.clone();
|
||||
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err());
|
||||
let mut long = valid.clone();
|
||||
long["effect"]["expression"] = serde_json::json!("a".repeat(4097));
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err());
|
||||
let mut malformed = valid;
|
||||
malformed["condition"] = serde_json::json!({"type":"any","conditions":{}});
|
||||
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_conditions_short_circuit_guarded_numeric_expressions() {
|
||||
let mut doc = design();
|
||||
doc.set_param("count", "0", Some(""), None).unwrap();
|
||||
let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() };
|
||||
let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() };
|
||||
doc.configs.rules = vec![rule(
|
||||
"guarded",
|
||||
RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] },
|
||||
RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() },
|
||||
)];
|
||||
assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
|
||||
assert_eq!(doc.param_values().0["width"].v, 10.0);
|
||||
doc.configs.rules[0].condition = RuleCondition::Any {
|
||||
conditions: vec![
|
||||
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() },
|
||||
invalid_when_zero,
|
||||
],
|
||||
};
|
||||
assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
|
||||
assert_eq!(doc.param_values().0["width"].v, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() {
|
||||
let valid = serde_json::to_value(rule(
|
||||
"r",
|
||||
RuleCondition::Always,
|
||||
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
|
||||
))
|
||||
.unwrap();
|
||||
let mut deep = valid.clone();
|
||||
let mut condition = serde_json::json!({"type":"always"});
|
||||
for _ in 0..8 {
|
||||
condition = serde_json::json!({"type":"not","condition":condition});
|
||||
}
|
||||
deep["condition"] = condition;
|
||||
let mut wide = valid.clone();
|
||||
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
|
||||
let mut invalid_context = valid.clone();
|
||||
invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000));
|
||||
for invalid in [
|
||||
Value::Null,
|
||||
serde_json::json!([deep]),
|
||||
serde_json::json!([wide]),
|
||||
serde_json::json!(vec![valid; 129]),
|
||||
serde_json::json!([invalid_context]),
|
||||
] {
|
||||
let mut saved = serde_json::to_value(design()).unwrap();
|
||||
saved["configs"]["rules"] = invalid;
|
||||
let error = crate::format::read(&saved.to_string()).unwrap_err().to_string();
|
||||
assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() {
|
||||
let mut doc = Document::new("Cross-unit drive graph");
|
||||
doc.add_feature(
|
||||
crate::FeatureKind::Box {
|
||||
corner: ["0".into(), "0".into(), "0".into()],
|
||||
length: "10 mm".into(),
|
||||
width: "5 mm".into(),
|
||||
height: "10 mm".into(),
|
||||
operation: crate::Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let width = doc.features[0].param_names[0].clone();
|
||||
let depth = doc.features[0].param_names[1].clone();
|
||||
doc.rename_param(&width, "width").unwrap();
|
||||
doc.rename_param(&depth, "depth").unwrap();
|
||||
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
||||
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
||||
doc.units = "in".into();
|
||||
let mut first =
|
||||
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
|
||||
first.length_unit = Some("mm".into());
|
||||
let mut second =
|
||||
rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() });
|
||||
second.length_unit = Some("mm".into());
|
||||
doc.configs.rules = vec![first];
|
||||
doc.apply_configuration("Default").unwrap();
|
||||
assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm"));
|
||||
doc.configs.rules.push(second);
|
||||
let before = doc.clone();
|
||||
assert!(doc.apply_configuration("Default").is_err());
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
use super::*;
|
||||
use crate::{FeatureKind, Model, PlaneRef};
|
||||
use serde_json::json;
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
|
||||
fn shell_document() -> (Document, u64, u64) {
|
||||
let mut doc = Document::new("certified shell");
|
||||
doc.set_param("width", "20", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
let base = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(json!({"type":"box","corner":[0,0,0],"length":"width","width":"12","height":"10"})).unwrap(),
|
||||
Some("Case"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(5., 6., 10.)],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
tangent_chain: false,
|
||||
},
|
||||
Some("Shell"),
|
||||
)
|
||||
.unwrap();
|
||||
(doc, base, shell)
|
||||
}
|
||||
|
||||
fn face_index(body: &ModelBody, inner: bool) -> usize {
|
||||
body.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: current, source, .. })
|
||||
if *current == inner && matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis:0,high:true,.. }))
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_raw_bounds_flat_identity_and_clone_remap() {
|
||||
let plane = solvecraft_geom::Plane::XY;
|
||||
let same = |planes: &[Option<solvecraft_geom::Plane>]| super::super::styles::shell_plane_family(planes.iter().copied());
|
||||
assert!(same(&[Some(plane), Some(plane)]));
|
||||
assert!(!same(&[]));
|
||||
assert!(!same(&[Some(plane), None]));
|
||||
for different in [
|
||||
solvecraft_geom::Plane { origin: Vec3::X, ..plane },
|
||||
solvecraft_geom::Plane { x: -plane.x, y: -plane.y, ..plane },
|
||||
solvecraft_geom::Plane { y: -plane.y, ..plane },
|
||||
] {
|
||||
assert!(!same(&[Some(plane), Some(different)]));
|
||||
}
|
||||
let valid = json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"c4"},"inner":true});
|
||||
assert!(validate_certificate_json(&valid).is_ok());
|
||||
let mut certificate: FaceCertificate = serde_json::from_value(valid.clone()).unwrap();
|
||||
certificate.remap_features(&[(2, 20), (3, 30)].into_iter().collect());
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap()["source_producer"], 30);
|
||||
assert_eq!(serde_json::to_value(&certificate).unwrap()["source"]["sketch"], 20);
|
||||
for bad in [
|
||||
json!({"kind":"shell_planar","source_producer":0,"source":{"kind":"box","axis":0,"high":true},"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":null,"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":valid,"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"extrude_side","sketch":2,"curve":"x".repeat(1_000_000)},"inner":false}),
|
||||
json!({"kind":"shell_planar","source_producer":3,"source":{"kind":"box","axis":0,"high":true,"extra":[[[]]]},"inner":false}),
|
||||
] {
|
||||
assert!(validate_certificate_json(&bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
|
||||
let (mut doc, base, shell) = shell_document();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
|
||||
let state = model.state();
|
||||
let body = state.body("Case").unwrap();
|
||||
let outer = doc.strong_face_target(body, face_index(body, false), Vec3::default()).unwrap();
|
||||
let outer_matches: Vec<_> = body.body.face_sources().unwrap().iter().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { inner: false, source, .. }) if matches!(source.as_ref(), solvecraft_kernel::FaceSource::Box { axis: 0, high: true, .. })).then_some((index, body.body.face_plane(index)))
|
||||
}).collect();
|
||||
assert_eq!(outer_matches.len(), 1, "outer support must be one physical face: {outer_matches:?}");
|
||||
assert!(super::super::styles::shell_plane_family(outer_matches.iter().map(|(_, plane)| *plane)));
|
||||
let outer_plane = body.body.face_plane(face_index(body, false)).unwrap();
|
||||
let outer_anchor = doc.capture_face_anchor(body, face_index(body, false), &outer_plane, &[], 0, &[]).unwrap();
|
||||
let projected = crate::project::resolve_anchored_face(&doc, &state, &outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
|
||||
let mut linked = solvecraft_sketch::Sketch::new();
|
||||
let link = linked.add_link(solvecraft_sketch::LinkKind::Project, projected.source, &projected.geom).unwrap();
|
||||
assert!(!linked.update_link(&link, &projected.geom).unwrap());
|
||||
let inner_index = face_index(body, true);
|
||||
let inner = doc.strong_face_target(body, inner_index, Vec3::default()).unwrap();
|
||||
assert_ne!(outer.key(), inner.key());
|
||||
assert!(matches!(&inner.certificate, Some(FaceCertificate::ShellPlanar { source_producer,inner:true,.. }) if *source_producer==base));
|
||||
assert_eq!(inner.origin_feature, shell);
|
||||
let picked = body.body.face_plane(inner_index).unwrap();
|
||||
assert!((picked.origin.x - 18.).abs() < 1e-8);
|
||||
let anchor = doc.capture_face_anchor(body, inner_index, &picked, &[], 0, &[]).unwrap();
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let points: Vec<_> =
|
||||
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].into_iter().map(|(x, y)| sketch.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) },
|
||||
sketch,
|
||||
},
|
||||
Some("Inner face sketch"),
|
||||
)
|
||||
.unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Face boss"),
|
||||
)
|
||||
.unwrap();
|
||||
model.evaluate(&doc);
|
||||
let before = model.state().bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
doc.set_param("width", "30", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "3", Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let changed = model.state();
|
||||
let plane = doc.resolve_face_anchor(&anchor, 0, &changed).unwrap();
|
||||
assert!((plane.origin.x - 27.).abs() < 1e-8);
|
||||
let after = changed.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!((solvecraft_kernel::measure(&changed.bodies.iter().find(|body| body.feature == boss).unwrap().body).unwrap().volume - 8.).abs() < 1e-8);
|
||||
assert!((doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap().origin.x - 30.).abs() < 1e-8);
|
||||
let changed_outer_plane = doc.resolve_face_anchor(&outer_anchor, 0, &changed).unwrap();
|
||||
let refreshed =
|
||||
crate::project::resolve_anchored_face(&doc, &changed, &changed_outer_plane, solvecraft_sketch::LinkKind::Project, &outer_anchor).unwrap();
|
||||
assert!(!linked.update_link(&link, &refreshed.geom).unwrap(), "same exterior boundary must retain projected IDs");
|
||||
assert!((after.min.x - before.min.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
|
||||
assert!((after.max.x - before.max.x - 9.).abs() < 1e-8, "{before:?} -> {after:?}");
|
||||
assert!((after.min.y - before.min.y).abs() < 1e-8 && (after.min.z - before.min.z).abs() < 1e-8);
|
||||
let saved = doc.to_json();
|
||||
assert!(saved.contains("solvecraft/13"));
|
||||
let loaded = crate::format::read(&saved).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
let cold_state = cold.state();
|
||||
let cold_bounds = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!(after.min.dist(cold_bounds.min) < 1e-9 && after.max.dist(cold_bounds.max) < 1e-9);
|
||||
assert!(loaded.resolve_face_anchor(&anchor, 0, &cold_state).unwrap().origin.dist(plane.origin) < 1e-9);
|
||||
let mut wrong = anchor.clone();
|
||||
wrong.target.origin_feature = base;
|
||||
assert!(doc.resolve_face_anchor(&wrong, 0, &changed).is_err());
|
||||
let mut wrong_source = anchor.clone();
|
||||
if let Some(FaceCertificate::ShellPlanar { source_producer, .. }) = &mut wrong_source.target.certificate {
|
||||
*source_producer = shell;
|
||||
}
|
||||
assert!(doc.resolve_face_anchor(&wrong_source, 0, &changed).is_err());
|
||||
doc.feature_mut(shell).unwrap().suppressed = true;
|
||||
model.evaluate(&doc);
|
||||
let lost = model.state();
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &lost).is_err());
|
||||
assert!(!lost.bodies.iter().any(|body| body.feature == boss), "lost attachment must prevent downstream geometry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
|
||||
let (doc, _, _) = shell_document();
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/12");
|
||||
let loaded = crate::format::read(&legacy.to_string()).unwrap();
|
||||
let mut current: serde_json::Value = serde_json::from_str(&loaded.to_json()).unwrap();
|
||||
assert_eq!(current["format"], "solvecraft/13");
|
||||
current["format"] = json!("solvecraft/12");
|
||||
assert_eq!(legacy, current, "identity migration must preserve every prior field");
|
||||
legacy["format"] = json!("solvecraft/14");
|
||||
assert!(crate::format::read(&legacy.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regularized_shell_keeps_legacy_and_explicit_single_face_projections_closed() {
|
||||
let (mut doc, _, _) = shell_document();
|
||||
let mut model = Model::new();
|
||||
for (width, wall) in [(20., 2.), (30., 3.)] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for state in [model.state(), cold.state()] {
|
||||
let body = state.body("Case").unwrap();
|
||||
let index = face_index(body, false);
|
||||
let mesh = body.mesh();
|
||||
assert_eq!(body.body.face_sources().unwrap().iter().filter(|source|matches!(source,Some(solvecraft_kernel::FaceSource::ShellPlanar {inner:false,source,..}) if matches!(source.as_ref(),solvecraft_kernel::FaceSource::Box {axis:0,high:true,..}))).count(),1,"no physical outer-wall strip face may remain");
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.find_map(|(tri, face)| (*face as usize == index).then(|| mesh.tri(tri)).flatten())
|
||||
.map(|[a, b, c]| (a + b + c) / 3.)
|
||||
.unwrap();
|
||||
let source = solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: point };
|
||||
let (values, _) = doc.param_values();
|
||||
let legacy = crate::project::resolve(&doc, &values, &state, &plane, solvecraft_sketch::LinkKind::Project, &source).unwrap();
|
||||
let explicit = crate::project::resolve_face_indices(&state, &plane, solvecraft_sketch::LinkKind::Project, "Case", &[index]).unwrap();
|
||||
assert_eq!(legacy.geom, explicit.geom);
|
||||
assert_eq!(explicit.geom.len(), 4, "raw planar piece keeps all four edges");
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let link = sketch.add_link(solvecraft_sketch::LinkKind::Project, source, &legacy.geom).unwrap();
|
||||
sketch.links[0].face_name = Some(crate::naming::face_names(body)[index].clone());
|
||||
let warnings = crate::project::refresh_links(&doc, &values, &state, &plane, &mut sketch);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(!sketch.links[0].lost);
|
||||
assert!(!sketch.update_link(&link, &explicit.geom).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material() {
|
||||
let (mut doc, _, shell) = shell_document();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
let body = state.body("Case").unwrap();
|
||||
let index = face_index(body, false);
|
||||
let plane = body.body.face_plane(index).unwrap();
|
||||
let stem = crate::naming::face_names(body)[index].clone();
|
||||
assert_eq!(crate::naming::strip_piece(&stem), stem);
|
||||
// Reconstruct the original raw shell before certificate stamping/regularization,
|
||||
// and run the unchanged historical naming/capture path on its actual strip.
|
||||
let before = model.state_before(shell);
|
||||
// Legacy Shell did not propagate construction certificates. Omit transient
|
||||
// metadata here; operand paint also disables new wall-run coalescence so
|
||||
// this control retains the actual historical raw pieces.
|
||||
let raw_source = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(20., 12., 10.)).unwrap().with_paint(Some(solvecraft_kernel::Paint {
|
||||
opacity: 1.,
|
||||
faces: vec![solvecraft_kernel::FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }],
|
||||
}));
|
||||
let raw = solvecraft_kernel::shell(&raw_source, &[Vec3::new(5., 6., 10.)], 2.).unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), raw, shell);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(shell).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
|
||||
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
|
||||
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
|
||||
sketch
|
||||
.add_link(
|
||||
solvecraft_sketch::LinkKind::Project,
|
||||
solvecraft_sketch::LinkSource::Face { body: "Case".into(), at: Vec3::new(20., 6., 1.) },
|
||||
&geometry,
|
||||
)
|
||||
.unwrap();
|
||||
sketch.links[0].face_name = Some(former.clone());
|
||||
let original = sketch.clone();
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch { plane: PlaneRef::Face { plane, at: Vec3::new(20., 6., 1.), name: Some(former), anchor: None }, sketch },
|
||||
Some("Saved strip sketch"),
|
||||
)
|
||||
.unwrap();
|
||||
let boss = 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 strip profile material"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/12");
|
||||
let mut doc = crate::format::read(&legacy.to_string()).unwrap();
|
||||
for width in [20., 30.] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "3", Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for current in [&model, &cold] {
|
||||
let warning = current.results.iter().find(|r| r.id == sk).unwrap().warning.as_deref().unwrap();
|
||||
assert!(warning.contains("no longer exists; last geometry retained"), "{warning}");
|
||||
let state = current.state();
|
||||
let solved = state.sketch(sk).unwrap();
|
||||
assert!(solved.sketch.links[0].lost);
|
||||
assert_eq!(solved.sketch.curves, original.curves);
|
||||
assert_eq!(solved.sketch.points, original.points);
|
||||
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&material.body).unwrap().volume - 24.).abs() < 1e-8,
|
||||
"old24mm3 profile must not silently become120mm3 wall profile"
|
||||
);
|
||||
let bounds = material.mesh().bounds();
|
||||
assert!((bounds.min.x - width).abs() < 1e-8 && (bounds.max.x - width - 1.).abs() < 1e-8);
|
||||
assert!((bounds.max.y - 12.).abs() < 1e-8 && (bounds.max.z - 2.).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
use solvecraft_kernel::{FacePaint, Paint};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
let mut doc = Document::new("saved curved strip");
|
||||
doc.set_param("width", "80", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
let base = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(json!({"type":"box","corner":["-width/2",-30,0],"length":"width","width":"60","height":"30"})).unwrap(),
|
||||
Some("Case"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(base).unwrap().body_names = vec!["Case".into()];
|
||||
doc.add_feature(
|
||||
serde_json::from_value(json!({"type":"fillet","body":"Case","edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":"6"}))
|
||||
.unwrap(),
|
||||
Some("Corners"),
|
||||
)
|
||||
.unwrap();
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(0., 0., 30.)],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
tangent_chain: false,
|
||||
},
|
||||
Some("Shell"),
|
||||
)
|
||||
.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(shell);
|
||||
// Per-face paint disables the construction optimization: this control keeps
|
||||
// the real pre-repair slab faces without forging a name or source witness.
|
||||
let old_source = before
|
||||
.body("Case")
|
||||
.unwrap()
|
||||
.body
|
||||
.clone()
|
||||
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let cavity_box = solvecraft_kernel::box_solid(Vec3::new(-38., -28., 2.), Vec3::new(38., 28., 35.)).unwrap();
|
||||
let cavity = solvecraft_kernel::fillet(
|
||||
&cavity_box,
|
||||
&[Vec3::new(38., 28., 15.), Vec3::new(-38., 28., 15.), Vec3::new(-38., -28., 15.), Vec3::new(38., -28., 15.)],
|
||||
4.,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = solvecraft_kernel::boolean(&old_source, &cavity, solvecraft_kernel::BoolOp::Cut).unwrap().unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), raw, shell);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(shell).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let t = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let at = Vec3::new(34. + 6. * t, 24. + 6. * t, 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
let stem = crate::naming::strip_piece(&former).to_string();
|
||||
assert_ne!(former, stem, "actual old cylindrical strip must have a piece suffix");
|
||||
let state = model.state();
|
||||
let current = state.body("Case").unwrap();
|
||||
let names = crate::naming::face_names(current);
|
||||
let index = names.iter().position(|name| name == &stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(index));
|
||||
assert!(current.body.face_plane(index).is_none(), "witness grants no planar attachment authority");
|
||||
assert!(old_body.body.face_sources().is_none_or(|sources| sources.iter().enumerate().all(|(index, _)| !old_body.body.face_was_coalesced(index))));
|
||||
let mut old_state = (*model.state()).clone();
|
||||
old_state.bodies = vec![old_body];
|
||||
let source = LinkSource::Face { body: "Case".into(), at };
|
||||
// Recover the saved projection from the old face's complete raw topology.
|
||||
// Display seam suppression must not discard a historical strip boundary.
|
||||
let old_body = old_state.body("Case").unwrap();
|
||||
let old_index = crate::naming::face_on_body(old_body, &former, at).unwrap().0;
|
||||
let mesh = old_body.mesh();
|
||||
let mut geometry = Vec::new();
|
||||
for (index, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
if !faces.contains(&(old_index as u32)) {
|
||||
continue;
|
||||
}
|
||||
let points = &mesh.edges[index];
|
||||
let a = solvecraft_geom::Plane::YZ.to_local(*points.first().unwrap());
|
||||
let b = solvecraft_geom::Plane::YZ.to_local(*points.last().unwrap());
|
||||
let (a, b) = (Vec2::new(a.x, a.y), Vec2::new(b.x, b.y));
|
||||
assert!(a.dist(b) > 1e-6);
|
||||
for point in points {
|
||||
let q = solvecraft_geom::Plane::YZ.to_local(*point);
|
||||
let q = Vec2::new(q.x, q.y);
|
||||
let direction = b - a;
|
||||
let t = ((q - a).dot(direction) / direction.dot(direction)).clamp(0., 1.);
|
||||
assert!(q.dist(a + direction * t) < 1e-7, "exact cylindrical quadrant projects to a straight support");
|
||||
}
|
||||
geometry.push(solvecraft_sketch::LinkGeom::Line(a, b));
|
||||
}
|
||||
assert_eq!(geometry.len(), 4, "historical curved strip has a complete four-edge rectangular projection");
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, source, &geometry).unwrap();
|
||||
sketch.links[0].face_name = Some(former);
|
||||
let original = sketch.clone();
|
||||
let sk = doc
|
||||
.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane: solvecraft_geom::Plane::YZ }, sketch }, Some("Saved corner strip"))
|
||||
.unwrap();
|
||||
let boss = 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("Retained strip material"),
|
||||
)
|
||||
.unwrap();
|
||||
for (width, wall) in [(80., 2.), (100., 3.)] {
|
||||
doc.set_param("width", &width.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let loaded = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
for current in [&model, &cold] {
|
||||
let result = current.results.iter().find(|r| r.id == sk).unwrap();
|
||||
assert!(result.error.is_none());
|
||||
assert!(result.warning.as_deref().unwrap().contains("no longer exists; last geometry retained"));
|
||||
let state = current.state();
|
||||
let solved = state.sketch(sk).unwrap();
|
||||
assert!(solved.sketch.links[0].lost);
|
||||
assert_eq!(solved.sketch.points, original.points);
|
||||
assert_eq!(solved.sketch.curves, original.curves);
|
||||
let material = state.bodies.iter().find(|body| body.feature == boss).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&material.body).unwrap().volume - 12.).abs() < 1e-7,
|
||||
"two-mm strip cannot expand to a full thirty-mm wall"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Stable configuration style targets. Display indices and placement names are never saved.
|
||||
use super::Column;
|
||||
use crate::{DocError, Document, FeatureKind, ModelState, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solvecraft_geom::Plane>>) -> bool {
|
||||
let mut planes = planes.into_iter();
|
||||
let Some(Some(first)) = planes.next() else { return false };
|
||||
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigFaceTarget {
|
||||
pub body: String,
|
||||
pub origin_feature: u64,
|
||||
pub name: String,
|
||||
/// Definition-frame point for diagnostics and rendering; never a resolver fallback.
|
||||
pub point: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub certificate: Option<super::FaceCertificate>,
|
||||
}
|
||||
impl ConfigFaceTarget {
|
||||
pub fn key(&self) -> String {
|
||||
// Length-prefix encoding keeps punctuation in body/name unambiguous.
|
||||
format!("{}:{}{}", self.body.len(), self.body, self.name)
|
||||
}
|
||||
pub fn matches(&self, face: &crate::appearance::FaceLook) -> bool {
|
||||
face.body == self.body && face.name.as_ref().is_some_and(|name| crate::naming::strip_piece(name) == self.name)
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(certificate) = &self.certificate {
|
||||
certificate.validate()?;
|
||||
}
|
||||
if self.body.is_empty()
|
||||
|| self.body.len() > 128
|
||||
|| self.name.is_empty()
|
||||
|| self.name.len() > 512
|
||||
|| self.origin_feature == 0
|
||||
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
|
||||
{
|
||||
return Err(DocError::Invalid("invalid configuration face target".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_face_target_json(target: &Value) -> Result<()> {
|
||||
let fields = target.as_object().ok_or_else(|| DocError::Invalid("face target must be an object".into()))?;
|
||||
if !(4..=5).contains(&fields.len())
|
||||
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str()))
|
||||
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|
||||
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
})
|
||||
{
|
||||
return Err(DocError::Invalid("invalid or oversized configuration face target".into()));
|
||||
}
|
||||
if let Some(certificate) = fields.get("certificate") {
|
||||
super::validate_certificate_json(certificate)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Budget malformed style nodes before decoding their typed representation.
|
||||
pub fn validate_styles_json(value: &Value) -> Result<()> {
|
||||
if let Some(columns) = value.get("configs").and_then(|v| v.get("columns")).and_then(Value::as_array) {
|
||||
if columns.len() > super::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("too many configuration columns".into()));
|
||||
}
|
||||
for column in columns {
|
||||
if let Some(target) = column.get("face_appearance") {
|
||||
validate_face_target_json(target)?;
|
||||
}
|
||||
if let Some(id) = column.get("component_appearance")
|
||||
&& id.as_u64().is_none_or(|id| id == 0)
|
||||
{
|
||||
return Err(DocError::Invalid("configuration component ID must be nonzero".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(faces) = value.get("appearances").and_then(|value| value.get("faces")).and_then(Value::as_array) {
|
||||
for face in faces {
|
||||
if let Some(certificate) = face.get("certificate") {
|
||||
super::validate_certificate_json(certificate)?;
|
||||
if !certificate.is_null()
|
||||
&& (face.get("strict_name").and_then(Value::as_bool) != Some(true)
|
||||
|| face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|
||||
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|
||||
|| face.get("point").and_then(Value::as_array).is_none_or(|point| {
|
||||
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
|
||||
}))
|
||||
{
|
||||
return Err(DocError::Invalid("invalid certified face appearance".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub(crate) fn flat_certificate_producer_matches(&self, producer: u64, certificate: &super::FaceCertificate) -> bool {
|
||||
use super::FaceCertificate;
|
||||
let Some(feature) = self.feature(producer) else { return false };
|
||||
match (certificate, &feature.kind) {
|
||||
(FaceCertificate::Box { .. }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => true,
|
||||
(FaceCertificate::ExtrudeCap { sketch, .. }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => sketch == current && extent.taper.is_none(),
|
||||
(FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: current, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == current && extent.taper.is_none()
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Certify named generator lineage; generic index-derived naming is unsupported.
|
||||
pub fn certify_config_face(&self, target: &ConfigFaceTarget) -> Result<()> {
|
||||
target.validate()?;
|
||||
let prefix = format!("F{}:", target.origin_feature);
|
||||
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
if crate::naming::strip_piece(&target.name) != target.name {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
if let Some(certificate) = &target.certificate {
|
||||
let valid = match (certificate, &feature.kind) {
|
||||
(super::FaceCertificate::Box { axis, high }, FeatureKind::Box { operation: crate::Operation::NewBody, .. }) => {
|
||||
["x", "y", "z"].get(*axis as usize).is_some_and(|axis| role == format!("{}{axis}", if *high { "+" } else { "-" }))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none()
|
||||
&& role == format!("cap:{}:{}", if *high { "high" } else { "low" }, super::cap_key(outer, holes))
|
||||
}
|
||||
(super::FaceCertificate::ExtrudeSide { sketch, curve }, FeatureKind::Extrude { sketch: producer_sketch, extent, operation: crate::Operation::NewBody, .. }) => {
|
||||
sketch == producer_sketch && extent.taper.is_none() && role == format!("side:{curve}")
|
||||
&& self.feature(*sketch).is_some_and(|feature| matches!(&feature.kind, FeatureKind::Sketch { sketch, .. } if sketch.curves.iter().any(|candidate| candidate.id == *curve && !candidate.construction && matches!(candidate.kind, solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Circle { .. } | solvecraft_sketch::CurveKind::Arc { .. }))))
|
||||
}
|
||||
(super::FaceCertificate::Imported { .. }, FeatureKind::Import { .. }) => role.strip_prefix("face:").is_some_and(|index| index.parse::<usize>().is_ok()),
|
||||
(super::FaceCertificate::ShellPlanar { source_producer, source, inner }, FeatureKind::Shell { .. }) => {
|
||||
*source_producer != target.origin_feature
|
||||
&& self.flat_certificate_producer_matches(*source_producer, source)
|
||||
&& super::shell_certificate_role(source, *inner).is_ok_and(|expected| role == expected)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
return if valid { Ok(()) } else { Err(DocError::Invalid("face certificate no longer matches its producer".into())) };
|
||||
}
|
||||
let valid = match &feature.kind {
|
||||
FeatureKind::Box { .. } => ["+x", "-x", "+y", "-y", "+z", "-z"].contains(&role),
|
||||
FeatureKind::Cylinder { .. } => ["top", "bottom", "side"].contains(&role),
|
||||
// Curved side lineage currently uses centroid/nearest-curve heuristics;
|
||||
// certify caps only until the naming layer supplies proven curve provenance.
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
extent.from.is_none()
|
||||
&& extent.start_offset.as_deref().is_none_or(|value| {
|
||||
crate::expr::is_literal(value)
|
||||
&& crate::expr::eval_with(value, &|name| Err(DocError::Unknown(name.into())))
|
||||
.ok()
|
||||
.and_then(|value| value.to_kind(crate::expr::Kind::Length).ok())
|
||||
.is_some_and(|value| value.abs() < 1e-12)
|
||||
})
|
||||
&& extent.distance2.is_none()
|
||||
&& ["start", "top", "bottom"].contains(&role)
|
||||
}
|
||||
FeatureKind::Revolve { angle2, symmetric, .. } => angle2.is_none() && !symmetric && ["start", "end"].contains(&role),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Err(DocError::Invalid("this face has no supported stable semantic lineage".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Verify the current named pieces describe one surface, not ambiguous generated roles.
|
||||
pub fn configuration_face_indices(&self, body: &crate::ModelBody, target: &ConfigFaceTarget) -> Result<Vec<usize>> {
|
||||
self.certify_config_face(target)?;
|
||||
if body.body.is_mesh() || body.definition_name() != target.body {
|
||||
return Err(DocError::Invalid("configured face definition does not match".into()));
|
||||
}
|
||||
if let Some(super::FaceCertificate::ShellPlanar { source_producer, source, inner }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, current)| {
|
||||
matches!(current, Some(solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(producer), source: current_source, inner: current_inner, .. }) if *producer == target.origin_feature && inner == current_inner && super::flat_source_matches(source, current_source, *source_producer)).then_some(index)
|
||||
}).collect();
|
||||
// A shell can split one original support into several pieces. All pieces
|
||||
// must retain the same canonical frame, not merely lie on a common plane.
|
||||
if !shell_plane_family(indices.iter().map(|index| body.body.face_plane(*index))) {
|
||||
return Err(DocError::Unknown("shell planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if matches!(target.certificate, Some(super::FaceCertificate::Box { .. } | super::FaceCertificate::ExtrudeCap { .. })) {
|
||||
let indices: Vec<_> = body
|
||||
.body
|
||||
.face_sources()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter_map(|(index, source)| {
|
||||
let matched = match (&target.certificate, source) {
|
||||
(
|
||||
Some(super::FaceCertificate::Box { axis, high }),
|
||||
Some(solvecraft_kernel::FaceSource::Box { producer: Some(producer), axis: current_axis, high: current_high, .. }),
|
||||
) => *producer == target.origin_feature && axis == current_axis && high == current_high,
|
||||
(
|
||||
Some(super::FaceCertificate::ExtrudeCap { sketch, high, outer, holes }),
|
||||
Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap {
|
||||
producer,
|
||||
sketch: current_sketch,
|
||||
high: current_high,
|
||||
outer: current_outer,
|
||||
holes: current_holes,
|
||||
..
|
||||
}),
|
||||
) => {
|
||||
*producer == target.origin_feature
|
||||
&& sketch == current_sketch
|
||||
&& high == current_high
|
||||
&& outer == current_outer
|
||||
&& holes == current_holes
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
matched.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Unknown("attached planar face certificate is missing or ambiguous".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
|
||||
}).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if let Some(super::FaceCertificate::Imported { payload_sha256, item, face }) = &target.certificate {
|
||||
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
|
||||
matches!(source, Some(solvecraft_kernel::FaceSource::Imported { item: current_item, face: current_face, payload_sha256: Some(digest), producer: Some(producer) }) if current_item == item && current_face == face && digest == payload_sha256 && *producer == target.origin_feature).then_some(index)
|
||||
}).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown("immutable import face certificate no longer exists".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
let names = crate::naming::face_names(body);
|
||||
let indices: Vec<usize> =
|
||||
names.iter().enumerate().filter(|(_, name)| crate::naming::strip_piece(name) == target.name).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
return Err(DocError::Unknown(format!("configured face `{}` on `{}` no longer exists", target.name, target.body)));
|
||||
}
|
||||
if let Some(certificate) = &target.certificate {
|
||||
for index in &indices {
|
||||
if self.configuration_face_certificate(body, *index)?.as_ref() != Some(certificate) {
|
||||
return Err(DocError::Invalid("face pieces do not share the exact certified source".into()));
|
||||
}
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
if target.name.ends_with(":side") {
|
||||
if indices.len() != 1 {
|
||||
return Err(DocError::Invalid("split curved face lineage is not yet certified".into()));
|
||||
}
|
||||
return Ok(indices);
|
||||
}
|
||||
// Caps and box faces must share an oriented plane. This rejects generic cap-role
|
||||
// collisions while permitting the coplanar pieces of a split supported face.
|
||||
let mesh = body.mesh();
|
||||
let mut plane: Option<(Vec3, Vec3)> = None;
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if indices.binary_search(&(*face as usize)).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
|
||||
let Some(normal) = (b - a).cross(c - a).normalized() else { continue };
|
||||
if let Some((point, base)) = plane {
|
||||
let magnitude = [point.x, point.y, point.z, a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z]
|
||||
.iter()
|
||||
.fold(body.body.size(), |scale, value| scale.max(value.abs()));
|
||||
let tolerance = (magnitude * f64::EPSILON * 128.0).max(1e-7);
|
||||
if normal.dot(base) < 1.0 - 1e-6 || [a, b, c].iter().any(|vertex| (*vertex - point).dot(base).abs() > tolerance) {
|
||||
return Err(DocError::Invalid("face lineage names more than one oriented surface".into()));
|
||||
}
|
||||
} else {
|
||||
plane = Some((a, normal));
|
||||
}
|
||||
}
|
||||
if plane.is_none() {
|
||||
return Err(DocError::Invalid("configured face has no valid surface".into()));
|
||||
}
|
||||
Ok(indices)
|
||||
}
|
||||
pub fn config_face_look(&self, target: &ConfigFaceTarget) -> Option<crate::appearance::Look> {
|
||||
self.appearances.faces.iter().rev().find(|face| target.matches(face)).map(|face| face.look.clone())
|
||||
}
|
||||
/// Validate against the final rebuilt definition state. Missing names never pick nearby faces.
|
||||
pub fn configuration_style_status(&self, state: &ModelState) -> Result<Vec<serde_json::Value>> {
|
||||
let mut result = Vec::new();
|
||||
for column in &self.configs.columns {
|
||||
match column {
|
||||
Column::ComponentAppearance(id) => {
|
||||
let component =
|
||||
self.components.iter().find(|c| c.id == *id).ok_or_else(|| DocError::Unknown(format!("component {id} is gone")))?;
|
||||
result.push(serde_json::json!({"key":self.config_key(column),"target":component.name,"shared":true,"dormant":false}));
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
self.certify_config_face(target)?;
|
||||
let dormant = match state.body(&target.body) {
|
||||
None if self.feature(target.origin_feature).is_some_and(|feature| feature.suppressed) => true,
|
||||
None => return Err(DocError::Unknown(format!("configured face body `{}` is gone", target.body))),
|
||||
Some(body) => {
|
||||
self.configuration_face_indices(body, target)?;
|
||||
false
|
||||
}
|
||||
};
|
||||
result.push(serde_json::json!({"key":self.config_key(column),"target":format!("{} / {}",target.body,target.name),"shared":true,"dormant":dormant}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
/// Maintain columns and row cell alignment when component definitions are deleted.
|
||||
pub fn remove_configuration_components(&mut self, ids: &[u64]) {
|
||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c,Column::ComponentAppearance(id) if ids.contains(id))).collect();
|
||||
let mut mask = keep.iter();
|
||||
self.configs.columns.retain(|_| mask.next().copied().unwrap_or(false));
|
||||
for row in &mut self.configs.rows {
|
||||
let mut mask = keep.iter();
|
||||
row.cells.retain(|_| mask.next().copied().unwrap_or(false));
|
||||
}
|
||||
self.appearances.components.retain(|id, _| !ids.contains(id));
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,10 @@ pub(crate) struct Ctx<'a> {
|
||||
pub doc: &'a Document,
|
||||
pub vals: &'a BTreeMap<String, Value>,
|
||||
pub st: &'a ModelState,
|
||||
/// How deep the plane resolution that led here already is. A plane built from model
|
||||
/// geometry resolves its sides through the document again, so the count has to continue
|
||||
/// across that hop; restarting it lets a plane that refers to itself recurse forever.
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
fn bad(m: impl Into<String>) -> DocError {
|
||||
@@ -281,7 +285,7 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
|
||||
/// A reference as a plane.
|
||||
pub(crate) fn plane(ctx: &Ctx, g: &GeoRef) -> Result<Plane> {
|
||||
match g {
|
||||
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, 0, Some(ctx.st)),
|
||||
GeoRef::Plane { plane } => ctx.doc.resolve_plane_in(ctx.vals, plane, ctx.depth + 1, Some(ctx.st)),
|
||||
GeoRef::Face { at, name } => {
|
||||
let (b, fi, q) = face_of(ctx.st, *at, name)?;
|
||||
match kernel::face_surface(&b.body, fi) {
|
||||
@@ -527,7 +531,7 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
let (p, tan) = along(&path_points(ctx, path)?, t).ok_or_else(|| bad("the path has no length"))?;
|
||||
Plane::from_normal(p, tan).ok_or_else(degenerate)
|
||||
}
|
||||
_ => ctx.doc.resolve_plane_in(ctx.vals, p, 0, Some(ctx.st)),
|
||||
_ => ctx.doc.resolve_plane_in(ctx.vals, p, ctx.depth + 1, Some(ctx.st)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+224
-19
@@ -56,18 +56,30 @@ pub enum PlaneRef {
|
||||
AlongPath { path: Box<GeoRef>, t: String },
|
||||
/// Through a straight edge (or line, axis) and square to a plane (or planar face).
|
||||
Perpendicular { plane: Box<GeoRef>, line: Box<GeoRef> },
|
||||
/// A planar body face: found again on each evaluation (the face with this plane's normal
|
||||
/// nearest `at`), so sketches on it follow when earlier features change; `plane` is the
|
||||
/// plane as picked (its frame, and the fallback when the face is gone). `name` is the face's
|
||||
/// persistent name (found first; see `naming`).
|
||||
/// A planar body face. Certified attachments resolve only their exact source and report
|
||||
/// an error when it is lost; `plane` then supplies a repair guide. Legacy references without
|
||||
/// an anchor try the persistent `name`, then the nearest parallel face at `at`, and retain
|
||||
/// the picked `plane` as their historical fallback.
|
||||
Face {
|
||||
plane: Plane,
|
||||
at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
/// Exact attachment identity for newly certified faces; absent on legacy designs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
anchor: Option<Box<crate::FaceAnchor>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Complete source curves of an outer boundary and each of its holes. Unlike a
|
||||
/// span multiset, this identity is independent of adaptive spline subdivision.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WholeProfileBoundary {
|
||||
pub outer: Vec<String>,
|
||||
pub holes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Which closed profiles of a sketch a feature uses.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -76,9 +88,16 @@ pub enum ProfileSel {
|
||||
All,
|
||||
/// Profiles by index in the solved sketch's profile list.
|
||||
Indices { indices: Vec<usize> },
|
||||
/// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable
|
||||
/// across parameter changes.
|
||||
/// Profiles whose outer loop is made of exactly these curves, or pieces of them where other
|
||||
/// curves cut them (order and repeats don't matter; see `sketch::profile_by_curves`). Found
|
||||
/// again on every evaluation, so stable across parameter changes; no match or several is
|
||||
/// an error, never another profile.
|
||||
Curves { loops: Vec<Vec<String>> },
|
||||
/// Complete current boundaries of topology-aware analytic offset associations.
|
||||
/// The local association identity survives retirement and resurrection of arc roles.
|
||||
OffsetBoundaries { associations: Vec<String> },
|
||||
/// Exact whole source coverage, including holes; partial source curves do not match.
|
||||
WholeBoundaries { profiles: Vec<WholeProfileBoundary> },
|
||||
/// The profile containing each sketch point.
|
||||
Points { points: Vec<Vec2> },
|
||||
}
|
||||
@@ -116,6 +135,16 @@ pub enum Direction {
|
||||
Symmetric,
|
||||
}
|
||||
|
||||
/// Angular starts in older designs used the raw axis sign. New revolves measure starts
|
||||
/// along the same sketch-normal sweep direction as the kernel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RevolveConvention {
|
||||
#[default]
|
||||
LegacyAxisStart,
|
||||
SketchNormal,
|
||||
}
|
||||
|
||||
/// Extent of an extrude.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Extent {
|
||||
@@ -161,10 +190,97 @@ pub enum AxisRef {
|
||||
Line { origin: Vec3, dir: Vec3 },
|
||||
}
|
||||
|
||||
/// Persistent cylindrical Emboss selection, authored in the feature component frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmbossFace {
|
||||
pub body: String,
|
||||
pub at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub path: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub author_path: Vec<u64>,
|
||||
}
|
||||
|
||||
impl EmbossFace {
|
||||
/// The selected sketch placement, shared by evaluation and interactive guides.
|
||||
pub fn sketch_frame(&self, doc: &Document, author: u64, sketch_owner: u64, body: &crate::ModelBody) -> Result<crate::Mat> {
|
||||
let owner = doc.model_body_component(body);
|
||||
if sketch_owner == owner {
|
||||
return Self::component_frame(doc, owner, &self.path);
|
||||
}
|
||||
if sketch_owner == author {
|
||||
return Self::component_frame(doc, author, &self.author_path);
|
||||
}
|
||||
let path = doc.first_path(sketch_owner);
|
||||
let mut component = sketch_owner;
|
||||
for _ in 0..64 {
|
||||
if component == 0 {
|
||||
break;
|
||||
}
|
||||
let mut occurrences = doc.occurrences.iter().filter(|o| o.component == component);
|
||||
let occurrence = occurrences.next().ok_or_else(|| DocError::Invalid("sketch placement no longer exists".into()))?;
|
||||
if occurrences.next().is_some() {
|
||||
return Err(DocError::Invalid("sketch placement is ambiguous".into()));
|
||||
}
|
||||
component = occurrence.parent;
|
||||
}
|
||||
if component != 0 {
|
||||
return Err(DocError::Invalid("sketch placement exceeds depth limit".into()));
|
||||
}
|
||||
Self::component_frame(doc, sketch_owner, &path)
|
||||
}
|
||||
|
||||
/// Resolve this exact placement without expanding every assembly instance.
|
||||
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
|
||||
let owner = doc.model_body_component(body);
|
||||
let mut frame = Self::component_frame(doc, owner, &self.path)?;
|
||||
if let Some(offset) = doc.model_body_offset(body) {
|
||||
frame = crate::mat_mul(&frame, offset);
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// Resolve one saved component path without expanding other occurrences.
|
||||
pub fn component_frame(doc: &Document, owner: u64, path: &[u64]) -> Result<crate::Mat> {
|
||||
if path.len() > 64 || doc.occurrences.len() > 100_000 {
|
||||
return Err(DocError::Invalid("selected cylinder path exceeds work limits".into()));
|
||||
}
|
||||
let mut frame = crate::IDENTITY;
|
||||
let mut component = 0;
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for id in path {
|
||||
if *id == 0 || !seen.insert(*id) {
|
||||
return Err(DocError::Invalid("invalid selected cylinder path".into()));
|
||||
}
|
||||
let occurrence = doc
|
||||
.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id && o.parent == component)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder placement no longer exists".into()))?;
|
||||
frame = crate::mat_mul(&frame, &occurrence.transform);
|
||||
component = occurrence.component;
|
||||
}
|
||||
if path.is_empty() {
|
||||
frame = doc.component_transform(owner);
|
||||
} else if component != owner {
|
||||
return Err(DocError::Invalid("selected cylinder path belongs to another component".into()));
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum FeatureKind {
|
||||
/// A recorded timeline ownership boundary. Geometry is converted from its
|
||||
/// current local frame into the destination component frame exactly once.
|
||||
BodyTransfer {
|
||||
bodies: Vec<String>,
|
||||
from: u64,
|
||||
to: u64,
|
||||
transform: crate::Mat,
|
||||
},
|
||||
Sketch {
|
||||
plane: PlaneRef,
|
||||
sketch: Sketch,
|
||||
@@ -195,6 +311,9 @@ pub enum FeatureKind {
|
||||
/// Symmetric: `angle` split evenly either side of the profile.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
symmetric: bool,
|
||||
/// Missing fields retain the angular placement of existing designs.
|
||||
#[serde(default)]
|
||||
convention: RevolveConvention,
|
||||
/// To Object: turn until the face, vertex or point here.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to: Option<Vec3>,
|
||||
@@ -437,13 +556,15 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// Raise (emboss) or sink (deboss) sketch profiles on the planar face they lie on.
|
||||
/// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall.
|
||||
Emboss {
|
||||
sketch: u64,
|
||||
profiles: ProfileSel,
|
||||
depth: String,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
deboss: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
face: Option<EmbossFace>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
@@ -1040,7 +1161,8 @@ impl FeatureKind {
|
||||
| K::SurfaceTrim { .. }
|
||||
| K::Move { .. }
|
||||
| K::Import { .. }
|
||||
| K::MeshImport { .. } => Vec::new(),
|
||||
| K::MeshImport { .. }
|
||||
| K::BodyTransfer { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1065,6 +1187,7 @@ impl FeatureKind {
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "ExtrudeFeature",
|
||||
FeatureKind::Revolve { .. } => "RevolveFeature",
|
||||
@@ -1129,6 +1252,7 @@ impl FeatureKind {
|
||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||
pub fn base_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "Extrude",
|
||||
FeatureKind::Revolve { .. } => "Revolve",
|
||||
@@ -1207,6 +1331,22 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile selections and their source sketches without cloning feature data.
|
||||
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
|
||||
match self {
|
||||
FeatureKind::Extrude { sketch, profiles, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles, .. }
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles, .. }
|
||||
| FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Patch { sketch: Some(sketch), profiles, .. } => vec![(*sketch, profiles)],
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
|
||||
match self {
|
||||
FeatureKind::Shell { faces, .. }
|
||||
@@ -1220,6 +1360,7 @@ impl FeatureKind {
|
||||
FeatureKind::Extrude { extent, .. } => extent.to.iter_mut().chain(extent.from.iter_mut()).collect(),
|
||||
FeatureKind::Revolve { to: Some(p), .. } => vec![p],
|
||||
FeatureKind::Thread { face, .. } => vec![face],
|
||||
FeatureKind::Emboss { face: Some(face), .. } => vec![&mut face.at],
|
||||
FeatureKind::Boss { position, .. } | FeatureKind::SnapFit { position, .. } | FeatureKind::Rest { position, .. } => vec![position],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
@@ -1255,6 +1396,9 @@ impl FeatureKind {
|
||||
if let Some(d) = &extent.taper {
|
||||
v.push(d);
|
||||
}
|
||||
if let Some(d) = &extent.to_offset {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
v.push(angle);
|
||||
@@ -1380,7 +1524,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
|
||||
FeatureKind::Patch { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -1586,7 +1730,7 @@ impl Document {
|
||||
/// else its component's, else its material's look, else the colour it was imported with.
|
||||
/// (Faces may have their own: `face_looks`.)
|
||||
pub fn body_color(&self, b: &crate::ModelBody) -> Option<[f32; 3]> {
|
||||
self.body_look(&b.name, b.feature).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
|
||||
self.model_body_look(b).map(|l| l.rgb()).or_else(|| b.body.color()).or_else(|| b.imported.as_ref().and_then(|i| i.color))
|
||||
}
|
||||
|
||||
/// The model with each body carrying the colour, opacity and face colours it is shown with
|
||||
@@ -1595,17 +1739,12 @@ impl Document {
|
||||
let mut st = state.clone();
|
||||
for b in &mut st.bodies {
|
||||
let c = self.body_color(b);
|
||||
let look = self.body_look(&b.name, b.feature);
|
||||
let faces: Vec<solvecraft_kernel::FacePaint> = self
|
||||
.face_colors(b)
|
||||
.into_iter()
|
||||
.map(|(face, l)| solvecraft_kernel::FacePaint { face, color: l.rgb(), opacity: l.opacity as f32 })
|
||||
.collect();
|
||||
let opacity = look
|
||||
.map(|l| l.opacity as f32)
|
||||
.or_else(|| b.body.paint().map(|p| p.opacity))
|
||||
.or_else(|| b.imported.as_ref().and_then(|i| i.opacity))
|
||||
.unwrap_or(1.0);
|
||||
let opacity = self.model_body_opacity(b) as f32;
|
||||
let paint = solvecraft_kernel::Paint { opacity, faces };
|
||||
if c != b.body.color() || b.body.paint() != Some(&paint) {
|
||||
b.body = b.body.clone().with_color(c).with_paint(Some(paint));
|
||||
@@ -1621,9 +1760,36 @@ impl Document {
|
||||
|
||||
/// The component a body belongs to: where it was moved, else its feature's.
|
||||
pub fn body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.features
|
||||
.iter()
|
||||
.take(self.marker.unwrap_or(usize::MAX))
|
||||
.rev()
|
||||
.find_map(|f| {
|
||||
if f.suppressed {
|
||||
return None;
|
||||
}
|
||||
match &f.kind {
|
||||
FeatureKind::BodyTransfer { bodies, to, .. } if bodies.iter().any(|b| b == body) => Some(*to),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| self.legacy_body_component(body, feature))
|
||||
}
|
||||
|
||||
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Ownership in this evaluated timeline state, rather than a later transfer.
|
||||
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
|
||||
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
|
||||
}
|
||||
|
||||
/// Recorded transfers already converted geometry into the new local frame.
|
||||
pub fn model_body_offset(&self, body: &crate::ModelBody) -> Option<&crate::Mat> {
|
||||
if body.transferred { None } else { self.body_offsets.get(&body.name) }
|
||||
}
|
||||
|
||||
/// Add a component under `parent` and return its id.
|
||||
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
|
||||
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
|
||||
@@ -1704,6 +1870,12 @@ impl Document {
|
||||
if self.features.len() >= MAX_FEATURES {
|
||||
return Err(DocError::Invalid("too many features".into()));
|
||||
}
|
||||
let component = if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &kind {
|
||||
crate::validate_body_transfer(self, bodies, *from, *to, transform)?;
|
||||
*to
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id + 1;
|
||||
let name = match name {
|
||||
@@ -1716,7 +1888,7 @@ impl Document {
|
||||
suppressed: false,
|
||||
body_names: Vec::new(),
|
||||
param_names: Vec::new(),
|
||||
component: 0,
|
||||
component,
|
||||
edge_refs: Vec::new(),
|
||||
edge_names: Vec::new(),
|
||||
face_names: Vec::new(),
|
||||
@@ -1739,9 +1911,26 @@ impl Document {
|
||||
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
|
||||
let mut gone = vec![id];
|
||||
gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id));
|
||||
let gone_params: Vec<String> = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect();
|
||||
self.configs
|
||||
.rules
|
||||
.retain(|rule| !rule.references_features(&gone) && !rule.parameter_references().iter().any(|name| gone_params.contains(name)));
|
||||
self.features.retain(|f| !gone.contains(&f.id));
|
||||
// Configuration columns of deleted features go with them.
|
||||
let keep: Vec<bool> = self.configs.columns.iter().map(|c| !matches!(c, crate::config::Column::Suppress(id) if gone.contains(id))).collect();
|
||||
let keep: Vec<bool> = self
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
crate::config::Column::Suppress(id) => !gone.contains(id),
|
||||
crate::config::Column::Param(name) => !gone_params.contains(name),
|
||||
crate::config::Column::FaceAppearance(target) => !gone.contains(&target.origin_feature),
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
self.appearances
|
||||
.faces
|
||||
.retain(|face| !face.strict_name || !face.name.as_ref().is_some_and(|name| gone.iter().any(|id| name.starts_with(&format!("F{id}:")))));
|
||||
if keep.contains(&false) {
|
||||
let mut k = keep.iter();
|
||||
self.configs.columns.retain(|_| k.next().copied().unwrap_or(true));
|
||||
@@ -1768,6 +1957,19 @@ impl Document {
|
||||
|
||||
fn referenced_names(&self) -> Vec<String> {
|
||||
let mut used: Vec<String> = Vec::new();
|
||||
for rule in &self.configs.rules {
|
||||
used.extend(rule.parameter_references());
|
||||
}
|
||||
for (index, column) in self.configs.columns.iter().enumerate() {
|
||||
if let crate::config::Column::Param(name) = column {
|
||||
used.push(name.clone());
|
||||
for row in &self.configs.rows {
|
||||
if let Some(value) = row.cells.get(index) {
|
||||
used.extend(expr::references(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in &self.features {
|
||||
for e in f.kind.expressions() {
|
||||
used.extend(expr::references(e));
|
||||
@@ -1930,7 +2132,7 @@ impl Document {
|
||||
| PlaneRef::AlongPath { .. }
|
||||
| PlaneRef::Perpendicular { .. } => {
|
||||
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
|
||||
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st }, p)
|
||||
crate::construct::resolve_geo_plane(&crate::construct::Ctx { doc: self, vals, st, depth }, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1960,6 +2162,9 @@ impl Document {
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
for c in &sk.constraints {
|
||||
sk.validate(&c.kind)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+621
-116
File diff suppressed because it is too large
Load Diff
+338
-58
@@ -2,18 +2,66 @@
|
||||
//! kernel's extrude, boolean, offset and transform operations).
|
||||
|
||||
use super::*;
|
||||
use crate::EmbossFace;
|
||||
use crate::apply_point;
|
||||
use crate::sheet;
|
||||
use solvecraft_geom::{Loop2, Seg2};
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets } => {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
|
||||
let regions = select_profiles(ss, profiles)?;
|
||||
let d = val(vals, depth, Kind::Length)?;
|
||||
if !(d > 0.0) {
|
||||
return Err(DocError::Invalid("the emboss depth must be positive".into()));
|
||||
}
|
||||
if let Some(face) = face {
|
||||
if !targets.is_empty() && (targets.len() != 1 || targets.first() != Some(&face.body)) {
|
||||
return Err(DocError::Invalid("cylinder emboss must keep its selected target body".into()));
|
||||
}
|
||||
let i =
|
||||
st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?;
|
||||
let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?;
|
||||
let body_frame = face.body_frame(doc, &mb)?;
|
||||
let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?;
|
||||
let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0);
|
||||
let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?;
|
||||
let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, &mb)?;
|
||||
let sketch_transform = crate::mat_mul(&inverse, &sketch_frame);
|
||||
let plane = Plane::new(
|
||||
crate::apply_point(&sketch_transform, ss.plane.origin),
|
||||
crate::apply_vector(&sketch_transform, ss.plane.x),
|
||||
crate::apply_vector(&sketch_transform, ss.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?;
|
||||
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at);
|
||||
let name = f
|
||||
.face_names
|
||||
.first()
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face has no persistent identity".into()))?;
|
||||
{
|
||||
let mut scoped = ModelState::default();
|
||||
scoped.bodies.push(mb.clone());
|
||||
let (picked, split) = crate::naming::point_on_face(&scoped, name, at)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?;
|
||||
if split {
|
||||
return Err(DocError::Invalid("selected cylinder face was split".into()));
|
||||
}
|
||||
let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked)
|
||||
.ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?;
|
||||
if crate::naming::face_names(&mb).get(index as usize) != Some(name) {
|
||||
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
|
||||
}
|
||||
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
|
||||
}
|
||||
let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, ®ions, if *deboss { -d } else { d })?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, result, mb.feature);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) };
|
||||
let tools = kernel::extrude(&ss.plane, ®ions, a, b)?;
|
||||
if st.bodies.is_empty() {
|
||||
@@ -242,12 +290,22 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
|
||||
}
|
||||
|
||||
/// Rebuild a sheet body's solid from its model.
|
||||
fn replace_sheet_body(mb: &mut ModelBody, body: Body) {
|
||||
let mut next = ModelBody::new(mb.name.clone(), body, mb.feature);
|
||||
next.component = mb.component;
|
||||
next.transferred = mb.transferred;
|
||||
next.source_name = mb.source_name.clone();
|
||||
next.source_transform = mb.source_transform;
|
||||
next.imported = mb.imported.clone();
|
||||
*mb = next;
|
||||
}
|
||||
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
*mb = ModelBody::new(mb.name.clone(), solid, mb.feature);
|
||||
replace_sheet_body(mb, solid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -466,7 +524,7 @@ pub(super) fn sheet_eval(
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
*b = ModelBody::new(b.name.clone(), solid, b.feature);
|
||||
replace_sheet_body(b, solid);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
@@ -481,79 +539,301 @@ fn poly_area(p: &[Vec2]) -> f64 {
|
||||
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern
|
||||
/// (parallel to a panel, through its thickness). Returns the sheet bodies handled.
|
||||
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
/// Current unmodified extrusion caps, including placement, certify the tool's depth.
|
||||
/// Tessellation can drift in thickness at distant coordinates and cannot certify a slab.
|
||||
fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option<(Plane, Region2)>)> {
|
||||
use kernel::FaceSource;
|
||||
#[derive(PartialEq)]
|
||||
enum Signature<'a> {
|
||||
Raw(&'a Region2),
|
||||
Certified(&'a [String], &'a [Vec<String>]),
|
||||
}
|
||||
let sources = body.face_sources()?;
|
||||
if sources.len() != body.face_count() || sources.len() > 16_384 {
|
||||
return None;
|
||||
}
|
||||
let n = source.normal();
|
||||
let mut family = None;
|
||||
let mut caps = [None, None];
|
||||
let mut projection_roundoff: f64 = 0.0;
|
||||
let mut footprint = None;
|
||||
for (index, entry) in sources.iter().enumerate() {
|
||||
let (group, cap) = match entry.as_ref()? {
|
||||
FaceSource::ExtrudeSide { .. } => (None, None),
|
||||
FaceSource::ExtrudeCap { high, region, plane } => (None, Some((*high, *plane, Signature::Raw(region)))),
|
||||
FaceSource::CertifiedExtrudeSide { producer, sketch, .. } => (Some((*producer, *sketch)), None),
|
||||
FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane } => {
|
||||
(Some((*producer, *sketch)), Some((*high, *plane, Signature::Certified(outer, holes))))
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
match family {
|
||||
None => family = Some(group),
|
||||
Some(previous) if previous != group => return None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some((high, plane, signature)) = cap {
|
||||
if high && let Signature::Raw(region) = &signature {
|
||||
footprint = Some((plane, (*region).clone()));
|
||||
}
|
||||
let valid = |p: &Plane| {
|
||||
p.origin.is_finite()
|
||||
&& p.x.is_finite()
|
||||
&& p.y.is_finite()
|
||||
&& (p.x.len2() - 1.0).abs() <= 1e-10
|
||||
&& (p.y.len2() - 1.0).abs() <= 1e-10
|
||||
&& p.x.dot(p.y).abs() <= 1e-10
|
||||
&& p.normal().cross(n).len() <= 1e-10
|
||||
};
|
||||
let current = body.planar_face(index)?;
|
||||
if !valid(&plane) || !valid(¤t) {
|
||||
return None;
|
||||
}
|
||||
// Bound floating point projection separately from model geometry tolerance.
|
||||
let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0);
|
||||
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff {
|
||||
return None;
|
||||
}
|
||||
projection_roundoff = projection_roundoff.max(roundoff);
|
||||
let distance = (plane.origin - source.origin).dot(n);
|
||||
if !distance.is_finite() || caps[usize::from(high)].is_some() {
|
||||
return None;
|
||||
}
|
||||
caps[usize::from(high)] = Some((distance, signature));
|
||||
}
|
||||
}
|
||||
let [Some((low, low_signature)), Some((high, high_signature))] = caps else { return None };
|
||||
(low_signature == high_signature && low != high).then_some((low.min(high), low.max(high), projection_roundoff, footprint))
|
||||
}
|
||||
|
||||
/// Exact prismatic machining, retained in sheet material/depth metadata.
|
||||
pub(super) fn sheet_cut(
|
||||
f: &Feature,
|
||||
st: &mut ModelState,
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let targets = if targets.is_empty() { &f.participants } else { targets };
|
||||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||||
let eligible = |original: &crate::sheet::SheetBody| {
|
||||
all || if targets.is_empty() {
|
||||
st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id))
|
||||
} else {
|
||||
targets.contains(&original.body)
|
||||
}
|
||||
};
|
||||
if !st.sheets.iter().any(&eligible) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let n = ss.plane.normal();
|
||||
// How far the tools reach along the sketch normal.
|
||||
let mut work = sheet::FlatWork::new();
|
||||
let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count()));
|
||||
if tools.len() > 1000 || faces > 16_384 {
|
||||
return Err(DocError::Invalid("sheet machining tools exceed the bounded geometry budget".into()));
|
||||
}
|
||||
work.charge(faces.saturating_mul(5))?;
|
||||
let mut span = (f64::MAX, f64::MIN);
|
||||
for t in tools {
|
||||
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions {
|
||||
let d = (p - ss.plane.origin).dot(n);
|
||||
span = (span.0.min(d), span.1.max(d));
|
||||
let mut cap_roundoff: f64 = 0.0;
|
||||
let mut current_footprints = Vec::new();
|
||||
for tool in tools {
|
||||
let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, &ss.plane) else {
|
||||
// Modified tools need a current geometric prism certificate. The generic
|
||||
// sheet preflight permits actual no-change, otherwise refuses stale metadata.
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if span.0 != f64::MAX && ((span.0 - lo).abs() > cap_roundoff + roundoff || (span.1 - hi).abs() > cap_roundoff + roundoff) {
|
||||
// Different profile tools cannot be represented as one filled depth interval.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
span = (span.0.min(lo), span.1.max(hi));
|
||||
cap_roundoff = cap_roundoff.max(roundoff);
|
||||
if let Some(footprint) = footprint {
|
||||
current_footprints.push(footprint);
|
||||
}
|
||||
}
|
||||
let mut handled = Vec::new();
|
||||
for i in 0..st.sheets.len() {
|
||||
let Some(sh) = st.sheets.get(i).cloned() else { continue };
|
||||
if !targets.is_empty() && !targets.contains(&sh.body) {
|
||||
// Only untrimmed Distance tools may use their exact authored scalar interval.
|
||||
// The cap certificate still qualifies their current physical slab above.
|
||||
let selected_edges =
|
||||
regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add);
|
||||
work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?;
|
||||
let nominal = constructor.filter(|c| c.plane == ss.plane).and_then(|c| {
|
||||
let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?;
|
||||
(current_footprints.len() == tools.len()
|
||||
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
|
||||
.then_some(c.interval)
|
||||
});
|
||||
let mut plans = Vec::new();
|
||||
'sheets: for original in &st.sheets {
|
||||
if !eligible(original) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<(Option<usize>, Region2)> = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })]
|
||||
}
|
||||
} else {
|
||||
sh.parallel_panels(&ss.plane)
|
||||
let Some(mb) = st.body(&original.body) else {
|
||||
continue;
|
||||
};
|
||||
let mut added = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
// The panel's faces along the sketch normal must both be inside the cut.
|
||||
let w = sh.world(pi);
|
||||
let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n);
|
||||
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::<Vec<_>>() } else { panel.outer.polyline(1e-3) };
|
||||
for r in ®ions {
|
||||
let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue };
|
||||
let fp = flat.outer.polyline(1e-3);
|
||||
let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p));
|
||||
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
|
||||
added.push(flat);
|
||||
let mut sh = original.clone();
|
||||
if *operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, *operation)? {
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if let Some(bends) = sh.unmodeled_solid(&ss.plane)? {
|
||||
for tool in tools {
|
||||
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
|
||||
return Err(DocError::Invalid(
|
||||
"this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if added.is_empty() {
|
||||
continue;
|
||||
let candidates = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() }
|
||||
} else {
|
||||
sh.material_panels(Some(&ss.plane), &mut work)?
|
||||
};
|
||||
let mut modeled = false;
|
||||
for (pi, panel) in candidates {
|
||||
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let sign = crate::apply_vector(&w, Vec3::Z).dot(n);
|
||||
if sign.abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign);
|
||||
let roundoff = (cap_roundoff
|
||||
+ 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + ss.plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0))
|
||||
/ sign.abs();
|
||||
let nominal_depth = if apply_point(&w, Vec3::ZERO) == ss.plane.origin
|
||||
&& let Some((lo, hi)) = nominal
|
||||
&& (span.0 - lo).abs() <= roundoff
|
||||
&& (span.1 - hi).abs() <= roundoff
|
||||
{
|
||||
// Equality of the two frame origins proves zero origin displacement;
|
||||
// use authored distances, never an inferred endpoint coincidence.
|
||||
let normal = crate::apply_vector(&w, Vec3::X).cross(crate::apply_vector(&w, Vec3::Y));
|
||||
let exact_sign = if normal == n {
|
||||
1.0
|
||||
} else if normal == -n {
|
||||
-1.0
|
||||
} else {
|
||||
sign
|
||||
};
|
||||
Some((lo / exact_sign, hi / exact_sign))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if nominal_depth.is_none() && roundoff > 1e-7 {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
plans.push((original.body.clone(), Some((original.clone(), mb.body.clone()))));
|
||||
continue 'sheets;
|
||||
}
|
||||
let (a, b) = nominal_depth.unwrap_or((a, b));
|
||||
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
|
||||
// Sub-resolution layers must not be silently erased by endpoint snapping.
|
||||
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
|
||||
let mapped = if extent.to.is_some() && current_footprints.len() == tools.len() {
|
||||
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
} else {
|
||||
regions.iter().map(|r| sh.region_to_flat(pi, &ss.plane, r)).collect::<Result<Vec<_>>>()?
|
||||
};
|
||||
if *operation == Operation::Intersect {
|
||||
if z1 <= z0 {
|
||||
sh.holes.push(panel);
|
||||
modeled = true;
|
||||
continue;
|
||||
}
|
||||
let tool = sheet::material_union(mapped, &mut work)?;
|
||||
let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?;
|
||||
let removed = sheet::material_boolean(std::slice::from_ref(&panel), &retained, kernel::BoolOp::Cut, &mut work)?;
|
||||
if removed.is_empty() && !sh.material_outside_depth(&panel, z0, z1, &mut work)? {
|
||||
modeled = true;
|
||||
continue;
|
||||
}
|
||||
if !retained.is_empty() && !depth_resolved {
|
||||
return Err(DocError::Invalid(
|
||||
"sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(),
|
||||
));
|
||||
}
|
||||
sh.holes.extend(removed);
|
||||
// Preserve original stock thickness and prior machining. Intersection
|
||||
// removes the complementary layers above/below every surviving region.
|
||||
for region in retained {
|
||||
if z0 > 0.0 {
|
||||
sh.record_pocket(region.clone(), 0.0, z0, &mut work)?;
|
||||
}
|
||||
if z1 < sh.t {
|
||||
sh.record_pocket(region, z1, sh.t, &mut work)?;
|
||||
}
|
||||
}
|
||||
modeled = true;
|
||||
} else if z1 > z0 {
|
||||
for flat in mapped {
|
||||
let clipped =
|
||||
sheet::material_boolean(std::slice::from_ref(&panel), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?;
|
||||
if !clipped.is_empty() && !depth_resolved {
|
||||
return Err(DocError::Invalid(
|
||||
"sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(),
|
||||
));
|
||||
}
|
||||
if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 {
|
||||
// Keep a single original tool boundary across structural seams.
|
||||
// Re-clipping notch polygons creates coincident seam endpoints.
|
||||
if !sh.holes.contains(&flat) {
|
||||
sh.holes.push(flat);
|
||||
}
|
||||
modeled = true;
|
||||
} else {
|
||||
for region in clipped {
|
||||
sh.record_pocket(region, z0, z1, &mut work)?;
|
||||
modeled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(s) = st.sheets.get_mut(i) {
|
||||
s.holes.extend(added);
|
||||
if !modeled {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, *operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
} else if sh.flat_material()?.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
}
|
||||
}
|
||||
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
|
||||
for (name, result) in plans {
|
||||
match result {
|
||||
Some((sh, body)) => {
|
||||
if let Some(mb) = st.bodies.iter_mut().find(|b| b.name == name) {
|
||||
replace_sheet_body(mb, body);
|
||||
}
|
||||
if let Some(target) = st.sheets.iter_mut().find(|s| s.body == name) {
|
||||
*target = sh;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
st.bodies.retain(|b| b.name != name);
|
||||
st.sheets.retain(|s| s.body != name);
|
||||
st.threads.retain(|t| t.body != name);
|
||||
}
|
||||
}
|
||||
rebuild_sheet(st, i)?;
|
||||
handled.push(sh.body.clone());
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool {
|
||||
let mut inside = false;
|
||||
let m = poly.len();
|
||||
for i in 0..m {
|
||||
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
|
||||
/// `level` from `at` along `n`: the edges used by one of them only, chained.
|
||||
fn face_loops(mesh: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Exact ordinary face attachments. Missing identities never invoke a proximity fallback.
|
||||
use crate::config::ConfigFaceTarget;
|
||||
use crate::{DocError, Document, EmbossFace, FeatureKind, Model, ModelBody, ModelState, PlaneRef, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FaceAnchor {
|
||||
pub target: ConfigFaceTarget,
|
||||
pub component: u64,
|
||||
pub path: Vec<u64>,
|
||||
pub author_path: Vec<u64>,
|
||||
/// Origin and x direction in the exact canonical face plane's coordinates.
|
||||
pub coordinates: [f64; 2],
|
||||
pub x_axis: [f64; 2],
|
||||
}
|
||||
|
||||
impl FaceAnchor {
|
||||
pub fn remap_target(
|
||||
&mut self,
|
||||
features: &std::collections::BTreeMap<u64, u64>,
|
||||
bodies: &std::collections::BTreeMap<String, String>,
|
||||
) -> Result<()> {
|
||||
if bodies.contains_key(&self.target.body) && !features.contains_key(&self.target.origin_feature) {
|
||||
return Err(DocError::Invalid("cannot clone an attached face without its certified producer".into()));
|
||||
}
|
||||
self.target.name = crate::remap_topology(&self.target.name, features, bodies);
|
||||
if let Some(body) = bodies.get(&self.target.body) {
|
||||
self.target.body = body.clone();
|
||||
}
|
||||
if let Some(feature) = features.get(&self.target.origin_feature) {
|
||||
self.target.origin_feature = *feature;
|
||||
}
|
||||
if let Some(certificate) = &mut self.target.certificate {
|
||||
certificate.remap_features(features);
|
||||
}
|
||||
self.validate()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.target.validate()?;
|
||||
if self.target.certificate.is_none()
|
||||
|| self.path.len() > 64
|
||||
|| self.author_path.len() > 64
|
||||
|| self.path.iter().chain(&self.author_path).any(|id| *id == 0)
|
||||
|| (self.component == 0 && !self.path.is_empty())
|
||||
|| (self.component != 0 && self.path.is_empty())
|
||||
|| self.coordinates.iter().any(|value| !value.is_finite() || value.abs() > 1e9)
|
||||
|| self.x_axis.iter().any(|value| !value.is_finite())
|
||||
|| (self.x_axis[0].hypot(self.x_axis[1]) - 1.).abs() > 1e-6
|
||||
{
|
||||
return Err(DocError::Invalid("invalid exact face attachment".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn author_frame(&self, doc: &Document, author: u64) -> Result<crate::Mat> {
|
||||
if author != 0 && self.author_path.is_empty() {
|
||||
return Err(DocError::Invalid(
|
||||
"attached sketch requires an explicit author occurrence path; activate a placement or choose a fixed plane".into(),
|
||||
));
|
||||
}
|
||||
EmbossFace::component_frame(doc, author, &self.author_path)
|
||||
}
|
||||
pub fn source_to_author(&self, doc: &Document, author: u64, body: &ModelBody) -> Result<crate::Mat> {
|
||||
self.validate()?;
|
||||
if doc.model_body_component(body) != self.component {
|
||||
return Err(DocError::Invalid("attached face's component ownership changed; reattach explicitly".into()));
|
||||
}
|
||||
let inverse = crate::mat_inverse(&self.author_frame(doc, author)?)
|
||||
.ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?;
|
||||
let source =
|
||||
EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() };
|
||||
Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
|
||||
match plane {
|
||||
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_face_reference(plane: &PlaneRef) -> bool {
|
||||
match plane {
|
||||
PlaneRef::Face { .. } => true,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn resolve_attached_plane(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, crate::expr::Value>,
|
||||
plane: &PlaneRef,
|
||||
author: u64,
|
||||
state: &ModelState,
|
||||
framed: &ModelState,
|
||||
) -> Result<Plane> {
|
||||
fn replace(doc: &Document, plane: &mut PlaneRef, author: u64, state: &ModelState, depth: usize) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Err(DocError::Invalid("attached plane nesting exceeds budget".into()));
|
||||
}
|
||||
match plane {
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut resolved = plane.clone();
|
||||
replace(self, &mut resolved, author, state, 0)?;
|
||||
self.resolve_plane_in(vals, &resolved, 0, Some(framed))
|
||||
}
|
||||
}
|
||||
|
||||
/// All status planes are world-space. A lost plane is a repair guide only.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FaceAttachmentStatus {
|
||||
Fixed { plane: Option<Plane> },
|
||||
Legacy { plane: Option<Plane> },
|
||||
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
|
||||
Lost { message: String, repair_plane: Option<Plane> },
|
||||
}
|
||||
|
||||
fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result<Plane> {
|
||||
Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y))
|
||||
.ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Capture a known selected face in the definition model, without another nearest pick.
|
||||
pub fn capture_face_anchor(
|
||||
&self,
|
||||
body: &ModelBody,
|
||||
index: usize,
|
||||
picked: &Plane,
|
||||
target_path: &[u64],
|
||||
author: u64,
|
||||
author_path: &[u64],
|
||||
) -> Result<FaceAnchor> {
|
||||
let source = body
|
||||
.body
|
||||
.face_plane(index)
|
||||
.ok_or_else(|| DocError::Invalid("this face has no exact planar source; choose a fixed plane instead".into()))?;
|
||||
let target = self.strong_face_target(body, index, source.origin)?;
|
||||
if target.certificate.is_none() {
|
||||
return Err(DocError::Invalid("this face has no supported construction certificate; choose a fixed plane instead".into()));
|
||||
}
|
||||
self.configuration_face_indices(body, &target)?;
|
||||
let mut anchor = FaceAnchor {
|
||||
target,
|
||||
component: self.model_body_component(body),
|
||||
path: target_path.to_vec(),
|
||||
author_path: author_path.to_vec(),
|
||||
coordinates: [0., 0.],
|
||||
x_axis: [1., 0.],
|
||||
};
|
||||
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &source)?;
|
||||
if picked.normal().dot(canonical.normal()) < 1. - 1e-7 || (picked.origin - canonical.origin).dot(canonical.normal()).abs() > 1e-7 {
|
||||
return Err(DocError::Invalid("picked plane does not match its exact source face".into()));
|
||||
}
|
||||
let delta = picked.origin - canonical.origin;
|
||||
anchor.coordinates = [delta.dot(canonical.x), delta.dot(canonical.y)];
|
||||
anchor.x_axis = [picked.x.dot(canonical.x), picked.x.dot(canonical.y)];
|
||||
anchor.validate()?;
|
||||
Ok(anchor)
|
||||
}
|
||||
|
||||
pub fn resolve_face_anchor(&self, anchor: &FaceAnchor, author: u64, state: &ModelState) -> Result<Plane> {
|
||||
anchor.validate()?;
|
||||
let body =
|
||||
state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("attached body `{}` no longer exists", anchor.target.body)))?;
|
||||
let indices = self.configuration_face_indices(body, &anchor.target)?;
|
||||
let first = indices.first().copied().ok_or_else(|| DocError::Unknown("attached face no longer exists".into()))?;
|
||||
let plane = body.body.face_plane(first).ok_or_else(|| DocError::Invalid("attached face no longer has an exact planar source".into()))?;
|
||||
for index in indices.iter().skip(1) {
|
||||
let other = body.body.face_plane(*index).ok_or_else(|| DocError::Invalid("attached face pieces are not exactly planar".into()))?;
|
||||
if plane.normal().dot(other.normal()) < 1. - 1e-8 || (other.origin - plane.origin).dot(plane.normal()).abs() > 1e-7 {
|
||||
return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into()));
|
||||
}
|
||||
}
|
||||
let canonical = map_plane(&anchor.source_to_author(self, author, body)?, &plane)?;
|
||||
let origin = canonical.origin + canonical.x * anchor.coordinates[0] + canonical.y * anchor.coordinates[1];
|
||||
let x = canonical.x * anchor.x_axis[0] + canonical.y * anchor.x_axis[1];
|
||||
Plane::new(origin, x, canonical.normal().cross(x)).ok_or_else(|| DocError::Invalid("attached sketch basis is degenerate".into()))
|
||||
}
|
||||
|
||||
pub fn sketch_face_attachment(&self, feature: u64, model: &Model) -> FaceAttachmentStatus {
|
||||
let Some(feature) = self.feature(feature) else {
|
||||
return FaceAttachmentStatus::Lost { message: "sketch no longer exists".into(), repair_plane: None };
|
||||
};
|
||||
let FeatureKind::Sketch { plane, .. } = &feature.kind else {
|
||||
return FaceAttachmentStatus::Fixed { plane: None };
|
||||
};
|
||||
let Some(anchor) = plane_face_anchor(plane) else {
|
||||
let path = self.first_path(feature.component);
|
||||
let unique = path.iter().all(|id| {
|
||||
self.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id)
|
||||
.is_some_and(|selected| self.occurrences.iter().filter(|o| o.component == selected.component).count() == 1)
|
||||
});
|
||||
let world = unique.then(|| EmbossFace::component_frame(self, feature.component, &path).ok()).flatten();
|
||||
let current = model.state().sketch(feature.id).map(|sketch| sketch.plane).or_else(|| {
|
||||
let (vals, _) = self.param_values();
|
||||
self.resolve_plane(&vals, plane, 0).ok()
|
||||
});
|
||||
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
|
||||
return if has_face_reference(plane) {
|
||||
FaceAttachmentStatus::Legacy { plane: current }
|
||||
} else {
|
||||
FaceAttachmentStatus::Fixed { plane: current }
|
||||
};
|
||||
};
|
||||
let (vals, _) = self.param_values();
|
||||
let picked = self.resolve_plane(&vals, plane, 0).ok();
|
||||
let world = anchor.author_frame(self, feature.component);
|
||||
let repair_plane = world.as_ref().ok().and_then(|matrix| picked.as_ref().and_then(|picked| map_plane(matrix, picked).ok()));
|
||||
let state = model.state_before(feature.id);
|
||||
match crate::state_in_face_anchor_frame(self, &state, anchor, feature.component)
|
||||
.and_then(|framed| self.resolve_attached_plane(&vals, plane, feature.component, &state, &framed))
|
||||
.and_then(|plane| map_plane(&world?, &plane))
|
||||
{
|
||||
Ok(plane) => FaceAttachmentStatus::Healthy { plane, target: anchor.target.clone(), path: anchor.path.clone() },
|
||||
Err(error) => FaceAttachmentStatus::Lost { message: format!("lost face attachment on `{}`: {error}", anchor.target.body), repair_plane },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_face_anchor_json(value: &Value) -> Result<()> {
|
||||
let fields = value.as_object().ok_or_else(|| DocError::Invalid("face anchor must be an object".into()))?;
|
||||
if fields.len() != 6 || fields.keys().any(|key| !["target", "component", "path", "author_path", "coordinates", "x_axis"].contains(&key.as_str()))
|
||||
{
|
||||
return Err(DocError::Invalid("unknown or missing face anchor fields".into()));
|
||||
}
|
||||
let target = fields.get("target").ok_or_else(|| DocError::Invalid("face anchor target missing".into()))?;
|
||||
crate::config::validate_face_target_json(target)?;
|
||||
if target.get("certificate").is_none_or(Value::is_null) {
|
||||
return Err(DocError::Invalid("face anchor needs a source certificate".into()));
|
||||
}
|
||||
let component =
|
||||
fields.get("component").and_then(Value::as_u64).ok_or_else(|| DocError::Invalid("face anchor component must be an integer".into()))?;
|
||||
for key in ["path", "author_path"] {
|
||||
if fields.get(key).and_then(Value::as_array).is_none_or(|path| {
|
||||
path.len() > 64
|
||||
|| path.iter().any(|id| id.as_u64().is_none_or(|id| id == 0))
|
||||
|| (key == "path" && component == 0 && !path.is_empty())
|
||||
|| (key == "path" && component != 0 && path.is_empty())
|
||||
}) {
|
||||
return Err(DocError::Invalid("face anchor path is invalid or oversized".into()));
|
||||
}
|
||||
}
|
||||
for key in ["coordinates", "x_axis"] {
|
||||
let values = fields
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.filter(|values| values.len() == 2)
|
||||
.ok_or_else(|| DocError::Invalid("face anchor vectors require exactly two values".into()))?;
|
||||
if values.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9)) {
|
||||
return Err(DocError::Invalid("face anchor vector is invalid".into()));
|
||||
}
|
||||
if key == "x_axis" && (values[0].as_f64().unwrap_or(0.).hypot(values[1].as_f64().unwrap_or(0.)) - 1.).abs() > 1e-6 {
|
||||
return Err(DocError::Invalid("face anchor basis must be normalized".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk reference-bearing JSON before typed decoding, excluding bulk geometry payloads.
|
||||
pub fn validate_face_anchors_json(value: &Value) -> Result<()> {
|
||||
let mut stack = vec![(value, 0usize)];
|
||||
let mut visited = 0usize;
|
||||
while let Some((value, depth)) = stack.pop() {
|
||||
visited += 1;
|
||||
if visited > 2_000_000 || depth > 128 {
|
||||
return Err(DocError::Invalid("face anchor structure exceeds work limits".into()));
|
||||
}
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
if fields.get("type").and_then(Value::as_str) == Some("face")
|
||||
&& let Some(anchor) = fields.get("anchor")
|
||||
&& !anchor.is_null()
|
||||
{
|
||||
validate_face_anchor_json(anchor)?;
|
||||
}
|
||||
for (key, child) in fields {
|
||||
if !["sketch", "meshes", "step", "configs", "appearances", "materials"].contains(&key.as_str())
|
||||
&& matches!(child, Value::Array(_) | Value::Object(_))
|
||||
{
|
||||
stack.push((child, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for child in values {
|
||||
if matches!(child, Value::Array(_) | Value::Object(_)) {
|
||||
stack.push((child, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solvecraft_geom::Vec3;
|
||||
fn fixture() -> (Document, ModelBody, usize, Plane) {
|
||||
let mut doc = Document::default();
|
||||
let kind: FeatureKind =
|
||||
serde_json::from_value(serde_json::json!({"type":"box","corner":[0,0,0],"length":"10","width":"10","height":"10"})).unwrap();
|
||||
let id = doc.add_feature(kind, Some("Base")).unwrap();
|
||||
let body = ModelBody::new(
|
||||
"Base".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 10.)).unwrap().with_box_certificate(id).unwrap(),
|
||||
id,
|
||||
);
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
|
||||
.unwrap();
|
||||
let canonical = body.body.face_plane(index).unwrap();
|
||||
let picked = Plane::new(canonical.origin + canonical.x * 2. + canonical.y * 3., canonical.y, -canonical.x).unwrap();
|
||||
(doc, body, index, picked)
|
||||
}
|
||||
#[test]
|
||||
fn exact_anchor_preserves_picked_basis_and_follows_height_without_parallel_rescue() {
|
||||
let (doc, body, index, picked) = fixture();
|
||||
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let anchor: FaceAnchor = serde_json::from_str(&serde_json::to_string(&anchor).unwrap()).unwrap();
|
||||
assert!(anchor.author_frame(&doc, 1).is_err(), "strong author attachment cannot infer a first placement");
|
||||
let mut missing_source = anchor.clone();
|
||||
missing_source.component = 1;
|
||||
assert!(missing_source.validate().is_err(), "strong source attachment needs an explicit path");
|
||||
assert!(validate_face_anchor_json(&serde_json::to_value(missing_source).unwrap()).is_err());
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(body);
|
||||
let initial = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
|
||||
assert!((initial.origin - picked.origin).len() < 1e-9);
|
||||
assert!((initial.x - picked.x).len() < 1e-9);
|
||||
state.bodies[0] = ModelBody::new(
|
||||
"Base".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 20.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature)
|
||||
.unwrap(),
|
||||
anchor.target.origin_feature,
|
||||
);
|
||||
let changed = doc.resolve_face_anchor(&anchor, 0, &state).unwrap();
|
||||
assert!((changed.origin.z - 20.).abs() < 1e-9);
|
||||
assert!((changed.origin.x - initial.origin.x).abs() < 1e-9);
|
||||
state.bodies[0] = ModelBody::new(
|
||||
"Control".into(),
|
||||
solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature)
|
||||
.unwrap(),
|
||||
anchor.target.origin_feature,
|
||||
);
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err());
|
||||
state.bodies[0].name = "Base".into();
|
||||
state.bodies[0].body = solvecraft_kernel::box_solid(Vec3::default(), Vec3::new(10., 10., 30.))
|
||||
.unwrap()
|
||||
.with_box_certificate(anchor.target.origin_feature + 1)
|
||||
.unwrap();
|
||||
assert!(doc.resolve_face_anchor(&anchor, 0, &state).is_err(), "reused name cannot replace the producer");
|
||||
}
|
||||
#[test]
|
||||
fn nested_anchor_offset_tracks_source_and_raw_budgets_precede_decode() {
|
||||
let (doc, body, index, picked) = fixture();
|
||||
let anchor = doc.capture_face_anchor(&body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let plane = PlaneRef::Offset {
|
||||
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor.clone())) }),
|
||||
distance: "2".into(),
|
||||
};
|
||||
let mut state = ModelState::default();
|
||||
state.bodies.push(body);
|
||||
let (vals, _) = doc.param_values();
|
||||
let result = doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).unwrap();
|
||||
assert!((result.origin.z - 12.).abs() < 1e-9);
|
||||
state.bodies.clear();
|
||||
assert!(doc.resolve_attached_plane(&vals, &plane, 0, &state, &state).is_err());
|
||||
let mut raw = serde_json::to_value(anchor).unwrap();
|
||||
assert!(validate_face_anchor_json(&raw).is_ok());
|
||||
raw["path"] = serde_json::json!([1]);
|
||||
assert!(validate_face_anchor_json(&raw).is_err());
|
||||
raw["path"] = serde_json::json!([]);
|
||||
raw["target"]["name"] = Value::String("x".repeat(1_000_000));
|
||||
assert!(validate_face_anchor_json(&raw).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn extrude_cap_profile_reordering_never_retargets_an_ordinal_body() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
for offset in [0., 30.] {
|
||||
let points: Vec<_> = [(0., 0.), (10., 0.), (10., 10.), (0., 10.)]
|
||||
.iter()
|
||||
.map(|(x, y)| sketch.add_point(Vec2::new(x + offset, *y), None).unwrap())
|
||||
.collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
}
|
||||
let mut doc = Document::default();
|
||||
let source = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let extent: crate::Extent = serde_json::from_value(serde_json::json!({"distance":"10"})).unwrap();
|
||||
let producer = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: source,
|
||||
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
|
||||
extent,
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let state = model.state();
|
||||
assert_eq!(state.bodies.len(), 2);
|
||||
let solved = state.sketch(source).unwrap();
|
||||
let regions = crate::select_profiles(solved, &crate::ProfileSel::All).unwrap();
|
||||
let raw = solvecraft_kernel::extrude(&solved.plane, ®ions, 0., 10.).unwrap();
|
||||
assert!(
|
||||
crate::config::extrude_cap_bindings(solved, &raw, 0).is_none(),
|
||||
"exhausted certification work must leave the complete producer uncertified"
|
||||
);
|
||||
let bindings = crate::config::extrude_cap_bindings(solved, &raw, 10_000_000).unwrap();
|
||||
assert_eq!(bindings.iter().flatten().flatten().count(), 4, "two profiles each retain both exact caps within budget");
|
||||
let mut duplicate = raw.clone();
|
||||
duplicate.extend(raw);
|
||||
let ambiguous = crate::config::extrude_cap_bindings(solved, &duplicate, 10_000_000).unwrap();
|
||||
assert!(ambiguous.iter().flatten().all(Option::is_none), "duplicated profile identities cannot stamp ambiguous caps");
|
||||
let body = &state.bodies[0];
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::CertifiedExtrudeCap { high: true, .. })))
|
||||
.unwrap();
|
||||
let picked = body.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
let FeatureKind::Extrude { profiles, .. } = &mut doc.feature_mut(producer).unwrap().kind else {
|
||||
panic!("fixture must retain its extrude feature")
|
||||
};
|
||||
*profiles = crate::ProfileSel::Indices { indices: vec![1, 0] };
|
||||
model.evaluate(&doc);
|
||||
let changed = model.state();
|
||||
assert_eq!(changed.bodies.len(), 2);
|
||||
if doc.resolve_face_anchor(&anchor, 0, &changed).is_ok() {
|
||||
let current = changed.body(&anchor.target.body).unwrap().mesh().bounds();
|
||||
assert!((current.min.x - bounds.min.x).abs() < 1e-9, "a same-role cap of the other profile must never replace the source");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wrapped_legacy_face_is_reported_as_legacy_without_inferred_upgrade() {
|
||||
let (mut doc, _, _, picked) = fixture();
|
||||
let id = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Offset {
|
||||
base: Box::new(PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: None }),
|
||||
distance: "2".into(),
|
||||
},
|
||||
sketch: solvecraft_sketch::Sketch::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let FaceAttachmentStatus::Legacy { plane: Some(plane) } = doc.sketch_face_attachment(id, &model) else {
|
||||
panic!("wrapped legacy attachment must retain its classification and fixed-plane escape");
|
||||
};
|
||||
assert!((plane.origin.z - 12.).abs() < 1e-9);
|
||||
let FeatureKind::Sketch { plane, .. } = &doc.feature(id).unwrap().kind else { panic!("fixture must retain its sketch feature") };
|
||||
assert!(plane_face_anchor(plane).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn warm_root_body_offset_edit_matches_cold_attached_sketch_and_downstream_bounds() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let (mut doc, _, _, _) = fixture();
|
||||
let mut warm = Model::new();
|
||||
warm.evaluate(&doc);
|
||||
let source = warm.state();
|
||||
let body = &source.bodies[0];
|
||||
let index = body
|
||||
.body
|
||||
.face_sources()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|source| matches!(source, Some(solvecraft_kernel::FaceSource::Box { axis: 2, high: true, .. })))
|
||||
.unwrap();
|
||||
let picked = body.body.face_plane(index).unwrap();
|
||||
let anchor = doc.capture_face_anchor(body, index, &picked, &[], 0, &[]).unwrap();
|
||||
let source_name = body.name.clone();
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let points: Vec<_> =
|
||||
[(2., 2.), (4., 2.), (4., 4.), (2., 4.)].iter().map(|(x, y)| sketch.add_point(Vec2::new(*x, *y), None).unwrap()).collect();
|
||||
for edge in 0..4 {
|
||||
sketch.add_line_pts(points[edge], points[(edge + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sk = doc
|
||||
.add_feature(
|
||||
FeatureKind::Sketch {
|
||||
plane: PlaneRef::Face { plane: picked, at: picked.origin, name: None, anchor: Some(Box::new(anchor)) },
|
||||
sketch,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
|
||||
Some("Boss"),
|
||||
)
|
||||
.unwrap();
|
||||
warm.evaluate(&doc);
|
||||
let initial = warm.state();
|
||||
let before = initial.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
let mut offset = crate::IDENTITY;
|
||||
offset[3][0] = 5.;
|
||||
offset[3][2] = 3.;
|
||||
doc.body_offsets.insert(source_name, offset);
|
||||
warm.evaluate(&doc);
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&doc);
|
||||
let warm_state = warm.state();
|
||||
let cold_state = cold.state();
|
||||
let after = warm_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
let rebuilt = cold_state.bodies.iter().find(|body| body.feature == boss).unwrap().mesh().bounds();
|
||||
assert!(
|
||||
(after.min - before.min - Vec3::new(5., 0., 3.)).len() < 1e-7,
|
||||
"before={before:?}, warm={after:?}, cold={rebuilt:?}, warm_plane={:?}, cold_plane={:?}",
|
||||
warm_state.sketch(sk).map(|sk| sk.plane),
|
||||
cold_state.sketch(sk).map(|sk| sk.plane)
|
||||
);
|
||||
assert!((after.min - rebuilt.min).len() < 1e-7);
|
||||
assert!((after.max - rebuilt.max).len() < 1e-7);
|
||||
let FaceAttachmentStatus::Healthy { plane, .. } = doc.sketch_face_attachment(sk, &warm) else {
|
||||
panic!("offset source must remain attached");
|
||||
};
|
||||
assert!((plane.origin - warm_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
|
||||
assert!((warm_state.sketch(sk).unwrap().plane.origin - cold_state.sketch(sk).unwrap().plane.origin).len() < 1e-9);
|
||||
}
|
||||
}
|
||||
+923
-9
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
//! Pre-allocation bounds for versioned analytic offset roles and boundary references.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, FeatureKind, ProfileSel, Result};
|
||||
|
||||
fn invalid() -> DocError {
|
||||
DocError::Invalid("damaged design: invalid analytic offset topology or boundary reference".into())
|
||||
}
|
||||
|
||||
fn id(value: &Value) -> Result<&str> {
|
||||
value.as_str().filter(|id| !id.is_empty() && id.len() <= 512 && !id.chars().any(char::is_control)).ok_or_else(invalid)
|
||||
}
|
||||
|
||||
fn exact_keys(value: &Value, keys: &[&str]) -> Result<()> {
|
||||
let object = value.as_object().ok_or_else(invalid)?;
|
||||
if object.len() != keys.len() || object.keys().any(|key| !keys.contains(&key.as_str())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a complete-source profile reference before copying or decoding it.
|
||||
pub fn whole_profile_selection_from_value(value: &Value) -> Result<ProfileSel> {
|
||||
validate_whole_reference(value)?;
|
||||
serde_json::from_value(value.clone()).map_err(|_| invalid())
|
||||
}
|
||||
|
||||
fn validate_whole_reference(value: &Value) -> Result<()> {
|
||||
exact_keys(value, &["type", "profiles"])?;
|
||||
if value.get("type").and_then(Value::as_str) != Some("whole_boundaries") {
|
||||
return Err(invalid());
|
||||
}
|
||||
let profiles = value.get("profiles").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if profiles.is_empty() || profiles.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut identities = BTreeSet::new();
|
||||
for profile in profiles {
|
||||
exact_keys(profile, &["outer", "holes"])?;
|
||||
let mut boundary = |value: &Value| -> Result<Vec<String>> {
|
||||
let values = value.as_array().ok_or_else(invalid)?;
|
||||
if values.is_empty() || values.len() > 512 {
|
||||
return Err(invalid());
|
||||
}
|
||||
total += values.len();
|
||||
if total > 20_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut previous = None;
|
||||
// Bound each raw ID before any owned allocation. Require canonical order.
|
||||
for value in values {
|
||||
let current = id(value)?;
|
||||
if previous.is_some_and(|previous| previous >= current) {
|
||||
return Err(invalid());
|
||||
}
|
||||
previous = Some(current);
|
||||
}
|
||||
values.iter().map(|value| Ok(id(value)?.to_owned())).collect()
|
||||
};
|
||||
let outer = boundary(profile.get("outer").ok_or_else(invalid)?)?;
|
||||
let holes = profile.get("holes").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if holes.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut sorted_holes = Vec::with_capacity(holes.len());
|
||||
let mut all: BTreeSet<_> = outer.iter().cloned().collect();
|
||||
for hole in holes {
|
||||
let hole = boundary(hole)?;
|
||||
if sorted_holes.last().is_some_and(|previous| previous >= &hole) || hole.iter().any(|curve| !all.insert(curve.clone())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
sorted_holes.push(hole);
|
||||
}
|
||||
if !identities.insert((outer, sorted_holes)) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_raw(value: &Value) -> Result<()> {
|
||||
let Some(features) = value.get("features").and_then(Value::as_array) else { return Ok(()) };
|
||||
let mut total_roles = 0usize;
|
||||
for feature in features {
|
||||
if feature.get("type").and_then(Value::as_str) != Some("sketch") {
|
||||
continue;
|
||||
}
|
||||
let Some(offsets) = feature.get("sketch").and_then(|sketch| sketch.get("offsets")) else { continue };
|
||||
let offsets = offsets.as_array().ok_or_else(invalid)?;
|
||||
let mut groups = BTreeSet::new();
|
||||
let mut outputs = BTreeSet::new();
|
||||
let mut point_owners = std::collections::BTreeMap::new();
|
||||
let mut sketch_roles = 0usize;
|
||||
for offset in offsets {
|
||||
let Some(topology) = offset.get("topology").filter(|topology| !topology.is_null()) else { continue };
|
||||
if groups.len() >= 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
exact_keys(topology, &["version", "id", "roles"])?;
|
||||
let group = id(topology.get("id").ok_or_else(invalid)?)?;
|
||||
if !groups.insert(group)
|
||||
|| topology.get("version").and_then(Value::as_u64) != Some(1)
|
||||
|| offset.get("closed").and_then(Value::as_bool) != Some(true)
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let distance = offset.get("distance").and_then(Value::as_f64).ok_or_else(invalid)?;
|
||||
if !distance.is_finite() || distance.abs() <= 1e-9 || distance.abs() >= 1e9 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let sources = offset.get("sources").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
let roles = topology.get("roles").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
let active = offset.get("outputs").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if roles.is_empty() || roles.len() > 512 || roles.len() != sources.len() || active.len() > roles.len() {
|
||||
return Err(invalid());
|
||||
}
|
||||
total_roles = total_roles.saturating_add(roles.len());
|
||||
sketch_roles = sketch_roles.saturating_add(roles.len());
|
||||
if total_roles > 1_000_000 || sketch_roles > 20_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut active_index = 0;
|
||||
let mut source_ids = BTreeSet::new();
|
||||
for (source, role) in sources.iter().zip(roles) {
|
||||
let source = source.as_array().ok_or_else(invalid)?;
|
||||
if source.len() != 2 || source.get(1).and_then(Value::as_bool).is_none() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let source = id(source.first().ok_or_else(invalid)?)?;
|
||||
if !source_ids.insert(source) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let role_object = role.as_object().ok_or_else(invalid)?;
|
||||
if role_object.keys().any(|key| !matches!(key.as_str(), "output" | "kind" | "start" | "end" | "retired" | "flags")) {
|
||||
return Err(invalid());
|
||||
}
|
||||
if let Some(flags) = role.get("flags") {
|
||||
exact_keys(flags, &["construction", "centerline", "reversed", "fixed"])?;
|
||||
if flags.as_object().is_none_or(|flags| flags.values().any(|flag| flag.as_bool().is_none())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
let output = id(role.get("output").ok_or_else(invalid)?)?;
|
||||
if source == output || !outputs.insert(output) {
|
||||
return Err(invalid());
|
||||
}
|
||||
if role.get("start") == role.get("end") {
|
||||
return Err(invalid());
|
||||
}
|
||||
for key in ["start", "end"] {
|
||||
let point = id(role.get(key).ok_or_else(invalid)?)?;
|
||||
if point == output || point == source || point_owners.insert(point, group).is_some_and(|owner| owner != group) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
let kind = role.get("kind").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
let retired = role.get("retired").and_then(Value::as_bool).ok_or_else(invalid)?;
|
||||
if !matches!(kind, "line" | "arc") || (retired && kind != "arc") {
|
||||
return Err(invalid());
|
||||
}
|
||||
if !retired {
|
||||
if active.get(active_index).map(id).transpose()?.is_none_or(|id| id != output) {
|
||||
return Err(invalid());
|
||||
}
|
||||
active_index += 1;
|
||||
}
|
||||
}
|
||||
if active_index != active.len() || active_index < 3 {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
if groups.iter().any(|group| outputs.contains(group) || point_owners.contains_key(group))
|
||||
|| outputs.iter().any(|output| point_owners.contains_key(output))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
fn references(value: &Value) -> Result<()> {
|
||||
if value.get("type").and_then(Value::as_str) == Some("whole_boundaries") {
|
||||
return validate_whole_reference(value);
|
||||
}
|
||||
if value.get("type").and_then(Value::as_str) == Some("offset_boundaries") {
|
||||
exact_keys(value, &["type", "associations"])?;
|
||||
let ids = value.get("associations").and_then(Value::as_array).ok_or_else(invalid)?;
|
||||
if ids.is_empty() || ids.len() > 128 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for value in ids {
|
||||
if !seen.insert(id(value)?) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// Only feature profile fields can contain this variant. Do not impose a new
|
||||
// document-wide node limit on unrelated legacy sketches or imported payloads.
|
||||
for feature in features {
|
||||
if let Some(selection) = feature.get("profiles") {
|
||||
references(selection)?;
|
||||
}
|
||||
if let Some(sections) = feature.get("sections").and_then(Value::as_array) {
|
||||
if sections.len() > 10_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
for section in sections {
|
||||
if let Some(selection) = section.get("profiles") {
|
||||
references(selection)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_typed(document: &Document) -> Result<()> {
|
||||
let mut sketches = std::collections::BTreeMap::new();
|
||||
for feature in &document.features {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
sketch.validate_offset_topologies()?;
|
||||
sketches.insert(feature.id, sketch);
|
||||
}
|
||||
for (sketch, selection) in feature.kind.profile_sels() {
|
||||
if let ProfileSel::OffsetBoundaries { associations } = selection {
|
||||
// A deleted association or source sketch is a repairable model error,
|
||||
// just like an ordinary missing curve reference. Keep that design loadable.
|
||||
let Some(sketch) = sketches.get(&sketch) else { continue };
|
||||
for association in associations {
|
||||
let count =
|
||||
sketch.offsets.iter().filter_map(|offset| offset.topology.as_ref()).filter(|topology| &topology.id == association).count();
|
||||
if count > 1 {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> Value {
|
||||
json!({"features":[{"type":"sketch","sketch":{"offsets":[{
|
||||
"sources":[["s0",true],["s1",true],["s2",true],["s3",true]],
|
||||
"outputs":["l0","l1","l2"],"closed":true,"distance":1.4,
|
||||
"topology":{"version":1,"id":"o1","roles":[
|
||||
{"output":"l0","kind":"line","start":"p0","end":"p1","retired":false},
|
||||
{"output":"a0","kind":"arc","start":"p1","end":"p2","retired":true},
|
||||
{"output":"l1","kind":"line","start":"p2","end":"p3","retired":false},
|
||||
{"output":"l2","kind":"line","start":"p3","end":"p0","retired":false}
|
||||
]}
|
||||
}]}}]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_boundaries_raw_identity_is_bounded_canonical_and_complete() {
|
||||
let good = json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"]]}]});
|
||||
assert!(matches!(whole_profile_selection_from_value(&good).unwrap(), ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1));
|
||||
let malformed = [
|
||||
json!({"type":"whole_boundaries","profiles":[]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":[],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s1"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2","s1"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s1"],"holes":[["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"],["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[],"extra":true}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[]},{"outer":["s2"],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["x".repeat(513)],"holes":[]}]}),
|
||||
json!({"type":"whole_boundaries","profiles":[{"outer":["a\nb"],"holes":[]}]}),
|
||||
];
|
||||
for value in malformed {
|
||||
assert!(whole_profile_selection_from_value(&value).is_err(), "accepted malformed reference {value}");
|
||||
assert!(validate_raw(&json!({"features":[{"type":"extrude","profiles":value}]})).is_err());
|
||||
}
|
||||
let many = (0..129).map(|index| json!({"outer":[format!("c{index:03}")],"holes":[]})).collect::<Vec<_>>();
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":&many[..128]})).is_ok());
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":many})).is_err());
|
||||
let oversized = (0..513).map(|index| format!("c{index:03}")).collect::<Vec<_>>();
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":oversized,"holes":[]}]})).is_err());
|
||||
let too_many_holes = (0..129).map(|index| vec![format!("hole{index:03}")]).collect::<Vec<_>>();
|
||||
assert!(
|
||||
whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":[{"outer":["outer"],"holes":too_many_holes}]})).is_err()
|
||||
);
|
||||
let aggregate = (0..40)
|
||||
.map(|profile| {
|
||||
let outer = (0..512).map(|curve| format!("profile{profile:03}curve{curve:03}")).collect::<Vec<_>>();
|
||||
json!({"outer":outer,"holes":[]})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":aggregate})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_offset_roles_reject_aliases_order_damage_and_hostile_shapes() {
|
||||
let good = fixture();
|
||||
assert!(validate_raw(&good).is_ok());
|
||||
let path = "/features/0/sketch/offsets/0";
|
||||
let mut flags = good.clone();
|
||||
flags.pointer_mut(&format!("{path}/topology/roles/1")).unwrap()["flags"] =
|
||||
json!({"construction":true,"centerline":false,"reversed":true,"fixed":false});
|
||||
assert!(validate_raw(&flags).is_ok());
|
||||
*flags.pointer_mut(&format!("{path}/topology/roles/1/flags/construction")).unwrap() = json!(1);
|
||||
assert!(validate_raw(&flags).is_err());
|
||||
for (suffix, replacement) in [
|
||||
("/topology/version", json!(2)),
|
||||
("/topology/id", json!("x".repeat(513))),
|
||||
("/closed", json!(false)),
|
||||
("/distance", json!(1e9)),
|
||||
("/outputs", json!(["l1", "l0", "l2"])),
|
||||
("/outputs", json!(["l0", "a0", "l1", "l2"])),
|
||||
("/topology/roles/1/kind", json!("line")),
|
||||
("/topology/roles/1/output", json!("l0")),
|
||||
("/topology/roles/1/start", json!("a0")),
|
||||
("/topology/roles/0/kind", json!("guess")),
|
||||
("/sources/0/1", json!(0)),
|
||||
("/sources/0/0", json!("l0")),
|
||||
("/topology/roles", json!(vec![json!({}); 513])),
|
||||
] {
|
||||
let mut damaged = good.clone();
|
||||
*damaged.pointer_mut(&format!("{path}{suffix}")).unwrap() = replacement;
|
||||
assert!(validate_raw(&damaged).is_err(), "accepted {suffix}");
|
||||
}
|
||||
let mut extra = good.clone();
|
||||
extra.pointer_mut(&format!("{path}/topology")).unwrap()["fallback"] = json!([]);
|
||||
assert!(validate_raw(&extra).is_err());
|
||||
let mut excessive = good.clone();
|
||||
let chain = excessive.pointer(path).unwrap().clone();
|
||||
let groups = (0..129)
|
||||
.map(|index| {
|
||||
let mut chain = chain.clone();
|
||||
chain["topology"]["id"] = json!(format!("o{index}"));
|
||||
for source in chain["sources"].as_array_mut().unwrap() {
|
||||
source[0] = json!(format!("g{index}-{}", source[0].as_str().unwrap()));
|
||||
}
|
||||
for output in chain["outputs"].as_array_mut().unwrap() {
|
||||
*output = json!(format!("g{index}-{}", output.as_str().unwrap()));
|
||||
}
|
||||
for role in chain["topology"]["roles"].as_array_mut().unwrap() {
|
||||
for key in ["output", "start", "end"] {
|
||||
role[key] = json!(format!("g{index}-{}", role[key].as_str().unwrap()));
|
||||
}
|
||||
}
|
||||
chain
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
excessive["features"][0]["sketch"]["offsets"] = json!(&groups[..128]);
|
||||
assert!(validate_raw(&excessive).is_ok(), "the permitted group boundary must be accepted");
|
||||
excessive["features"][0]["sketch"]["offsets"] = json!(groups);
|
||||
assert!(validate_raw(&excessive).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_boundary_references_are_bounded_and_future_versions_refuse_first() {
|
||||
let mut good = fixture();
|
||||
good["features"].as_array_mut().unwrap().push(json!({"type":"extrude","profiles":{"type":"offset_boundaries","associations":["o1"]}}));
|
||||
assert!(validate_raw(&good).is_ok());
|
||||
for associations in [json!([]), json!(["o1", "o1"]), json!(["x".repeat(513)]), json!(vec!["o"; 129]), json!([0])] {
|
||||
let mut damaged = good.clone();
|
||||
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 SolidCraft"));
|
||||
}
|
||||
// Absent metadata preserves the historical one-to-one chain representation.
|
||||
let mut legacy = fixture();
|
||||
legacy["features"][0]["sketch"]["offsets"][0].as_object_mut().unwrap().remove("topology");
|
||||
legacy["features"][0]["sketch"]["offsets"][0]["closed"] = json!(false);
|
||||
assert!(validate_raw(&legacy).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_one_through_eleven_keep_legacy_offset_geometry_and_ids() {
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
let center = sketch.add_point(solvecraft_geom::Vec2::new(4., 7.), Some("center")).unwrap();
|
||||
sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 10., Some(center), Some("source")).unwrap();
|
||||
sketch.add_circle(solvecraft_geom::Vec2::new(4., 7.), 9., Some(center), Some("offset")).unwrap();
|
||||
sketch.offsets.push(solvecraft_sketch::OffsetChain {
|
||||
sources: vec![("source".into(), true)],
|
||||
outputs: vec!["offset".into()],
|
||||
distance: 1.,
|
||||
closed: true,
|
||||
topology: None,
|
||||
});
|
||||
let mut document = Document::new("legacy offset circles");
|
||||
let sketch_id =
|
||||
document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Circles")).unwrap();
|
||||
let original: Value = serde_json::from_str(&document.to_json()).unwrap();
|
||||
for version in 1..=11 {
|
||||
let mut old = original.clone();
|
||||
old["format"] = json!(crate::format::format_string(version));
|
||||
let upgraded = crate::format::read(&old.to_string()).unwrap();
|
||||
let saved: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
|
||||
assert_eq!(saved["features"][0]["sketch"]["offsets"], original["features"][0]["sketch"]["offsets"]);
|
||||
assert_eq!(saved["features"][0]["sketch"]["curves"], original["features"][0]["sketch"]["curves"]);
|
||||
assert_eq!(saved["features"][0]["sketch"]["points"], original["features"][0]["sketch"]["points"]);
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&upgraded);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()));
|
||||
let state = model.state();
|
||||
let solved = state.sketch(sketch_id).unwrap();
|
||||
let radius = solved.sketch.curves.iter().find(|curve| curve.id == "offset").map(|curve| &curve.kind);
|
||||
assert!(matches!(radius,Some(solvecraft_sketch::CurveKind::Circle {r,..}) if *r ==9.));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_boundary_or_defining_sketch_reopens_as_a_model_error_without_retarget() {
|
||||
for missing_sketch in [false, true] {
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
sketch.add_circle(solvecraft_geom::Vec2::new(0., 0.), 10., None, Some("unrelated-circle")).unwrap();
|
||||
let mut document = Document::new("repairable lost boundary");
|
||||
let id = document.add_feature(FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: if missing_sketch { 999_999 } else { id },
|
||||
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
|
||||
extent: crate::Extent {
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let reopened = crate::format::read(&document.to_json()).unwrap();
|
||||
assert_eq!(reopened, document);
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&reopened);
|
||||
assert!(model.results.last().unwrap().error.is_some());
|
||||
assert!(model.state().bodies.is_empty(), "lost boundary must not select the remaining ordinary circle");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,7 @@ pub fn motion(kind: JointKind, q: &[f64]) -> Mat {
|
||||
|
||||
/// Bodies of a component in the model (its own frame).
|
||||
fn component_bodies<'a>(doc: &Document, st: &'a ModelState, component: u64) -> Vec<&'a crate::ModelBody> {
|
||||
st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == component).collect()
|
||||
st.bodies.iter().filter(|b| doc.model_body_component(b) == component).collect()
|
||||
}
|
||||
|
||||
/// The component an occurrence places (0 for the root).
|
||||
|
||||
+18
-2
@@ -7,18 +7,28 @@
|
||||
|
||||
pub mod appearance;
|
||||
mod assembly;
|
||||
mod body_transfer;
|
||||
mod body_transfer_sheet;
|
||||
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
|
||||
pub mod canvas;
|
||||
mod clipboard;
|
||||
pub use clipboard::{clone_expression, remap_topology};
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
pub mod face_anchor;
|
||||
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
|
||||
pub mod format;
|
||||
mod format_offsets;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
pub mod motion;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
mod profile_refs;
|
||||
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
|
||||
pub mod plastic;
|
||||
pub mod project;
|
||||
mod project3d;
|
||||
@@ -28,8 +38,9 @@ pub mod sheet;
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, edge_names_for, edge_refs, parse_metric_thread, pattern_matrices,
|
||||
state_in_frame, world_state,
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for, edge_refs,
|
||||
object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, state_in_frame,
|
||||
state_in_frame_at, through_all_distance, world_state,
|
||||
};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
@@ -51,5 +62,10 @@ pub enum DocError {
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod named_projection_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
mod anchor_frame;
|
||||
pub use anchor_frame::state_in_face_anchor_frame;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::{Document, ModelBody, ModelState};
|
||||
use solvecraft_geom::{Plane, Vec3};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn named_box(name: &str, width: f64, height: f64) -> ModelBody {
|
||||
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(width, width, height)).unwrap();
|
||||
let mut names = vec!["unrelated".to_string(); body.faces(0.01).unwrap().len()];
|
||||
let top = body.faces(0.01).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99)).unwrap().index;
|
||||
names[top] = "shared:top".into();
|
||||
let mut result = ModelBody::new(name.into(), body, 1);
|
||||
result.names = crate::naming::NameCell::known(Arc::new(names));
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_manual_projection_keeps_its_body_and_face_at_shared_boundaries() {
|
||||
let doc = Document::default();
|
||||
let mut initial = ModelState::default();
|
||||
initial.bodies.extend([named_box("Source", 20., 10.), named_box("Control", 5., 10.)]);
|
||||
let plane = Plane::XY.offset(20.);
|
||||
let source = LinkSource::Face { body: "Source".into(), at: Vec3::new(1., 1., 10.) };
|
||||
let resolved = crate::project::resolve(&doc, &BTreeMap::new(), &initial, &plane, LinkKind::Project, &source).unwrap();
|
||||
let mut sketch = Sketch::default();
|
||||
let link = sketch.add_link(LinkKind::Project, source, &resolved.geom).unwrap();
|
||||
sketch.links[0].face_name = Some("shared:top".into());
|
||||
sketch.links[0].source = LinkSource::Face { body: "Source".into(), at: Vec3::new(0., 0., 10.) };
|
||||
let mut current = ModelState::default();
|
||||
current.bodies.extend([named_box("Source", 20., 20.), named_box("Control", 5., 10.)]);
|
||||
assert!(crate::project::refresh_links(&doc, &BTreeMap::new(), ¤t, &plane, &mut sketch).is_empty());
|
||||
assert!(!sketch.links[0].lost);
|
||||
assert!(sketch.points.iter().any(|point| point.pos.x > 19.));
|
||||
assert!(sketch.points.iter().any(|point| point.pos.y > 19.));
|
||||
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,at} if body=="Source" && (at.z-20.).abs()<1e-6));
|
||||
// The closest named point can sit on the wall/top seam: retain its actual
|
||||
// face index rather than re-picking the adjacent vertical wall.
|
||||
let index = crate::naming::face_on_body(current.body("Source").unwrap(), "shared:top", Vec3::new(0., 0., 10.)).unwrap().0;
|
||||
assert!(current.body("Source").unwrap().body.faces(0.01).unwrap()[index].plane_normal.unwrap().z > 0.99);
|
||||
let saved = sketch.clone();
|
||||
let mut missing = ModelState::default();
|
||||
missing.bodies.push(named_box("Control", 5., 10.));
|
||||
let warnings = crate::project::refresh_links(&doc, &BTreeMap::new(), &missing, &plane, &mut sketch);
|
||||
assert!(sketch.links[0].lost);
|
||||
assert!(!warnings.is_empty());
|
||||
assert_eq!(sketch.curves, saved.curves);
|
||||
assert_eq!(sketch.points, saved.points);
|
||||
assert_eq!(sketch.links[0].id, link);
|
||||
assert!(matches!(&sketch.links[0].source,LinkSource::Face{body,..} if body=="Source"));
|
||||
}
|
||||
@@ -195,11 +195,22 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
|| xb.max.z < bb.min.z - tol
|
||||
|| xb.min.z > bb.max.z + tol)
|
||||
};
|
||||
// Independently created solids own their faces even when another body
|
||||
// occupies the same coordinates. Boolean operations still inherit inputs.
|
||||
let independent_solid = matches!(
|
||||
o.feature.kind,
|
||||
FeatureKind::Box { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Cylinder { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Sphere { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Torus { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Extrude { operation: crate::Operation::NewBody, .. }
|
||||
| FeatureKind::Revolve { operation: crate::Operation::NewBody, .. }
|
||||
);
|
||||
let inputs: Vec<(&ModelBody, FaceIndex, Arc<Vec<String>>)> = o
|
||||
.before
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|x| !x.body.is_mesh() && overlaps(x))
|
||||
.filter(|x| !independent_solid && !x.body.is_mesh() && overlaps(x))
|
||||
.take(64)
|
||||
.map(|x| {
|
||||
let n = face_count(x);
|
||||
@@ -320,16 +331,20 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
|
||||
if let Some(f) = idx.face_at(p, tol)
|
||||
&& let Some(n) = nm.get(f)
|
||||
{
|
||||
// A later operation may merge several pieces of the same input face.
|
||||
// Count their shared ancestry before testing the majority; a piece
|
||||
// number identifies topology subdivision, not a different source.
|
||||
let n = strip_piece(n);
|
||||
match votes.iter_mut().find(|(v, _)| v == n) {
|
||||
Some(v) => v.1 += 1,
|
||||
None => votes.push((n.clone(), 1)),
|
||||
None => votes.push((n.to_string(), 1)),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
votes.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1)).map(|(n, _)| strip_piece(n).to_string())
|
||||
votes.first().filter(|(_, n)| *n * 2 >= ps.len().max(1) && votes.get(1).is_none_or(|(_, other)| n > other)).map(|(n, _)| n.clone())
|
||||
}
|
||||
|
||||
/// A face name without its piece number (`#k`).
|
||||
@@ -644,6 +659,21 @@ pub fn face_names_at(st: &ModelState, pts: &[Vec3]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Capture a face name from the recorded projection body only.
|
||||
pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
|
||||
if body.body.is_mesh() || !point.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
let count = face_count(body);
|
||||
if mesh.triangles.len() > 2_000_000 || count > 50_000 {
|
||||
return None;
|
||||
}
|
||||
let tolerance = (body.body.size() * 1e-3).max(1e-3);
|
||||
let index = FaceIndex::new(&mesh, count).face_at(point, tolerance)?;
|
||||
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
|
||||
}
|
||||
|
||||
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
|
||||
/// `true` for "it was split"); `None` when no face has the name.
|
||||
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
|
||||
@@ -668,3 +698,56 @@ pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool
|
||||
}
|
||||
best.map(|(_, q, split)| (q, split))
|
||||
}
|
||||
|
||||
/// Resolve a legacy named face only on its recorded source body. Return the
|
||||
/// selected face index as well as the closest point, which may lie on a shared
|
||||
/// edge and therefore must not be used to pick the face a second time.
|
||||
pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec3, bool)> {
|
||||
if body.body.is_mesh() {
|
||||
return None;
|
||||
}
|
||||
let names = face_names(body);
|
||||
let mesh = body.mesh();
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, usize, Vec3, bool)> = None;
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let index = usize::try_from(*face).ok()?;
|
||||
let Some(candidate) = names.get(index) else { continue };
|
||||
let exact = candidate == name;
|
||||
if !exact && strip_piece(candidate) != stem {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
|
||||
let q = closest(p, a, b, c);
|
||||
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
|
||||
if best.is_none_or(|(old, _, _, _)| distance < old) {
|
||||
best = Some((distance, index, q, !exact));
|
||||
}
|
||||
}
|
||||
best.map(|(_, index, q, split)| (index, q, split))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merged_face_votes_collectively_for_pieces_of_one_source() {
|
||||
let body = ModelBody::new("Input".into(), solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(4., 1., 1.)).unwrap(), 1);
|
||||
let mut mesh = Mesh::default();
|
||||
let mut points = Vec::new();
|
||||
for i in 0..3u32 {
|
||||
let x = f64::from(i);
|
||||
mesh.positions.extend([Vec3::new(x, 0., 0.), Vec3::new(x + 0.8, 0., 0.), Vec3::new(x, 0.8, 0.)]);
|
||||
mesh.triangles.push([i * 3, i * 3 + 1, i * 3 + 2]);
|
||||
mesh.tri_face.push(i);
|
||||
points.push(Vec3::new(x + 0.2, 0.2, 0.));
|
||||
}
|
||||
let pieces = Arc::new(vec!["F1:side:wall#1".into(), "F1:side:wall#2".into(), "F1:side:wall#3".into()]);
|
||||
assert_eq!(vote(&points, &[(&body, FaceIndex::new(&mesh, 3), pieces)], 1e-8, None).as_deref(), Some("F1:side:wall"));
|
||||
let distinct = Arc::new(vec!["F1:side:wall#1".into(), "F2:side:wall#2".into(), "F3:side:wall#3".into()]);
|
||||
let inputs = [(&body, FaceIndex::new(&mesh, 3), distinct)];
|
||||
assert_eq!(vote(&points, &inputs, 1e-8, None), None);
|
||||
assert_eq!(vote(&points[..2], &inputs, 1e-8, None), None, "equal different-source votes must not pick a lexicographic winner");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ impl FeatureKind {
|
||||
if let Some(d) = &mut extent.taper {
|
||||
v.push(("Taper angle", d, A));
|
||||
}
|
||||
if let Some(d) = &mut extent.to_offset {
|
||||
v.push(("To offset", d, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, angle2, .. } => {
|
||||
v.push(("Angle", angle, A));
|
||||
@@ -271,7 +274,8 @@ impl FeatureKind {
|
||||
| FeatureKind::Loft { .. }
|
||||
| FeatureKind::Sweep { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -502,6 +506,53 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change existing expressions against their final dependency graph, independent of
|
||||
/// column order. Unlike `change_param`, this never creates a missing parameter.
|
||||
pub fn change_params_atomic(&mut self, changes: &[(String, String)]) -> Result<()> {
|
||||
if changes.len() > crate::config::MAX_COLUMNS {
|
||||
return Err(DocError::Invalid("too many parameter changes".into()));
|
||||
}
|
||||
let mut names = BTreeSet::new();
|
||||
let mut changed = BTreeSet::new();
|
||||
let (_, before_errors) = self.param_values();
|
||||
let mut next = self.clone();
|
||||
for (name, expression) in changes {
|
||||
if !names.insert(name.clone()) {
|
||||
return Err(DocError::Invalid(format!("duplicate parameter `{name}`")));
|
||||
}
|
||||
if expression.len() > 4096 || expression.trim().is_empty() {
|
||||
return Err(DocError::Expr("expression is empty or too long".into()));
|
||||
}
|
||||
if let Some(p) = next.params.iter_mut().find(|p| p.name == *name) {
|
||||
if p.expr != expression.trim() {
|
||||
changed.insert(name.clone());
|
||||
}
|
||||
p.expr = expression.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
let hit = next.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k)));
|
||||
let Some((i, k)) = hit else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
|
||||
let kind = next.features.get(i).and_then(|f| f.kind.inputs().get(k).map(|x| x.2)).unwrap_or(Kind::Unitless);
|
||||
let normalized = next.with_design_unit(expression, kind);
|
||||
let Some(feature) = next.features.get_mut(i) else { return Err(DocError::Unknown(format!("parameter `{name}`"))) };
|
||||
let Some((_, target, _)) = feature.kind.inputs_mut().into_iter().nth(k) else {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
};
|
||||
if *target != normalized {
|
||||
changed.insert(name.clone());
|
||||
}
|
||||
*target = normalized;
|
||||
}
|
||||
let (_, errors) = next.param_values();
|
||||
for (name, error) in errors {
|
||||
if changed.contains(&name) || !before_errors.contains_key(&name) {
|
||||
return Err(DocError::Expr(format!("parameter `{name}`: {error}")));
|
||||
}
|
||||
}
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The parameters table (user, sketch dimensions, feature inputs).
|
||||
pub fn param_rows(&self) -> Vec<ParamRow> {
|
||||
let (vals, errs) = self.param_values();
|
||||
@@ -639,6 +690,8 @@ pub enum ParamUser {
|
||||
Feature { feature: u64, name: String, input: String },
|
||||
/// A sketch dimension (`constraint` is its id).
|
||||
Dimension { feature: u64, name: String, constraint: String },
|
||||
/// A configured column or an expression saved in a configuration row.
|
||||
Configuration { row: Option<String>, column: String },
|
||||
}
|
||||
|
||||
impl ParamUser {
|
||||
@@ -647,6 +700,10 @@ impl ParamUser {
|
||||
ParamUser::Parameter { name } => format!("parameter {name}"),
|
||||
ParamUser::Feature { name, input, .. } => format!("{name} ({input})"),
|
||||
ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"),
|
||||
ParamUser::Configuration { row, column } => match row {
|
||||
Some(row) => format!("configuration `{row}` ({column})"),
|
||||
None => format!("configuration column {column}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,6 +773,24 @@ impl Document {
|
||||
/// expressions refer to it, and the sketch dimensions it drives.
|
||||
pub fn param_users(&self, name: &str) -> Vec<ParamUser> {
|
||||
let mut out = Vec::new();
|
||||
for rule in &self.configs.rules {
|
||||
if rule.parameter_references().iter().any(|reference| reference == name) {
|
||||
out.push(ParamUser::Configuration { row: None, column: format!("rule `{}`", rule.name) });
|
||||
}
|
||||
}
|
||||
for (index, column) in self.configs.columns.iter().enumerate() {
|
||||
if let crate::config::Column::Param(target) = column {
|
||||
let key = self.config_key(column);
|
||||
if target == name {
|
||||
out.push(ParamUser::Configuration { row: None, column: key.clone() });
|
||||
}
|
||||
for row in &self.configs.rows {
|
||||
if row.cells.get(index).is_some_and(|value| expr::references(value).iter().any(|reference| reference == name)) {
|
||||
out.push(ParamUser::Configuration { row: Some(row.name.clone()), column: key.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in &self.params {
|
||||
if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Parameter { name: p.name.clone() });
|
||||
@@ -828,9 +903,14 @@ impl Document {
|
||||
}
|
||||
}
|
||||
// Configuration cells refer to parameters too.
|
||||
for rule in &mut self.configs.rules {
|
||||
rule.rename_parameter(old, new);
|
||||
}
|
||||
for r in &mut self.configs.rows {
|
||||
for cell in &mut r.cells {
|
||||
*cell = expr::rename_reference(cell, old, new);
|
||||
for (column, cell) in self.configs.columns.iter().zip(&mut r.cells) {
|
||||
if matches!(column, crate::config::Column::Param(_)) {
|
||||
*cell = expr::rename_reference(cell, old, new);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.param_comments.remove(old) {
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Exact profile references through analytic offset topology transitions.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{DocError, ProfileSel, Result, SolvedSketch, WholeProfileBoundary};
|
||||
|
||||
const MAX_SELECTIONS: usize = 128;
|
||||
|
||||
fn whole_boundary(profile: &solvecraft_sketch::Profile) -> Option<WholeProfileBoundary> {
|
||||
let (outer, holes) = profile.whole_curve_boundaries()?;
|
||||
Some(WholeProfileBoundary { outer, holes })
|
||||
}
|
||||
|
||||
fn validate_whole(profiles: &[WholeProfileBoundary]) -> Result<()> {
|
||||
if profiles.is_empty() || profiles.len() > MAX_SELECTIONS {
|
||||
return Err(DocError::Invalid("whole-source profile selection needs bounded unique profiles".into()));
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut identities = BTreeSet::new();
|
||||
for profile in profiles {
|
||||
if profile.holes.len() > 128 || !identities.insert((&profile.outer, &profile.holes)) {
|
||||
return Err(DocError::Invalid("invalid whole-source profile holes or duplicate identity".into()));
|
||||
}
|
||||
let mut all = BTreeSet::new();
|
||||
for boundary in std::iter::once(&profile.outer).chain(&profile.holes) {
|
||||
total += boundary.len();
|
||||
if boundary.is_empty() || boundary.len() > 512 || total > 20_000 {
|
||||
return Err(DocError::Invalid("whole-source boundary exceeds its identity budget".into()));
|
||||
}
|
||||
let mut previous = None;
|
||||
for curve in boundary {
|
||||
if curve.is_empty()
|
||||
|| curve.len() > 512
|
||||
|| curve.chars().any(char::is_control)
|
||||
|| previous.is_some_and(|previous| previous >= curve)
|
||||
|| !all.insert(curve)
|
||||
{
|
||||
return Err(DocError::Invalid("whole-source boundary needs canonical disjoint curve identities".into()));
|
||||
}
|
||||
previous = Some(curve);
|
||||
}
|
||||
}
|
||||
if profile.holes.windows(2).any(|pair| pair[0] >= pair[1]) {
|
||||
return Err(DocError::Invalid("whole-source holes need canonical unique identities".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn whole_profile_indices(ss: &SolvedSketch, profiles: &[WholeProfileBoundary]) -> Result<Vec<usize>> {
|
||||
validate_whole(profiles)?;
|
||||
let current = ss.profiles.iter().map(whole_boundary).collect::<Vec<_>>();
|
||||
profiles
|
||||
.iter()
|
||||
.map(|identity| {
|
||||
let mut matches = current.iter().enumerate().filter(|(_, candidate)| candidate.as_ref() == Some(identity));
|
||||
let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("complete source profile in {}", ss.name)))?;
|
||||
if matches.next().is_some() {
|
||||
return Err(DocError::Invalid(format!("ambiguous complete source profile in {}", ss.name)));
|
||||
}
|
||||
Ok(index)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_ids(a: &[String], b: &[String]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let (mut a, mut b) = (a.iter().collect::<Vec<_>>(), b.iter().collect::<Vec<_>>());
|
||||
a.sort();
|
||||
b.sort();
|
||||
a == b
|
||||
}
|
||||
|
||||
fn unique_profile(ss: &SolvedSketch, curves: &[String]) -> Result<usize> {
|
||||
let mut matches = ss.profiles.iter().enumerate().filter(|(_, profile)| same_ids(curves, &profile.outer_curves));
|
||||
let (index, _) = matches.next().ok_or_else(|| DocError::Unknown(format!("profile bounded by {curves:?} in {}", ss.name)))?;
|
||||
if matches.next().is_some() {
|
||||
return Err(DocError::Invalid(format!("ambiguous profile boundary in {}", ss.name)));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn reserved_boundaries(ss: &SolvedSketch) -> Result<std::collections::BTreeMap<Vec<String>, String>> {
|
||||
let mut found = std::collections::BTreeMap::new();
|
||||
for offset in &ss.sketch.offsets {
|
||||
let Some(topology) = &offset.topology else { continue };
|
||||
let mut reserved = topology.roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
|
||||
reserved.sort();
|
||||
if found.insert(reserved, topology.id.clone()).is_some() {
|
||||
return Err(DocError::Invalid("profile role set belongs to multiple offset associations".into()));
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Resolve exact selection identities to the solved sketch's current profile indices.
|
||||
/// Complete reserved role sets follow their association; partial sets never expand.
|
||||
pub fn profile_selection_indices(ss: &SolvedSketch, selection: &ProfileSel) -> Result<Vec<usize>> {
|
||||
let indices = match selection {
|
||||
ProfileSel::All => (0..ss.profiles.len()).collect(),
|
||||
ProfileSel::Indices { indices } => indices.clone(),
|
||||
ProfileSel::Curves { loops } => {
|
||||
let boundaries = reserved_boundaries(ss)?;
|
||||
let mut indices = Vec::with_capacity(loops.len());
|
||||
for curves in loops {
|
||||
let mut key = curves.clone();
|
||||
key.sort();
|
||||
if let Some(id) = boundaries.get(&key) {
|
||||
if curves.len() > 512 || curves.iter().collect::<BTreeSet<_>>().len() != curves.len() {
|
||||
return Err(DocError::Invalid("offset profile has repeated or excessive curve roles".into()));
|
||||
}
|
||||
indices.push(unique_profile(ss, &ss.sketch.offset_boundary(id)?)?);
|
||||
} else {
|
||||
// Repeated saved IDs identify exact graph spans. Their multiplicity
|
||||
// must not silently turn into a whole-source certificate on resize.
|
||||
if curves.iter().collect::<BTreeSet<_>>().len() != curves.len()
|
||||
&& !ss.profiles.iter().any(|profile| same_ids(curves, &profile.outer_curves))
|
||||
{
|
||||
return Err(crate::curve_pick_error(solvecraft_sketch::CurvePick::Missing, curves, &ss.name));
|
||||
}
|
||||
indices.push(
|
||||
solvecraft_sketch::profile_by_curves(&ss.profiles, curves)
|
||||
.map_err(|error| crate::curve_pick_error(error, curves, &ss.name))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
indices
|
||||
}
|
||||
ProfileSel::OffsetBoundaries { associations } => {
|
||||
if associations.is_empty()
|
||||
|| associations.len() > MAX_SELECTIONS
|
||||
|| associations.iter().collect::<BTreeSet<_>>().len() != associations.len()
|
||||
{
|
||||
return Err(DocError::Invalid("offset profile selection needs unique bounded association identities".into()));
|
||||
}
|
||||
associations.iter().map(|id| unique_profile(ss, &ss.sketch.offset_boundary(id)?)).collect::<Result<Vec<_>>>()?
|
||||
}
|
||||
ProfileSel::WholeBoundaries { profiles } => whole_profile_indices(ss, profiles)?,
|
||||
ProfileSel::Points { points } => {
|
||||
let mut indices = Vec::with_capacity(points.len());
|
||||
for point in points {
|
||||
let (index, _) = ss
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, profile)| profile.region.contains(*point))
|
||||
.min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area))
|
||||
.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [point.x, point.y], ss.name)))?;
|
||||
indices.push(index);
|
||||
}
|
||||
indices
|
||||
}
|
||||
};
|
||||
if indices.iter().any(|index| *index >= ss.profiles.len()) {
|
||||
return Err(DocError::Invalid(format!("out-of-range profile selection in {}", ss.name)));
|
||||
}
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
/// Persist selected complete offset boundaries by association identity. Mixed selections
|
||||
/// retain exact ordinary loops and the complete reserved role set of each offset boundary.
|
||||
pub fn capture_profile_selection(ss: &SolvedSketch, indices: &[usize]) -> Result<ProfileSel> {
|
||||
let checked = profile_selection_indices(ss, &ProfileSel::Indices { indices: indices.to_vec() })?;
|
||||
if checked.is_empty() || checked.iter().collect::<BTreeSet<_>>().len() != checked.len() {
|
||||
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
|
||||
}
|
||||
// Capture adaptive free-form boundaries only with complete source provenance,
|
||||
// including every hole. Ordinary analytic captures keep their historical form.
|
||||
let adaptive = checked.iter().any(|index| {
|
||||
ss.profiles.get(*index).is_some_and(|profile| {
|
||||
std::iter::once(&profile.outer_curves)
|
||||
.chain(&profile.hole_curves)
|
||||
.any(|curves| curves.iter().collect::<BTreeSet<_>>().len() != curves.len())
|
||||
})
|
||||
});
|
||||
if adaptive {
|
||||
let identities = checked.iter().map(|index| ss.profiles.get(*index).and_then(whole_boundary)).collect::<Option<Vec<_>>>();
|
||||
if let Some(profiles) = identities {
|
||||
// Dynamic analytic offset groups need their own association identities.
|
||||
// Do not silently flatten a mixed selection into current active curve IDs.
|
||||
let mut dynamic = false;
|
||||
for profile in &profiles {
|
||||
for boundary in std::iter::once(&profile.outer).chain(&profile.holes) {
|
||||
dynamic |= ss.sketch.offset_boundary_for_curves(boundary)?.is_some();
|
||||
}
|
||||
}
|
||||
if !dynamic && whole_profile_indices(ss, &profiles)? == checked {
|
||||
return Ok(ProfileSel::WholeBoundaries { profiles });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut associations = Vec::new();
|
||||
let mut loops = Vec::new();
|
||||
for index in checked {
|
||||
let curves = &ss.profiles.get(index).ok_or_else(|| DocError::Invalid("profile index changed during capture".into()))?.outer_curves;
|
||||
if curves.is_empty() {
|
||||
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
|
||||
}
|
||||
// Ordinary free-form loops retain one curve ID per exact span. That
|
||||
// historical multiset is valid profile identity, but is not a candidate
|
||||
// for the unique reserved-role set of a topology-aware association.
|
||||
let association =
|
||||
if curves.iter().collect::<BTreeSet<_>>().len() == curves.len() { ss.sketch.offset_boundary_for_curves(curves)? } else { None };
|
||||
if let Some(id) = association {
|
||||
unique_profile(ss, curves)?;
|
||||
let offset = ss
|
||||
.sketch
|
||||
.offsets
|
||||
.iter()
|
||||
.filter_map(|offset| offset.topology.as_ref())
|
||||
.find(|topology| topology.id == id)
|
||||
.ok_or_else(|| DocError::Unknown(format!("offset association {id}")))?;
|
||||
loops.push(offset.roles.iter().map(|role| role.output.clone()).collect());
|
||||
associations.push(id);
|
||||
} else {
|
||||
if ss.profiles.iter().filter(|profile| same_ids(curves, &profile.outer_curves)).count() != 1 {
|
||||
return Ok(ProfileSel::Indices { indices: indices.to_vec() });
|
||||
}
|
||||
loops.push(curves.clone());
|
||||
}
|
||||
}
|
||||
if associations.len() > MAX_SELECTIONS {
|
||||
return Err(DocError::Invalid("too many offset association profiles".into()));
|
||||
}
|
||||
if associations.len() == loops.len() { Ok(ProfileSel::OffsetBoundaries { associations }) } else { Ok(ProfileSel::Curves { loops }) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use solvecraft_geom::{Plane, Vec2};
|
||||
use solvecraft_sketch::{Sketch, find_profiles, solve};
|
||||
|
||||
fn rounded_source(sketch: &mut Sketch, radius: f64) -> Vec<(String, bool)> {
|
||||
let endpoints = [
|
||||
Vec2::new(-37., -32. - radius),
|
||||
Vec2::new(37., -32. - radius),
|
||||
Vec2::new(37. + radius, -32.),
|
||||
Vec2::new(37. + radius, 32.),
|
||||
Vec2::new(37., 32. + radius),
|
||||
Vec2::new(-37., 32. + radius),
|
||||
Vec2::new(-37. - radius, 32.),
|
||||
Vec2::new(-37. - radius, -32.),
|
||||
];
|
||||
let points: Vec<_> =
|
||||
endpoints.iter().enumerate().map(|(index, point)| sketch.add_point(*point, Some(&format!("source-p{index}"))).unwrap()).collect();
|
||||
let centers = [Vec2::new(37., -32.), Vec2::new(37., 32.), Vec2::new(-37., 32.), Vec2::new(-37., -32.)];
|
||||
for index in 0..8 {
|
||||
let id = format!("source-edge{index}");
|
||||
if index % 2 == 0 {
|
||||
sketch.add_line_pts(points[index], points[(index + 1) % 8], Some(&id)).unwrap();
|
||||
} else {
|
||||
let center = centers[index / 2];
|
||||
sketch
|
||||
.add_arc(
|
||||
center,
|
||||
endpoints[index],
|
||||
endpoints[(index + 1) % 8],
|
||||
[None, Some(points[index]), Some(points[(index + 1) % 8])],
|
||||
Some(&id),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for point in &mut sketch.points {
|
||||
point.fixed = true;
|
||||
}
|
||||
(0..8).map(|index| (format!("source-edge{index}"), true)).collect()
|
||||
}
|
||||
|
||||
fn set_source_radius(sketch: &mut Sketch, radius: f64) {
|
||||
let points = [
|
||||
Vec2::new(-37., -32. - radius),
|
||||
Vec2::new(37., -32. - radius),
|
||||
Vec2::new(37. + radius, -32.),
|
||||
Vec2::new(37. + radius, 32.),
|
||||
Vec2::new(37., 32. + radius),
|
||||
Vec2::new(-37., 32. + radius),
|
||||
Vec2::new(-37. - radius, 32.),
|
||||
Vec2::new(-37. - radius, -32.),
|
||||
];
|
||||
for (index, value) in points.into_iter().enumerate() {
|
||||
sketch.points.iter_mut().find(|point| point.id == format!("source-p{index}")).unwrap().pos = value;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_sharp_offset_profile_survives_arc_resurrection_and_reopen() {
|
||||
let mut sketch = Sketch::new();
|
||||
let sources = rounded_source(&mut sketch, 1.);
|
||||
let created = sketch.add_convex_offset(sources, 1.4).unwrap();
|
||||
assert_eq!(created.curves.len(), 4);
|
||||
let mut document = crate::Document::new("selected logical inner boundary");
|
||||
let feature =
|
||||
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&document);
|
||||
let state = model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
let index = unique_profile(solved, &created.curves).unwrap();
|
||||
let selection = capture_profile_selection(solved, &[index]).unwrap();
|
||||
assert_eq!(selection, ProfileSel::OffsetBoundaries { associations: vec![created.association.clone()] });
|
||||
let original_roles = solved.sketch.offsets[0].topology.as_ref().unwrap().roles.iter().map(|role| role.output.clone()).collect::<Vec<_>>();
|
||||
let sharp_area = crate::select_profiles(solved, &selection).unwrap()[0].area();
|
||||
assert!((sharp_area - 73.2 * 63.2).abs() < 1e-7, "{sharp_area}");
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
model.evaluate(&document);
|
||||
let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
|
||||
assert!((volume - 2. * 73.2 * 63.2).abs() < 1e-6, "{volume}");
|
||||
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
assert_eq!(reopened, document);
|
||||
let mut cold = crate::Model::new();
|
||||
cold.evaluate(&reopened);
|
||||
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-6);
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
|
||||
set_source_radius(sketch, 2.);
|
||||
model.evaluate(&document);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
|
||||
let state = model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
let active = solved.sketch.offset_boundary(&created.association).unwrap();
|
||||
assert_eq!(active, original_roles);
|
||||
let rounded_area = 75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6;
|
||||
assert!((crate::select_profiles(solved, &selection).unwrap()[0].area() - rounded_area).abs() < 1e-7);
|
||||
let volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
|
||||
// Kernel volume integrates the curved display mesh; the analytic region above
|
||||
// checks the exact rounded geometry independently of that tessellation.
|
||||
assert!((volume - 2. * rounded_area).abs() < 0.002, "{volume}");
|
||||
assert!(
|
||||
crate::select_profiles(solved, &ProfileSel::Curves { loops: vec![created.curves.clone()] }).is_err(),
|
||||
"partial old active set must not expand after resurrection"
|
||||
);
|
||||
assert_eq!(
|
||||
profile_selection_indices(solved, &ProfileSel::Curves { loops: vec![original_roles] }).unwrap(),
|
||||
profile_selection_indices(solved, &selection).unwrap()
|
||||
);
|
||||
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
cold.evaluate(&reopened);
|
||||
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - volume).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_profile_selection_keeps_legacy_large_and_duplicate_semantics() {
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_circle(Vec2::new(0., 0.), 3., None, Some("circle")).unwrap();
|
||||
let report = solve(&mut sketch);
|
||||
let profiles = find_profiles(&sketch);
|
||||
let ss = SolvedSketch { feature: 1, name: "ordinary circle".into(), plane: Plane::XY, sketch, report, profiles };
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
let indices = vec![0; 600];
|
||||
let selection = ProfileSel::Indices { indices: indices.clone() };
|
||||
assert_eq!(profile_selection_indices(&ss, &selection).unwrap(), indices);
|
||||
assert_eq!(capture_profile_selection(&ss, &indices).unwrap(), selection);
|
||||
let points = ProfileSel::Points { points: vec![Vec2::new(0., 0.); 600] };
|
||||
assert_eq!(profile_selection_indices(&ss, &points).unwrap(), indices);
|
||||
let curves = ProfileSel::Curves { loops: vec![vec!["circle".into()]; 600] };
|
||||
let regions = crate::select_profiles(&ss, &curves).unwrap();
|
||||
assert_eq!(regions.len(), 600);
|
||||
assert!(regions.iter().all(|region| (region.area() - std::f64::consts::PI * 9.).abs() < 1e-9));
|
||||
assert!(profile_selection_indices(&ss, &ProfileSel::Indices { indices: vec![1] }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_offset_ring_whole_source_survives_adaptive_spans_and_reopen() {
|
||||
let mut sketch = Sketch::new();
|
||||
let major = sketch.add_point(Vec2::new(10., 0.), None).unwrap();
|
||||
let ellipse = sketch.add_curve(solvecraft_sketch::CurveKind::Ellipse { c: 0, m: major, r: 5. }, None).unwrap();
|
||||
let first = sketch.add_freeform_offset(ellipse, true, -1.).unwrap();
|
||||
sketch.add_freeform_offset(first, true, -0.5).unwrap();
|
||||
let mut document = crate::Document::new("periodic offset ring multiset");
|
||||
let feature =
|
||||
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
let mut model = crate::Model::new();
|
||||
model.evaluate(&document);
|
||||
let state = model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
assert_eq!(solved.profiles.len(), 3);
|
||||
let (index, profile) = solved.profiles.iter().enumerate().min_by(|(_, a), (_, b)| a.area.total_cmp(&b.area)).unwrap();
|
||||
assert!(profile.outer_curves.len() > 1);
|
||||
assert_eq!(profile.outer_curves.iter().collect::<BTreeSet<_>>().len(), 1);
|
||||
assert!(solved.sketch.offset_boundary_for_curves(&profile.outer_curves).is_err(), "strict association identity still refuses repeated roles");
|
||||
let selection = capture_profile_selection(solved, &[index]).unwrap();
|
||||
assert!(matches!(&selection, ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1 && profiles[0].holes.len() == 1));
|
||||
let legacy_spans = ProfileSel::Curves { loops: vec![profile.outer_curves.clone()] };
|
||||
assert_eq!(profile_selection_indices(solved, &legacy_spans).unwrap(), vec![index]);
|
||||
let legacy_roundtrip: ProfileSel = serde_json::from_str(&serde_json::to_string(&legacy_spans).unwrap()).unwrap();
|
||||
assert_eq!(legacy_roundtrip, legacy_spans, "ordinary legacy span multiplicities remain serialized exactly");
|
||||
assert_eq!(profile_selection_indices(solved, &selection).unwrap(), vec![index]);
|
||||
let selected = crate::select_profiles(solved, &selection).unwrap();
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert!((selected[0].area() - profile.area).abs() < 1e-10);
|
||||
assert!(selected[0].contains(Vec2::new(11.25, 0.)));
|
||||
assert!(!selected[0].contains(Vec2::new(10.5, 0.)));
|
||||
assert!(!selected[0].contains(Vec2::new(11.75, 0.)));
|
||||
let saved = serde_json::to_string(&selection).unwrap();
|
||||
let reopened: ProfileSel = serde_json::from_str(&saved).unwrap();
|
||||
let reopened_doc = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
let mut cold = crate::Model::new();
|
||||
cold.evaluate(&reopened_doc);
|
||||
let cold_state = cold.state();
|
||||
let regions = crate::select_profiles(cold_state.sketch(feature).unwrap(), &reopened).unwrap();
|
||||
assert_eq!(regions, selected);
|
||||
|
||||
// An identical complete identity in two profiles is ambiguous, even when
|
||||
// their boundary geometry and graph coverage certificates are identical.
|
||||
let mut ambiguous = cold_state.sketch(feature).unwrap().clone();
|
||||
ambiguous.profiles.push(ambiguous.profiles[index].clone());
|
||||
assert!(profile_selection_indices(&ambiguous, &reopened).is_err());
|
||||
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
},
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
model.evaluate(&document);
|
||||
let initial_volume = solvecraft_kernel::measure(&model.state().bodies[0].body).unwrap().volume;
|
||||
// Kernel measurement integrates the curved mesh. Scale the established
|
||||
// periodic-ring tolerance (0.05 mm³ at height3) to this height2 extrusion.
|
||||
// Exact profile identity and cold/warm equality below remain strict.
|
||||
let volume_tolerance = 0.05 * 2. / 3.;
|
||||
assert!(
|
||||
(initial_volume - 2. * profile.area).abs() < volume_tolerance,
|
||||
"initial kernel volume {initial_volume}, analytic area {}",
|
||||
profile.area
|
||||
);
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
|
||||
sketch.points[major].pos.x = 12.;
|
||||
model.evaluate(&document);
|
||||
assert!(model.results.iter().all(|result| result.error.is_none()), "{:?}", model.results);
|
||||
let updated_state = model.state();
|
||||
let updated = updated_state.sketch(feature).unwrap();
|
||||
assert!(profile_selection_indices(updated, &legacy_spans).is_err(), "legacy tessellation-span multiplicity intentionally remains exact");
|
||||
let updated_index = profile_selection_indices(updated, &selection).unwrap()[0];
|
||||
let updated_region = &updated.profiles[updated_index].region;
|
||||
// Probe away from exact spline-span seams, using the independent analytic
|
||||
// ellipse point and unit outward gradient rather than a stored profile sample.
|
||||
let parameter = 0.2_f64;
|
||||
let source_point = Vec2::new(12. * parameter.cos(), 5. * parameter.sin());
|
||||
let normal = Vec2::new(parameter.cos() / 12., parameter.sin() / 5.).normalized().unwrap();
|
||||
let ring_point = source_point + normal * 1.25;
|
||||
assert!(updated_region.contains(ring_point));
|
||||
assert!(!updated_region.contains(source_point + normal * 0.5));
|
||||
assert!(!updated_region.contains(source_point + normal * 1.75));
|
||||
let updated_volume = solvecraft_kernel::measure(&updated_state.bodies[0].body).unwrap().volume;
|
||||
assert!(updated_volume > initial_volume);
|
||||
assert!(
|
||||
(updated_volume - 2. * updated_region.area()).abs() < volume_tolerance,
|
||||
"updated kernel volume {updated_volume}, analytic area {}",
|
||||
updated_region.area()
|
||||
);
|
||||
let reopened_document = crate::Document::from_json(&document.to_json()).unwrap();
|
||||
cold.evaluate(&reopened_document);
|
||||
assert!(cold.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((solvecraft_kernel::measure(&cold.state().bodies[0].body).unwrap().volume - updated_volume).abs() < 1e-6);
|
||||
|
||||
// A new hole is a semantic boundary change, not the old ring. Whole-source
|
||||
// identities include holes and must not silently absorb the new material cutout.
|
||||
let crate::FeatureKind::Sketch { sketch, .. } = &mut document.features[0].kind else { panic!("sketch") };
|
||||
sketch.add_circle(ring_point, 0.1, None, Some("new-hole")).unwrap();
|
||||
model.evaluate(&document);
|
||||
let changed = model.state();
|
||||
assert!(profile_selection_indices(changed.sketch(feature).unwrap(), &selection).is_err());
|
||||
assert!(model.results.last().unwrap().error.is_some());
|
||||
}
|
||||
}
|
||||
+253
-12
@@ -20,6 +20,9 @@ const AXIS_HALF: f64 = 100.0;
|
||||
/// Points in one fitted chain at most (longer chains are thinned).
|
||||
const MAX_CHAIN: usize = 4000;
|
||||
|
||||
#[path = "project_order.rs"]
|
||||
mod order;
|
||||
|
||||
/// A resolved link: its geometry in sketch coordinates and the (possibly moved) reference.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Resolved {
|
||||
@@ -29,6 +32,9 @@ pub struct Resolved {
|
||||
pub source: LinkSource,
|
||||
/// The reference was not exactly where it was and was re-found on the nearest geometry.
|
||||
pub moved: bool,
|
||||
/// Exact face selected during this resolution, before its closest point is
|
||||
/// projected onto an edge shared with another face. Computed metadata only.
|
||||
pub face_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Resolutions of body-based references, keyed by the reference, the sketch plane and the
|
||||
@@ -91,6 +97,7 @@ fn resolve_uncached(
|
||||
) -> Result<Resolved> {
|
||||
let lost = |what: &str| DocError::Invalid(format!("{what} no longer exists"));
|
||||
let mut moved = false;
|
||||
let mut face_index = None;
|
||||
let mut source = src.clone();
|
||||
let mut wires: Vec<Vec<Vec3>> = Vec::new();
|
||||
// Included curves that leave the sketch plane stay 3D.
|
||||
@@ -122,6 +129,7 @@ fn resolve_uncached(
|
||||
LinkSource::Face { body, at } => {
|
||||
let (bi, mesh) = body_mesh(st, body, *at)?;
|
||||
let (f, q, d) = nearest_face(&mesh, *at).ok_or_else(|| lost("the face"))?;
|
||||
face_index = Some(usize::try_from(f).map_err(|_| DocError::Invalid("face index exceeds platform limit".into()))?);
|
||||
let size = mesh.bounds().diagonal().max(1e-9);
|
||||
if d > size * 0.25 {
|
||||
return Err(lost("the face"));
|
||||
@@ -133,15 +141,13 @@ fn resolve_uncached(
|
||||
LinkKind::Intersect => section(plane, &mesh, Some(f)),
|
||||
_ => {
|
||||
let mut g = Vec::new();
|
||||
for e in mesh.face_edges(f) {
|
||||
if mesh.seams.get(e).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
|
||||
for e in projection_face_edges(target, &mesh, f, plane)? {
|
||||
if let Some(pts) = mesh.edges.get(e) {
|
||||
g.extend(fit_world(plane, pts));
|
||||
}
|
||||
}
|
||||
g
|
||||
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,19 +270,242 @@ fn resolve_uncached(
|
||||
if geom.is_empty() && wires.is_empty() {
|
||||
return Err(DocError::Invalid(format!("the {} projects to nothing on this sketch plane", src.describe())));
|
||||
}
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved })
|
||||
Ok(Resolved { geom: merge_split_circles(geom), wires, source, moved, face_index })
|
||||
}
|
||||
|
||||
/// Re-resolve every link of a sketch (in place). Returns warnings for lost, moved or rebuilt
|
||||
/// links; lost links keep their last geometry.
|
||||
fn lost(reference: &str) -> DocError {
|
||||
DocError::Unknown(format!("{reference} no longer exists"))
|
||||
}
|
||||
|
||||
fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str, index: usize, at: Vec3) -> Result<Resolved> {
|
||||
let target = st.body(body).ok_or_else(|| lost("named projection body"))?;
|
||||
let mesh = target.mesh();
|
||||
if mesh.triangles.len() > 2_000_000 || mesh.edges.len() > 2_000_000 {
|
||||
return Err(DocError::Invalid("named face projection work limit exceeded".into()));
|
||||
}
|
||||
let face_index = Some(index);
|
||||
let index = u32::try_from(index).map_err(|_| DocError::Invalid("named projection face index exceeds limit".into()))?;
|
||||
let geom = if kind == LinkKind::Intersect {
|
||||
section(plane, &mesh, Some(index))
|
||||
} else {
|
||||
let mut geom = Vec::new();
|
||||
for edge in projection_face_edges(target, &mesh, index, plane)? {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geom.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
let geom = merge_split_circles(geom);
|
||||
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
|
||||
};
|
||||
if geom.is_empty() {
|
||||
return Err(DocError::Invalid("named face projects to nothing on this sketch plane".into()));
|
||||
}
|
||||
Ok(Resolved { geom, wires: Vec::new(), source: LinkSource::Face { body: body.into(), at }, moved: false, face_index })
|
||||
}
|
||||
|
||||
fn projection_face_edges(body: &crate::ModelBody, mesh: &Mesh, index: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let shell_plane = body
|
||||
.body
|
||||
.face_sources()
|
||||
.and_then(|sources| sources.get(index as usize))
|
||||
.is_some_and(|source| matches!(source, Some(solvecraft_kernel::FaceSource::ShellPlanar { .. })));
|
||||
if shell_plane { order::raw_face_edges(mesh, index, plane) } else { order::face_edges(mesh, index, plane) }
|
||||
}
|
||||
|
||||
fn certified_planar_construction(body: &crate::ModelBody, index: usize) -> bool {
|
||||
body.body.face_sources().and_then(|sources| sources.get(index)).is_some_and(|source| {
|
||||
matches!(
|
||||
source,
|
||||
Some(
|
||||
solvecraft_kernel::FaceSource::ShellPlanar { producer: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::Box { producer: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::CertifiedExtrudeSide { plane: Some(_), .. }
|
||||
| solvecraft_kernel::FaceSource::CertifiedExtrudeCap { .. }
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_anchored_face(doc: &Document, st: &ModelState, plane: &Plane, kind: LinkKind, anchor: &crate::FaceAnchor) -> Result<Resolved> {
|
||||
let body = st.body(&anchor.target.body).ok_or_else(|| lost("anchored projection body"))?;
|
||||
let indices = doc.configuration_face_indices(body, &anchor.target)?;
|
||||
resolve_face_indices(st, plane, kind, &body.name, &indices)
|
||||
}
|
||||
|
||||
/// Project explicitly selected current face indices, without claiming persistent provenance.
|
||||
/// Callers retaining a link must supply their own replay identity policy.
|
||||
pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body_name: &str, indices: &[usize]) -> Result<Resolved> {
|
||||
let body = st.body(body_name).ok_or_else(|| lost("projection body"))?;
|
||||
let mesh = body.mesh();
|
||||
if mesh.triangles.len() > 2_000_000 || indices.len() > 10_000 {
|
||||
return Err(DocError::Invalid("anchored projection work limit exceeded".into()));
|
||||
}
|
||||
let work = mesh.triangles.len().saturating_add(mesh.edges.len()).saturating_mul(indices.len());
|
||||
if work > 10_000_000 {
|
||||
return Err(DocError::Invalid("anchored projection aggregate work limit exceeded".into()));
|
||||
}
|
||||
let selected: std::collections::BTreeSet<_> = indices.iter().copied().collect();
|
||||
let mut geometry = Vec::new();
|
||||
if kind == LinkKind::Intersect {
|
||||
for index in indices {
|
||||
let index = u32::try_from(*index).map_err(|_| DocError::Invalid("anchored face index exceeds format bounds".into()))?;
|
||||
geometry.extend(section(plane, &mesh, Some(index)));
|
||||
}
|
||||
} else {
|
||||
let edges = if let [index] = indices {
|
||||
projection_face_edges(
|
||||
body,
|
||||
&mesh,
|
||||
u32::try_from(*index).map_err(|_| DocError::Invalid("face index exceeds format bounds".into()))?,
|
||||
plane,
|
||||
)?
|
||||
} else {
|
||||
order::family_edges(&mesh, &selected, plane)?
|
||||
};
|
||||
for edge in edges {
|
||||
if let Some(points) = mesh.edges.get(edge) {
|
||||
geometry.extend(fit_world(plane, points));
|
||||
}
|
||||
}
|
||||
}
|
||||
if kind != LinkKind::Intersect && (indices.len() > 1 || indices.first().is_some_and(|index| certified_planar_construction(body, *index))) {
|
||||
geometry = merge_family_lines(geometry)?;
|
||||
}
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.find_map(|(triangle, face)| selected.contains(&(*face as usize)).then(|| mesh.tri(triangle)).flatten().map(|[a, b, c]| (a + b + c) / 3.0))
|
||||
.ok_or_else(|| lost("anchored projection face"))?;
|
||||
if geometry.is_empty() {
|
||||
return Err(lost("anchored projection boundaries"));
|
||||
}
|
||||
Ok(Resolved {
|
||||
geom: merge_split_circles(geometry),
|
||||
wires: Vec::new(),
|
||||
source: LinkSource::Face { body: body.name.clone(), at: point },
|
||||
moved: false,
|
||||
face_index: if indices.len() == 1 { indices.first().copied() } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_face_anchor_links(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: &crate::FaceAnchor,
|
||||
) -> Vec<String> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
|
||||
}
|
||||
|
||||
/// Remove degree-two straight stations introduced by coplanar face subdivision.
|
||||
/// The walk uses endpoint incidence; geometric sorting does not imply adjacency.
|
||||
fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
|
||||
if geom.len().saturating_mul(geom.len()).saturating_mul(3) > 10_000_000 {
|
||||
return Err(DocError::Invalid("face family straight-boundary work limit exceeded".into()));
|
||||
}
|
||||
let ends = |curve: &LinkGeom| match curve {
|
||||
LinkGeom::Line(a, b) | LinkGeom::Arc { a, b, .. } | LinkGeom::Conic { a, b, .. } => Some((*a, *b)),
|
||||
LinkGeom::Spline { pts, periodic: false, .. } => pts.first().zip(pts.last()).map(|(a, b)| (*a, *b)),
|
||||
_ => None,
|
||||
};
|
||||
let scale = geom.iter().filter_map(&ends).flat_map(|(a, b)| [a.len(), b.len()]).fold(1., f64::max);
|
||||
let tol = scale * f64::EPSILON * 512.;
|
||||
let mut used = vec![false; geom.len()];
|
||||
let mut result = Vec::new();
|
||||
for (index, curve) in geom.iter().enumerate() {
|
||||
if used.get(index).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = used.get_mut(index) {
|
||||
*slot = true;
|
||||
}
|
||||
let LinkGeom::Line(a, b) = curve else {
|
||||
result.push(curve.clone());
|
||||
continue;
|
||||
};
|
||||
let mut endpoints = [*a, *b];
|
||||
for side in 0..2 {
|
||||
let mut current = index;
|
||||
for _ in 0..geom.len() {
|
||||
let Some(point) = endpoints.get(side).copied() else { break };
|
||||
let mut incidence = geom
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(other, curve)| ends(curve).filter(|(a, b)| a.dist(point) <= tol || b.dist(point) <= tol).map(|_| other));
|
||||
let (Some(one), Some(two), None) = (incidence.next(), incidence.next(), incidence.next()) else { break };
|
||||
let other = if one == current {
|
||||
two
|
||||
} else if two == current {
|
||||
one
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
if used.get(other).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
let Some(LinkGeom::Line(a, b)) = geom.get(other) else { break };
|
||||
let next = if a.dist(point) <= tol { *b } else { *a };
|
||||
let Some(opposite) = endpoints.get(1 - side).copied() else { break };
|
||||
let (Some(incoming), Some(outgoing)) = ((point - opposite).normalized(), (next - point).normalized()) else { break };
|
||||
if incoming.dot(outgoing) <= 0. || incoming.cross(outgoing).abs() > f64::EPSILON * 512. {
|
||||
break;
|
||||
}
|
||||
if let Some(slot) = used.get_mut(other) {
|
||||
*slot = true;
|
||||
}
|
||||
if let Some(slot) = endpoints.get_mut(side) {
|
||||
*slot = next;
|
||||
}
|
||||
current = other;
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (endpoints.first(), endpoints.get(1)) {
|
||||
result.push(LinkGeom::Line(*a, *b));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Vec<String> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
}
|
||||
|
||||
fn refresh_links_in(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
) -> Vec<String> {
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
for l in &links {
|
||||
let mut source = l.source.clone();
|
||||
if let (Some(name), LinkSource::Face { at, .. }) = (&l.face_name, &mut source) {
|
||||
if let Some((p, split)) = crate::naming::point_on_face(st, name, *at) {
|
||||
let mut named_face = None;
|
||||
if !l.auto_face
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
if let Some((index, p, split)) = st.body(body).and_then(|target| crate::naming::face_on_body(target, name, *at)) {
|
||||
let stem = crate::naming::strip_piece(name);
|
||||
let removed_piece = split
|
||||
&& name != stem
|
||||
&& st.body(body).is_some_and(|target| {
|
||||
(certified_planar_construction(target, index) || target.body.face_was_coalesced(index))
|
||||
&& crate::naming::face_names(target).get(index).is_some_and(|current| current == stem)
|
||||
});
|
||||
if removed_piece {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
warn.push(format!("projected face piece `{name}` no longer exists; last geometry retained"));
|
||||
continue;
|
||||
}
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
if split {
|
||||
warn.push(format!("projected face `{name}` was split; using the nearest piece"));
|
||||
}
|
||||
@@ -286,7 +515,19 @@ pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelS
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match resolve(doc, vals, st, plane, l.kind, &source) {
|
||||
let resolved = if l.auto_face {
|
||||
anchor
|
||||
.filter(|anchor| {
|
||||
l.face_name.as_ref() == Some(&anchor.target.name) && matches!(&l.source,LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
})
|
||||
.ok_or_else(|| lost("exact auto-projected attachment"))
|
||||
.and_then(|anchor| resolve_anchored_face(doc, st, plane, l.kind, anchor))
|
||||
} else if let Some((body, index, at)) = named_face {
|
||||
resolve_named_face(st, plane, l.kind, &body, index, at)
|
||||
} else {
|
||||
resolve(doc, vals, st, plane, l.kind, &source)
|
||||
};
|
||||
match resolved {
|
||||
Ok(r) => {
|
||||
sk.set_link_source(&l.id, r.source);
|
||||
let _ = sk.set_link_wires(&l.id, &r.wires);
|
||||
@@ -439,9 +680,9 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
|
||||
(Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some(CurveKind::Spline { pts, control, degree }) if parallel => {
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic }) if parallel => {
|
||||
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
|
||||
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree }]).unwrap_or_default()
|
||||
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic }]).unwrap_or_default()
|
||||
}
|
||||
Some(CurveKind::Conic { a, b, apex, rho }) if parallel => match (sk.point(*a), sk.point(*apex), sk.point(*b)) {
|
||||
(Some(a), Some(x), Some(b)) => vec![LinkGeom::Conic { a: map(a), apex: map(x), b: map(b), rho: *rho }],
|
||||
@@ -557,7 +798,7 @@ fn as_spline(pts: &[Vec2], tol: f64) -> Option<LinkGeom> {
|
||||
<= tol
|
||||
});
|
||||
if ok {
|
||||
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3 });
|
||||
return Some(LinkGeom::Spline { pts: fit, control: false, degree: 3, periodic: false });
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
@@ -456,7 +456,7 @@ fn link_polyline(g: &solvecraft_sketch::LinkGeom) -> Vec<Vec2> {
|
||||
LinkGeom::Ellipse { c, major, minor } => {
|
||||
(0..=96).map(|i| solvecraft_sketch::ellipse_point(c, major, minor, i as f64 / 96.0 * std::f64::consts::TAU)).collect()
|
||||
}
|
||||
LinkGeom::Spline { ref pts, control, degree } => solvecraft_sketch::spline_polyline(pts, control, degree),
|
||||
LinkGeom::Spline { ref pts, control, degree, periodic } => solvecraft_sketch::spline_polyline_mode(pts, control, degree, periodic),
|
||||
LinkGeom::Point(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Stable face projection order: outer boundary first, then holes by geometry.
|
||||
//! B-rep edge indices depend on face enumeration and must not choose sketch curve IDs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solvecraft_geom::{Mesh, Plane, Vec3};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
type Key = (i64, i64, i64);
|
||||
|
||||
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
/// Full boundary of an explicitly selected analytic planar piece. Display seams
|
||||
/// hide subdivisions for picking, but cannot remove a raw piece's closing edge.
|
||||
pub(super) fn raw_face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
boundary_edges(mesh, mesh.face_edges(face), plane)
|
||||
}
|
||||
|
||||
/// Exterior of an exact current face family. Shared topological edges are internal;
|
||||
/// geometric proximity never merges distinct edges or chooses a different face.
|
||||
pub(super) fn family_edges(mesh: &Mesh, selected: &std::collections::BTreeSet<usize>, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let mut edges = Vec::new();
|
||||
for (index, faces) in mesh.edge_faces.iter().enumerate() {
|
||||
let count = faces.iter().filter(|face| selected.contains(&(**face as usize))).count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
if faces.len() > 2 || (faces.len() == 2 && faces.first() == faces.get(1)) {
|
||||
return Err(DocError::Invalid("face family has ambiguous edge adjacency".into()));
|
||||
}
|
||||
if count == 1 && !mesh.seams.get(index).copied().unwrap_or(false) {
|
||||
edges.push(index);
|
||||
}
|
||||
}
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
fn boundary_edges(mesh: &Mesh, mut edges: Vec<usize>, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let bounds = mesh.bounds();
|
||||
let origin = bounds.min;
|
||||
let local_origin = plane.to_local(origin);
|
||||
let tol = (bounds.diagonal() * 1e-8).max(1e-9);
|
||||
if !tol.is_finite() {
|
||||
return Err(DocError::Invalid("face projection has invalid bounds".into()));
|
||||
}
|
||||
let key = |p: Vec3| -> Key {
|
||||
let p = p - origin;
|
||||
((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64)
|
||||
};
|
||||
if edges.iter().any(|e| !mesh.edges.get(*e).is_some_and(|p| p.len() >= 2 && p.iter().all(|p| p.is_finite()))) {
|
||||
return Err(DocError::Invalid("face projection has an invalid boundary edge".into()));
|
||||
}
|
||||
if edges.len() > solvecraft_sketch::MAX_LINK_ENTITIES {
|
||||
return Err(DocError::Invalid("face projection exceeds the boundary edge limit".into()));
|
||||
}
|
||||
let geometry_key = |e: usize| {
|
||||
let pts = mesh.edges.get(e).map(Vec::as_slice).unwrap_or_default();
|
||||
let lo = pts.iter().copied().fold(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::min);
|
||||
let hi = pts.iter().copied().fold(Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), Vec3::max);
|
||||
(key(lo), key(hi))
|
||||
};
|
||||
edges.sort_by_key(|e| geometry_key(*e));
|
||||
let mut at: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
|
||||
for e in &edges {
|
||||
if let Some(pts) = mesh.edges.get(*e) {
|
||||
for point in [pts.first(), pts.last()].into_iter().flatten() {
|
||||
at.entry(key(*point)).or_default().push(*e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut used = vec![false; mesh.edges.len()];
|
||||
let mut boundaries = Vec::new();
|
||||
for start_edge in &edges {
|
||||
if used.get(*start_edge).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(pts) = mesh.edges.get(*start_edge) else { continue };
|
||||
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { continue };
|
||||
let start = if key(*a) <= key(*b) { *a } else { *b };
|
||||
let mut current = start;
|
||||
let mut edge = *start_edge;
|
||||
let mut boundary = Vec::new();
|
||||
let mut twice_area = 0.0;
|
||||
for _ in 0..edges.len() {
|
||||
let Some(pts) = mesh.edges.get(edge) else { break };
|
||||
let (Some(a), Some(b)) = (pts.first(), pts.last()) else { break };
|
||||
let forward = current.dist(*a) <= current.dist(*b);
|
||||
for pair in pts.windows(2) {
|
||||
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
|
||||
let (a, b) = (plane.to_local(*a) - local_origin, plane.to_local(*b) - local_origin);
|
||||
twice_area += if forward { a.cross(b) } else { b.cross(a) };
|
||||
}
|
||||
current = if forward { *b } else { *a };
|
||||
if let Some(slot) = used.get_mut(edge) {
|
||||
*slot = true;
|
||||
}
|
||||
boundary.push(edge);
|
||||
if current.dist(start) <= tol {
|
||||
break;
|
||||
}
|
||||
// Neighbouring quantization cells cover points on either side of a rounding
|
||||
// boundary. Connectivity is checked in world space, not in the projection.
|
||||
let (x, y, z) = key(current);
|
||||
let mut candidates = Vec::new();
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let cell = (x.saturating_add(dx), y.saturating_add(dy), z.saturating_add(dz));
|
||||
if let Some(list) = at.get(&cell) {
|
||||
candidates.extend(list.iter().copied().filter(|e| {
|
||||
!used.get(*e).copied().unwrap_or(true)
|
||||
&& mesh.edges.get(*e).is_some_and(|p| {
|
||||
p.first().is_some_and(|a| a.dist(current) <= tol) || p.last().is_some_and(|b| b.dist(current) <= tol)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|e| geometry_key(*e));
|
||||
candidates.dedup();
|
||||
if candidates.len() > 1 {
|
||||
return Err(DocError::Invalid("face projection has a branched boundary".into()));
|
||||
}
|
||||
let Some(next) = candidates.first() else { break };
|
||||
edge = *next;
|
||||
}
|
||||
boundary.sort_by_key(|e| geometry_key(*e));
|
||||
let closed = current.dist(start) <= tol;
|
||||
if !closed || !twice_area.is_finite() {
|
||||
return Err(DocError::Invalid("face projection has an open or invalid boundary".into()));
|
||||
}
|
||||
// Equal-sized holes use their geometric key, not round-off from summing the
|
||||
// same area at different positions or walking their edges in reverse.
|
||||
let area = (twice_area.abs() / (tol * bounds.diagonal().max(tol))).round() as i64;
|
||||
let first = boundary.first().copied().map(geometry_key);
|
||||
boundaries.push((area, first, boundary));
|
||||
}
|
||||
boundaries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
Ok(boundaries.into_iter().flat_map(|(_, _, edges)| edges).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn face_family_omits_only_exact_shared_topological_edges() {
|
||||
let mut mesh = Mesh::default();
|
||||
for (a, b, faces) in [
|
||||
((0., 0.), (2., 0.), vec![0]),
|
||||
((2., 0.), (2., 1.), vec![0]),
|
||||
((2., 1.), (0., 1.), vec![0, 1]),
|
||||
((0., 1.), (0., 0.), vec![0]),
|
||||
((2., 1.), (2., 2.), vec![1]),
|
||||
((2., 2.), (0., 2.), vec![1]),
|
||||
((0., 2.), (0., 1.), vec![1]),
|
||||
] {
|
||||
mesh.edges.push(vec![Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
|
||||
mesh.positions.extend([Vec3::new(a.0, a.1, 0.), Vec3::new(b.0, b.1, 0.)]);
|
||||
mesh.edge_faces.push(faces);
|
||||
}
|
||||
let selected = [0, 1].into_iter().collect();
|
||||
let edges = family_edges(&mesh, &selected, &Plane::XY).unwrap();
|
||||
assert_eq!(edges.len(), 6);
|
||||
assert!(!edges.contains(&2));
|
||||
let geometry: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
|
||||
assert_eq!(super::super::merge_family_lines(geometry).unwrap().len(), 4);
|
||||
assert_eq!(family_edges(&mesh, &[0].into_iter().collect(), &Plane::XY).unwrap().len(), 4);
|
||||
mesh.edge_faces[2] = vec![0, 1, 2];
|
||||
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
|
||||
mesh.edge_faces[2] = vec![0, 0];
|
||||
assert!(family_edges(&mesh, &selected, &Plane::XY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_crossing_boundaries_are_not_geometrically_cancelled() {
|
||||
let mut mesh = Mesh::default();
|
||||
for (face, x, y) in [(0, 0., 0.), (1, 1., 1.)] {
|
||||
let corners = [Vec3::new(x, y, 0.), Vec3::new(x + 2., y, 0.), Vec3::new(x + 2., y + 2., 0.), Vec3::new(x, y + 2., 0.)];
|
||||
mesh.positions.extend(corners);
|
||||
for i in 0..4 {
|
||||
mesh.edges.push(vec![corners[i], corners[(i + 1) % 4]]);
|
||||
mesh.edge_faces.push(vec![face]);
|
||||
}
|
||||
}
|
||||
let edges = family_edges(&mesh, &[0, 1].into_iter().collect(), &Plane::XY).unwrap();
|
||||
assert_eq!(edges.len(), 8);
|
||||
let geom: Vec<_> = edges.iter().flat_map(|edge| super::super::fit_world(&Plane::XY, &mesh.edges[*edge])).collect();
|
||||
let mut sk = solvecraft_sketch::Sketch::new();
|
||||
let link = sk
|
||||
.add_link(solvecraft_sketch::LinkKind::Project, solvecraft_sketch::LinkSource::Face { body: "ambiguous".into(), at: Vec3::ZERO }, &geom)
|
||||
.unwrap();
|
||||
assert!(sk.update_link(&link, &geom).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn family_line_normalization_preserves_corners_arcs_branches_and_retracing() {
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::LinkGeom;
|
||||
let line = |a, b| LinkGeom::Line(Vec2::new(a, 0.), Vec2::new(b, 0.));
|
||||
assert_eq!(super::super::merge_family_lines(vec![line(0., 1.), line(2., 1.)]).unwrap(), vec![line(0., 2.)]);
|
||||
for geometry in [
|
||||
vec![line(0., 1.), line(1., 0.5)],
|
||||
vec![line(0., 1.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(2., 1e-8))],
|
||||
vec![line(0., 1.), line(1., 2.), LinkGeom::Line(Vec2::new(1., 0.), Vec2::new(1., 1.))],
|
||||
vec![line(0., 1.), line(1., 2.), LinkGeom::Conic { a: Vec2::new(1., 0.), apex: Vec2::new(2., 1.), b: Vec2::new(3., 1.), rho: 0.5 }],
|
||||
vec![
|
||||
line(0., 1.),
|
||||
line(1., 2.),
|
||||
LinkGeom::Spline { pts: vec![Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)], control: false, degree: 2, periodic: false },
|
||||
],
|
||||
vec![line(0., 1.), LinkGeom::Arc { c: Vec2::new(1., 1.), a: Vec2::new(1., 0.), b: Vec2::new(2., 1.) }],
|
||||
vec![line(0., 1.), line(1.00001, 2.)],
|
||||
] {
|
||||
assert_eq!(super::super::merge_family_lines(geometry.clone()).unwrap(), geometry);
|
||||
}
|
||||
}
|
||||
|
||||
fn rectangles() -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
for (x, y) in [(40.0, 30.0), (38.0, 28.0), (4.0, 2.0)] {
|
||||
let pts = [Vec3::new(-x, -y, 0.0), Vec3::new(x, -y, 0.0), Vec3::new(x, y, 0.0), Vec3::new(-x, y, 0.0)];
|
||||
mesh.positions.extend(pts);
|
||||
for i in 0..4 {
|
||||
mesh.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
|
||||
mesh.edge_faces.push(vec![7]);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_and_edge_identity_are_independent_of_mesh_enumeration() {
|
||||
let baseline = rectangles();
|
||||
let expected: Vec<_> =
|
||||
face_edges(&baseline, 7, &Plane::XY).unwrap().into_iter().map(|e| super::super::fit_world(&Plane::XY, &baseline.edges[e])).collect();
|
||||
for shift in 0..baseline.edges.len() {
|
||||
for reverse in [false, true] {
|
||||
let mut mesh = baseline.clone();
|
||||
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5) % baseline.edges.len()].clone()).collect();
|
||||
mesh.edges.rotate_left(shift);
|
||||
if reverse {
|
||||
mesh.edges.reverse();
|
||||
for pts in &mut mesh.edges {
|
||||
pts.reverse();
|
||||
}
|
||||
}
|
||||
let actual: Vec<_> = face_edges(&mesh, 7, &Plane::XY)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let mut pts = mesh.edges[e].clone();
|
||||
// Lines' direction does not change their geometry.
|
||||
if pts.first().unwrap().x > pts.last().unwrap().x || pts.first().unwrap().y > pts.last().unwrap().y {
|
||||
pts.reverse();
|
||||
}
|
||||
super::super::fit_world(&Plane::XY, &pts)
|
||||
})
|
||||
.collect();
|
||||
let normalized: Vec<_> = expected
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let solvecraft_sketch::LinkGeom::Line(a, b) = g[0] else { panic!("line") };
|
||||
if a.x > b.x || a.y > b.y { vec![solvecraft_sketch::LinkGeom::Line(b, a)] } else { g.clone() }
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(actual, normalized);
|
||||
let ordered = face_edges(&mesh, 7, &Plane::XY).unwrap();
|
||||
for (loop_index, chunk) in ordered.chunks(4).enumerate() {
|
||||
let want = [40.0, 38.0, 4.0][loop_index];
|
||||
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x.abs() <= want));
|
||||
assert!(chunk.iter().flat_map(|e| &mesh.edges[*e]).any(|p| p.x.abs() == want));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_boundary_arc_sweeps_survive_individual_edge_reversal() {
|
||||
use solvecraft_geom::{Seg2, Vec2};
|
||||
use solvecraft_sketch::LinkGeom;
|
||||
let mut mesh = Mesh::default();
|
||||
for (extent, radius) in [(10.0, 3.0), (8.0, 1.0)] {
|
||||
for k in 0..4 {
|
||||
let angle = k as f64 * std::f64::consts::FRAC_PI_2;
|
||||
let rotate = |p: Vec2| (0..k).fold(p, |p, _| p.perp());
|
||||
let centre = rotate(Vec2::new(extent - radius, extent - radius));
|
||||
let arc = Seg2::Arc { center: centre, radius, start: angle, sweep: std::f64::consts::FRAC_PI_2 };
|
||||
let line = Seg2::Line { a: arc.point_at(1.0), b: rotate(Vec2::new(-(extent - radius), extent)) };
|
||||
for seg in [arc, line] {
|
||||
let pts: Vec<_> = (0..=16).map(|i| Plane::XY.to_world(seg.point_at(i as f64 / 16.0))).collect();
|
||||
mesh.positions.extend(pts.iter().copied());
|
||||
mesh.edges.push(pts);
|
||||
mesh.edge_faces.push(vec![2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let normalized = |mesh: &Mesh| -> Vec<Vec<LinkGeom>> {
|
||||
face_edges(mesh, 2, &Plane::XY)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
super::super::fit_world(&Plane::XY, &mesh.edges[e])
|
||||
.into_iter()
|
||||
.map(|g| match g {
|
||||
LinkGeom::Line(a, b) if (a.x, a.y) > (b.x, b.y) => LinkGeom::Line(b, a),
|
||||
g => g,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let expected = normalized(&mesh);
|
||||
for shift in 0..mesh.edges.len() {
|
||||
let mut permuted = mesh.clone();
|
||||
permuted.edges = (0..mesh.edges.len()).map(|i| mesh.edges[(i * 5 + shift) % mesh.edges.len()].clone()).collect();
|
||||
for (i, points) in permuted.edges.iter_mut().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
let actual = normalized(&permuted);
|
||||
for (a, b) in actual.iter().zip(&expected) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(b) {
|
||||
match (a, b) {
|
||||
(LinkGeom::Line(a, b), LinkGeom::Line(c, d)) => assert!(a.dist(*c) < 1e-9 && b.dist(*d) < 1e-9),
|
||||
(LinkGeom::Arc { c, a, b }, LinkGeom::Arc { c: cc, a: aa, b: bb }) => {
|
||||
assert!(c.dist(*cc) < 1e-9 && a.dist(*aa) < 1e-9 && b.dist(*bb) < 1e-9);
|
||||
}
|
||||
_ => panic!("boundary changed kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_and_branched_boundaries_are_reported_without_partial_geometry() {
|
||||
let mut open = rectangles();
|
||||
open.edges.pop();
|
||||
open.edge_faces.pop();
|
||||
assert!(face_edges(&open, 7, &Plane::XY).unwrap_err().to_string().contains("open"));
|
||||
let mut branched = rectangles();
|
||||
branched.edges.push(vec![Vec3::new(-40.0, -30.0, 0.0), Vec3::ZERO]);
|
||||
branched.edge_faces.push(vec![7]);
|
||||
assert!(face_edges(&branched, 7, &Plane::XY).is_err());
|
||||
let mut invalid = rectangles();
|
||||
invalid.edges[0].push(Vec3::new(f64::NAN, 0.0, 0.0));
|
||||
assert!(face_edges(&invalid, 7, &Plane::XY).unwrap_err().to_string().contains("invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_area_holes_use_geometric_order_after_translation() {
|
||||
let mut baseline = rectangles();
|
||||
baseline.edges.truncate(4);
|
||||
baseline.edge_faces.truncate(4);
|
||||
for x in [-12.0, 12.0] {
|
||||
let pts = [Vec3::new(x - 2.0, -2.0, 0.0), Vec3::new(x + 2.0, -2.0, 0.0), Vec3::new(x + 2.0, 2.0, 0.0), Vec3::new(x - 2.0, 2.0, 0.0)];
|
||||
for i in 0..4 {
|
||||
baseline.edges.push(vec![pts[i], pts[(i + 1) % 4]]);
|
||||
baseline.edge_faces.push(vec![7]);
|
||||
}
|
||||
}
|
||||
let shift = Vec3::new(12345.6789, -23456.7891, 987.0);
|
||||
for p in &mut baseline.positions {
|
||||
*p += shift;
|
||||
}
|
||||
for points in &mut baseline.edges {
|
||||
for p in points {
|
||||
*p += shift;
|
||||
}
|
||||
}
|
||||
for offset in 0..baseline.edges.len() {
|
||||
let mut mesh = baseline.clone();
|
||||
mesh.edges = (0..mesh.edges.len()).map(|i| baseline.edges[(i * 5 + offset) % baseline.edges.len()].clone()).collect();
|
||||
for (i, points) in mesh.edges.iter_mut().enumerate() {
|
||||
if i % 2 == 0 {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
let order = face_edges(&mesh, 7, &Plane::XY).unwrap();
|
||||
assert!(order[4..8].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x < shift.x));
|
||||
assert!(order[8..12].iter().flat_map(|e| &mesh.edges[*e]).all(|p| p.x > shift.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
+665
-126
@@ -16,6 +16,126 @@ use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec};
|
||||
use crate::expr::{self, Kind, Value};
|
||||
use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul};
|
||||
|
||||
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
|
||||
// These are work limits, never geometry truncation: a larger input returns a useful error.
|
||||
const MAX_FLAT_POINTS: usize = 16_384;
|
||||
const MAX_FLAT_PIECES: usize = 4096;
|
||||
const MAX_FLAT_WORK: usize = 500_000_000;
|
||||
|
||||
fn flat_budget_error() -> DocError {
|
||||
DocError::Invalid("sheet metal flat geometry exceeds the bounded processing budget".into())
|
||||
}
|
||||
|
||||
pub(crate) struct FlatWork(usize);
|
||||
|
||||
impl FlatWork {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(MAX_FLAT_WORK)
|
||||
}
|
||||
pub(crate) fn charge(&mut self, work: usize) -> Result<()> {
|
||||
self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn flat_polyline(lp: &Loop2) -> Result<Vec<Vec2>> {
|
||||
if lp.segs.len() > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let mut points = Vec::new();
|
||||
for seg in &lp.segs {
|
||||
let p = seg.polyline(1e-3);
|
||||
let add = p.len().saturating_sub(1);
|
||||
if points.len().saturating_add(add) > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
points.extend(p.iter().take(add));
|
||||
}
|
||||
Ok(points)
|
||||
}
|
||||
|
||||
fn flat_points(polys: &[Vec<Vec2>]) -> Result<usize> {
|
||||
let n = polys.iter().try_fold(0usize, |n, p| n.checked_add(p.len()).ok_or_else(flat_budget_error))?;
|
||||
if n > MAX_FLAT_POINTS || polys.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn planar_region(region: &Region2) -> Result<Region2> {
|
||||
if region.holes.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
region_complexity(std::slice::from_ref(region))?;
|
||||
let convert = |lp: &Loop2| -> Result<Loop2> {
|
||||
if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
|
||||
flat_polyline(lp)?;
|
||||
Ok(lp.clone())
|
||||
} else {
|
||||
// Keep free-form profiles through controlled 0.001 mm chord sampling.
|
||||
Ok(Loop2::polygon(&flat_polyline(lp)?))
|
||||
}
|
||||
};
|
||||
Ok(Region2 {
|
||||
outer: convert(®ion.outer)?.ccw(),
|
||||
holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
|
||||
regions_complexity(regions.iter())
|
||||
}
|
||||
|
||||
fn regions_complexity<'a>(regions: impl Iterator<Item = &'a Region2>) -> Result<(usize, usize)> {
|
||||
let (mut edges, mut points) = (0usize, 0usize);
|
||||
let mut pieces = 0usize;
|
||||
for region in regions {
|
||||
pieces += 1;
|
||||
if pieces > MAX_FLAT_PIECES || region.holes.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
for lp in std::iter::once(®ion.outer).chain(®ion.holes) {
|
||||
edges = edges.saturating_add(lp.segs.len());
|
||||
if edges > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
points = points.saturating_add(flat_polyline(lp)?.len());
|
||||
if points > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((edges, points))
|
||||
}
|
||||
|
||||
pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
let (ae, ap) = region_complexity(a)?;
|
||||
let (be, bp) = region_complexity(b)?;
|
||||
let edges = ae.saturating_add(be);
|
||||
work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?;
|
||||
let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?;
|
||||
region_complexity(®ions)?;
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
// Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not
|
||||
// repeatedly arrange a growing accumulated boundary against just one small region.
|
||||
pub(crate) fn material_union(regions: Vec<Region2>, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
region_complexity(®ions)?;
|
||||
let mut groups: Vec<Vec<Region2>> = regions.into_iter().map(|r| vec![r]).collect();
|
||||
while groups.len() > 1 {
|
||||
let mut next = Vec::new();
|
||||
let mut iter = groups.into_iter();
|
||||
while let Some(a) = iter.next() {
|
||||
match iter.next() {
|
||||
Some(b) => next.push(material_boolean(&a, &b, kernel::BoolOp::Union, work)?),
|
||||
None => next.push(a),
|
||||
}
|
||||
}
|
||||
groups = next;
|
||||
}
|
||||
Ok(groups.pop().unwrap_or_default())
|
||||
}
|
||||
/// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SheetRule {
|
||||
@@ -178,6 +298,10 @@ pub struct SheetBody {
|
||||
pub flanges: Vec<SheetFlange>,
|
||||
/// Cut-outs (flat coordinates) through the sheet.
|
||||
pub holes: Vec<Region2>,
|
||||
/// Planar machining in flat coordinates, with depths measured from the flat bottom.
|
||||
/// Derived from feature history; these records are not a document schema change.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub pockets: Vec<SheetPocket>,
|
||||
/// Unfolded.
|
||||
pub flat: bool,
|
||||
/// Gap between flanges that meet at a corner.
|
||||
@@ -187,6 +311,13 @@ pub struct SheetBody {
|
||||
pub base_curves: Vec<(String, Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct SheetPocket {
|
||||
pub region: Region2,
|
||||
pub z0: f64,
|
||||
pub z1: f64,
|
||||
}
|
||||
|
||||
/// A piece of the sheet: a panel (None = the base) or a flange's bend zone.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Piece {
|
||||
@@ -424,65 +555,71 @@ impl SheetBody {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pieces of the sheet as cells: base and flange panels and bend zones, each less the
|
||||
/// cut-outs over it (cut-outs inside a panel stay exact holes; ones crossing a piece's edge
|
||||
/// must be convex polygons).
|
||||
fn cells(&self) -> Result<Vec<Cell>> {
|
||||
let mut pieces: Vec<(Piece, Vec<Vec2>)> =
|
||||
self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), dedup(&r.outer.ccw().polyline(1e-3)))).collect();
|
||||
for (i, f) in self.flanges.iter().enumerate() {
|
||||
pieces.push((Piece::Zone(i), dedup(&self.zone(f).outer.ccw().polyline(1e-3))));
|
||||
/// The pieces of the sheet as cells: base and flange panels and bend zones, each less
|
||||
/// the combined cut-out material. Planar clipping retains holes and disconnected islands.
|
||||
fn cells_with_work(&self, work: &mut FlatWork) -> Result<Vec<Cell>> {
|
||||
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let raw = self
|
||||
.flanges
|
||||
.iter()
|
||||
.fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len)))
|
||||
.saturating_add(self.holes.iter().fold(0usize, |n, h| n.saturating_add(h.outer.segs.len())));
|
||||
if raw > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let mut pieces: Vec<(Piece, Region2)> = self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), r)).collect();
|
||||
for (i, f) in self.flanges.iter().enumerate() {
|
||||
pieces.push((Piece::Zone(i), self.zone(f)));
|
||||
}
|
||||
let mut cuts = Vec::new();
|
||||
let mut cut_points = 0usize;
|
||||
for hole in &self.holes {
|
||||
let region = planar_region(hole)?;
|
||||
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1);
|
||||
if cut_points > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
cuts.push(region);
|
||||
}
|
||||
cuts.extend(self.through_pocket_regions(work)?);
|
||||
let cuts = material_union(cuts, work)?;
|
||||
let mut out = Vec::new();
|
||||
for (piece, poly) in pieces {
|
||||
let mut inner: Vec<Vec<Seg2>> = Vec::new();
|
||||
let mut crossing: Vec<Vec<Vec2>> = Vec::new();
|
||||
for h in &self.holes {
|
||||
let hp = dedup(&h.outer.ccw().polyline(1e-3));
|
||||
if matches!(piece, Piece::Panel(_)) && hp.iter().all(|p| point_in(&poly, *p)) {
|
||||
inner.push(h.outer.ccw().reversed().segs.clone());
|
||||
} else if hp.iter().any(|p| point_in(&poly, *p)) || poly.iter().any(|p| point_in(&hp, *p)) || polys_cross(&poly, &hp) {
|
||||
if !is_convex(&hp) {
|
||||
return Err(DocError::Invalid(
|
||||
"not supported yet: cut-outs over a panel's edge or a bend that are not convex polygons".into(),
|
||||
));
|
||||
}
|
||||
crossing.push(hp);
|
||||
let mut points = 0usize;
|
||||
for (piece, region) in pieces {
|
||||
let panel = planar_region(®ion)?;
|
||||
let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? };
|
||||
for region in material {
|
||||
points = points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1);
|
||||
if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
}
|
||||
if crossing.is_empty() {
|
||||
let outer = Loop2::polygon(&poly).segs;
|
||||
out.push(Cell {
|
||||
piece,
|
||||
outer: if matches!(piece, Piece::Panel(None)) { self.base.outer.ccw().segs.clone() } else { outer },
|
||||
holes: inner,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let mut parts = vec![poly];
|
||||
for h in &crossing {
|
||||
parts = parts.iter().flat_map(|p| convex_diff(p, h)).collect();
|
||||
}
|
||||
let loops = merge_loops(&parts);
|
||||
let (outers, holes): (Vec<Vec<Vec2>>, Vec<Vec<Vec2>>) = loops.into_iter().partition(|l| signed_area(l) > 0.0);
|
||||
for o in outers {
|
||||
let mut hs: Vec<Vec<Seg2>> =
|
||||
holes.iter().filter(|h| h.first().is_some_and(|p| point_in(&o, *p))).map(|h| Loop2::polygon(h).segs).collect();
|
||||
hs.extend(inner.iter().filter(|h| h.first().is_some_and(|s| point_in(&o, s.start()))).cloned());
|
||||
out.push(Cell { piece, outer: Loop2::polygon(&o).segs, holes: hs });
|
||||
out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Faces of the solid made by `cells` between heights `z0` and `z1`.
|
||||
fn faces(&self, cells: &[Cell], z0: f64, z1: f64) -> Result<Vec<FaceSpec>> {
|
||||
fn faces(&self, cells: &[Cell], z0: f64, z1: f64, work: &mut FlatWork) -> Result<Vec<FaceSpec>> {
|
||||
// Split straight edges where other cells' corners touch them, so neighbours share edges.
|
||||
let edges = cells
|
||||
.iter()
|
||||
.fold(0usize, |n, c| n.saturating_add(c.outer.len()).saturating_add(c.holes.iter().fold(0usize, |m, h| m.saturating_add(h.len()))));
|
||||
if edges > MAX_FLAT_POINTS || cells.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
work.charge(edges.saturating_mul(edges).saturating_mul(4))?;
|
||||
let corners: Vec<Vec2> = cells.iter().flat_map(|c| c.outer.iter().chain(c.holes.iter().flatten()).map(Seg2::start)).collect();
|
||||
let split_loop = |segs: &[Seg2]| -> Vec<Seg2> {
|
||||
let mut split_edges = 0usize;
|
||||
let mut split_loop = |segs: &[Seg2]| -> Result<Vec<Seg2>> {
|
||||
let mut v = Vec::new();
|
||||
for e in segs {
|
||||
let Seg2::Line { a, b } = *e else {
|
||||
split_edges += 1;
|
||||
if split_edges > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
v.push(*e);
|
||||
continue;
|
||||
};
|
||||
@@ -499,18 +636,31 @@ impl SheetBody {
|
||||
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
|
||||
let mut prev = a;
|
||||
for t in ts {
|
||||
split_edges += 1;
|
||||
if split_edges > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let q = a + d * t;
|
||||
v.push(Seg2::Line { a: prev, b: q });
|
||||
prev = q;
|
||||
}
|
||||
split_edges += 1;
|
||||
if split_edges > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
v.push(Seg2::Line { a: prev, b });
|
||||
}
|
||||
v
|
||||
Ok(v)
|
||||
};
|
||||
let cells: Vec<Cell> = cells
|
||||
.iter()
|
||||
.map(|c| Cell { piece: c.piece, outer: split_loop(&c.outer), holes: c.holes.iter().map(|h| split_loop(h)).collect() })
|
||||
.collect();
|
||||
let mut split_cells = Vec::new();
|
||||
for c in cells {
|
||||
let outer = split_loop(&c.outer)?;
|
||||
let holes = c.holes.iter().map(|h| split_loop(h)).collect::<Result<_>>()?;
|
||||
split_cells.push(Cell { piece: c.piece, outer, holes });
|
||||
}
|
||||
// Reserve the complete adjacency scan before visiting any wall.
|
||||
work.charge(split_edges.saturating_mul(split_edges).saturating_mul(2))?;
|
||||
let cells = split_cells;
|
||||
let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6;
|
||||
// Shared with a neighbour: the same flat edge the other way, landing in the same place
|
||||
// (flat neighbours across a corner of two bends do not).
|
||||
@@ -602,40 +752,276 @@ impl SheetBody {
|
||||
if self.flat {
|
||||
return self.flat_solid();
|
||||
}
|
||||
Ok(kernel::sew(&self.faces(&self.cells()?, 0.0, self.t)?)?)
|
||||
let mut work = FlatWork::new();
|
||||
let cells = self.cells_with_work(&mut work)?;
|
||||
// Validate the bounded material first, then build close to the sheet's own
|
||||
// origin. Fitting planes after a distant component transfer loses precision.
|
||||
let mut local = self.clone();
|
||||
local.frame = Plane::XY;
|
||||
let body = kernel::sew(&local.faces(&cells, 0.0, self.t, &mut work)?)?;
|
||||
let body = local.machine_pockets(body, &mut work)?;
|
||||
self.place_local_solid(body)
|
||||
}
|
||||
|
||||
/// The flat pattern as one extrusion (outline with its cut-outs).
|
||||
pub fn flat_solid(&self) -> Result<Body> {
|
||||
let loops = self.outline()?;
|
||||
let mut loops: Vec<Loop2> = loops.into_iter().map(|l| Loop2::polygon(&l)).collect();
|
||||
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
|
||||
let mut it = loops.into_iter();
|
||||
let outer = it.next().ok_or_else(|| DocError::Invalid("empty flat pattern".into()))?.ccw();
|
||||
let mut holes: Vec<Loop2> = it.map(|l| l.ccw().reversed()).collect();
|
||||
let op = outer.polyline(1e-3);
|
||||
for h in &self.holes {
|
||||
let hp = h.outer.polyline(1e-3);
|
||||
if hp.iter().all(|p| point_in(&op, *p)) {
|
||||
holes.push(h.outer.ccw().reversed());
|
||||
} else {
|
||||
return Err(DocError::Invalid("not supported yet: cut-outs over the flat pattern's edge".into()));
|
||||
}
|
||||
}
|
||||
let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?;
|
||||
kernel::extrude(&pl, &[Region2 { outer, holes }], 0.0, self.t)?.into_iter().next().ok_or_else(|| DocError::Invalid("flat pattern".into()))
|
||||
let regions = self.flat_regions()?;
|
||||
let mut local = self.clone();
|
||||
local.frame = Plane::XY;
|
||||
local.flat = true;
|
||||
let body = kernel::extrude_planar_regions(&Plane::XY, ®ions, 0.0, self.t)?;
|
||||
let body = local.machine_pockets(body, &mut FlatWork::new())?;
|
||||
self.place_local_solid(body)
|
||||
}
|
||||
|
||||
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
|
||||
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
|
||||
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&self.base.outer.ccw().polyline(1e-3))];
|
||||
for f in &self.flanges {
|
||||
polys.push(dedup(&self.zone(f).outer.ccw().polyline(1e-3)));
|
||||
if f.leg > 1e-9 {
|
||||
polys.push(dedup(&self.panel(f).outer.ccw().polyline(1e-3)));
|
||||
fn place_local_solid(&self, body: Body) -> Result<Body> {
|
||||
if self.frame == Plane::XY { Ok(body) } else { Ok(kernel::transform_matrix(&body, self.frame_mat())?) }
|
||||
}
|
||||
|
||||
/// Retain bounded derived machining records, without duplicating identical replay work.
|
||||
pub(crate) fn record_pocket(&mut self, region: Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result<()> {
|
||||
if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 {
|
||||
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
|
||||
}
|
||||
if self.pockets.len() > 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let pocket = SheetPocket { region, z0, z1 };
|
||||
let (old_edges, old_points) = regions_complexity(self.pockets.iter().map(|p| &p.region))?;
|
||||
let (new_edges, new_points) = region_complexity(std::slice::from_ref(&pocket.region))?;
|
||||
work.charge(
|
||||
old_edges.saturating_add(old_points).saturating_add(new_edges.saturating_add(new_points).saturating_mul(self.pockets.len().max(1))),
|
||||
)?;
|
||||
if self.pockets.contains(&pocket) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.pockets.len() >= 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
if old_edges.saturating_add(new_edges) > MAX_FLAT_POINTS || old_points.saturating_add(new_points) > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
self.pockets.push(pocket);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Certify whether a planar panel has material outside a retained thickness interval.
|
||||
/// Classification uses exact planar arrangements at every pocket depth boundary.
|
||||
pub(crate) fn material_outside_depth(&self, panel: &Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result<bool> {
|
||||
if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 {
|
||||
return Err(DocError::Invalid("invalid sheet retained depth".into()));
|
||||
}
|
||||
if self.pockets.len() > 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
|
||||
let mut levels = vec![0.0, self.t, z0, z1];
|
||||
for p in &self.pockets {
|
||||
if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 {
|
||||
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
|
||||
}
|
||||
levels.extend([p.z0, p.z1]);
|
||||
}
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup();
|
||||
work.charge(levels.len().saturating_mul(self.pockets.len().max(1)))?;
|
||||
for zs in levels.windows(2) {
|
||||
if zs[0] >= z0 && zs[1] <= z1 {
|
||||
continue;
|
||||
}
|
||||
let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect();
|
||||
let removed = material_union(active, work)?;
|
||||
if !material_boolean(std::slice::from_ref(panel), &removed, kernel::BoolOp::Cut, work)?.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let loops = merge_loops(&polys);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Replay exact planar machining after forming the full-thickness material.
|
||||
fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result<Body> {
|
||||
if self.pockets.len() > 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
|
||||
let panels = if self.flat {
|
||||
vec![(None, self.flat_regions()?)]
|
||||
} else {
|
||||
self.material_panels(None, work)?.into_iter().map(|(pi, r)| (pi, vec![r])).collect()
|
||||
};
|
||||
for pocket in &self.pockets {
|
||||
if !pocket.z0.is_finite() || !pocket.z1.is_finite() || pocket.z0 < 0.0 || pocket.z1 > self.t || pocket.z0 >= pocket.z1 {
|
||||
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
|
||||
}
|
||||
if !self.flat {
|
||||
for flange in &self.flanges {
|
||||
if flat_regions_overlap(&pocket.region, &self.zone(flange), work)? {
|
||||
return Err(DocError::Invalid(
|
||||
"a sheet pocket crosses a bend zone; planar pocket machining cannot yet preserve curved bend material".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (pi, panel) in &panels {
|
||||
let clipped = material_boolean(panel, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, work)?;
|
||||
if clipped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let w = if self.flat { self.frame_mat() } else { self.world(*pi) };
|
||||
let plane = plane_of(&w, Vec3::ZERO, Vec3::X, Vec3::Y)?;
|
||||
// BRep replay is costlier than planar clipping; share the command budget.
|
||||
work.charge(100_000)?;
|
||||
let tool = kernel::extrude_planar_regions(&plane, &clipped, pocket.z0, pocket.z1)?;
|
||||
body = kernel::boolean(&body, &tool, kernel::BoolOp::Cut)?
|
||||
.ok_or_else(|| DocError::Invalid("sheet pocket removes all sheet material".into()))?;
|
||||
}
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Actual curved material, used to certify unsupported folded machining before mutation.
|
||||
pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result<Option<Body>> {
|
||||
if self.flat || self.flanges.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
let cells: Vec<_> = self
|
||||
.cells_with_work(&mut work)?
|
||||
.into_iter()
|
||||
.filter(|c| match c.piece {
|
||||
Piece::Zone(_) => true,
|
||||
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
|
||||
})
|
||||
.collect();
|
||||
if cells.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?))
|
||||
}
|
||||
|
||||
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
if self.pockets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if self.pockets.len() > 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
|
||||
let mut levels = vec![0.0, self.t];
|
||||
for p in &self.pockets {
|
||||
if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 {
|
||||
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
|
||||
}
|
||||
levels.extend([p.z0, p.z1]);
|
||||
}
|
||||
levels.sort_by(f64::total_cmp);
|
||||
levels.dedup();
|
||||
work.charge(levels.len().saturating_mul(self.pockets.len()))?;
|
||||
// Most pockets leave an untouched thickness layer. Certify that cheap case
|
||||
// before arranging hundreds of unrelated profiles at every depth level.
|
||||
if levels.windows(2).any(|zs| !self.pockets.iter().any(|p| p.z0 <= zs[0] && p.z1 >= zs[1])) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut through: Option<Vec<Region2>> = None;
|
||||
for zs in levels.windows(2) {
|
||||
let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect();
|
||||
if active.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let removed = material_union(active, work)?;
|
||||
through = Some(match through {
|
||||
None => removed,
|
||||
Some(previous) => material_boolean(&previous, &removed, kernel::BoolOp::Intersect, work)?,
|
||||
});
|
||||
if through.as_ref().is_some_and(Vec::is_empty) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
Ok(through.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
|
||||
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
|
||||
let material = self.flat_regions()?;
|
||||
let mut work = FlatWork::new();
|
||||
let mut out = Vec::new();
|
||||
for pocket in &self.pockets {
|
||||
for region in material_boolean(&material, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, &mut work)? {
|
||||
if out.len() >= 1000 {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
out.push(SheetPocket { region, z0: pocket.z0, z1: pocket.z1 });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Actual flat material boundaries after cut-outs. The same panel and cut regions build
|
||||
/// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts
|
||||
/// into notches. Lines and circular arcs remain exact through clipping and merging.
|
||||
pub fn flat_regions(&self) -> Result<Vec<Region2>> {
|
||||
let regions = self.flat_material()?;
|
||||
if regions.is_empty() {
|
||||
return Err(DocError::Invalid("empty flat pattern".into()));
|
||||
}
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
pub(crate) fn flat_material(&self) -> Result<Vec<Region2>> {
|
||||
let mut work = FlatWork::new();
|
||||
// Merge structural pieces before cutting: clipping each panel first introduces
|
||||
// coincident notch endpoints at seams, while a single material cut has no seams.
|
||||
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let raw = self.flanges.iter().fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len)));
|
||||
if raw > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let mut regions = Vec::new();
|
||||
let mut panel_points = 0usize;
|
||||
for (_, r) in self.panels() {
|
||||
let r = planar_region(&r)?;
|
||||
panel_points = panel_points.saturating_add(region_complexity(std::slice::from_ref(&r))?.1);
|
||||
if panel_points > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
regions.push(r);
|
||||
}
|
||||
for f in &self.flanges {
|
||||
regions.push(planar_region(&self.zone(f))?);
|
||||
region_complexity(®ions)?;
|
||||
}
|
||||
let regions = material_union(regions, &mut work)?;
|
||||
let mut cuts = Vec::new();
|
||||
let mut cut_points = 0usize;
|
||||
for hole in &self.holes {
|
||||
let region = planar_region(hole)?;
|
||||
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(®ion))?.1);
|
||||
if cut_points > MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
cuts.push(region);
|
||||
}
|
||||
cuts.extend(self.through_pocket_regions(&mut work)?);
|
||||
let cuts = material_union(cuts, &mut work)?;
|
||||
let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? };
|
||||
Ok(regions)
|
||||
}
|
||||
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
|
||||
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
|
||||
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?)];
|
||||
for f in &self.flanges {
|
||||
polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?));
|
||||
if f.leg > 1e-9 {
|
||||
polys.push(dedup(&flat_polyline(&self.panel(f).outer.ccw())?));
|
||||
}
|
||||
flat_points(&polys)?;
|
||||
}
|
||||
let loops = merge_loops(&polys, &mut FlatWork::new())?;
|
||||
if loops.is_empty() {
|
||||
return Err(DocError::Invalid("empty flat pattern".into()));
|
||||
}
|
||||
@@ -670,13 +1056,13 @@ impl SheetBody {
|
||||
/// the sewn (faces, edges, vertices).
|
||||
pub fn round_hole_correction(&self) -> (i64, i64, i64) {
|
||||
let (mut df, mut de, mut dv) = (0i64, 0i64, 0i64);
|
||||
for h in &self.holes {
|
||||
let Ok(regions) = self.flat_regions() else { return (0, 0, 0) };
|
||||
for h in regions.iter().flat_map(|r| &r.holes) {
|
||||
let arcs: Vec<(Vec2, f64)> =
|
||||
h.outer.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect();
|
||||
let n = h.outer.segs.len() as i64;
|
||||
let full = !arcs.is_empty()
|
||||
&& arcs.len() == h.outer.segs.len()
|
||||
&& arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9);
|
||||
h.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect();
|
||||
let n = h.segs.len() as i64;
|
||||
let full =
|
||||
!arcs.is_empty() && arcs.len() == h.segs.len() && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9);
|
||||
if full && n > 1 {
|
||||
df += 1 - n;
|
||||
de += 2 - 3 * n;
|
||||
@@ -688,7 +1074,7 @@ impl SheetBody {
|
||||
|
||||
/// Bounding size of the flat pattern (x, y in the flat frame).
|
||||
pub fn flat_size(&self) -> Result<(f64, f64)> {
|
||||
let loops = self.outline()?;
|
||||
let loops: Vec<Vec<Vec2>> = self.flat_regions()?.iter().map(|r| r.outer.polyline(1e-3)).collect();
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in loops.iter().flatten() {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
@@ -1114,8 +1500,9 @@ impl SheetBody {
|
||||
}
|
||||
|
||||
/// Map a region in a world plane to flat coordinates through a panel's placement.
|
||||
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
|
||||
let inv = mat_inverse(&self.world(panel))?;
|
||||
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Result<Region2> {
|
||||
region_complexity(std::slice::from_ref(r))?;
|
||||
let inv = mat_inverse(&self.world(panel)).ok_or_else(|| DocError::Invalid("invalid sheet panel frame".into()))?;
|
||||
let map = |p: Vec2| {
|
||||
let q = apply_point(&inv, plane.to_world(p));
|
||||
Vec2::new(q.x, q.y)
|
||||
@@ -1138,16 +1525,18 @@ impl SheetBody {
|
||||
other => Seg2::Line { a: map(other.start()), b: map(other.end()) },
|
||||
}
|
||||
};
|
||||
let curved_other = r.outer.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. }));
|
||||
let mut lp = if curved_other {
|
||||
Loop2::polygon(&r.outer.polyline(1e-3).into_iter().map(map).collect::<Vec<_>>())
|
||||
} else {
|
||||
Loop2 { segs: r.outer.segs.iter().map(seg).collect() }
|
||||
let map_loop = |lp: &Loop2| -> Result<Loop2> {
|
||||
let curved_other = lp.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. }));
|
||||
if curved_other {
|
||||
Ok(Loop2::polygon(&flat_polyline(lp)?.into_iter().map(map).collect::<Vec<_>>()))
|
||||
} else {
|
||||
Ok(Loop2 { segs: lp.segs.iter().map(seg).collect() })
|
||||
}
|
||||
};
|
||||
if lp.signed_area() < 0.0 {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
Some(Region2 { outer: lp, holes: Vec::new() })
|
||||
Ok(Region2 {
|
||||
outer: map_loop(&r.outer)?.ccw(),
|
||||
holes: r.holes.iter().map(|h| map_loop(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Panels whose plane is parallel to `plane` (world): (panel, region).
|
||||
@@ -1161,6 +1550,22 @@ impl SheetBody {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn material_panels(&self, plane: Option<&Plane>, work: &mut FlatWork) -> Result<Vec<(Option<usize>, Region2)>> {
|
||||
Ok(self
|
||||
.cells_with_work(work)?
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let Piece::Panel(pi) = c.piece else {
|
||||
return None;
|
||||
};
|
||||
if plane.is_some_and(|p| apply_vector(&self.world(pi), Vec3::Z).cross(p.normal()).len() > 1e-6) {
|
||||
return None;
|
||||
}
|
||||
Some((pi, Region2 { outer: Loop2 { segs: c.outer }, holes: c.holes.into_iter().map(|segs| Loop2 { segs }).collect() }))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in(poly: &[Vec2], p: Vec2) -> bool {
|
||||
@@ -1178,7 +1583,9 @@ fn point_in(poly: &[Vec2], p: Vec2) -> bool {
|
||||
/// The boundary of a union of counter-clockwise polygons that meet edge to edge: edges are
|
||||
/// split where corners touch them, edges running both ways cancel, the rest chain into loops
|
||||
/// (outer loops counter-clockwise, holes clockwise; straight runs merged).
|
||||
fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
|
||||
fn merge_loops(polys: &[Vec<Vec2>], work: &mut FlatWork) -> Result<Vec<Vec<Vec2>>> {
|
||||
let n = flat_points(polys)?;
|
||||
work.charge(n.saturating_mul(n).saturating_mul(2))?;
|
||||
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
for p in polys {
|
||||
let m = p.len();
|
||||
@@ -1208,9 +1615,13 @@ fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
|
||||
ts.sort_by(|x, y| x.total_cmp(y));
|
||||
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12);
|
||||
for w in ts.windows(2) {
|
||||
if split.len() >= MAX_FLAT_POINTS {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
split.push((*a + d * w[0], *a + d * w[1]));
|
||||
}
|
||||
}
|
||||
work.charge(split.len().saturating_mul(split.len()).saturating_mul(2))?;
|
||||
let same = |p: Vec2, q: Vec2| p.dist(q) < 1e-6;
|
||||
let keep: Vec<(Vec2, Vec2)> = split
|
||||
.iter()
|
||||
@@ -1248,7 +1659,7 @@ fn merge_loops(polys: &[Vec<Vec2>]) -> Vec<Vec<Vec2>> {
|
||||
loops.push(clean);
|
||||
}
|
||||
}
|
||||
loops
|
||||
Ok(loops)
|
||||
}
|
||||
|
||||
fn signed_area(p: &[Vec2]) -> f64 {
|
||||
@@ -1256,27 +1667,6 @@ fn signed_area(p: &[Vec2]) -> f64 {
|
||||
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// A convex polygon minus a convex polygon (both counter-clockwise), as convex pieces.
|
||||
fn convex_diff(a: &[Vec2], h: &[Vec2]) -> Vec<Vec<Vec2>> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = a.to_vec();
|
||||
let m = h.len();
|
||||
for i in 0..m {
|
||||
let (Some(&p), Some(&q)) = (h.get(i), h.get((i + 1) % m)) else { continue };
|
||||
let Some(d) = (q - p).normalized() else { continue };
|
||||
let left = Vec2::new(-d.y, d.x);
|
||||
let outside = dedup(&clip(&rest, p, left * -1.0));
|
||||
if outside.len() >= 3 && signed_area(&outside).abs() > 1e-12 {
|
||||
out.push(outside);
|
||||
}
|
||||
rest = dedup(&clip(&rest, p, left));
|
||||
if rest.len() < 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Do two polygons' edges cross?
|
||||
fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) };
|
||||
@@ -1291,25 +1681,25 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_convex(p: &[Vec2]) -> bool {
|
||||
let m = p.len();
|
||||
(0..m).all(|i| match (p.get(i), p.get((i + 1) % m), p.get((i + 2) % m)) {
|
||||
(Some(a), Some(b), Some(c)) => (*b - *a).cross(*c - *b) >= -1e-9,
|
||||
_ => true,
|
||||
})
|
||||
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
|
||||
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
|
||||
let (a, b) = (planar_region(a)?, planar_region(b)?);
|
||||
Ok(!material_boolean(&[a], &[b], kernel::BoolOp::Intersect, work)?.is_empty())
|
||||
}
|
||||
|
||||
/// A sheet from a closed profile (base flange): the region in `plane`, thickness along its normal.
|
||||
pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region: Region2) -> SheetBody {
|
||||
let holes = region.holes.into_iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
SheetBody {
|
||||
body: body.into(),
|
||||
rule: rule.into(),
|
||||
t: rv.thickness,
|
||||
k: rv.k_factor,
|
||||
frame: plane,
|
||||
base: region,
|
||||
base: Region2 { outer: region.outer, holes: Vec::new() },
|
||||
flanges: Vec::new(),
|
||||
holes: Vec::new(),
|
||||
holes,
|
||||
pockets: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
base_curves: Vec::new(),
|
||||
@@ -1361,6 +1751,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
base: Region2 { outer: Loop2 { segs: Vec::new() }, holes: Vec::new() },
|
||||
flanges: Vec::new(),
|
||||
holes: Vec::new(),
|
||||
pockets: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
base_curves: Vec::new(),
|
||||
@@ -1510,4 +1901,152 @@ mod tests {
|
||||
s.flat = true;
|
||||
assert!(s.solid().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_material_keeps_each_hole_with_its_own_region() {
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.holes.push(rect(Vec2::new(45.0, -5.0), Vec2::new(55.0, -5.0), Vec2::new(55.0, 65.0), Vec2::new(45.0, 65.0)));
|
||||
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] });
|
||||
let regions = s.flat_regions().unwrap();
|
||||
assert_eq!(regions.len(), 2);
|
||||
let left = regions.iter().find(|r| r.outer.contains(Vec2::new(10.0, 10.0))).unwrap();
|
||||
let right = regions.iter().find(|r| r.outer.contains(Vec2::new(90.0, 10.0))).unwrap();
|
||||
assert_eq!(left.holes.len(), 1);
|
||||
assert!(left.holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
|
||||
assert!(right.holes.is_empty());
|
||||
let area: f64 = regions.iter().map(Region2::area).sum();
|
||||
assert!((area - (5400.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6);
|
||||
assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_cut_retains_exact_central_material_island() {
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 10.0), holes: vec![Loop2::circle(Vec2::new(50.0, 30.0), 5.0)] });
|
||||
let regions = s.flat_regions().unwrap();
|
||||
assert_eq!(regions.len(), 2);
|
||||
let island = regions.iter().find(|r| r.outer.contains(Vec2::new(50.0, 30.0))).unwrap();
|
||||
assert!(island.holes.is_empty());
|
||||
assert!(island.outer.segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
|
||||
let area: f64 = regions.iter().map(Region2::area).sum();
|
||||
assert!((area - (6000.0 - std::f64::consts::PI * 75.0)).abs() < 1e-6);
|
||||
assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_profile_uses_bounded_sampling_with_exact_round_holes() {
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.base.outer.segs = vec![
|
||||
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 0.0) },
|
||||
Seg2::Line { a: Vec2::new(100.0, 0.0), b: Vec2::new(100.0, 60.0) },
|
||||
Seg2::Cubic { p0: Vec2::new(100.0, 60.0), p1: Vec2::new(200.0 / 3.0, 80.0), p2: Vec2::new(100.0 / 3.0, 80.0), p3: Vec2::new(0.0, 60.0) },
|
||||
Seg2::Line { a: Vec2::new(0.0, 60.0), b: Vec2::ZERO },
|
||||
];
|
||||
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 3.0), holes: vec![] });
|
||||
let regions = s.flat_regions().unwrap();
|
||||
assert!(regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })));
|
||||
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { .. })));
|
||||
// The cubic has x=100(1-t), y=60+60t(1-t), hence an exact blank area of 7000.
|
||||
let expected = (7000.0 - std::f64::consts::PI * 9.0) * 2.5;
|
||||
assert!((vol(&s.solid().unwrap()) - expected).abs() < 0.5);
|
||||
assert!((vol(&s.flat_solid().unwrap()) - expected).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_geometry_budgets_reject_aggregate_and_intermediate_work() {
|
||||
let mut s = plate(100.0, 60.0);
|
||||
// Each arc is individually legal, but their combined sampled points exceed the cap.
|
||||
s.base.outer.segs = vec![Seg2::Arc { center: Vec2::ZERO, radius: 1000.0, start: 0.0, sweep: std::f64::consts::TAU }; 8];
|
||||
assert!(s.flat_regions().unwrap_err().to_string().contains("budget"));
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.holes = vec![Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 1.0), holes: vec![] }; MAX_FLAT_PIECES + 1];
|
||||
assert!(s.flat_regions().unwrap_err().to_string().contains("budget"));
|
||||
let p = vec![vec![Vec2::ZERO; MAX_FLAT_POINTS]];
|
||||
assert!(merge_loops(&p, &mut FlatWork::new()).unwrap_err().to_string().contains("budget"));
|
||||
// Exhausting a clipping/merge allowance must report an error before work begins.
|
||||
assert!(merge_loops(&[vec![Vec2::ZERO, Vec2::X, Vec2::Y]], &mut FlatWork(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_round_cutouts_union_with_exact_arc_boundaries() {
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] });
|
||||
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(22.0, 30.0), 5.0), holes: vec![] });
|
||||
let regions = s.flat_regions().unwrap();
|
||||
assert_eq!(regions[0].holes.len(), 1);
|
||||
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9)));
|
||||
let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt();
|
||||
let area = 6000.0 - (50.0 * std::f64::consts::PI - intersection);
|
||||
assert!((regions[0].area() - area).abs() < 1e-6);
|
||||
assert!((vol(&s.solid().unwrap()) - area * 2.5).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_and_repeated_round_cutouts_keep_only_the_largest_exact_boundary() {
|
||||
for reverse in [false, true] {
|
||||
let mut s = plate(100.0, 60.0);
|
||||
let mut radii = vec![3.0, 5.0, 5.0];
|
||||
if reverse {
|
||||
radii.reverse();
|
||||
}
|
||||
for radius in radii {
|
||||
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), radius), holes: vec![] });
|
||||
}
|
||||
let regions = s.flat_regions().unwrap();
|
||||
assert_eq!(regions[0].holes.len(), 1);
|
||||
assert!((regions[0].area() - (6000.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6);
|
||||
let volume = vol(&s.solid().unwrap());
|
||||
// Body measure tessellates curved faces; the exact region area above is the
|
||||
// stronger analytic check, while this follows the existing sheet measure budget.
|
||||
assert!((volume - (6000.0 - std::f64::consts::PI * 25.0) * 2.5).abs() < 0.1, "{volume}");
|
||||
assert_eq!(s.round_hole_correction(), (-1, -4, -2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sewing_budget_rejects_edge_splitting_growth_before_allocating_faces() {
|
||||
let s = plate(100.0, 60.0);
|
||||
let cells: Vec<Cell> = (0..250)
|
||||
.map(|i| Cell {
|
||||
piece: Piece::Panel(None),
|
||||
outer: Loop2::polygon(&[Vec2::new(i as f64, 0.0), Vec2::new(250.0, 0.0), Vec2::new(i as f64, 1.0)]).segs,
|
||||
holes: vec![],
|
||||
})
|
||||
.collect();
|
||||
assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget"));
|
||||
assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pocket_geometry_and_depth_budgets_are_checked_before_replay() {
|
||||
let pocket = SheetPocket {
|
||||
region: rect(Vec2::new(20.0, 20.0), Vec2::new(30.0, 20.0), Vec2::new(30.0, 30.0), Vec2::new(20.0, 30.0)),
|
||||
z0: 0.0,
|
||||
z1: 1.0,
|
||||
};
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.pockets = vec![pocket.clone(); 1001];
|
||||
assert!(s.flat_solid().unwrap_err().to_string().contains("budget"));
|
||||
s.pockets = vec![pocket.clone()];
|
||||
s.pockets[0].region.outer.segs = vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }; MAX_FLAT_POINTS + 1];
|
||||
assert!(s.flat_solid().unwrap_err().to_string().contains("budget"));
|
||||
for (z0, z1) in [(f64::NAN, 1.0), (-1.0, 1.0), (0.0, 3.0), (1.0, 1.0)] {
|
||||
s.pockets = vec![SheetPocket { z0, z1, ..pocket.clone() }];
|
||||
assert!(s.flat_solid().unwrap_err().to_string().contains("depth"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn pocket_recording_deduplicates_at_limit_and_rejects_growth_before_mutation() {
|
||||
let mut s = plate(100.0, 60.0);
|
||||
let pocket =
|
||||
SheetPocket { region: rect(Vec2::new(1.0, 1.0), Vec2::new(2.0, 1.0), Vec2::new(2.0, 2.0), Vec2::new(1.0, 2.0)), z0: 0.0, z1: 0.5 };
|
||||
s.pockets = vec![pocket.clone(); 1000];
|
||||
let mut work = FlatWork::new();
|
||||
s.record_pocket(pocket.region.clone(), pocket.z0, pocket.z1, &mut work).unwrap();
|
||||
assert_eq!(s.pockets.len(), 1000);
|
||||
assert!(s.record_pocket(pocket.region.clone(), 0.0, 0.6, &mut work).is_err());
|
||||
assert_eq!(s.pockets.len(), 1000);
|
||||
s.pockets.push(pocket.clone());
|
||||
assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ fn errors_are_reported_not_fatal() {
|
||||
targets: vec![],
|
||||
angle2: None,
|
||||
symmetric: false,
|
||||
convention: Default::default(),
|
||||
to: None,
|
||||
},
|
||||
None,
|
||||
@@ -366,3 +367,36 @@ fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() {
|
||||
assert!(std::sync::Arc::ptr_eq(&a.mesh(), &b.mesh()));
|
||||
assert!(!a.mesh().triangles.is_empty());
|
||||
}
|
||||
|
||||
/// A construction plane that is its own side resolves through the model and back into the
|
||||
/// document. The depth count has to survive that hop, or the recursion overflows the stack and
|
||||
/// aborts the process, which no panic guard can intercept.
|
||||
#[test]
|
||||
fn self_referencing_construction_planes_are_errors() {
|
||||
let selfref = |name: &str| Box::new(construct::GeoRef::Plane { plane: PlaneRef::Construction { name: name.into() } });
|
||||
let xz = || Box::new(construct::GeoRef::Plane { plane: PlaneRef::Origin { name: "XZ".into() } });
|
||||
|
||||
// Directly its own first side.
|
||||
let mut doc = Document::new("selfplane");
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P1"), b: xz() } }, Some("P1")).unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().any(|r| r.error.is_some()), "a plane that is its own side must report an error: {:?}", m.results);
|
||||
|
||||
// Two planes that are each other's side.
|
||||
let mut doc = Document::new("twoplanes");
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P2"), b: xz() } }, Some("P1")).unwrap();
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Midplane { a: selfref("P1"), b: xz() } }, Some("P2")).unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().any(|r| r.error.is_some()), "a two-plane cycle must report an error: {:?}", m.results);
|
||||
|
||||
// A perpendicular plane whose base is itself, and a sketch placed on it.
|
||||
let mut doc = Document::new("perpplane");
|
||||
let line = Box::new(construct::GeoRef::World { axis: "X".into() });
|
||||
doc.add_feature(FeatureKind::ConstructionPlane { plane: PlaneRef::Perpendicular { plane: selfref("P1"), line } }, Some("P1")).unwrap();
|
||||
doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Construction { name: "P1".into() }, sketch: Sketch::new() }, Some("Sketch1")).unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().any(|r| r.error.is_some()), "a cyclic perpendicular plane must report an error: {:?}", m.results);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"),
|
||||
CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch)
|
||||
.icon("plane")
|
||||
.params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"),
|
||||
.params("sketch: id or name; plane: as sketch.create; author_path?: exact owner occurrence ids; explicitly rebinds or freezes the plane while preserving geometry and dimension names"),
|
||||
CommandSpec::new("browser.group", "New Group", group)
|
||||
.icon("folder")
|
||||
.params("folder: bodies|sketches|construction; component?: id (default 0); name?; items?: [keys] (they leave other groups)"),
|
||||
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
or folder?: bodies|sketches|construction|origin (all of it). View state: not saved, not an undo step",
|
||||
or folder?: bodies|sketches|construction|origin (all of it); or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). View state: not saved, not an undo step",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -191,6 +191,66 @@ fn shown_item(s: &Session, key: &str) -> Option<Shown> {
|
||||
fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "browser.visibility";
|
||||
let visible = bool_(p, "visible").ok_or_else(|| bad(cmd, "`visible` must be true or false"))?;
|
||||
if let Some(value) = p.get("paths") {
|
||||
if p.get("items").is_some() || p.get("folder").is_some() {
|
||||
return Err(bad(cmd, "give paths, items or folder, not several"));
|
||||
}
|
||||
let paths =
|
||||
value.as_array().filter(|a| !a.is_empty() && a.len() <= 10_000).ok_or_else(|| bad(cmd, "paths must list 1..10000 occurrence paths"))?;
|
||||
let paths: Vec<Vec<u64>> = paths
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let path = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "each path must list 1..64 occurrence ids"))?;
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(path)
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
if bool_(p, "isolate").unwrap_or(false) {
|
||||
if !visible {
|
||||
return Err(bad(cmd, "isolate requires visible:true"));
|
||||
}
|
||||
let mut hidden = Vec::new();
|
||||
let mut ancestor_bodies = Vec::new();
|
||||
let mut stack = vec![Vec::<u64>::new()];
|
||||
let mut count = 0;
|
||||
while let Some(parent) = stack.pop() {
|
||||
for child in s.occurrence_children(&parent)? {
|
||||
count += 1;
|
||||
if count > 100_000 {
|
||||
return Err(bad(cmd, "isolate exceeds 100000 placements"));
|
||||
}
|
||||
if paths.iter().any(|selected| child.path.starts_with(selected)) {
|
||||
continue;
|
||||
}
|
||||
if paths.iter().any(|selected| selected.starts_with(&child.path)) {
|
||||
ancestor_bodies.extend(child.bodies.into_iter().map(|b| b.world));
|
||||
stack.push(child.path);
|
||||
} else {
|
||||
hidden.push(child.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut root_bodies: Vec<String> =
|
||||
s.model.state().bodies.iter().filter(|b| s.doc.model_body_component(b) == 0).map(|b| b.name.clone()).collect();
|
||||
root_bodies.extend(ancestor_bodies);
|
||||
s.visibility.hidden_occurrences = hidden;
|
||||
s.visibility.hidden_bodies = root_bodies;
|
||||
s.revision += 1;
|
||||
return Ok(json!({"visible":true,"isolated":paths,"visibility":s.visibility}));
|
||||
}
|
||||
for path in &paths {
|
||||
s.visibility.hidden_occurrences.retain(|p| p != path);
|
||||
if !visible {
|
||||
s.visibility.hidden_occurrences.push(path.clone());
|
||||
}
|
||||
}
|
||||
s.revision += 1;
|
||||
return Ok(json!({"visible":visible,"changed":paths.len(),"visibility":s.visibility}));
|
||||
}
|
||||
let items: Vec<Shown> = match (str_(p, "folder"), p.get("items")) {
|
||||
(Some(_), Some(_)) => return Err(bad(cmd, "give `items` or `folder`, not both")),
|
||||
(Some("bodies"), None) => s.world_state().bodies.iter().map(|b| Shown::Body(b.name.clone())).collect(),
|
||||
@@ -211,6 +271,9 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None) => return Err(bad(cmd, "`items` or `folder` is required")),
|
||||
};
|
||||
let v = &mut s.visibility;
|
||||
if visible && str_(p, "folder") == Some("bodies") {
|
||||
v.hidden_occurrences.clear();
|
||||
}
|
||||
for it in &items {
|
||||
match it {
|
||||
Shown::Sketch(id) => {
|
||||
@@ -247,6 +310,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "units": u, "kept": [] }));
|
||||
}
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
||||
let before = s.doc.length_inputs();
|
||||
s.doc_mut().units = u.to_string();
|
||||
// Unit-less user parameters holding a bare number read it in the design's units where they
|
||||
@@ -278,6 +342,10 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
s.doc_mut().preserve_configuration_units(&old, &kept);
|
||||
if let Some(row) = matched_configuration {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
}
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
}
|
||||
|
||||
@@ -393,6 +461,19 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Later features refer to bodies by name.
|
||||
let mut failed = None;
|
||||
for f in d.features.iter_mut().filter(|f| f.id != fid) {
|
||||
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
&& anchor.target.body == old
|
||||
{
|
||||
let ids = std::collections::BTreeMap::from([(anchor.target.origin_feature, anchor.target.origin_feature)]);
|
||||
let bodies = std::collections::BTreeMap::from([(old.clone(), name.to_string())]);
|
||||
anchor.remap_target(&ids, &bodies)?;
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face && matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==old) {
|
||||
link.face_name = Some(anchor.target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Ok(mut v) = serde_json::to_value(&f.kind) else { continue };
|
||||
if replace_str(&mut v, &old, name, 0) {
|
||||
match serde_json::from_value(v) {
|
||||
@@ -413,6 +494,17 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for f in d.appearances.faces.iter_mut().filter(|f| f.body == old) {
|
||||
f.body = name.to_string();
|
||||
}
|
||||
for column in &mut d.configs.columns {
|
||||
match column {
|
||||
solvecraft_doc::config::Column::Material(body) | solvecraft_doc::config::Column::Appearance(body) if *body == old => {
|
||||
*body = name.to_string();
|
||||
}
|
||||
solvecraft_doc::config::Column::FaceAppearance(target) if target.body == old => {
|
||||
target.body = name.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(o) = d.body_offsets.remove(&old) {
|
||||
d.body_offsets.insert(name.to_string(), o);
|
||||
}
|
||||
@@ -440,13 +532,89 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("plane").is_none() {
|
||||
return Err(bad(cmd, "`plane` is required"));
|
||||
}
|
||||
let plane = super::sketch::plane_ref(s, p, cmd)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.resolve_plane_in(&vals, &plane, 0, Some(&s.model.state()))?;
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) {
|
||||
let feature = s.doc.feature(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let component = feature.component;
|
||||
let index = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "sketch disappeared"))?;
|
||||
let author_path = super::sketch::author_path_for_rebind(s, feature, p, cmd)?;
|
||||
let marker = s.doc.marker;
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = Some(index + 1);
|
||||
baseline.refresh();
|
||||
let visible_geometry = baseline.model.state().sketch(id).map(|resolved| resolved.sketch.clone());
|
||||
let mut trial = s.scratch();
|
||||
trial.active_component = component;
|
||||
trial.active_occurrence_path = (!author_path.is_empty()).then_some(author_path);
|
||||
// A new attachment may only depend on geometry preceding this sketch.
|
||||
trial.doc_mut().marker = Some(index);
|
||||
trial.refresh();
|
||||
let old_anchor = trial
|
||||
.doc
|
||||
.feature(id)
|
||||
.and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
|
||||
_ => None,
|
||||
})
|
||||
.cloned();
|
||||
let plane = super::sketch::plane_ref(&trial, p, cmd)?;
|
||||
if super::sketch::face::is_face(p) {
|
||||
super::sketch::face::capture_positions(&mut trial, &plane)?;
|
||||
}
|
||||
if let Some(anchor) = old_anchor
|
||||
&& super::sketch::face::anchor(&plane)
|
||||
.is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path)
|
||||
{
|
||||
let sketch = trial.doc_mut().sketch_mut(id)?;
|
||||
if let Some(mut visible) = visible_geometry {
|
||||
let constraints: std::collections::BTreeMap<_, _> =
|
||||
sketch.constraints.iter().map(|constraint| (constraint.id.clone(), constraint.clone())).collect();
|
||||
for constraint in &mut visible.constraints {
|
||||
if let Some(original) = constraints.get(&constraint.id) {
|
||||
if std::mem::discriminant(&constraint.kind) != std::mem::discriminant(&original.kind) {
|
||||
return Err(bad(cmd, "a dimension role changed during border refresh; repair it explicitly before rebinding"));
|
||||
}
|
||||
// Keep evaluated entity indices after linked topology rebuilds.
|
||||
constraint.param = original.param.clone();
|
||||
constraint.driven = original.driven;
|
||||
constraint.text = original.text;
|
||||
if let Some(value) = original.kind.value() {
|
||||
constraint.kind.set_value(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
*sketch = visible;
|
||||
}
|
||||
let affected: Vec<_> = sketch
|
||||
.links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
link.auto_face
|
||||
&& link.face_name.as_ref() == Some(&anchor.target.name)
|
||||
&& matches!(&link.source,solvecraft_sketch::LinkSource::Face {body,..} if *body==anchor.target.body)
|
||||
})
|
||||
.map(|link| link.id.clone())
|
||||
.collect();
|
||||
for link in affected {
|
||||
sketch.break_link(&link)?;
|
||||
}
|
||||
}
|
||||
if let Some(FeatureKind::Sketch { plane: slot, .. }) = trial.doc_mut().feature_mut(id).map(|feature| &mut feature.kind) {
|
||||
*slot = plane;
|
||||
}
|
||||
Ok(json!({ "sketch": id }))
|
||||
trial.doc_mut().marker = Some(index + 1);
|
||||
trial.refresh();
|
||||
if let Some(error) = trial.model.result(id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error.clone()));
|
||||
}
|
||||
trial.doc_mut().marker = marker;
|
||||
trial.refresh();
|
||||
s.doc = trial.doc;
|
||||
s.pending_moves = trial.pending_moves;
|
||||
|
||||
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
|
||||
_ => None,
|
||||
});
|
||||
Ok(json!({ "sketch": id,"attachment":attachment }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -62,11 +62,68 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let feats = s.clipboard.clone();
|
||||
let comp = s.active_component;
|
||||
let mut sketches: BTreeMap<u64, u64> = BTreeMap::new();
|
||||
let mut bodies: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut used: std::collections::BTreeSet<String> = s.model.state().bodies.iter().map(|body| body.name.clone()).collect();
|
||||
let mut notices = Vec::new();
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp));
|
||||
solvecraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?;
|
||||
let mut names: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut made = Vec::new();
|
||||
for f in feats {
|
||||
let mut kind = f.kind.clone();
|
||||
kind.remap_refs(&sketches, &names);
|
||||
if let FeatureKind::Sketch { plane, .. } = &mut kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
|
||||
{
|
||||
let internal = bodies.contains_key(&anchor.target.body);
|
||||
if !internal && translate.is_some_and(|translate| translate.len() > 1e-12) {
|
||||
return Err(bad(cmd, "translated paste retains an external exact face attachment; explicitly convert it to a fixed plane first"));
|
||||
}
|
||||
if !internal {
|
||||
notices.push(format!("pasted sketch retains external exact face attachment to {}", anchor.target.body));
|
||||
}
|
||||
anchor.remap_target(&sketches, &bodies)?;
|
||||
if internal {
|
||||
anchor.component = comp;
|
||||
anchor.path = author_path.clone();
|
||||
}
|
||||
anchor.author_path = author_path.clone();
|
||||
let target = anchor.target.clone();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
for link in &mut sketch.links {
|
||||
if link.auto_face {
|
||||
if let solvecraft_sketch::LinkSource::Face { body, .. } = &mut link.source
|
||||
&& let Some(mapped) = bodies.get(body)
|
||||
{
|
||||
*body = mapped.clone();
|
||||
}
|
||||
link.face_name = Some(target.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputs = if f.body_names.is_empty() {
|
||||
s.model
|
||||
.result(f.id)
|
||||
.map(|result| result.state.bodies.iter().filter(|body| body.feature == f.id).map(|body| body.name.clone()).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
f.body_names.clone()
|
||||
};
|
||||
let copied_names: Vec<_> = outputs
|
||||
.iter()
|
||||
.map(|original| {
|
||||
let base = format!("{original} (copy)");
|
||||
let mut name = base.clone();
|
||||
let mut suffix = 2usize;
|
||||
while !used.insert(name.clone()) {
|
||||
name = format!("{base} {suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
bodies.insert(original.clone(), name.clone());
|
||||
name
|
||||
})
|
||||
.collect();
|
||||
let doc = s.doc_mut();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut kind {
|
||||
// Each dimension of the copy gets its own parameter, with the same expression.
|
||||
@@ -90,12 +147,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = doc.add_feature(kind, None)?;
|
||||
if let Some(nf) = doc.feature_mut(id) {
|
||||
nf.component = comp;
|
||||
nf.body_names = f.body_names.iter().map(|b| format!("{b} (copy)")).collect();
|
||||
nf.body_names = copied_names;
|
||||
}
|
||||
let new_name = doc.feature(id).map(|x| x.name.clone()).unwrap_or_default();
|
||||
if matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
sketches.insert(f.id, id);
|
||||
}
|
||||
sketches.insert(f.id, id);
|
||||
names.insert(f.name.clone(), new_name.clone());
|
||||
made.push(json!({"id": id, "name": new_name, "from": f.name}));
|
||||
}
|
||||
@@ -105,7 +160,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|m| m["id"].as_u64())
|
||||
.filter_map(|id| s.model.result(id).and_then(|r| r.error.clone()).map(|e| json!({"feature": id, "error": e})))
|
||||
.collect();
|
||||
Ok(json!({"pasted": made, "errors": errors}))
|
||||
Ok(json!({"pasted": made, "errors": errors, "notices":notices}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+226
-127
@@ -8,31 +8,47 @@ use solvecraft_doc::{FeatureKind, Mat, Occurrence};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
use crate::params::{bad, bool_, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "component_properties.rs"]
|
||||
pub(super) mod properties;
|
||||
#[path = "component_transfer.rs"]
|
||||
mod transfer;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "occurrence_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod occurrence_tests;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.create", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
|
||||
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true)"),
|
||||
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
|
||||
.icon("component")
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo(),
|
||||
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
|
||||
.icon("measure").noundo()
|
||||
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
|
||||
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
|
||||
.icon("measure").noundo()
|
||||
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
|
||||
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
|
||||
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
|
||||
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
|
||||
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
|
||||
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
|
||||
.params("component (or occurrence): what to place again; parent?: component (default: the same parent); translate?: [x,y,z]"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: copied with its features into a new, independent component; translate?: [x,y,z]"),
|
||||
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"),
|
||||
CommandSpec::new("component.paste_new", "Paste New", paste_new).params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
|
||||
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence)
|
||||
.params("occurrence; translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position)"),
|
||||
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"),
|
||||
CommandSpec::new("occurrence.ground", "Ground", ground).params("occurrence; grounded?: bool (default toggles)"),
|
||||
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("drops pending occurrence moves"),
|
||||
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
|
||||
];
|
||||
|
||||
fn key(v: Option<&Value>) -> Option<String> {
|
||||
@@ -57,12 +73,61 @@ fn translation(t: Vec3) -> Mat {
|
||||
solvecraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
|
||||
}
|
||||
|
||||
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
|
||||
let v = match p.get(key) {
|
||||
None => default,
|
||||
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
|
||||
};
|
||||
if !v.is_finite() || (coordinate && [v.x, v.y, v.z].iter().any(|x| x.abs() > 1e9)) {
|
||||
return Err(bad(cmd, format!("{key} exceeds supported numeric range")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(super) fn valid_position(m: &Mat) -> bool {
|
||||
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (column(0), column(1), column(2));
|
||||
m.iter().flatten().all(|x| x.is_finite())
|
||||
&& m[3][..3].iter().all(|x| x.abs() <= 1e9)
|
||||
&& [x, y, z].iter().all(|axis| (axis.dot(*axis) - 1.0).abs() < 1e-6)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6
|
||||
&& x.cross(y).dot(z) > 0.0
|
||||
&& [m[0][3], m[1][3], m[2][3]].iter().all(|x| x.abs() < 1e-9)
|
||||
&& (m[3][3] - 1.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.create";
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
|
||||
let parent_path = if let Some(value) = p.get("parent_path") {
|
||||
let path = value
|
||||
.as_array()
|
||||
.filter(|a| a.len() < 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "parent_path must list up to 63 occurrence ids"))?;
|
||||
let component = if path.is_empty() { 0 } else { s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?.component };
|
||||
if component != parent {
|
||||
return Err(bad(cmd, "parent_path belongs to another component"));
|
||||
}
|
||||
path
|
||||
} else if parent == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(parent))
|
||||
} else {
|
||||
s.doc.first_path(parent)
|
||||
};
|
||||
if parent_path.len() >= 64 {
|
||||
return Err(bad(cmd, "new component exceeds occurrence depth limit"));
|
||||
}
|
||||
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
|
||||
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let mut path = parent_path;
|
||||
if let Some(o) = s.doc.occurrence_of(id) {
|
||||
path.push(o.id);
|
||||
}
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = Some(path);
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let occ = s.doc.occurrence_of(id).map(|o| o.id);
|
||||
@@ -70,30 +135,23 @@ fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
for b in &bodies {
|
||||
if st.body(b).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
for b in bodies {
|
||||
let id = s.doc_mut().add_component(Some(&b), parent)?;
|
||||
s.doc_mut().body_components.insert(b.clone(), id);
|
||||
made.push(json!({"component": id, "body": b}));
|
||||
}
|
||||
Ok(json!({ "components": made }))
|
||||
transfer::from_bodies(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component(s, p, "component", "component.activate")?;
|
||||
let path = if p.get("path").is_some() || p.get("occurrence").is_some() {
|
||||
let occurrence = occurrence(s, p, "component.activate")?;
|
||||
let path = occurrence_path(s, p, occurrence, "component.activate")?;
|
||||
if s.occurrence_placement(&path)?.component != id {
|
||||
return Err(bad("component.activate", "path belongs to another component"));
|
||||
}
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
s.active_component = id;
|
||||
s.active_occurrence_path = path;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
}
|
||||
@@ -102,7 +160,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc
|
||||
.features
|
||||
.iter()
|
||||
@@ -149,12 +207,22 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "the design itself can't be deleted"));
|
||||
}
|
||||
let gone: Vec<u64> = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect();
|
||||
if s.doc.features.iter().any(|feature| {
|
||||
!gone.contains(&feature.component)
|
||||
&& matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to))
|
||||
}) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source",
|
||||
));
|
||||
}
|
||||
let feats: Vec<u64> = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect();
|
||||
let d = s.doc_mut();
|
||||
for f in &feats {
|
||||
let _ = d.delete_feature(*f);
|
||||
}
|
||||
d.components.retain(|c| !gone.contains(&c.id));
|
||||
d.remove_configuration_components(&gone);
|
||||
d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent));
|
||||
let moved_out: Vec<String> = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect();
|
||||
d.body_components.retain(|_, c| !gone.contains(c));
|
||||
@@ -166,36 +234,11 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = component(s, p, "component", cmd)?;
|
||||
let bodies = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
for b in &bodies {
|
||||
let feature = st.body(b).map(|x| x.feature).unwrap_or(0);
|
||||
let own = s.doc.feature(feature).map(|f| f.component).unwrap_or(0);
|
||||
// The body keeps its place in the world: where it is shown now (its component's frame and
|
||||
// any earlier offset), seen from the new component's frame.
|
||||
let from = s.doc.body_component(b, feature);
|
||||
let now = solvecraft_doc::mat_mul(&s.doc.component_transform(from), s.doc.body_offsets.get(b).unwrap_or(&solvecraft_doc::IDENTITY));
|
||||
let offset = solvecraft_doc::mat_inverse(&s.doc.component_transform(to))
|
||||
.map(|inv| solvecraft_doc::mat_mul(&inv, &now))
|
||||
.unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let doc = s.doc_mut();
|
||||
if own == to {
|
||||
doc.body_components.remove(b);
|
||||
} else {
|
||||
doc.body_components.insert(b.clone(), to);
|
||||
}
|
||||
if solvecraft_doc::is_identity(&offset) {
|
||||
doc.body_offsets.remove(b);
|
||||
} else {
|
||||
doc.body_offsets.insert(b.clone(), offset);
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "component": to}))
|
||||
transfer::move_bodies(s, p)
|
||||
}
|
||||
|
||||
pub(crate) fn transfer_constructor_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
transfer::move_result(s, p)
|
||||
}
|
||||
|
||||
fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -222,16 +265,30 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.copy";
|
||||
let comp = if p.get("occurrence").is_some() {
|
||||
let selected = if p.get("occurrence").is_some() {
|
||||
let o = occurrence(s, p, cmd)?;
|
||||
s.doc.occurrences.iter().find(|x| x.id == o).map(|x| x.component).unwrap_or(0)
|
||||
Some(o)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let comp = if let Some(id) = selected {
|
||||
s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.component).unwrap_or(0)
|
||||
} else {
|
||||
component(s, p, "component", cmd)?
|
||||
};
|
||||
let src = s.doc.occurrence_of(comp).cloned().ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
let src = if let Some(id) = selected { s.doc.occurrences.iter().find(|o| o.id == id) } else { s.doc.occurrence_of(comp) }
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "that component has no occurrence"))?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, src.id, cmd)?;
|
||||
}
|
||||
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { src.parent };
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), &src.transform);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = s.pending_moves.get(&src.id).unwrap_or(&src.transform);
|
||||
let m = solvecraft_doc::mat_mul(&translation(t), base);
|
||||
if !valid_position(&m) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
|
||||
Ok(json!({"occurrence": id, "component": comp}))
|
||||
}
|
||||
@@ -243,68 +300,55 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "pick a component, not the design"));
|
||||
}
|
||||
let src = s.doc.components.iter().find(|c| c.id == comp).cloned().ok_or_else(|| bad(cmd, "component"))?;
|
||||
let occ = s.doc.occurrence_of(comp).cloned();
|
||||
let new_id = s.doc_mut().add_component(Some(&format!("{} (copy)", src.name)), src.parent)?;
|
||||
// Copy the component's features with fresh ids and names; references between them follow.
|
||||
let feats: Vec<solvecraft_doc::Feature> = s.doc.features.iter().filter(|f| f.component == comp).cloned().collect();
|
||||
let mut idmap: std::collections::HashMap<u64, u64> = Default::default();
|
||||
let mut namemap: std::collections::HashMap<String, String> = Default::default();
|
||||
let mut new_ids = Vec::new();
|
||||
for f in &feats {
|
||||
let nid = s.doc_mut().add_feature(f.kind.clone(), Some(&format!("{} (copy)", f.name)))?;
|
||||
idmap.insert(f.id, nid);
|
||||
let nname = s.doc.feature(nid).map(|x| x.name.clone()).unwrap_or_default();
|
||||
namemap.insert(f.name.clone(), nname);
|
||||
new_ids.push(nid);
|
||||
}
|
||||
for nid in &new_ids {
|
||||
let Some(f) = s.doc.feature(*nid).cloned() else { continue };
|
||||
let mut v = serde_json::to_value(&f.kind).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
remap(&mut v, &idmap, &namemap, 0);
|
||||
let kind: FeatureKind = serde_json::from_value(v).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(slot) = s.doc_mut().feature_mut(*nid) {
|
||||
slot.kind = kind;
|
||||
slot.component = new_id;
|
||||
let occ = if p.get("occurrence").is_some() {
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
if occurrence.component != comp {
|
||||
return Err(bad(cmd, "occurrence belongs to another component"));
|
||||
}
|
||||
Some(occurrence)
|
||||
} else {
|
||||
s.doc.occurrence_of(comp).cloned()
|
||||
};
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let base = occ.as_ref().map(|o| s.pending_moves.get(&o.id).copied().unwrap_or(o.transform)).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
let transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
if !valid_position(&transform) {
|
||||
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let base = occ.map(|o| o.transform).unwrap_or(solvecraft_doc::IDENTITY);
|
||||
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == new_id) {
|
||||
o.transform = solvecraft_doc::mat_mul(&translation(t), &base);
|
||||
}
|
||||
Ok(json!({"component": new_id, "features": new_ids}))
|
||||
let parent = occ.as_ref().map(|o| o.parent).unwrap_or(src.parent);
|
||||
super::component_clone::paste(s, comp, occ, transform, parent, p)
|
||||
}
|
||||
|
||||
/// Point copied features at their copied sketches and features.
|
||||
fn remap(v: &mut Value, ids: &std::collections::HashMap<u64, u64>, names: &std::collections::HashMap<String, String>, depth: usize) {
|
||||
if depth > 32 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o.iter_mut() {
|
||||
match (k.as_str(), &*x) {
|
||||
("sketch" | "path_sketch", Value::Number(n)) => {
|
||||
if let Some(m) = n.as_u64().and_then(|i| ids.get(&i)) {
|
||||
*x = json!(m);
|
||||
}
|
||||
}
|
||||
("features", Value::Array(_)) => {
|
||||
if let Value::Array(a) = x {
|
||||
for e in a.iter_mut() {
|
||||
if let Some(m) = e.as_str().and_then(|n| names.get(n)) {
|
||||
*e = json!(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => remap(x, ids, names, depth + 1),
|
||||
}
|
||||
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
|
||||
let path = if let Some(value) = p.get("path") {
|
||||
value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 64)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(cmd, "`path` must list 1..64 occurrence ids"))?
|
||||
} else {
|
||||
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
|
||||
let mut path = s.doc.first_path(o.parent);
|
||||
// A world frame requires an explicit containing placement when any ancestor is instanced.
|
||||
for ancestor in &path {
|
||||
if let Some(a) = s.doc.occurrences.iter().find(|a| a.id == *ancestor)
|
||||
&& s.doc.occurrences.iter().filter(|x| x.component == a.component).count() > 1
|
||||
{
|
||||
return Err(bad(cmd, "occurrence has multiple placements; supply its `path`"));
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| remap(x, ids, names, depth + 1)),
|
||||
_ => {}
|
||||
path.push(id);
|
||||
path
|
||||
};
|
||||
if path.last() != Some(&id) {
|
||||
return Err(bad(cmd, "path and occurrence disagree"));
|
||||
}
|
||||
s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -314,19 +358,56 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if o.grounded {
|
||||
return Err(bad(cmd, format!("{} is grounded", o.name)));
|
||||
}
|
||||
let t = p.get("translate").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z);
|
||||
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
|
||||
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
|
||||
let angle = match p.get("angle") {
|
||||
Some(Value::String(e)) => s.doc.eval(e, solvecraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
_ => num(p, "angle").unwrap_or(0.0),
|
||||
None => 0.0,
|
||||
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
|
||||
};
|
||||
if !(t.is_finite() && angle.is_finite()) {
|
||||
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite move"));
|
||||
}
|
||||
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
if angle != 0.0 && axis.normalized().is_none() {
|
||||
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
|
||||
}
|
||||
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
|
||||
let delta = solvecraft_doc::rigid(t, origin, axis, angle);
|
||||
let cur = s.pending_moves.get(&id).copied().unwrap_or(o.transform);
|
||||
let space = match p.get("space") {
|
||||
None => "local",
|
||||
Some(Value::String(space)) => space.as_str(),
|
||||
_ => return Err(bad(cmd, "space must be local or world")),
|
||||
};
|
||||
if !matches!(space, "local" | "world") {
|
||||
return Err(bad(cmd, "space must be local or world"));
|
||||
}
|
||||
let path = if p.get("path").is_some() || space == "world" { Some(occurrence_path(s, p, id, cmd)?) } else { None };
|
||||
let delta = if space == "world" {
|
||||
let placement = s.occurrence_placement(path.as_deref().unwrap_or(&[]))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?;
|
||||
solvecraft_doc::mat_mul(&solvecraft_doc::mat_mul(&inverse, &delta), &placement.parent_world)
|
||||
} else {
|
||||
delta
|
||||
};
|
||||
let cur = if let Some(value) = p.get("base_transform") {
|
||||
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
|
||||
if !valid_position(&base)
|
||||
|| base[0][3].abs() > 1e-12
|
||||
|| base[1][3].abs() > 1e-12
|
||||
|| base[2][3].abs() > 1e-12
|
||||
|| (base[3][3] - 1.0).abs() > 1e-12
|
||||
|| solvecraft_doc::mat_inverse(&base).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
|
||||
}
|
||||
base
|
||||
} else {
|
||||
s.pending_moves.get(&id).copied().unwrap_or(o.transform)
|
||||
};
|
||||
let next = solvecraft_doc::mat_mul(&delta, &cur);
|
||||
if !valid_position(&next) {
|
||||
return Err(bad(cmd, "move transform exceeds supported numeric range"));
|
||||
}
|
||||
if bool_(p, "capture").unwrap_or(true) {
|
||||
s.pending_moves.remove(&id);
|
||||
if let Some(x) = s.doc_mut().occurrences.iter_mut().find(|x| x.id == id) {
|
||||
@@ -342,6 +423,9 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.ground";
|
||||
let id = occurrence(s, p, cmd)?;
|
||||
if p.get("path").is_some() {
|
||||
occurrence_path(s, p, id, cmd)?;
|
||||
}
|
||||
let want = bool_(p, "grounded");
|
||||
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
|
||||
o.grounded = want.unwrap_or(!o.grounded);
|
||||
@@ -361,9 +445,24 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "captured": n }))
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let n = s.pending_moves.len();
|
||||
s.pending_moves.clear();
|
||||
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = s.pending_moves.len();
|
||||
if let Some(value) = p.get("occurrences") {
|
||||
let ids = value
|
||||
.as_array()
|
||||
.filter(|a| !a.is_empty() && a.len() <= 10_000)
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad("component.revert_position", "occurrences must list 1..10000 ids"))?;
|
||||
if ids.iter().any(|id| !s.doc.occurrences.iter().any(|o| o.id == *id)) {
|
||||
return Err(bad("component.revert_position", "no occurrence"));
|
||||
}
|
||||
for id in ids {
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
} else {
|
||||
s.pending_moves.clear();
|
||||
}
|
||||
let n = before - s.pending_moves.len();
|
||||
s.revision += 1;
|
||||
Ok(json!({ "reverted": n }))
|
||||
}
|
||||
@@ -374,7 +473,7 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
|
||||
if s.active_component == 0 || crate::frames::mapped() {
|
||||
return;
|
||||
}
|
||||
let m = s.doc.component_transform(s.active_component);
|
||||
let m = s.active_component_frame();
|
||||
if solvecraft_doc::is_identity(&m) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
//! Independent copies of a component definition graph, assembled and evaluated atomically.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
|
||||
|
||||
const CMD: &str = "component.paste_new";
|
||||
const MAX_WORK: usize = 10_000;
|
||||
|
||||
fn mesh_distance(mesh: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3) -> f64 {
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.filter_map(|triangle| mesh.tri(triangle))
|
||||
.map(|[a, b, c]| {
|
||||
let normal = (b - a).cross(c - a);
|
||||
let squared = normal.dot(normal);
|
||||
let projected = if squared > 0. { point - normal * ((point - a).dot(normal) / squared) } else { point };
|
||||
if squared > 0. && [(a, b), (b, c), (c, a)].iter().all(|(start, end)| (*end - *start).cross(projected - *start).dot(normal) >= 0.) {
|
||||
return point.dist(projected);
|
||||
}
|
||||
[(a, b), (b, c), (c, a)]
|
||||
.iter()
|
||||
.map(|(start, end)| {
|
||||
let edge = *end - *start;
|
||||
let t = if edge.dot(edge) > 0. { ((point - *start).dot(edge) / edge.dot(edge)).clamp(0., 1.) } else { 0. };
|
||||
point.dist(*start + edge * t)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
fn material_probe_matches(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, point: solvecraft_geom::Vec3, tol_a: f64, tol_b: f64) -> bool {
|
||||
if !point.is_finite() || !tol_a.is_finite() || !tol_b.is_finite() || tol_a <= 0. || tol_b <= 0. {
|
||||
return false;
|
||||
}
|
||||
if a.contains(point) == b.contains(point) {
|
||||
return true;
|
||||
}
|
||||
// A chord approximation does not define material ON a boundary. Certify
|
||||
// nearby stock and void instead, outside both meshes' approximation strips.
|
||||
if mesh_distance(a, point) > tol_a || mesh_distance(b, point) > tol_b {
|
||||
return false;
|
||||
}
|
||||
let offset = 4. * tol_a.max(tol_b);
|
||||
let mut material = false;
|
||||
let mut void = false;
|
||||
for axis in [solvecraft_geom::Vec3::X, solvecraft_geom::Vec3::Y, solvecraft_geom::Vec3::Z] {
|
||||
for sign in [-1., 1.] {
|
||||
let q = point + axis * (sign * offset);
|
||||
if !q.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if mesh_distance(a, q) <= tol_a || mesh_distance(b, q) <= tol_b {
|
||||
continue;
|
||||
}
|
||||
let inside = a.contains(q);
|
||||
if inside != b.contains(q) {
|
||||
return false;
|
||||
}
|
||||
material |= inside;
|
||||
void |= !inside;
|
||||
}
|
||||
}
|
||||
material && void
|
||||
}
|
||||
|
||||
fn cloned_path(source: &Document, path: &[u64], root: u64, destination: &[u64], occurrences: &BTreeMap<u64, u64>) -> Result<Vec<u64>> {
|
||||
let start = path
|
||||
.iter()
|
||||
.position(|id| source.occurrences.iter().any(|o| o.id == *id && o.component == root))
|
||||
.ok_or_else(|| bad(CMD, "copied Emboss placement is outside the copied component tree"))?;
|
||||
let mut mapped = destination.to_vec();
|
||||
for id in path.iter().skip(start + 1) {
|
||||
mapped.push(*occurrences.get(id).ok_or_else(|| bad(CMD, "copied Emboss path has an external descendant"))?);
|
||||
}
|
||||
if mapped.len() > 64 {
|
||||
return Err(bad(CMD, "copied Emboss path exceeds placement depth limit"));
|
||||
}
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
fn unique(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
let stem = format!("{base} (copy)");
|
||||
let mut name = stem.clone();
|
||||
let mut n = 2;
|
||||
while used.contains(&name) {
|
||||
name = format!("{stem} {n}");
|
||||
n += 1;
|
||||
}
|
||||
used.insert(name.clone());
|
||||
name
|
||||
}
|
||||
|
||||
fn graph(doc: &Document, root: u64) -> Result<Vec<u64>> {
|
||||
let definitions: BTreeSet<_> = doc.components.iter().map(|c| c.id).collect();
|
||||
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence.component);
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut stack = vec![(root, Vec::<u64>::new())];
|
||||
let mut work = 0;
|
||||
while let Some((id, mut ancestors)) = stack.pop() {
|
||||
work += 1;
|
||||
if work > MAX_WORK || ancestors.len() >= 64 {
|
||||
return Err(bad(CMD, "component graph exceeds copy work limit"));
|
||||
}
|
||||
if ancestors.contains(&id) {
|
||||
return Err(bad(CMD, "component graph contains a cycle"));
|
||||
}
|
||||
if !definitions.contains(&id) {
|
||||
return Err(bad(CMD, "component graph has a missing definition"));
|
||||
}
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
output.push(id);
|
||||
ancestors.push(id);
|
||||
if let Some(children) = children.get(&id) {
|
||||
if work.saturating_add(stack.len()).saturating_add(children.len()) > MAX_WORK {
|
||||
return Err(bad(CMD, "component graph exceeds copy work limit"));
|
||||
}
|
||||
for child in children.iter().rev() {
|
||||
stack.push((*child, ancestors.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A shared node may be reached by a longer path after its first DFS visit. Check the
|
||||
// longest path over the complete DAG, rather than trusting the first placement depth.
|
||||
let mut indegree: BTreeMap<u64, usize> = output.iter().map(|id| (*id, 0)).collect();
|
||||
for parent in &output {
|
||||
if let Some(edges) = children.get(parent) {
|
||||
for child in edges {
|
||||
if let Some(degree) = indegree.get_mut(child) {
|
||||
*degree += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ready = std::collections::VecDeque::from([root]);
|
||||
let mut depth = BTreeMap::from([(root, 1usize)]);
|
||||
let mut sorted = Vec::new();
|
||||
while let Some(parent) = ready.pop_front() {
|
||||
sorted.push(parent);
|
||||
let parent_depth = *depth.get(&parent).unwrap_or(&1);
|
||||
if let Some(edges) = children.get(&parent) {
|
||||
for child in edges {
|
||||
let next = parent_depth + 1;
|
||||
if next > 64 {
|
||||
return Err(bad(CMD, "component graph exceeds copy depth limit"));
|
||||
}
|
||||
depth.entry(*child).and_modify(|d| *d = (*d).max(next)).or_insert(next);
|
||||
if let Some(degree) = indegree.get_mut(child) {
|
||||
*degree = degree.saturating_sub(1);
|
||||
if *degree == 0 {
|
||||
ready.push_back(*child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sorted.len() != output.len() {
|
||||
return Err(bad(CMD, "component graph contains a cycle"));
|
||||
}
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
pub(super) fn paste(
|
||||
s: &mut Session,
|
||||
component: u64,
|
||||
source_occurrence: Option<Occurrence>,
|
||||
transform: Mat,
|
||||
parent: u64,
|
||||
params: &Value,
|
||||
) -> Result<Value> {
|
||||
let source = s.doc.clone();
|
||||
let source_parameters: BTreeMap<_, _> = source.params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
let generated_parameters: BTreeSet<_> = source
|
||||
.params
|
||||
.iter()
|
||||
.filter(|p| p.model)
|
||||
.map(|p| p.name.as_str())
|
||||
.chain(source.features.iter().flat_map(|f| f.param_names.iter().map(String::as_str)))
|
||||
.collect();
|
||||
let definitions = graph(&source, component)?;
|
||||
let definition_records: BTreeMap<_, _> = source.components.iter().map(|c| (c.id, c)).collect();
|
||||
let selected: BTreeSet<_> = definitions.iter().copied().collect();
|
||||
if source.marker.is_some_and(|m| m < source.features.len()) {
|
||||
return Err(bad(CMD, "move the timeline marker to the end before Paste New"));
|
||||
}
|
||||
let feats: Vec<Feature> = source.features.iter().filter(|f| selected.contains(&f.component)).cloned().collect();
|
||||
if feats.len() + source.features.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "too many features to copy"));
|
||||
}
|
||||
for f in &feats {
|
||||
if s.model.result(f.id).is_some_and(|r| r.error.is_some()) {
|
||||
return Err(bad(CMD, format!("source feature {} has an evaluation error", f.name)));
|
||||
}
|
||||
}
|
||||
let state = s.model.state();
|
||||
if state.bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "body set exceeds copy work limit"));
|
||||
}
|
||||
if source.next_id.checked_add(100_000).is_none() {
|
||||
return Err(bad(CMD, "copy id allocation exceeds supported range"));
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let mut components = BTreeMap::new();
|
||||
let mut occurrences = BTreeMap::new();
|
||||
let mut features = BTreeMap::new();
|
||||
let mut names = BTreeMap::new();
|
||||
let mut bodies = BTreeMap::new();
|
||||
let mut parameters = BTreeMap::new();
|
||||
let mut used: BTreeSet<String> = source
|
||||
.features
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.chain(source.components.iter().map(|c| c.name.clone()))
|
||||
.chain(state.bodies.iter().map(|b| b.name.clone()))
|
||||
.collect();
|
||||
let mut new_ids = Vec::new();
|
||||
let mut frozen = Vec::new();
|
||||
// Allocate definitions, discarding their automatic placeholder occurrence except root.
|
||||
for old in &definitions {
|
||||
let c = definition_records.get(old).ok_or_else(|| bad(CMD, "missing definition"))?;
|
||||
let mapped_parent = if *old == component {
|
||||
parent
|
||||
} else {
|
||||
components.get(&c.parent).copied().unwrap_or(*components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?)
|
||||
};
|
||||
let name = unique(&c.name, &mut used);
|
||||
let new = scratch.doc_mut().add_component(Some(&name), mapped_parent)?;
|
||||
components.insert(*old, new);
|
||||
if *old != component {
|
||||
scratch.doc_mut().occurrences.retain(|o| o.component != new);
|
||||
}
|
||||
}
|
||||
let root = *components.get(&component).ok_or_else(|| bad(CMD, "missing clone root"))?;
|
||||
let root_occurrence = scratch.doc.occurrence_of(root).map(|o| o.id).ok_or_else(|| bad(CMD, "missing cloned placement"))?;
|
||||
let mut clone_path = if let Some(o) = &source_occurrence {
|
||||
let mut path = super::component::occurrence_path(s, params, o.id, CMD)?;
|
||||
path.pop();
|
||||
path
|
||||
} else {
|
||||
source.first_path(parent)
|
||||
};
|
||||
clone_path.push(root_occurrence);
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) {
|
||||
o.transform = transform;
|
||||
o.grounded = source_occurrence.as_ref().is_some_and(|o| o.grounded);
|
||||
}
|
||||
if let Some(o) = &source_occurrence {
|
||||
occurrences.insert(o.id, root_occurrence);
|
||||
}
|
||||
for old in source.occurrences.iter().filter(|o| selected.contains(&o.parent)) {
|
||||
let child = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing child definition"))?;
|
||||
let owner = *components.get(&old.parent).ok_or_else(|| bad(CMD, "missing parent definition"))?;
|
||||
let pose = s.pending_moves.get(&old.id).copied().unwrap_or(old.transform);
|
||||
if !super::component::valid_position(&pose) {
|
||||
return Err(bad(CMD, "invalid internal pending occurrence transform"));
|
||||
}
|
||||
let id = scratch.doc_mut().add_occurrence(child, owner, pose)?;
|
||||
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
|
||||
o.grounded = old.grounded;
|
||||
}
|
||||
occurrences.insert(old.id, id);
|
||||
}
|
||||
// Collect historical outputs too: a later Combine may have consumed a named tool body.
|
||||
let mut outputs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
|
||||
for f in &feats {
|
||||
if let Some(result) = s.model.result(f.id) {
|
||||
let created: Vec<_> = result.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect();
|
||||
for name in &created {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
outputs.insert(f.id, created);
|
||||
}
|
||||
for name in &f.body_names {
|
||||
if !name.is_empty() {
|
||||
bodies.entry(name.clone()).or_insert_with(|| unique(name, &mut used));
|
||||
}
|
||||
}
|
||||
if bodies.len() > MAX_WORK {
|
||||
return Err(bad(CMD, "historical body set exceeds copy work limit"));
|
||||
}
|
||||
}
|
||||
// Body assignment may put externally authored geometry into this graph. Freeze it explicitly.
|
||||
let owned_features: BTreeSet<_> = feats.iter().map(|f| f.id).collect();
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b)) && !owned_features.contains(&b.feature)) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(bad(CMD, "Paste New of a reassigned mesh body is not supported yet"));
|
||||
}
|
||||
let offset = source.model_body_offset(body).copied().unwrap_or(solvecraft_doc::IDENTITY);
|
||||
// An external transfer establishes the local baseline in this graph.
|
||||
// Freeze AFTER that boundary, then replay it as an identity ownership event.
|
||||
let entry = feats.iter().find(|f| {
|
||||
!f.suppressed
|
||||
&& matches!(&f.kind, FeatureKind::BodyTransfer { bodies, from, to, .. }
|
||||
if !selected.contains(from) && bodies.contains(&body.name)
|
||||
&& s.model.result(f.id).is_some_and(|result| !result.skipped && result.error.is_none()
|
||||
&& result.state.body(&body.name).is_some_and(|entered| source.model_body_component(entered) == *to)))
|
||||
});
|
||||
let before = feats.iter().find_map(|f| {
|
||||
let before = s.model.state_before(f.id);
|
||||
let prior = before.body(&body.name)?;
|
||||
let after = s.model.result(f.id)?.state.body(&body.name);
|
||||
if after.is_none_or(|after| !prior.body.same(&after.body)) { Some(before) } else { None }
|
||||
});
|
||||
let entered = entry.and_then(|f| s.model.result(f.id)).map(|r| r.state.clone());
|
||||
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], "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);
|
||||
let id = scratch.doc_mut().add_feature(
|
||||
FeatureKind::Import { file: format!("{} independent base", body.name), step, components: Vec::new() },
|
||||
Some(&feature_name),
|
||||
)?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = *components.get(&baseline_component).ok_or_else(|| bad(CMD, "reassigned baseline is outside copied component graph"))?;
|
||||
f.body_names = vec![new_name];
|
||||
}
|
||||
new_ids.push(id);
|
||||
frozen.push(body.name.clone());
|
||||
}
|
||||
// Allocate all history IDs before rewriting any forward reference.
|
||||
for f in &feats {
|
||||
let name = unique(&f.name, &mut used);
|
||||
let id = scratch.doc_mut().add_feature(f.kind.clone(), Some(&name))?;
|
||||
if let Some(new) = scratch.doc.feature(id) {
|
||||
for (old, pn) in f.param_names.iter().zip(&new.param_names) {
|
||||
parameters.insert(old.clone(), pn.clone());
|
||||
}
|
||||
}
|
||||
features.insert(f.id, id);
|
||||
names.insert(f.name.clone(), name);
|
||||
new_ids.push(id);
|
||||
}
|
||||
// Constraint dimensions own independent generated parameters; public user parameters stay shared.
|
||||
for f in &feats {
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for c in &sketch.constraints {
|
||||
if let Some(name) = &c.param
|
||||
&& !parameters.contains_key(name)
|
||||
&& let Some(p) = source_parameters.get(name.as_str())
|
||||
&& p.model
|
||||
{
|
||||
if scratch.doc.params.len() >= solvecraft_doc::MAX_PARAMS {
|
||||
return Err(bad(CMD, "too many copied parameters"));
|
||||
}
|
||||
let new = scratch.doc_mut().new_model_param(&p.expr, &p.unit);
|
||||
parameters.insert(name.clone(), new);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (old, new) in ¶meters {
|
||||
if let Some(comment) = source.param_comments.get(old) {
|
||||
scratch.doc_mut().param_comments.insert(new.clone(), comment.clone());
|
||||
}
|
||||
if let Some(p) = source_parameters.get(old.as_str())
|
||||
&& p.model
|
||||
{
|
||||
let expr = solvecraft_doc::clone_expression(&p.expr, ¶meters);
|
||||
if let Some(p) = scratch.doc_mut().params.iter_mut().find(|p| p.name == *new) {
|
||||
p.expr = expr;
|
||||
p.comment = source_parameters.get(old.as_str()).map(|p| p.comment.clone()).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
for old in &feats {
|
||||
let mut f = old.clone();
|
||||
f.id = *features.get(&old.id).ok_or_else(|| bad(CMD, "missing feature mapping"))?;
|
||||
f.name = names.get(&old.name).cloned().ok_or_else(|| bad(CMD, "missing feature name"))?;
|
||||
f.component = *components.get(&old.component).ok_or_else(|| bad(CMD, "missing component mapping"))?;
|
||||
if f.body_names.is_empty() {
|
||||
f.body_names = outputs.get(&old.id).cloned().unwrap_or_default();
|
||||
}
|
||||
f.remap_clone(&features, &names, &bodies, ¶meters)?;
|
||||
if let FeatureKind::BodyTransfer { from, to, transform, .. } = &mut f.kind {
|
||||
*to = *components.get(to).ok_or_else(|| bad(CMD, "transfer destination is outside the copied graph"))?;
|
||||
if let Some(mapped) = components.get(from) {
|
||||
*from = *mapped;
|
||||
} else {
|
||||
*from = *to;
|
||||
*transform = solvecraft_doc::IDENTITY;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.path.is_empty()
|
||||
&& let FeatureKind::Emboss { face: Some(old_face), .. } = &old.kind
|
||||
&& bodies.contains_key(&old_face.body)
|
||||
{
|
||||
face.path = cloned_path(&source, &old_face.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &mut f.kind
|
||||
&& !face.author_path.is_empty()
|
||||
{
|
||||
face.author_path = cloned_path(&source, &face.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let (FeatureKind::Sketch { plane, .. }, FeatureKind::Sketch { plane: old_plane, .. }) = (&mut f.kind, &old.kind)
|
||||
&& let (Some(anchor), Some(original)) = (super::sketch::face::anchor_mut(plane), super::sketch::face::anchor(old_plane))
|
||||
{
|
||||
if bodies.contains_key(&original.target.body) {
|
||||
anchor.component =
|
||||
*components.get(&original.component).ok_or_else(|| bad(CMD, "anchored source component is outside copied graph"))?;
|
||||
anchor.path = cloned_path(&source, &original.path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
anchor.author_path = cloned_path(&source, &original.author_path, component, &clone_path, &occurrences)?;
|
||||
}
|
||||
if let Some(slot) = scratch.doc_mut().feature_mut(f.id) {
|
||||
*slot = f;
|
||||
}
|
||||
}
|
||||
for (old, new) in &bodies {
|
||||
if let Some(mat) = source.materials.get(old) {
|
||||
scratch.doc_mut().materials.insert(new.clone(), mat.clone());
|
||||
}
|
||||
if let Some(look) = source.appearances.bodies.get(old) {
|
||||
scratch.doc_mut().appearances.bodies.insert(new.clone(), look.clone());
|
||||
}
|
||||
if !frozen.contains(old) {
|
||||
if let Some(comp) = source.body_components.get(old).and_then(|c| components.get(c)) {
|
||||
scratch.doc_mut().body_components.insert(new.clone(), *comp);
|
||||
}
|
||||
if let Some(offset) = source.body_offsets.get(old) {
|
||||
scratch.doc_mut().body_offsets.insert(new.clone(), *offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (old, new) in &components {
|
||||
if let Some(part) = source.parts.get(old) {
|
||||
scratch.doc_mut().parts.insert(*new, part.clone());
|
||||
}
|
||||
if let Some(look) = source.appearances.components.get(old) {
|
||||
scratch.doc_mut().appearances.components.insert(*new, look.clone());
|
||||
}
|
||||
}
|
||||
// A creator's history can belong here while its final body was reassigned outside.
|
||||
// Retain the independent history, but reproduce the current ownership at its end.
|
||||
let moved_out: Vec<_> = state
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| owned_features.contains(&b.feature) && !selected.contains(&source.model_body_component(b)))
|
||||
.filter_map(|b| bodies.get(&b.name).cloned())
|
||||
.collect();
|
||||
if !moved_out.is_empty() {
|
||||
let name = unique("Assigned-out bodies", &mut used);
|
||||
let id = scratch.doc_mut().add_feature(FeatureKind::Remove { bodies: moved_out }, Some(&name))?;
|
||||
if let Some(f) = scratch.doc_mut().feature_mut(id) {
|
||||
f.component = root;
|
||||
}
|
||||
new_ids.push(id);
|
||||
}
|
||||
for face in &source.appearances.faces {
|
||||
if let Some(new) = bodies.get(&face.body) {
|
||||
let mut face = face.clone();
|
||||
if frozen.contains(&face.body) {
|
||||
if face.strict_name {
|
||||
return Err(bad(CMD, "cannot copy strict face paint through uncertified foreign re-export"));
|
||||
}
|
||||
if let Some(name) = &face.name {
|
||||
face.point = solvecraft_doc::naming::point_on_face(&state, name, face.point)
|
||||
.map(|(point, _)| point)
|
||||
.ok_or_else(|| bad(CMD, "source frozen face appearance lost its identity"))?;
|
||||
}
|
||||
let source_body = state.body(&face.body).ok_or_else(|| bad(CMD, "source frozen face appearance lost its body"))?;
|
||||
face.point = solvecraft_doc::apply_point(source.model_body_offset(source_body).unwrap_or(&solvecraft_doc::IDENTITY), face.point);
|
||||
face.name = None;
|
||||
} else if let Some(name) = &mut face.name {
|
||||
*name = solvecraft_doc::remap_topology(name, &features, &bodies);
|
||||
}
|
||||
face.body = new.clone();
|
||||
if let Some(certificate) = &mut face.certificate {
|
||||
certificate.remap_features(&features);
|
||||
}
|
||||
scratch.doc_mut().appearances.faces.push(face);
|
||||
}
|
||||
}
|
||||
// Typed configuration and assembly helpers preserve original targets.
|
||||
scratch.doc_mut().configs.clone_targets(&solvecraft_doc::config::ConfigCloneMaps {
|
||||
features: features.clone(),
|
||||
components: components.clone(),
|
||||
bodies: bodies.clone(),
|
||||
parameters: parameters.clone(),
|
||||
})?;
|
||||
let mut notices = super::component_clone_assembly::clone_assembly(scratch.doc_mut(), &source, &occurrences, &features, &bodies)?;
|
||||
let mut reference_work = 0usize;
|
||||
for feature in &feats {
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &feature.kind
|
||||
&& !face.path.is_empty()
|
||||
&& !bodies.contains_key(&face.body)
|
||||
{
|
||||
notices.push(format!("{} retains external selected occurrence path {:?}", feature.name, face.path));
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &feature.kind {
|
||||
use solvecraft_sketch::LinkSource as L;
|
||||
let mut pending: Vec<_> = sketch.links.iter().map(|l| &l.source).collect();
|
||||
let mut work = 0;
|
||||
while let Some(link) = pending.pop() {
|
||||
work += 1;
|
||||
reference_work += 1;
|
||||
if work > MAX_WORK || reference_work > 100_000 {
|
||||
return Err(bad(CMD, "linked references exceed copy work limit"));
|
||||
}
|
||||
match link {
|
||||
L::Edge { body, .. }
|
||||
| L::Face { body, .. }
|
||||
| L::Body { body }
|
||||
| L::Vertex { body, .. }
|
||||
| L::Iso { body, .. }
|
||||
| L::Spun { body, .. } => {
|
||||
if !bodies.contains_key(body) {
|
||||
notices.push(format!("{} retains external linked body {body}", feature.name));
|
||||
}
|
||||
}
|
||||
L::SketchCurve { sketch, .. } | L::SketchPoint { sketch, .. } => {
|
||||
if !features.contains_key(sketch) {
|
||||
notices.push(format!("{} retains external linked sketch {sketch}", feature.name));
|
||||
}
|
||||
}
|
||||
L::OnSurface { sketch, body, .. } => {
|
||||
if !features.contains_key(sketch) || !bodies.contains_key(body) {
|
||||
notices.push(format!("{} retains external surface link", feature.name));
|
||||
}
|
||||
}
|
||||
L::Intersection { a, b } => {
|
||||
pending.push(a);
|
||||
pending.push(b);
|
||||
}
|
||||
L::Plane { name } if source.find_feature(name).is_some() && !names.contains_key(name) => {
|
||||
notices.push(format!("{} retains external linked plane {name}", feature.name));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { plane, .. } = &feature.kind
|
||||
&& let Some(anchor) = super::sketch::face::anchor(plane)
|
||||
&& !bodies.contains_key(&anchor.target.body)
|
||||
{
|
||||
notices.push(format!(
|
||||
"{} retains external exact face attachment to {} (producer {})",
|
||||
feature.name, anchor.target.body, anchor.target.origin_feature
|
||||
));
|
||||
}
|
||||
for id in feature.kind.input_sketches() {
|
||||
if !features.contains_key(&id) {
|
||||
notices.push(format!("{} retains an external sketch reference to {id}", feature.name));
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Pattern { features: references, .. } | FeatureKind::Mirror { features: references, .. } = &feature.kind {
|
||||
for name in references {
|
||||
if !names.contains_key(name) {
|
||||
notices.push(format!("{} retains external feature reference {name}", feature.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
for expression in feature.kind.expressions() {
|
||||
for name in solvecraft_doc::expr::references(expression) {
|
||||
reference_work += 1;
|
||||
if reference_work > 100_000 {
|
||||
return Err(bad(CMD, "expression references exceed copy work limit"));
|
||||
}
|
||||
if !parameters.contains_key(&name) && generated_parameters.contains(name.as_str()) {
|
||||
notices.push(format!("{} retains external model parameter {name}", feature.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
notices.sort();
|
||||
notices.dedup();
|
||||
scratch.refresh();
|
||||
let original_errors: BTreeMap<_, _> = s.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
let refreshed_errors: BTreeMap<_, _> = scratch.model.results.iter().map(|result| (result.id, result.error.as_ref())).collect();
|
||||
if source.features.iter().any(|feature| original_errors.get(&feature.id) != refreshed_errors.get(&feature.id)) {
|
||||
return Err(bad(CMD, "copy would change an original feature's error state"));
|
||||
}
|
||||
// Base imports acquire new face identities. Resolve copied face overrides against the
|
||||
// verified new geometry once, rather than weakening strict persistent-name semantics.
|
||||
let resolved = scratch.model.state();
|
||||
let mut face_updates = Vec::new();
|
||||
for (index, face) in scratch.doc.appearances.faces.iter().enumerate() {
|
||||
if bodies.iter().any(|(old, new)| frozen.contains(old) && *new == face.body) {
|
||||
let body = resolved.body(&face.body).ok_or_else(|| bad(CMD, "missing frozen face body"))?;
|
||||
let face_index =
|
||||
solvecraft_doc::appearance::face_index_at(body, face.point).ok_or_else(|| bad(CMD, "cannot reproduce frozen face appearance"))?;
|
||||
let name = solvecraft_doc::naming::face_names(body).get(face_index).cloned().ok_or_else(|| bad(CMD, "missing frozen face identity"))?;
|
||||
face_updates.push((index, name));
|
||||
}
|
||||
}
|
||||
for (index, name) in face_updates {
|
||||
if let Some(face) = scratch.doc_mut().appearances.faces.get_mut(index) {
|
||||
face.name = Some(name);
|
||||
}
|
||||
}
|
||||
for id in &new_ids {
|
||||
if let Some(error) = scratch.model.result(*id).and_then(|r| r.error.as_ref()) {
|
||||
return Err(bad(CMD, format!("copied feature failed: {error}")));
|
||||
}
|
||||
}
|
||||
let copied = scratch.model.state();
|
||||
let copied_bodies: BTreeMap<_, _> = copied.bodies.iter().map(|body| (body.name.as_str(), body)).collect();
|
||||
// Adding placements can invalidate cached source evaluation. Compare clone effects
|
||||
// against that same evaluation's source prefix, not older shared B-rep pointers.
|
||||
if scratch.doc.features.get(..source.features.len()) != Some(source.features.as_slice())
|
||||
|| scratch.doc.params.get(..source.params.len()) != Some(source.params.as_slice())
|
||||
{
|
||||
return Err(bad(CMD, "copy would change original feature history or parameters"));
|
||||
}
|
||||
let original_state = new_ids.first().map(|id| scratch.model.state_before(*id)).unwrap_or_else(|| copied.clone());
|
||||
let all_columns = std::mem::take(&mut scratch.doc_mut().configs.columns);
|
||||
scratch.doc_mut().configs.columns = all_columns.iter().skip(source.configs.columns.len()).cloned().collect();
|
||||
let style_validation = scratch.doc.configuration_style_status(&copied);
|
||||
scratch.doc_mut().configs.columns = all_columns;
|
||||
style_validation?;
|
||||
let mut proof_work = 0usize;
|
||||
let expected_count = state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))).count();
|
||||
let copied_components: BTreeSet<_> = components.values().copied().collect();
|
||||
let actual_count = copied.bodies.iter().filter(|b| copied_components.contains(&scratch.doc.model_body_component(b))).count();
|
||||
if actual_count != expected_count {
|
||||
return Err(bad(CMD, "copy produced an incomplete or unexpected set of bodies"));
|
||||
}
|
||||
for original in &original_state.bodies {
|
||||
let Some(after) = copied_bodies.get(original.name.as_str()) else {
|
||||
return Err(bad(CMD, "copy would remove a source body outside its independent history"));
|
||||
};
|
||||
if !original.body.same(&after.body) {
|
||||
return Err(bad(CMD, "copy would modify a source body; external destructive dependencies are not supported"));
|
||||
}
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| selected.contains(&source.model_body_component(b))) {
|
||||
let new = bodies.get(&body.name).ok_or_else(|| bad(CMD, format!("body {} has no clone identity", body.name)))?;
|
||||
let cloned = copied_bodies.get(new.as_str()).ok_or_else(|| bad(CMD, format!("copy did not reproduce body {}", body.name)))?;
|
||||
let a = solvecraft_kernel::transform_matrix(&body.body, *source.model_body_offset(body).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let b = solvecraft_kernel::transform_matrix(&cloned.body, *scratch.doc.model_body_offset(cloned).unwrap_or(&solvecraft_doc::IDENTITY))?;
|
||||
let ma = solvecraft_kernel::measure(&a)?;
|
||||
let mb = solvecraft_kernel::measure(&b)?;
|
||||
let tolerance = 1e-5 * (1.0 + (ma.bbox.max - ma.bbox.min).len()) + 64.0 * f64::EPSILON * (ma.bbox.min.len() + ma.bbox.max.len());
|
||||
let mut material_matches = true;
|
||||
let tol_a = (a.size() * 1e-3).max(1e-3).max(a.size() * 1e-6);
|
||||
let tol_b = (b.size() * 1e-3).max(1e-3).max(b.size() * 1e-6);
|
||||
let mesh_a = a.display_mesh(tol_a)?;
|
||||
let mesh_b = b.display_mesh(tol_b)?;
|
||||
// Precharge all 27 candidates, six possible replacements, their triangle
|
||||
// distances and the three containment rays before any proof traversal.
|
||||
proof_work = proof_work.saturating_add(mesh_a.triangles.len().saturating_add(mesh_b.triangles.len()).saturating_mul(27 * 7 * 4));
|
||||
if proof_work > 50_000_000 {
|
||||
return Err(bad(CMD, "geometry verification exceeds copy work limit"));
|
||||
}
|
||||
for x in [0.2, 0.5, 0.8] {
|
||||
for y in [0.2, 0.5, 0.8] {
|
||||
for z in [0.2, 0.5, 0.8] {
|
||||
let d = ma.bbox.max - ma.bbox.min;
|
||||
let p = ma.bbox.min + solvecraft_geom::Vec3::new(d.x * x, d.y * y, d.z * z);
|
||||
let roundoff = 64. * f64::EPSILON * (1. + p.len());
|
||||
material_matches &= material_probe_matches(&mesh_a, &mesh_b, p, tol_a + roundoff, tol_b + roundoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Meshes of the same current circular section can have different
|
||||
// chord-volume biases after STEP. Only independently certified current
|
||||
// sections may replace that volume comparison; shape mismatches still
|
||||
// refuse even when their volumes happen to agree.
|
||||
let volume_matches = match solvecraft_kernel::certified_prism_equivalent(&a, &b)? {
|
||||
Some(same) => same,
|
||||
None => (ma.volume - mb.volume).abs() <= 1e-5 * (1.0 + ma.volume.abs()),
|
||||
};
|
||||
if !volume_matches
|
||||
|| (ma.area - mb.area).abs() > 1e-5 * (1.0 + ma.area.abs())
|
||||
|| ma.centroid.dist(mb.centroid) > tolerance
|
||||
|| ma.bbox.min.dist(mb.bbox.min) > tolerance
|
||||
|| ma.bbox.max.dist(mb.bbox.max) > tolerance
|
||||
|| !material_matches
|
||||
{
|
||||
return Err(bad(CMD, format!("copy geometry differs for body {}", body.name)));
|
||||
}
|
||||
}
|
||||
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
|
||||
s.accept_computed(scratch);
|
||||
Ok(
|
||||
json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_clone_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,360 @@
|
||||
//! Typed remapping of assembly relationships for an independent component-tree copy.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use solvecraft_doc::Document;
|
||||
use solvecraft_doc::joints::{Assembly, JointOrigin, Snap};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const MAX_RECORDS: usize = 10_000;
|
||||
const MAX_REFERENCES: usize = 100_000;
|
||||
const MAX_TEXT: usize = 1_048_576;
|
||||
const MAX_NAME: usize = 8192;
|
||||
|
||||
fn invalid(message: &str) -> EngineError {
|
||||
EngineError::Other(format!("Paste New assembly: {message}"))
|
||||
}
|
||||
|
||||
fn add_references(count: &mut usize, more: usize) -> Result<()> {
|
||||
*count = count.saturating_add(more);
|
||||
if *count > MAX_REFERENCES {
|
||||
return Err(invalid("assembly references exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finite_origin(origin: &JointOrigin) -> Result<()> {
|
||||
let finite = match &origin.snap {
|
||||
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
|
||||
Snap::FaceCenter { pick, .. } | Snap::CircleCenter { pick, .. } => pick.is_finite(),
|
||||
Snap::Point { point, z } => point.is_finite() && z.is_none_or(|z| z.is_finite()),
|
||||
};
|
||||
if !finite {
|
||||
return Err(invalid("joint origin contains non-finite geometry"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn budget(assembly: &Assembly) -> Result<()> {
|
||||
let counts = [
|
||||
assembly.origins.len(),
|
||||
assembly.joints.len(),
|
||||
assembly.links.len(),
|
||||
assembly.contact_sets.len(),
|
||||
assembly.studies.len(),
|
||||
assembly.exploded.len(),
|
||||
];
|
||||
if counts.iter().any(|n| *n > MAX_RECORDS) {
|
||||
return Err(invalid("too many assembly records"));
|
||||
}
|
||||
let mut references = counts.iter().fold(0usize, |n, c| n.saturating_add(*c));
|
||||
let mut bytes = 0usize;
|
||||
let mut text = |s: &str| -> Result<()> {
|
||||
bytes = bytes.saturating_add(s.len());
|
||||
if s.len() > MAX_NAME || bytes > MAX_TEXT {
|
||||
return Err(invalid("assembly names exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for origin in &assembly.origins {
|
||||
finite_origin(&origin.origin)?;
|
||||
text(&origin.name)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.origin.snap {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
for joint in &assembly.joints {
|
||||
text(&joint.name)?;
|
||||
add_references(&mut references, joint.values.len().saturating_add(joint.limits.len()))?;
|
||||
if !joint.values.iter().all(|v| v.is_finite())
|
||||
|| !joint.limits.iter().flatten().all(|(lo, hi)| lo.is_finite() && hi.is_finite() && lo <= hi)
|
||||
|| !joint.offset.is_finite()
|
||||
|| !joint.angle.is_finite()
|
||||
{
|
||||
return Err(invalid("joint contains invalid motion values"));
|
||||
}
|
||||
for origin in [&joint.a, &joint.b] {
|
||||
finite_origin(origin)?;
|
||||
if let Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } = &origin.snap {
|
||||
text(face)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for contact in &assembly.contact_sets {
|
||||
text(&contact.name)?;
|
||||
add_references(&mut references, contact.occurrences.len())?;
|
||||
}
|
||||
for study in &assembly.studies {
|
||||
text(&study.name)?;
|
||||
add_references(&mut references, study.keys.len())?;
|
||||
for key in &study.keys {
|
||||
add_references(&mut references, key.points.len())?;
|
||||
if !key.points.iter().all(|(step, value)| step.is_finite() && value.is_finite()) {
|
||||
return Err(invalid("motion study contains non-finite keys"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for view in &assembly.exploded {
|
||||
text(&view.name)?;
|
||||
add_references(&mut references, view.moves.len())?;
|
||||
if !view.moves.iter().all(|(_, p)| p.is_finite()) {
|
||||
return Err(invalid("exploded view contains non-finite moves"));
|
||||
}
|
||||
}
|
||||
if !assembly.links.iter().all(|l| l.ratio.is_finite() && l.offset.is_finite()) {
|
||||
return Err(invalid("motion link contains non-finite values"));
|
||||
}
|
||||
if references > MAX_REFERENCES {
|
||||
return Err(invalid("assembly references exceed the processing budget"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_name(base: &str, used: &mut BTreeSet<String>) -> Result<String> {
|
||||
if !used.contains(base) {
|
||||
used.insert(base.to_string());
|
||||
return Ok(base.to_string());
|
||||
}
|
||||
for index in 1..=MAX_RECORDS {
|
||||
let name = if index == 1 { format!("{base} (copy)") } else { format!("{base} (copy {index})") };
|
||||
if name.len() > MAX_NAME {
|
||||
return Err(invalid("copied assembly name is too long"));
|
||||
}
|
||||
if used.insert(name.clone()) {
|
||||
return Ok(name);
|
||||
}
|
||||
}
|
||||
Err(invalid("could not allocate a unique assembly name"))
|
||||
}
|
||||
|
||||
fn next_id(id: &mut u64) -> Result<u64> {
|
||||
*id = id.checked_add(1).ok_or_else(|| invalid("assembly identifier space is exhausted"))?;
|
||||
Ok(*id)
|
||||
}
|
||||
|
||||
fn unique_ids(ids: impl Iterator<Item = u64>) -> Result<u64> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut largest = 0;
|
||||
for id in ids {
|
||||
if id == 0 || !seen.insert(id) {
|
||||
return Err(invalid("assembly identifiers must be nonzero and unique"));
|
||||
}
|
||||
largest = largest.max(id);
|
||||
}
|
||||
Ok(largest)
|
||||
}
|
||||
|
||||
fn validate_map<K: Ord, V: Ord>(map: &BTreeMap<K, V>) -> Result<()> {
|
||||
if map.len() > MAX_RECORDS || map.values().collect::<BTreeSet<_>>().len() != map.len() {
|
||||
return Err(invalid("copy maps exceed their budget or contain duplicate destinations"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remap certified naming tokens, rather than replacing digit substrings (F1 must not
|
||||
/// alter F10). Body-based legacy names are remapped only through an explicit identity map.
|
||||
fn face_name(name: &str, features: &BTreeMap<u64, u64>, bodies: &BTreeMap<String, String>) -> Option<String> {
|
||||
let body = name.match_indices(':').filter_map(|(end, _)| bodies.get_key_value(name.get(..end)?)).next_back();
|
||||
let mut out = String::new();
|
||||
let mut last = 0usize;
|
||||
let mut mapped = false;
|
||||
if let Some((old, new)) = body {
|
||||
out.push_str(new);
|
||||
last = old.len();
|
||||
mapped = true;
|
||||
}
|
||||
let bytes = name.as_bytes();
|
||||
let mut i = last;
|
||||
while i < bytes.len() {
|
||||
let boundary = i == 0
|
||||
|| bytes.get(i.wrapping_sub(1)).is_some_and(|c| matches!(*c, b'@' | b'|'))
|
||||
|| name.get(..i).is_some_and(|prefix| prefix.ends_with(":blend:") || prefix.ends_with(":inner:"));
|
||||
if bytes.get(i) == Some(&b'F') && boundary {
|
||||
let mut end = i + 1;
|
||||
while bytes.get(end).is_some_and(u8::is_ascii_digit) {
|
||||
end += 1;
|
||||
}
|
||||
if end > i + 1 && bytes.get(end).is_some_and(|c| matches!(*c, b':' | b'.')) {
|
||||
let id: u64 = name.get(i + 1..end)?.parse().ok()?;
|
||||
let replacement = features.get(&id)?;
|
||||
out.push_str(name.get(last..i)?);
|
||||
out.push_str(&format!("F{replacement}"));
|
||||
last = end;
|
||||
mapped |= i == 0;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !mapped {
|
||||
return None;
|
||||
}
|
||||
out.push_str(name.get(last..)?);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn origin(
|
||||
origin: &JointOrigin,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
features: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
) -> Option<JointOrigin> {
|
||||
let mut out = origin.clone();
|
||||
out.occurrence = *occurrences.get(&origin.occurrence)?;
|
||||
match &mut out.snap {
|
||||
Snap::FaceCenter { face: Some(face), .. } | Snap::CircleCenter { face: Some(face), .. } => *face = face_name(face, features, bodies)?,
|
||||
_ => {}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn report(messages: &mut Vec<String>, kind: &str, name: &str) {
|
||||
messages.push(format!("Skipped {kind} `{name}`: references outside the copied component tree or geometry without a copied identity."));
|
||||
}
|
||||
|
||||
/// Called with a scratch document after geometry cloning. Only wholly internal records
|
||||
/// are copied; cross-boundary relationships are reported and omitted without truncation.
|
||||
/// The assembly is swapped in only after every map, allocation and aggregate check passes.
|
||||
pub(super) fn clone_assembly(
|
||||
doc: &mut Document,
|
||||
source: &Document,
|
||||
occurrences: &BTreeMap<u64, u64>,
|
||||
features: &BTreeMap<u64, u64>,
|
||||
bodies: &BTreeMap<String, String>,
|
||||
) -> Result<Vec<String>> {
|
||||
budget(&source.assembly)?;
|
||||
budget(&doc.assembly)?;
|
||||
validate_map(occurrences)?;
|
||||
validate_map(features)?;
|
||||
validate_map(bodies)?;
|
||||
if doc.occurrences.len() > MAX_REFERENCES {
|
||||
return Err(invalid("too many destination occurrences"));
|
||||
}
|
||||
let mut map_bytes = 0usize;
|
||||
for (old, new) in bodies {
|
||||
map_bytes = map_bytes.saturating_add(old.len()).saturating_add(new.len());
|
||||
if old.is_empty() || new.is_empty() || old.len() > MAX_NAME || new.len() > MAX_NAME || map_bytes > MAX_TEXT {
|
||||
return Err(invalid("invalid or excessive body identity map"));
|
||||
}
|
||||
}
|
||||
let available: BTreeSet<u64> = doc.occurrences.iter().map(|o| o.id).chain(std::iter::once(0)).collect();
|
||||
if occurrences.values().any(|id| !available.contains(id)) {
|
||||
return Err(invalid("copy occurrence map points to a missing occurrence"));
|
||||
}
|
||||
unique_ids(source.assembly.joints.iter().map(|j| j.id))?;
|
||||
let mut joint_id = unique_ids(doc.assembly.joints.iter().map(|j| j.id))?;
|
||||
let aux = |a: &Assembly| {
|
||||
a.contact_sets.iter().map(|x| x.id).chain(a.studies.iter().map(|x| x.id)).chain(a.exploded.iter().map(|x| x.id)).collect::<Vec<_>>()
|
||||
};
|
||||
unique_ids(aux(&source.assembly).into_iter())?;
|
||||
let mut auxiliary_id = unique_ids(aux(&doc.assembly).into_iter())?;
|
||||
let mut a = doc.assembly.clone();
|
||||
let mut messages = Vec::new();
|
||||
let mut origin_names = a.origins.iter().map(|o| o.name.clone()).collect();
|
||||
for old in &source.assembly.origins {
|
||||
if !occurrences.contains_key(&old.origin.occurrence) {
|
||||
continue;
|
||||
}
|
||||
if let Some(new) = origin(&old.origin, occurrences, features, bodies) {
|
||||
let mut item = old.clone();
|
||||
item.name = unique_name(&old.name, &mut origin_names)?;
|
||||
item.origin = new;
|
||||
a.origins.push(item);
|
||||
} else {
|
||||
report(&mut messages, "joint origin", &old.name);
|
||||
}
|
||||
}
|
||||
let mut joint_names = a.joints.iter().map(|j| j.name.clone()).collect();
|
||||
let mut joints = BTreeMap::new();
|
||||
let mut touched_joints = BTreeSet::new();
|
||||
for old in &source.assembly.joints {
|
||||
if !occurrences.contains_key(&old.a.occurrence) && !occurrences.contains_key(&old.b.occurrence) {
|
||||
continue;
|
||||
}
|
||||
touched_joints.insert(old.id);
|
||||
match (origin(&old.a, occurrences, features, bodies), origin(&old.b, occurrences, features, bodies)) {
|
||||
(Some(aa), Some(bb)) => {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut joint_id)?;
|
||||
item.name = unique_name(&old.name, &mut joint_names)?;
|
||||
item.a = aa;
|
||||
item.b = bb;
|
||||
joints.insert(old.id, item.id);
|
||||
a.joints.push(item);
|
||||
}
|
||||
_ => report(&mut messages, "joint", &old.name),
|
||||
}
|
||||
}
|
||||
for old in &source.assembly.links {
|
||||
if !touched_joints.contains(&old.a) && !touched_joints.contains(&old.b) {
|
||||
continue;
|
||||
}
|
||||
match (joints.get(&old.a), joints.get(&old.b)) {
|
||||
(Some(aa), Some(bb)) => {
|
||||
let mut item = old.clone();
|
||||
item.a = *aa;
|
||||
item.b = *bb;
|
||||
a.links.push(item);
|
||||
}
|
||||
_ => report(&mut messages, "motion link", &format!("{} → {}", old.a, old.b)),
|
||||
}
|
||||
}
|
||||
let mut contact_names = a.contact_sets.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.contact_sets {
|
||||
if !old.occurrences.iter().any(|o| occurrences.contains_key(o)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(ids) = old.occurrences.iter().map(|o| occurrences.get(o).copied()).collect::<Option<Vec<_>>>() {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut contact_names)?;
|
||||
item.occurrences = ids;
|
||||
a.contact_sets.push(item);
|
||||
} else {
|
||||
report(&mut messages, "contact set", &old.name);
|
||||
}
|
||||
}
|
||||
let mut study_names = a.studies.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.studies {
|
||||
if !old.keys.iter().any(|k| touched_joints.contains(&k.joint)) {
|
||||
continue;
|
||||
}
|
||||
if old.keys.iter().all(|k| joints.contains_key(&k.joint)) {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut study_names)?;
|
||||
for key in &mut item.keys {
|
||||
key.joint = *joints.get(&key.joint).ok_or_else(|| invalid("missing copied study joint"))?;
|
||||
}
|
||||
a.studies.push(item);
|
||||
} else {
|
||||
report(&mut messages, "motion study", &old.name);
|
||||
}
|
||||
}
|
||||
let mut view_names = a.exploded.iter().map(|x| x.name.clone()).collect();
|
||||
for old in &source.assembly.exploded {
|
||||
if !old.moves.iter().any(|(o, _)| occurrences.contains_key(o)) {
|
||||
continue;
|
||||
}
|
||||
if old.moves.iter().all(|(o, _)| occurrences.contains_key(o)) {
|
||||
let mut item = old.clone();
|
||||
item.id = next_id(&mut auxiliary_id)?;
|
||||
item.name = unique_name(&old.name, &mut view_names)?;
|
||||
for (o, _) in &mut item.moves {
|
||||
*o = *occurrences.get(o).ok_or_else(|| invalid("missing copied exploded occurrence"))?;
|
||||
}
|
||||
a.exploded.push(item);
|
||||
} else {
|
||||
report(&mut messages, "exploded view", &old.name);
|
||||
}
|
||||
}
|
||||
budget(&a)?;
|
||||
doc.assembly = a;
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_clone_assembly_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,193 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use solvecraft_doc::joints::{Joint, JointKind, MotionLink, NamedOrigin};
|
||||
use solvecraft_doc::motion::{ContactSet, ExplodedView, MotionStudy, StudyKey};
|
||||
use solvecraft_doc::{IDENTITY, Occurrence};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn at(occurrence: u64) -> JointOrigin {
|
||||
JointOrigin { occurrence, snap: Snap::Point { point: Vec3::ZERO, z: None } }
|
||||
}
|
||||
|
||||
fn joint(id: u64, a: u64, b: u64) -> Joint {
|
||||
Joint {
|
||||
id,
|
||||
name: format!("Joint{id}"),
|
||||
kind: JointKind::Revolute,
|
||||
a: at(a),
|
||||
b: at(b),
|
||||
values: vec![0.25],
|
||||
limits: vec![Some((-1.0, 1.0))],
|
||||
flip: true,
|
||||
offset: 2.0,
|
||||
angle: 0.1,
|
||||
suppressed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn occurrence(id: u64) -> Occurrence {
|
||||
Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false }
|
||||
}
|
||||
|
||||
fn documents() -> (Document, Document) {
|
||||
let mut source = Document { occurrences: [1, 2, 8, 9].map(occurrence).to_vec(), ..Document::default() };
|
||||
source.assembly.joints = vec![joint(1, 1, 2), joint(2, 1, 2), joint(3, 2, 9), joint(4, 8, 9)];
|
||||
source.assembly.origins = vec![
|
||||
NamedOrigin {
|
||||
name: "Typed origin".into(),
|
||||
origin: JointOrigin { occurrence: 1, snap: Snap::FaceCenter { pick: Vec3::new(1.0, 2.0, 3.0), face: Some("F1:start@F10.2".into()) } },
|
||||
},
|
||||
NamedOrigin {
|
||||
name: "Foreign geometry".into(),
|
||||
origin: JointOrigin { occurrence: 1, snap: Snap::CircleCenter { pick: Vec3::ZERO, face: Some("F99:end".into()) } },
|
||||
},
|
||||
NamedOrigin { name: "Outside".into(), origin: at(9) },
|
||||
];
|
||||
source.assembly.links =
|
||||
vec![MotionLink { a: 1, ia: 0, b: 2, ib: 0, ratio: -2.0, offset: 0.3 }, MotionLink { a: 1, ia: 0, b: 3, ib: 0, ratio: 1.0, offset: 0.0 }];
|
||||
source.assembly.contact_sets = vec![
|
||||
ContactSet { id: 1, name: "Internal contact".into(), occurrences: vec![1, 2], suppressed: true },
|
||||
ContactSet { id: 2, name: "Cross contact".into(), occurrences: vec![1, 9], suppressed: false },
|
||||
];
|
||||
let key = |joint| StudyKey { joint, index: 0, points: vec![(0.0, 0.0), (10.0, 0.5)] };
|
||||
source.assembly.studies = vec![
|
||||
MotionStudy { id: 3, name: "Internal study".into(), steps: 10, keys: vec![key(1), key(2)] },
|
||||
MotionStudy { id: 4, name: "Cross study".into(), steps: 10, keys: vec![key(1), key(3)] },
|
||||
];
|
||||
source.assembly.exploded = vec![
|
||||
ExplodedView { id: 5, name: "Internal explode".into(), moves: vec![(1, Vec3::X * 10.0), (2, Vec3::Y * 20.0)] },
|
||||
ExplodedView { id: 6, name: "Cross explode".into(), moves: vec![(1, Vec3::X), (9, Vec3::Y)] },
|
||||
];
|
||||
let mut destination = source.clone();
|
||||
destination.occurrences.extend([101, 102].map(occurrence));
|
||||
(source, destination)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_relationships_receive_independent_ids_and_remapped_typed_references() {
|
||||
let (source, mut destination) = documents();
|
||||
let report =
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new())
|
||||
.unwrap();
|
||||
let a = &destination.assembly;
|
||||
assert_eq!(a.joints.len(), 6);
|
||||
assert_eq!((a.joints[4].id, a.joints[5].id), (5, 6));
|
||||
assert_eq!((a.joints[4].a.occurrence, a.joints[4].b.occurrence), (101, 102));
|
||||
assert_eq!(a.joints[4].values, source.assembly.joints[0].values);
|
||||
assert_eq!(a.joints[4].limits, source.assembly.joints[0].limits);
|
||||
assert_eq!(a.joints[4].name, "Joint1 (copy)");
|
||||
assert_eq!(a.origins.len(), 4);
|
||||
assert!(matches!(&a.origins[3].origin.snap, Snap::FaceCenter { face: Some(face), .. } if face == "F11:start@F110.2"));
|
||||
assert_eq!((a.links[2].a, a.links[2].b, a.links[2].ratio), (5, 6, -2.0));
|
||||
assert_eq!(a.contact_sets[2].id, 7);
|
||||
assert_eq!(a.contact_sets[2].occurrences, vec![101, 102]);
|
||||
assert!(a.contact_sets[2].suppressed);
|
||||
assert_eq!(a.studies[2].id, 8);
|
||||
assert_eq!(a.studies[2].keys.iter().map(|k| k.joint).collect::<Vec<_>>(), vec![5, 6]);
|
||||
assert_eq!(a.exploded[2].id, 9);
|
||||
assert_eq!(a.exploded[2].moves, vec![(101, Vec3::X * 10.0), (102, Vec3::Y * 20.0)]);
|
||||
assert_eq!(report.len(), 6);
|
||||
for kind in ["joint origin", "joint `Joint3`", "motion link", "contact set", "motion study", "exploded view"] {
|
||||
assert!(report.iter().any(|m| m.contains(kind)), "{kind}: {report:?}");
|
||||
}
|
||||
assert!(!report.iter().any(|m| m.contains("Outside") || m.contains("Joint4")));
|
||||
assert_eq!(destination.assembly.joints[..4], source.assembly.joints);
|
||||
destination.assembly.joints[4].values[0] = 0.75;
|
||||
assert_eq!(source.assembly.joints[0].values[0], 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_remaps_exact_tokens_unicode_and_legacy_body_identity() {
|
||||
let features = BTreeMap::from([(1, 21), (10, 30)]);
|
||||
let bodies = BTreeMap::from([("Wheel: inner".into(), "Wheel copy: inner".into())]);
|
||||
assert_eq!(face_name("F1:blend:F10:start@F1.3", &features, &bodies).as_deref(), Some("F21:blend:F30:start@F21.3"));
|
||||
assert_eq!(face_name("F1:side:曲線F10", &features, &bodies).as_deref(), Some("F21:side:曲線F10"));
|
||||
assert_eq!(face_name("F1:side:F100.7", &features, &bodies).as_deref(), Some("F21:side:F100.7"));
|
||||
assert_eq!(face_name("Wheel: inner:face:7", &features, &bodies).as_deref(), Some("Wheel copy: inner:face:7"));
|
||||
assert!(face_name("F100:start", &features, &bodies).is_none());
|
||||
assert!(face_name("F1:start@F99.1", &features, &bodies).is_none());
|
||||
assert!(face_name("Foreign:face:0", &features, &bodies).is_none());
|
||||
assert!(face_name("Foreign:face:0@F1.1", &features, &bodies).is_none());
|
||||
let bodies = BTreeMap::from([("F1:Housing".into(), "F1:Housing (copy)".into())]);
|
||||
assert_eq!(face_name("F1:Housing:face:0@F10.1", &features, &bodies).as_deref(), Some("F1:Housing (copy):face:0@F30.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_maps_identifier_exhaustion_and_aggregate_budgets_are_atomic() {
|
||||
let (source, destination) = documents();
|
||||
let mut cases = Vec::new();
|
||||
cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 101), (2, 101)])));
|
||||
cases.push((source.clone(), destination.clone(), BTreeMap::from([(1, 999)])));
|
||||
let mut overflow = destination.clone();
|
||||
overflow.assembly.joints[0].id = u64::MAX;
|
||||
cases.push((source.clone(), overflow, BTreeMap::from([(1, 101), (2, 102)])));
|
||||
let mut oversized = source.clone();
|
||||
oversized.assembly.contact_sets[0].occurrences = vec![1; MAX_REFERENCES + 1];
|
||||
cases.push((oversized, destination.clone(), BTreeMap::from([(1, 101), (2, 102)])));
|
||||
let mut invalid = source.clone();
|
||||
invalid.assembly.links[0].ratio = f64::NAN;
|
||||
cases.push((invalid, destination, BTreeMap::from([(1, 101), (2, 102)])));
|
||||
for (source, mut destination, occurrences) in cases {
|
||||
let before = destination.assembly.clone();
|
||||
assert!(clone_assembly(&mut destination, &source, &occurrences, &BTreeMap::from([(1, 11), (10, 110)]), &BTreeMap::new()).is_err());
|
||||
assert_eq!(destination.assembly, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_copy_has_unique_names_and_keeps_global_contact_switches_unchanged() {
|
||||
let (source, mut destination) = documents();
|
||||
destination.assembly.contact_enabled = true;
|
||||
destination.assembly.contact_all = true;
|
||||
let features = BTreeMap::from([(1, 11), (10, 110)]);
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 101), (2, 102)]), &features, &BTreeMap::new()).unwrap();
|
||||
destination.occurrences.extend([201, 202].map(occurrence));
|
||||
clone_assembly(&mut destination, &source, &BTreeMap::from([(1, 201), (2, 202)]), &features, &BTreeMap::new()).unwrap();
|
||||
assert_eq!(destination.assembly.joints[6].name, "Joint1 (copy 2)");
|
||||
assert!(destination.assembly.contact_enabled && destination.assembly.contact_all);
|
||||
let names: BTreeSet<&str> = destination.assembly.origins.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(names.len(), destination.assembly.origins.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_new_internal_motion_study_drives_only_the_copied_assembly_and_reopens() {
|
||||
use serde_json::{Value, json};
|
||||
fn run(s: &mut crate::Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
let mut s = crate::Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Mechanism"}))["component"].as_u64().unwrap();
|
||||
let fixed = run(&mut s, "component.create", json!({"name":"Support"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"SupportBox"}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence":fixed,"grounded":true}));
|
||||
run(&mut s, "component.activate", json!({"component":parent}));
|
||||
let moving = run(&mut s, "component.create", json!({"name":"Carriage"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[0,0,5],"body_name":"CarriageBox"}));
|
||||
run(&mut s, "joint.origin", json!({"name":"Support origin","occurrence":fixed,"point":[0,0,0]}));
|
||||
let source_joint = run(&mut s, "joint.as_built", json!({"type":"slider","a":fixed,"b":moving,"name":"Rail"}))["joint"].as_u64().unwrap();
|
||||
run(&mut s, "contact.create", json!({"name":"Internal contact","occurrences":[fixed,moving]}));
|
||||
run(&mut s, "motion.study", json!({"name":"Travel","steps":10,"keys":[{"joint":source_joint,"points":[[0,0],[10,6]]}]}));
|
||||
run(
|
||||
&mut s,
|
||||
"explode.create",
|
||||
json!({"name":"Exploded mechanism","moves":[{"occurrence":fixed,"translate":[10,0,0]},{"occurrence":moving,"translate":[0,10,0]}]}),
|
||||
);
|
||||
let original = s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform;
|
||||
run(&mut s, "component.activate", json!({"component":"root"}));
|
||||
let result = run(&mut s, "component.paste_new", json!({"component":parent,"translate":[20,0,0]}));
|
||||
assert_eq!(result["notices"], json!([]));
|
||||
assert_eq!(s.doc.assembly.joints.len(), 2);
|
||||
assert_eq!(s.doc.assembly.origins.len(), 2);
|
||||
assert_eq!(s.doc.assembly.contact_sets.len(), 2);
|
||||
assert_eq!(s.doc.assembly.exploded.len(), 2);
|
||||
let copied_joint = s.doc.assembly.joints.iter().find(|j| j.name == "Rail (copy)").unwrap().id;
|
||||
let copied_moving = s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().b.occurrence;
|
||||
assert_ne!(copied_moving, moving);
|
||||
run(&mut s, "motion.play", json!({"study":"Travel (copy)","step":10}));
|
||||
assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == source_joint).unwrap().values, vec![0.0]);
|
||||
assert_eq!(s.doc.assembly.joints.iter().find(|j| j.id == copied_joint).unwrap().values, vec![6.0]);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == moving).unwrap().transform, original);
|
||||
let reopened = Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.assembly, s.doc.assembly);
|
||||
assert!(reopened.assembly.studies.iter().any(|m| m.name == "Travel (copy)" && m.keys[0].joint == copied_joint));
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
fn component(s: &mut Session, name: &str) -> u64 {
|
||||
run(s, "component.create", json!({"name":name}))["component"].as_u64().unwrap()
|
||||
}
|
||||
fn volume(s: &mut Session, id: u64) -> f64 {
|
||||
run(s, "component.properties", json!({"component":id}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
fn box2(s: &mut Session) -> u64 {
|
||||
run(s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Cube"}))["feature"].as_u64().unwrap()
|
||||
}
|
||||
fn copy(s: &mut Session, id: u64) -> Value {
|
||||
run(s, "component.paste_new", json!({"component":id,"translate":[20,0,0]}))
|
||||
}
|
||||
fn near(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} != {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_independent_primitives_keep_their_own_semantic_faces() {
|
||||
let mut s = Session::default();
|
||||
let first = box2(&mut s);
|
||||
let second = run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Other"}))["feature"].as_u64().unwrap();
|
||||
let state = s.model.state();
|
||||
let a = state.body("Cube").unwrap();
|
||||
let b = state.body("Other").unwrap();
|
||||
let a_names = solvecraft_doc::naming::face_names(a);
|
||||
let b_names = solvecraft_doc::naming::face_names(b);
|
||||
assert!(a_names.contains(&format!("F{first}:+z")));
|
||||
assert!(b_names.contains(&format!("F{second}:+z")));
|
||||
assert!(!b_names.contains(&format!("F{first}:+z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_definition_dag_and_histories_are_independent() {
|
||||
let mut s = Session::default();
|
||||
let parent = component(&mut s, "Assembly");
|
||||
let child = component(&mut s, "Child");
|
||||
let feature = box2(&mut s);
|
||||
run(&mut s, "occurrence.copy", json!({"component":child}));
|
||||
let copied = copy(&mut s, parent);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
assert_eq!(copied["components"], 2);
|
||||
near(volume(&mut s, new), 16.0);
|
||||
let kids: Vec<_> = s.doc.occurrences.iter().filter(|o| o.parent == new).collect();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].component, kids[1].component);
|
||||
assert_ne!(kids[0].component, child);
|
||||
let parameter = s.doc.feature(feature).unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
|
||||
near(volume(&mut s, parent), 24.0);
|
||||
near(volume(&mut s, new), 16.0);
|
||||
let saved = s.doc.to_json();
|
||||
let reopened = solvecraft_doc::Document::from_json(&saved).unwrap();
|
||||
assert_eq!(reopened.occurrences.iter().filter(|o| o.parent == new).count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassigned_body_is_an_independent_base_feature() {
|
||||
let mut s = Session::default();
|
||||
let feature = box2(&mut s);
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
assert_eq!(copied["frozen_bodies"], json!(["Cube"]));
|
||||
near(volume(&mut s, new), 8.0);
|
||||
let parameter = s.doc.feature(feature).unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":parameter,"expression":"3 mm"}));
|
||||
near(volume(&mut s, part), 12.0);
|
||||
near(volume(&mut s, new), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_preserves_physical_material_and_independent_styles() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Part");
|
||||
box2(&mut s);
|
||||
run(&mut s, "material.assign", json!({"bodies":["Cube"],"material":"Brass"}));
|
||||
run(&mut s, "appearance.assign", json!({"components":[part.to_string()],"color":"#123456","opacity":0.4}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":["Cube"],"color":"#abcdef"}));
|
||||
run(&mut s, "appearance.assign", json!({"body":"Cube","faces":[[1,1,2]],"color":"#00ff00"}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
let props = run(&mut s, "component.properties", json!({"component":new}));
|
||||
near(props["total"]["mass_g"].as_f64().unwrap(), 0.068);
|
||||
let copied_body = s.model.state().bodies.iter().find(|b| b.component == new).unwrap().name.clone();
|
||||
assert_eq!(s.doc.appearances.bodies[&copied_body], s.doc.appearances.bodies["Cube"]);
|
||||
let state = s.model.state();
|
||||
let body = state.body(&copied_body).unwrap();
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":[copied_body],"color":"#ff0000"}));
|
||||
assert_eq!(s.doc.appearances.bodies["Cube"].color, [0xab, 0xcd, 0xef]);
|
||||
assert_eq!(s.doc.appearances.components[&new].opacity, 0.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassigned_body_owned_move_replays_once_and_original_stays_unchanged() {
|
||||
let mut s = Session::default();
|
||||
box2(&mut s);
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]}));
|
||||
let before = run(&mut s, "component.properties", json!({"component":part}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
let after = run(&mut s, "component.properties", json!({"component":new}));
|
||||
near(after["total"]["center_of_mass"][0].as_f64().unwrap(), 31.0);
|
||||
assert_eq!(run(&mut s, "component.properties", json!({"component":part})), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_external_destructive_target_is_atomic() {
|
||||
let mut s = Session::default();
|
||||
box2(&mut s);
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "solid.move", json!({"bodies":["Cube"],"translate":[10,0,0]}));
|
||||
let saved = s.doc.to_json();
|
||||
let selection = s.selection.clone();
|
||||
let active = s.active_component;
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("component.paste_new", &json!({"component":part})).is_err());
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.active_component, active);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_feature_parameter_dependencies_are_independent() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Part");
|
||||
let source = box2(&mut s);
|
||||
let source_names = s.doc.feature(source).unwrap().param_names.clone();
|
||||
run(&mut s, "parameters.change", json!({"name":source_names[1],"expression":format!("{} * 2",source_names[0])}));
|
||||
let result = copy(&mut s, part);
|
||||
let clone = result["component"].as_u64().unwrap();
|
||||
near(volume(&mut s, clone), 16.0);
|
||||
let clone_names = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find(|f| f.component == clone && matches!(f.kind, solvecraft_doc::FeatureKind::Box { .. }))
|
||||
.unwrap()
|
||||
.param_names
|
||||
.clone();
|
||||
assert_ne!(clone_names, source_names);
|
||||
run(&mut s, "parameters.change", json!({"name":source_names[0],"expression":"3 mm"}));
|
||||
near(volume(&mut s, part), 36.0);
|
||||
near(volume(&mut s, clone), 16.0);
|
||||
run(&mut s, "parameters.change", json!({"name":clone_names[0],"expression":"4 mm"}));
|
||||
near(volume(&mut s, clone), 64.0);
|
||||
near(volume(&mut s, part), 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_nested_pending_placement_survives_copy_and_undo() {
|
||||
let mut s = Session::default();
|
||||
let parent = component(&mut s, "Parent");
|
||||
let parent_first = s.doc.occurrence_of(parent).unwrap().id;
|
||||
let child = component(&mut s, "Child");
|
||||
let leaf = s.doc.occurrence_of(child).unwrap().id;
|
||||
box2(&mut s);
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":parent,"translate":[50,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":leaf,"capture":false,"translate":[5,0,0]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"capture":false,"translate":[2,0,0]}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let prior_context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":child,"occurrence":leaf,"path":[second,leaf],"translate":[10,0,0]}));
|
||||
let new_leaf = copied["occurrence"].as_u64().unwrap();
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), prior_context);
|
||||
assert_eq!(s.selection, vec![crate::Sel::Occurrence { path: vec![second, new_leaf] }]);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.occurrence_placement(&[parent_first, leaf]).unwrap().world[3][0], 5.0);
|
||||
let props = run(&mut s, "occurrence.properties", json!({"occurrence":new_leaf,"path":[second,new_leaf]}));
|
||||
near(props["total"]["center_of_mass"][0].as_f64().unwrap(), 68.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc.occurrences.iter().any(|o| o.id == new_leaf));
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_sheet_base_targets_its_own_sketch_history() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Sheet");
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Base"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch":"Base"}));
|
||||
let source_volume = volume(&mut s, part);
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
near(volume(&mut s, new), source_volume);
|
||||
let clone_sketch = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
|
||||
assert_ne!(clone_sketch, sketch);
|
||||
let base = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::SheetBase { .. })).unwrap();
|
||||
assert_eq!(base.kind.input_sketches(), vec![clone_sketch]);
|
||||
// Mutate only source geometry, then verify the copied base remains unchanged.
|
||||
if let solvecraft_doc::FeatureKind::Sketch { sketch, .. } = &mut s.doc_mut().feature_mut(sketch).unwrap().kind {
|
||||
for p in &mut sketch.points {
|
||||
p.pos.x *= 2.0;
|
||||
}
|
||||
}
|
||||
s.refresh();
|
||||
near(volume(&mut s, new), source_volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_pattern_outputs_and_later_body_operands_follow_clone() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "PatternPart");
|
||||
box2(&mut s);
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Cube"],"dir1":[1,0,0],"count1":3,"spacing1":5}));
|
||||
let last = s.model.state().bodies.last().unwrap().name.clone();
|
||||
run(&mut s, "body.rename", json!({"body":last,"name":"Custom Output"}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Custom Output"],"translate":[0,5,0]}));
|
||||
let copied = copy(&mut s, part);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
near(volume(&mut s, new), 24.0);
|
||||
let state = s.model.state();
|
||||
let target = state.bodies.iter().find(|b| b.name == "Custom Output (copy)").unwrap();
|
||||
near(solvecraft_kernel::measure(&target.body).unwrap().centroid.y, 6.0);
|
||||
assert_eq!(s.doc.features.iter().filter(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Move { .. })).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_follow_copied_parameters_material_and_strict_face_lineage() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Configured");
|
||||
let source = box2(&mut s);
|
||||
let length = s.doc.feature(source).unwrap().param_names[0].clone();
|
||||
let target = solvecraft_doc::config::ConfigFaceTarget {
|
||||
body: "Cube".into(),
|
||||
origin_feature: source,
|
||||
name: format!("F{source}:+z"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
certificate: None,
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"params":[length],"materials":["Cube"],"component_appearances":[part],"face_appearances":[target]}));
|
||||
let face_key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target.clone()));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name":"Large","values":{length.clone():"4 mm","material:Cube":"Brass",face_key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}),
|
||||
);
|
||||
let result = copy(&mut s, part);
|
||||
let new = result["component"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.configs.columns.len(), 8);
|
||||
run(&mut s, "config.activate", json!({"row":"Large"}));
|
||||
near(volume(&mut s, part), 16.0);
|
||||
near(volume(&mut s, new), 16.0);
|
||||
let props = run(&mut s, "component.properties", json!({"component":new}));
|
||||
near(props["total"]["mass_g"].as_f64().unwrap(), 0.136);
|
||||
let state = s.model.state();
|
||||
let body = state.bodies.iter().find(|b| b.component == new).unwrap();
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
|
||||
assert!(s.doc.configuration_style_status(&state).is_ok());
|
||||
assert!(s.doc.configuration_matches());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_extrude_cap_copy_keeps_independent_semantic_lineage() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Extruded");
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[2,2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let feature = run(&mut s, "solid.extrude", json!({"distance":2}))["feature"].as_u64().unwrap();
|
||||
let body_name = s.model.state().bodies.last().unwrap().name.clone();
|
||||
let target = solvecraft_doc::config::ConfigFaceTarget {
|
||||
body: body_name,
|
||||
origin_feature: feature,
|
||||
name: format!("F{feature}:top"),
|
||||
point: solvecraft_geom::Vec3::new(1.0, 1.0, 2.0),
|
||||
certificate: None,
|
||||
};
|
||||
run(&mut s, "config.configure", json!({"face_appearances":[target]}));
|
||||
let key = s.doc.config_key(&solvecraft_doc::config::Column::FaceAppearance(target));
|
||||
run(&mut s, "config.row", json!({"name":"Painted","values":{key:{"name":"Custom","color":[0,255,0],"opacity":1.0}}}));
|
||||
let copied = copy(&mut s, part)["component"].as_u64().unwrap();
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
let state = s.model.state();
|
||||
let body = state.bodies.iter().find(|b| b.component == copied).unwrap();
|
||||
assert!(solvecraft_doc::naming::face_names(body).contains(&format!("F{}:top", body.feature)));
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, look)| look.color == [0, 255, 0]));
|
||||
assert!(s.doc.configuration_style_status(&state).is_ok());
|
||||
let saved = s.doc.to_json();
|
||||
let reopened = solvecraft_doc::Document::from_json(&saved).unwrap();
|
||||
let mut model = solvecraft_doc::Model::default();
|
||||
model.evaluate(&reopened);
|
||||
assert!(reopened.configuration_style_status(&model.state()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_second_occurrence_emboss_copy_remaps_target_and_author_paths_and_reopens() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "SleevePart");
|
||||
let cylinder = run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}))["feature"].as_u64().unwrap();
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":part,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":part,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[80,10,7.5],"depth":1}));
|
||||
let original_volume = volume(&mut s, part);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":part,"occurrence":second,"path":[second],"translate":[20,0,0]}));
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
let placement = copied["occurrence"].as_u64().unwrap();
|
||||
let emboss = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solvecraft_doc::FeatureKind::Emboss { .. })).unwrap();
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &emboss.kind else { panic!("missing selected face") };
|
||||
assert_eq!(face.body, "Sleeve (copy)");
|
||||
assert_eq!(face.path, vec![placement]);
|
||||
assert_eq!(face.author_path, vec![placement]);
|
||||
near(volume(&mut s, new), original_volume);
|
||||
let clone_volume = volume(&mut s, new);
|
||||
let height = s.doc.feature(cylinder).unwrap().param_names[1].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":height,"expression":"30 mm"}));
|
||||
near(volume(&mut s, new), clone_volume);
|
||||
assert!(volume(&mut s, part) > original_volume);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.doc.features.iter().all(|f| reopened.model.result(f.id).is_some_and(|r| r.error.is_none())));
|
||||
near(volume(&mut reopened, new), clone_volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reassigned_sample_plate_paste_new_preserves_complex_frozen_geometry() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":[body]}));
|
||||
let part = created["components"][0]["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":part}));
|
||||
let occurrence = s.doc.occurrence_of(part).unwrap().id;
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence":occurrence,"grounded":true}));
|
||||
let original_volume = volume(&mut s, part);
|
||||
let source_mesh = s.model.state().body(&body).unwrap().mesh();
|
||||
let boss_probe = solvecraft_geom::Vec3::new(40.0, 35.0, 12.0);
|
||||
let source_boss = source_mesh.contains(boss_probe);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":part,"translate":[10,10,0]}));
|
||||
assert_eq!(s.doc.components.len(), 2);
|
||||
let new = copied["component"].as_u64().unwrap();
|
||||
// Imported curved faces may tessellate differently; keep the same geometric
|
||||
// accuracy required by copy, and independently probe plate and hole material.
|
||||
assert!((volume(&mut s, new) - original_volume).abs() < 1e-5 * original_volume);
|
||||
near(volume(&mut s, part), original_volume);
|
||||
let state = s.model.state();
|
||||
let copied_body = state.bodies.iter().find(|body| body.component == new).unwrap();
|
||||
let mesh = copied_body.mesh();
|
||||
for (point, expected) in
|
||||
[([5.0, 25.0, 4.0], true), ([12.0, 25.0, 4.0], false), ([68.0, 25.0, 4.0], false), ([40.0, 25.0, 12.0], false), ([0.0, 0.0, 4.0], false)]
|
||||
{
|
||||
assert_eq!(mesh.contains(solvecraft_geom::Vec3::new(point[0], point[1], point[2])), expected, "{point:?}");
|
||||
}
|
||||
assert_eq!(mesh.contains(boss_probe), source_boss);
|
||||
assert_eq!(state.body(&body).unwrap().mesh().contains(boss_probe), source_boss);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((volume(&mut reopened, new) - original_volume).abs() < 1e-5 * original_volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_copy_parameters_cannot_heal_an_unrelated_missing_reference() {
|
||||
let mut s = Session::default();
|
||||
let part = component(&mut s, "Valid");
|
||||
let valid = box2(&mut s);
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let kind = s.doc.feature(valid).unwrap().kind.clone();
|
||||
let broken = s.doc_mut().add_feature(kind, Some("Broken reference")).unwrap();
|
||||
let mut probe = (*s.doc).clone();
|
||||
let missing = probe.new_model_param("2 mm", "mm");
|
||||
let feature = s.doc_mut().feature_mut(broken).unwrap();
|
||||
feature.component = 0;
|
||||
if let solvecraft_doc::FeatureKind::Box { width, .. } = &mut feature.kind {
|
||||
*width = missing;
|
||||
}
|
||||
s.refresh();
|
||||
let original_error = s.model.result(broken).unwrap().error.clone();
|
||||
assert!(original_error.is_some());
|
||||
let saved = s.doc.to_json();
|
||||
let context = (s.active_component, s.active_occurrence_path.clone(), s.active_sketch);
|
||||
let selection = s.selection.clone();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("component.paste_new", &json!({"component":part})).unwrap_err();
|
||||
assert!(error.to_string().contains("original feature's error state"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.model.result(broken).unwrap().error, original_error);
|
||||
assert_eq!((s.active_component, s.active_occurrence_path.clone(), s.active_sketch), context);
|
||||
assert_eq!(s.selection, selection);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_transfer_entry_and_internal_rotated_transfer_clone_once() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let parent = component(&mut s, "Assembly");
|
||||
let a = component(&mut s, "A");
|
||||
let ao = s.doc.occurrence_of(a).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":ao,"translate":[10,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Foreign"]}));
|
||||
run(&mut s, "component.activate", json!({"component":parent}));
|
||||
let b = component(&mut s, "B");
|
||||
let bo = s.doc.occurrence_of(b).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":bo,"translate":[0,20,0],"angle":90,"axis":[1,0,0],"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Foreign"]}));
|
||||
run(&mut s, "solid.move", json!({"bodies":["Foreign"],"translate":[0,0,5]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
let copied = copy(&mut s, parent);
|
||||
assert_eq!(copied["frozen_bodies"], json!(["Foreign"]));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Foreign (copy)").unwrap();
|
||||
let after = body.mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
assert!(body.transferred);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.model.state().body("Foreign (copy)").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressed_external_entry_does_not_supply_clone_baseline() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":4,"width":3,"height":2,"body_name":"Foreign"}));
|
||||
let part = component(&mut s, "Part");
|
||||
let occurrence = s.doc.occurrence_of(part).unwrap().id;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[25,0,0],"angle":90,"axis":[0,0,1],"capture":true}));
|
||||
let skipped = s
|
||||
.doc_mut()
|
||||
.add_feature(
|
||||
solvecraft_doc::FeatureKind::BodyTransfer { bodies: vec!["Foreign".into()], from: 0, to: part, transform: solvecraft_doc::IDENTITY },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.doc_mut().feature_mut(skipped).unwrap().suppressed = true;
|
||||
s.refresh();
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let before = s.model.state().body("Foreign").unwrap().mesh().bounds();
|
||||
copy(&mut s, part);
|
||||
let after = s.model.state().body("Foreign (copy)").unwrap().mesh().bounds();
|
||||
assert!(before.min.dist(after.min) < 1e-6);
|
||||
assert!(before.max.dist(after.max) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_frozen_face_paint_ignores_consumed_legacy_offset() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Foreign"}));
|
||||
run(&mut s, "appearance.assign", json!({"body":"Foreign","faces":[[1,1,2]],"color":"#00ff00"}));
|
||||
s.doc_mut().body_offsets.insert(
|
||||
"Foreign".into(),
|
||||
solvecraft_doc::rigid(solvecraft_geom::Vec3::new(7.0, 0.0, 0.0), solvecraft_geom::Vec3::ZERO, solvecraft_geom::Vec3::Z, 0.0),
|
||||
);
|
||||
s.refresh();
|
||||
let part = component(&mut s, "Part");
|
||||
run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Foreign"]}));
|
||||
let original = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign").unwrap();
|
||||
let expected = solvecraft_doc::naming::point_on_face(&s.model.state(), original.name.as_ref().unwrap(), original.point).unwrap().0;
|
||||
copy(&mut s, part);
|
||||
let paint = s.doc.appearances.faces.iter().find(|face| face.body == "Foreign (copy)").unwrap();
|
||||
assert!(paint.point.dist(expected) < 1e-6, "{:?} versus {expected:?}", paint.point);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_material_proof_requires_resolvable_stock_and_void() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let a = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
let b = solvecraft_kernel::box_solid(Vec3::new(1e-5, 0., 0.), Vec3::new(10.00001, 10., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
let p = Vec3::new(5e-6, 5., 5.);
|
||||
assert_ne!(a.contains(p), b.contains(p));
|
||||
assert!((super::mesh_distance(&a, p) - 5e-6).abs() < 1e-12);
|
||||
assert!((super::mesh_distance(&b, p) - 5e-6).abs() < 1e-12);
|
||||
assert!(super::material_probe_matches(&a, &b, p, 1e-3, 1e-3));
|
||||
let shifted = solvecraft_kernel::box_solid(Vec3::new(1., 0., 0.), Vec3::new(11., 10., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
assert!(!super::material_probe_matches(&a, &shifted, p, 1e-3, 1e-3));
|
||||
let thin = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 1e-4)).unwrap().tessellate(1e-6).unwrap();
|
||||
let thin_shifted = solvecraft_kernel::box_solid(Vec3::new(0., 0., 1e-4), Vec3::new(10., 10., 2e-4)).unwrap().tessellate(1e-6).unwrap();
|
||||
// Every resolvable replacement is void: thin ambiguous material is refused.
|
||||
assert!(!super::material_probe_matches(&thin, &thin_shifted, Vec3::new(5., 5., 5e-5), 1e-3, 1e-3));
|
||||
assert!(!super::material_probe_matches(&a, &b, p, f64::INFINITY, 1e-3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_bore_boundary_copy_preserves_stock_void_and_reopen() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let bore = |center: f64| {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":30,"width":20,"height":10,"body_name":"Drilled"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base":[center,10,0],"radius":4,"height":10,"operation":"cut"}));
|
||||
s
|
||||
};
|
||||
let mut s = bore(10.);
|
||||
let source = s.model.state().body("Drilled").unwrap().body.tessellate(1e-3).unwrap();
|
||||
// The exact R4 wall passes through x6; a chord may end either side of that
|
||||
// cardinal angle, so its distance is bounded by the requested sag tolerance.
|
||||
assert!(super::mesh_distance(&source, Vec3::new(6., 10., 5.)) <= 1e-3);
|
||||
let shifted = bore(11.).model.state().body("Drilled").unwrap().body.tessellate(1e-3).unwrap();
|
||||
let lost = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(30., 20., 10.)).unwrap().tessellate(1e-3).unwrap();
|
||||
assert!(!super::material_probe_matches(&source, &shifted, Vec3::new(6.5, 10., 5.), 1e-3, 1e-3));
|
||||
assert!(!super::material_probe_matches(&source, &lost, Vec3::new(10., 10., 5.), 1e-3, 1e-3));
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Drilled"]}));
|
||||
let part = created["components"][0]["component"].as_u64().unwrap();
|
||||
let copied = copy(&mut s, part)["component"].as_u64().unwrap();
|
||||
let check = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
let body = state.bodies.iter().find(|body| session.doc.model_body_component(body) == copied).unwrap();
|
||||
let mesh = body.body.tessellate(1e-3).unwrap();
|
||||
for (point, stock) in
|
||||
[(Vec3::new(5.5, 10., 5.), true), (Vec3::new(6.5, 10., 5.), false), (Vec3::new(10., 10., 5.), false), (Vec3::new(18., 10., 5.), true)]
|
||||
{
|
||||
assert_eq!(mesh.contains(point), stock, "{point:?}");
|
||||
}
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_r6_bore_copy_uses_current_geometry_despite_mesh_measure_bias() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":20,"width":20,"height":10,"body_name":"Drilled"}));
|
||||
run(&mut s, "solid.cylinder", json!({"base":[10,10,0],"radius":6,"height":10,"operation":"cut"}));
|
||||
let source = s.model.state().body("Drilled").unwrap().body.clone();
|
||||
let original = solvecraft_kernel::step_export(&[&source], "source").unwrap();
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Drilled"]}));
|
||||
let part = created["components"][0]["component"].as_u64().unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let copied = copy(&mut s, part)["component"].as_u64().unwrap();
|
||||
let check = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
let body = state.bodies.iter().find(|b| session.doc.model_body_component(b) == copied).unwrap();
|
||||
assert_eq!(solvecraft_kernel::certified_prism_equivalent(&source, &body.body).unwrap(), Some(true));
|
||||
let mesh = body.body.tessellate(1e-3).unwrap();
|
||||
for (point, material) in
|
||||
[(Vec3::new(3., 10., 5.), true), (Vec3::new(5., 10., 5.), false), (Vec3::new(10., 10., 5.), false), (Vec3::new(18., 10., 5.), true)]
|
||||
{
|
||||
assert_eq!(mesh.contains(point), material, "{point:?}");
|
||||
}
|
||||
let original_body = state.body("Drilled").unwrap();
|
||||
assert_eq!(solvecraft_kernel::step_export(&[&original_body.body], "source").unwrap(), original);
|
||||
};
|
||||
check(&s);
|
||||
check(&Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Physical properties of an assembly placement, with original body material assignments.
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, Mat};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
|
||||
match p.get("descendants") {
|
||||
None => Ok(true),
|
||||
Some(Value::Bool(v)) => Ok(*v),
|
||||
Some(_) => Err(bad(cmd, "`descendants` must be a boolean")),
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_doc(s: &Session) -> Document {
|
||||
let mut doc = (*s.doc).clone();
|
||||
for occurrence in &mut doc.occurrences {
|
||||
if let Some(transform) = s.pending_moves.get(&occurrence.id) {
|
||||
occurrence.transform = *transform;
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// A property total must cover the entire assembly. The display traversal intentionally
|
||||
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
|
||||
pub(in crate::cmd) fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
|
||||
let mut out = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut stack = vec![(0, Vec::<u64>::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut children: std::collections::BTreeMap<u64, Vec<_>> = std::collections::BTreeMap::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child_path.clone(), world));
|
||||
stack.push((occurrence.component, child_path, world));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.properties";
|
||||
let id = if p.get("component").is_some() { super::component(s, p, "component", cmd)? } else { s.active_component };
|
||||
let doc = placed_doc(s);
|
||||
let path = doc.first_path(id);
|
||||
let name = if id == 0 {
|
||||
doc.name.clone()
|
||||
} else {
|
||||
doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).ok_or_else(|| bad(cmd, "no active component"))?
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
|
||||
out["component"] = json!(id);
|
||||
out["name"] = json!(name);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "occurrence.properties";
|
||||
let id = super::occurrence(s, p, cmd)?;
|
||||
let doc = placed_doc(s);
|
||||
let path = if p.get("path").is_some() {
|
||||
super::occurrence_path(s, p, id, cmd)?
|
||||
} else {
|
||||
let paths: Vec<Vec<u64>> =
|
||||
placements(&doc, cmd)?.into_iter().filter(|(_, path, _)| path.last() == Some(&id)).map(|(_, path, _)| path).collect();
|
||||
match paths.as_slice() {
|
||||
[path] => path.clone(),
|
||||
[] => return Err(bad(cmd, "occurrence has no placement")),
|
||||
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
|
||||
}
|
||||
};
|
||||
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, cmd)?;
|
||||
out["occurrence"] = json!(id);
|
||||
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, cmd: &str) -> Result<Value> {
|
||||
let state = s.model.state();
|
||||
let mut bodies = Vec::new();
|
||||
let (mut volume, mut area, mut mass) = (0.0, 0.0, 0.0);
|
||||
let mut moment = Vec3::ZERO;
|
||||
let (mut min, mut max) =
|
||||
(Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
let (mut faces, mut edges, mut vertices) = (0, 0, 0);
|
||||
let mut placement_count = 0;
|
||||
let selected_placements = if selected.is_empty() {
|
||||
if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solvecraft_doc::IDENTITY)] }
|
||||
} else {
|
||||
let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut out = vec![(placement.component, selected.to_vec(), placement.world)];
|
||||
let mut stack = if descendants { out.clone() } else { Vec::new() };
|
||||
while let Some((component, path, transform)) = stack.pop() {
|
||||
for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
if path.len() >= 64 || out.len() >= 100_000 {
|
||||
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
|
||||
}
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence.id);
|
||||
let world = solvecraft_doc::mat_mul(&transform, &occurrence.transform);
|
||||
out.push((occurrence.component, child.clone(), world));
|
||||
stack.push((occurrence.component, child, world));
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
for (component, path, transform) in selected_placements {
|
||||
placement_count += 1;
|
||||
for body in state.bodies.iter().filter(|b| doc.model_body_component(b) == component) {
|
||||
let transform: Mat = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
|
||||
let placed = solvecraft_kernel::transform_matrix(&body.body, transform)?;
|
||||
let measured = solvecraft_kernel::measure(&placed)?;
|
||||
let density = doc.density(&body.name);
|
||||
let body_mass = measured.volume * density / 1000.0;
|
||||
volume += measured.volume;
|
||||
area += measured.area;
|
||||
mass += body_mass;
|
||||
moment += measured.centroid * body_mass;
|
||||
min = Vec3::new(min.x.min(measured.bbox.min.x), min.y.min(measured.bbox.min.y), min.z.min(measured.bbox.min.z));
|
||||
max = Vec3::new(max.x.max(measured.bbox.max.x), max.y.max(measured.bbox.max.y), max.z.max(measured.bbox.max.z));
|
||||
faces += measured.merged.faces;
|
||||
edges += measured.merged.edges;
|
||||
vertices += measured.merged.vertices;
|
||||
bodies.push(json!({"name": body.name, "component": component, "path": path,
|
||||
"material": doc.materials.get(&body.name).map(String::as_str).unwrap_or("Default"),
|
||||
"density_g_cm3": density, "mass_g": body_mass, "volume_mm3": measured.volume,
|
||||
"area_mm2": measured.area, "center_of_mass": measured.centroid,
|
||||
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
|
||||
}
|
||||
}
|
||||
if !(volume.is_finite() && area.is_finite() && mass.is_finite() && moment.is_finite()) {
|
||||
return Err(bad(cmd, "physical properties exceed supported numeric range"));
|
||||
}
|
||||
let centroid = if mass > 0.0 { Some(moment / mass) } else { None };
|
||||
if centroid.is_some_and(|point| !point.is_finite()) {
|
||||
return Err(bad(cmd, "center of mass exceeds supported numeric range"));
|
||||
}
|
||||
let bbox = if bodies.is_empty() { Value::Null } else { json!({"min": min, "max": max}) };
|
||||
Ok(json!({"path": selected, "coordinate_frame": "world", "descendants": descendants,
|
||||
"placement_count": placement_count, "body_count": bodies.len(), "bodies": bodies,
|
||||
"total": {"volume_mm3": volume, "area_mm2": area, "mass_g": mass,
|
||||
"center_of_mass": centroid, "bbox": bbox, "faces": faces, "edges": edges, "vertices": vertices}}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_properties_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) -> Value {
|
||||
s.execute(cmd, ¶ms).unwrap()
|
||||
}
|
||||
fn close(actual: &Value, expected: f64) {
|
||||
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}");
|
||||
}
|
||||
fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.last().unwrap().name.clone();
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembly_counts_instances_and_resolves_original_materials() {
|
||||
let mut s = Session::default();
|
||||
let (component, occurrence, body) = box_in(&mut s, "Block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [body], "material": "Steel"}));
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}));
|
||||
let rho = s.doc.density(&body);
|
||||
let root = run(&mut s, "component.properties", json!({"component": "root"}));
|
||||
assert_eq!(root["body_count"], 2);
|
||||
close(&root["total"]["mass_g"], 2.0 * rho);
|
||||
close(&root["total"]["volume_mm3"], 2000.0);
|
||||
close(&root["total"]["center_of_mass"][0], 15.0);
|
||||
let first = run(&mut s, "component.properties", json!({"component": component}));
|
||||
assert_eq!(first["body_count"], 1);
|
||||
close(&first["total"]["center_of_mass"][0], 5.0);
|
||||
let second = run(&mut s, "occurrence.properties", json!({"occurrence": copy["occurrence"]}));
|
||||
close(&second["total"]["center_of_mass"][0], 25.0);
|
||||
assert_eq!(second["bodies"][0]["material"], "Steel");
|
||||
assert_eq!(first["path"], json!([occurrence]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_centroid_is_density_weighted_and_pending_moves_are_read_only() {
|
||||
let mut s = Session::default();
|
||||
let (a, oa, steel) = box_in(&mut s, "Steel block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [steel], "material": "Steel"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let (_, ob, plastic) = box_in(&mut s, "Plastic block");
|
||||
run(&mut s, "material.assign", json!({"bodies": [plastic], "material": "ABS Plastic"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": ob, "translate": [30, 0, 0], "capture": false}));
|
||||
let doc = s.doc.clone();
|
||||
let moves = s.pending_moves.clone();
|
||||
let r = run(&mut s, "component.properties", json!({"component": 0}));
|
||||
let ra = s.doc.density(&steel);
|
||||
let rb = s.doc.density(&plastic);
|
||||
close(&r["total"]["center_of_mass"][0], (5.0 * ra + 35.0 * rb) / (ra + rb));
|
||||
close(&r["total"]["bbox"]["max"][0], 40.0);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc, &doc));
|
||||
assert_eq!(s.pending_moves, moves);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": oa, "translate": [10, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}));
|
||||
let r = run(&mut s, "component.properties", json!({"component": a}));
|
||||
close(&r["total"]["center_of_mass"][0], 5.0);
|
||||
close(&r["total"]["center_of_mass"][1], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_require_a_path_and_can_exclude_descendants() {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name": "Assembly"}));
|
||||
let (_, child_occurrence, _) = box_in(&mut s, "Child");
|
||||
let copy = run(&mut s, "occurrence.copy", json!({"component": parent["component"], "translate": [100, 0, 0]}));
|
||||
assert!(s.execute("occurrence.properties", &json!({"occurrence": child_occurrence})).is_err());
|
||||
let r = run(&mut s, "occurrence.properties", json!({"occurrence": child_occurrence, "path": [copy["occurrence"], child_occurrence]}));
|
||||
close(&r["total"]["center_of_mass"][0], 105.0);
|
||||
let r = run(&mut s, "component.properties", json!({"component": parent["component"]}));
|
||||
assert_eq!(r["body_count"], 1);
|
||||
let r = run(&mut s, "component.properties", json!({"component": parent["component"], "descendants": false}));
|
||||
assert_eq!(r["body_count"], 0);
|
||||
assert!(r["total"]["center_of_mass"].is_null());
|
||||
assert!(r["total"]["bbox"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_reassignment_offsets_preserve_world_properties() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let c = run(&mut s, "component.create", json!({"name": "Moved"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": [body], "component": c["component"]}));
|
||||
let r = run(&mut s, "component.properties", json!({"component": c["component"]}));
|
||||
close(&r["total"]["center_of_mass"][0], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_reject_hostile_identifiers_and_options() {
|
||||
let mut s = Session::default();
|
||||
let (_, occurrence, _) = box_in(&mut s, "Block");
|
||||
for p in [json!({"component": []}), json!({"component": "missing"}), json!({"descendants": 1})] {
|
||||
assert!(s.execute("component.properties", &p).is_err());
|
||||
}
|
||||
for path in [json!([]), json!(["bad"]), json!([0]), json!(vec![occurrence; 65])] {
|
||||
assert!(s.execute("occurrence.properties", &json!({"occurrence": occurrence, "path": path})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_exponentially_instanced_assembly_refuses_partial_totals() {
|
||||
let mut s = Session::default();
|
||||
let mut last_occurrence = 0;
|
||||
// Only 17 component definitions and 34 occurrences, but over 100000 placements.
|
||||
for _ in 0..17 {
|
||||
let parent = s.active_component;
|
||||
let component = s.doc_mut().add_component(None, parent).unwrap();
|
||||
last_occurrence = s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
|
||||
s.active_component = component;
|
||||
}
|
||||
for command in ["component.properties", "occurrence.properties"] {
|
||||
let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessively_nested_assembly_refuses_partial_totals() {
|
||||
let mut s = Session::default();
|
||||
for _ in 0..65 {
|
||||
let parent = s.active_component;
|
||||
s.active_component = s.doc_mut().add_component(None, parent).unwrap();
|
||||
}
|
||||
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("traversal limits"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Atomic timeline ownership changes, preserving the selected world geometry.
|
||||
use super::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
type UniqueFrames = BTreeMap<u64, Option<(Vec<u64>, Mat)>>;
|
||||
|
||||
fn unique_frames(s: &Session, cmd: &str) -> Result<UniqueFrames> {
|
||||
let doc = s.placed_document();
|
||||
let mut children = BTreeMap::<u64, Vec<&Occurrence>>::new();
|
||||
for occurrence in &doc.occurrences {
|
||||
children.entry(occurrence.parent).or_default().push(occurrence);
|
||||
}
|
||||
let mut frames = BTreeMap::from([(0, Some((Vec::new(), solvecraft_doc::IDENTITY)))]);
|
||||
let mut stack = vec![(0, Vec::new(), solvecraft_doc::IDENTITY)];
|
||||
let mut work = 0usize;
|
||||
while let Some((component, path, matrix)) = stack.pop() {
|
||||
for occurrence in children.get(&component).into_iter().flatten() {
|
||||
work += 1;
|
||||
if path.len() >= 64 || work > 100_000 {
|
||||
return Err(bad(cmd, "transfer assembly exceeds 64 levels or 100000 placements"));
|
||||
}
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(occurrence.id);
|
||||
let frame = solvecraft_doc::mat_mul(&matrix, &occurrence.transform);
|
||||
frames.entry(occurrence.component).and_modify(|entry| *entry = None).or_insert_with(|| Some((child_path.clone(), frame)));
|
||||
stack.push((occurrence.component, child_path, frame));
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn body_names(s: &Session, p: &Value, cmd: &str, allow_boundary: bool) -> Result<Vec<String>> {
|
||||
if p.get("bodies").and_then(Value::as_array).is_none_or(|names| names.iter().any(|name| !name.is_string())) {
|
||||
return Err(bad(cmd, "bodies must be an array of body names"));
|
||||
}
|
||||
let names = string_list(p, "bodies");
|
||||
if names.is_empty() || names.len() > 1000 || names.iter().collect::<BTreeSet<_>>().len() != names.len() {
|
||||
return Err(bad(cmd, "bodies must list 1..1000 distinct existing definition bodies"));
|
||||
}
|
||||
if s.doc.marker.is_some() && !allow_boundary {
|
||||
return Err(bad(cmd, "finish timeline rollback before transferring bodies"));
|
||||
}
|
||||
let state = s.model.state();
|
||||
for name in &names {
|
||||
if state.body(name).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{name}`")));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn transfer(s: &mut Session, name: &str, to: u64, frames: &UniqueFrames, cmd: &str) -> Result<u64> {
|
||||
let state = s.model.state();
|
||||
let body = state.body(name).ok_or_else(|| bad(cmd, "body is missing"))?;
|
||||
let from = s.doc.model_body_component(body);
|
||||
let doc = s.placed_document();
|
||||
let unique = |component| {
|
||||
frames.get(&component).and_then(Option::as_ref).ok_or_else(|| {
|
||||
bad(cmd, "body transfer requires one source and destination placement; shared instances cannot preserve a unique world pose")
|
||||
})
|
||||
};
|
||||
let source = unique(from)?;
|
||||
let destination = unique(to)?;
|
||||
let old = doc.model_body_offset(body).map(|offset| solvecraft_doc::mat_mul(&source.1, offset)).unwrap_or(source.1);
|
||||
let inv = solvecraft_doc::mat_inverse(&destination.1).ok_or_else(|| bad(cmd, "destination frame is not invertible"))?;
|
||||
let transform = solvecraft_doc::mat_mul(&inv, &old);
|
||||
if !solvecraft_doc::valid_transfer_matrix(&transform) {
|
||||
return Err(bad(cmd, "body transfer exceeds supported rigid coordinate range"));
|
||||
}
|
||||
let inherited = (!s.doc.appearances.bodies.contains_key(name)).then(|| s.doc.appearances.components.get(&from).cloned()).flatten();
|
||||
let paths: BTreeSet<u64> = source.0.iter().chain(&destination.0).copied().collect();
|
||||
let captures: Vec<_> = paths.into_iter().filter_map(|id| s.pending_moves.get(&id).map(|transform| (id, *transform))).collect();
|
||||
if captures.iter().any(|(_, transform)| !solvecraft_doc::valid_transfer_matrix(transform)) {
|
||||
return Err(bad(cmd, "pending transfer position is not a supported rigid transform"));
|
||||
}
|
||||
drop(doc);
|
||||
for (id, transform) in captures {
|
||||
if let Some(occurrence) = s.doc_mut().occurrences.iter_mut().find(|occurrence| occurrence.id == id) {
|
||||
occurrence.transform = transform;
|
||||
}
|
||||
s.pending_moves.remove(&id);
|
||||
}
|
||||
if let Some(look) = inherited {
|
||||
s.doc_mut().appearances.bodies.insert(name.to_string(), look);
|
||||
}
|
||||
let id = s.doc_mut().add_feature(FeatureKind::BodyTransfer { bodies: vec![name.to_string()], from, to, transform }, None)?;
|
||||
if let Some(feature) = s.doc_mut().feature_mut(id) {
|
||||
feature.component = to;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn finish(s: &mut Session, scratch: &mut Session, features: &[u64], cmd: &str) -> Result<()> {
|
||||
scratch.refresh();
|
||||
for id in features {
|
||||
if let Some(error) = scratch.model.result(*id).and_then(|result| result.error.as_ref()) {
|
||||
return Err(bad(cmd, error));
|
||||
}
|
||||
}
|
||||
s.accept_computed(std::mem::take(scratch));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.from_bodies";
|
||||
let bodies = body_names(s, p, cmd, false)?;
|
||||
let mut scratch = s.scratch();
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
let mut targets = Vec::new();
|
||||
for name in &bodies {
|
||||
let component = scratch.doc_mut().add_component(Some(name), parent)?;
|
||||
targets.push(component);
|
||||
}
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for (name, component) in bodies.into_iter().zip(targets) {
|
||||
let feature = transfer(&mut scratch, &name, component, &frames, cmd)?;
|
||||
features.push(feature);
|
||||
made.push(json!({"component":component,"body":name,"feature":feature}));
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"components":made}))
|
||||
}
|
||||
|
||||
pub(super) fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, false)
|
||||
}
|
||||
|
||||
/// A solid constructor can attach its result immediately after the new feature,
|
||||
/// including inside the scratch timeline used by Edit Feature.
|
||||
pub(super) fn move_result(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_at_boundary(s, p, true)
|
||||
}
|
||||
|
||||
fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<Value> {
|
||||
let cmd = "component.move_bodies";
|
||||
let to = super::component(s, p, "component", cmd)?;
|
||||
let bodies = body_names(s, p, cmd, allow_boundary)?;
|
||||
let mut scratch = s.scratch();
|
||||
let frames = unique_frames(&scratch, cmd)?;
|
||||
let mut features = Vec::new();
|
||||
for name in &bodies {
|
||||
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
|
||||
}
|
||||
finish(s, &mut scratch, &features, cmd)?;
|
||||
Ok(json!({"bodies":bodies,"component":to,"features":features}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "component_transfer_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::*;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_assignment_preserves_sample_joined_material_and_timeline() {
|
||||
let mut s = Session::default();
|
||||
s.run_script(&crate::sample::script()).unwrap();
|
||||
let before = s.model.state();
|
||||
assert_eq!(before.bodies.len(), 1);
|
||||
let volume = solvecraft_kernel::measure(&before.body("Plate").unwrap().body).unwrap().volume;
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Plate"]}));
|
||||
let to = created["components"][0]["component"].as_u64().unwrap();
|
||||
let feature = created["components"][0]["feature"].as_u64().unwrap();
|
||||
let after = s.model.state();
|
||||
assert_eq!(after.bodies.len(), 1);
|
||||
let body = after.body("Plate").unwrap();
|
||||
assert_eq!(body.component, to);
|
||||
assert!(body.transferred);
|
||||
assert!((solvecraft_kernel::measure(&body.body).unwrap().volume - volume).abs() < 1e-6);
|
||||
assert!(body.mesh().contains(Vec3::new(40.0, 35.0, 12.0)));
|
||||
let index = s.doc.feature_index(feature).unwrap();
|
||||
s.doc_mut().marker = Some(index);
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, 0);
|
||||
s.doc_mut().marker = None;
|
||||
s.refresh();
|
||||
assert_eq!(s.world_state().body("Plate").unwrap().component, to);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transformed_pending_destination_repeated_transfer_preserves_world_pose() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Block"]}));
|
||||
let state = s.world_state();
|
||||
let bounds = state.body("Block").unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::ZERO) < 1e-6);
|
||||
assert!(bounds.max.dist(Vec3::new(10.0, 10.0, 10.0)) < 1e-6);
|
||||
assert!(!s.pending_moves.contains_key(&occurrence));
|
||||
let mut destination_reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
destination_reopened.refresh();
|
||||
assert!(destination_reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().body("Block").unwrap().mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_destination_refuses_without_mutating_document_or_session() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = run(&mut s, "component.create", json!({"name":"Shared"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.copy", json!({"component":c,"translate":[20,0,0]}));
|
||||
let document = s.doc.to_json();
|
||||
let active = s.active_component;
|
||||
assert!(s.execute("component.move_bodies", &json!({"component":c,"bodies":["Block"]})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(s.active_component, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_join_cut_and_new_destination_modeling_keep_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[2,2,0],"length":2,"width":2,"height":10,"operation":"cut"}));
|
||||
let original = s.model.state();
|
||||
assert_eq!(original.bodies.len(), 1);
|
||||
let v = solvecraft_kernel::measure(&original.body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((v - 1760.0).abs() < 1e-6);
|
||||
run(&mut s, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":["Block"],"color":"#bc8537"}));
|
||||
let created = run(&mut s, "component.from_bodies", json!({"bodies":["Block"]}));
|
||||
let c = created["components"][0]["component"].as_u64().unwrap();
|
||||
assert_eq!(s.doc.materials["Block"], "Brass");
|
||||
assert_eq!(s.doc.appearances.bodies["Block"].color, [188, 133, 55]);
|
||||
run(&mut s, "component.activate", json!({"component":c}));
|
||||
run(&mut s, "solid.box", json!({"corner":[18,0,0],"length":2,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let final_volume = solvecraft_kernel::measure(&s.model.state().body("Block").unwrap().body).unwrap().volume;
|
||||
assert!((final_volume - 1960.0).abs() < 1e-6);
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, 0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((solvecraft_kernel::measure(&reopened.model.state().body("Block").unwrap().body).unwrap().volume - final_volume).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_only_boundary_invalidates_old_boolean_cache() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
run(&mut s, "solid.box", json!({"corner":[8,0,0],"length":10,"width":10,"height":10,"operation":"join"}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: 0, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
let index = s.doc.feature_index(id).unwrap();
|
||||
let transfer = s.doc_mut().features.remove(index);
|
||||
s.doc_mut().features.insert(1, transfer);
|
||||
s.refresh();
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, c);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().bodies.len(), 2);
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transfer_cannot_change_evaluated_world_owner() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
let c = s.doc_mut().add_component(Some("Destination"), 0).unwrap();
|
||||
let id = s
|
||||
.doc_mut()
|
||||
.add_feature(FeatureKind::BodyTransfer { bodies: vec!["Block".into()], from: c, to: c, transform: solvecraft_doc::IDENTITY }, None)
|
||||
.unwrap();
|
||||
s.refresh();
|
||||
assert!(s.model.result(id).unwrap().error.is_some());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferred_thread_follows_body_frame_without_changing_world_axis() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":4,"height":20,"body_name":"Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face":[4,0,10],"designation":"M8"}));
|
||||
let before = s.world_state().threads[0].clone();
|
||||
let c = run(&mut s, "component.create", json!({"name":"Threaded"}));
|
||||
let to = c["component"].as_u64().unwrap();
|
||||
let occurrence = c["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]}));
|
||||
let after = s.world_state().threads[0].clone();
|
||||
assert!(after.axis_point.dist(before.axis_point) < 1e-6);
|
||||
assert!(after.axis.dist(before.axis) < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
|
||||
s.doc_mut().appearances.components.insert(source, solvecraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
|
||||
s.refresh();
|
||||
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
assert_eq!(s.doc.model_body_look(s.model.state().body("Block").unwrap()), Some(look));
|
||||
// An unrecorded legacy assignment retains its established replay policy.
|
||||
let mut legacy = Session::default();
|
||||
run(&mut legacy, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Old"}));
|
||||
let c = legacy.doc_mut().add_component(Some("Legacy"), 0).unwrap();
|
||||
legacy.doc_mut().body_components.insert("Old".into(), c);
|
||||
legacy.doc_mut().body_offsets.insert("Old".into(), solvecraft_doc::rigid(Vec3::new(7.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
legacy.refresh();
|
||||
assert!(!legacy.model.state().body("Old").unwrap().transferred);
|
||||
run(&mut legacy, "component.move_bodies", json!({"component":0,"bodies":["Old"]}));
|
||||
assert!((legacy.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&legacy.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!((reopened.world_state().body("Old").unwrap().mesh().bounds().min.x - 7.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_external_transfer_source_refuses_without_dangling_archive() {
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let b = run(&mut s, "component.create", json!({"name":"Destination"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "component.move_bodies", json!({"component":b,"bodies":["Block"]}));
|
||||
let document = s.doc.to_json();
|
||||
assert!(s.execute("component.delete", &json!({"component":a})).is_err());
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert!(solvecraft_doc::Document::from_json(&document).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() {
|
||||
let mut s = Session::default();
|
||||
let source = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = source["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":true}));
|
||||
run(&mut s, "sketch.create", json!({"name":"Badge","plane":{"origin":[30,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let emboss = run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[30,10,7.5],"depth":1}))["feature"].as_u64().unwrap();
|
||||
let before = solvecraft_kernel::measure(&s.world_state().body("Sleeve").unwrap().body).unwrap().volume;
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let destination = run(&mut s, "component.create", json!({"name":"Destination"}));
|
||||
let to = destination["component"].as_u64().unwrap();
|
||||
let destination_occurrence = destination["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":destination_occurrence,"translate":[0,40,0],"axis":[1,0,0],"angle":90,"capture":true}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Sleeve"]}));
|
||||
assert!(s.doc.features.iter().all(|feature| s.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume - before).abs() < 1e-5);
|
||||
let depth = reopened.doc.feature(emboss).unwrap().param_names[0].clone();
|
||||
run(&mut reopened, "parameters.change", json!({"name":depth,"expression":"2 mm"}));
|
||||
assert!(solvecraft_kernel::measure(&reopened.world_state().body("Sleeve").unwrap().body).unwrap().volume > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_undo_redo_restores_original_pending_source_world_pose() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, 0);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_checkpoint_restores_pending_world_pose_and_history() {
|
||||
let mut s = Session::default();
|
||||
let created = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let source = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let before = s.world_state().body("Block").unwrap().mesh().bounds();
|
||||
let undo_count = s.undo.len();
|
||||
let checkpoint = s.checkpoint();
|
||||
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
|
||||
s.restore_checkpoint(checkpoint).unwrap();
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.undo.len(), undo_count);
|
||||
assert_eq!(s.world_state().body("Block").unwrap().component, source);
|
||||
assert!(s.world_state().body("Block").unwrap().mesh().bounds().min.dist(before.min) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_transfer_destination_updates_history_owner_copy_and_reopen() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"}));
|
||||
let a = run(&mut s, "component.create", json!({"name":"A","parent":0}))["component"].as_u64().unwrap();
|
||||
let b = run(&mut s, "component.create", json!({"name":"B","parent":0}))["component"].as_u64().unwrap();
|
||||
let transferred = run(&mut s, "component.move_bodies", json!({"component":a,"bodies":["Block"]}));
|
||||
let feature = transferred["features"][0].as_u64().unwrap();
|
||||
run(&mut s, "timeline.edit", json!({"feature":feature,"set":{"to":b,"component":a}}));
|
||||
assert_eq!(s.doc.feature(feature).unwrap().component, b);
|
||||
assert_eq!(s.model.state().body("Block").unwrap().component, b);
|
||||
let saved = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("timeline.edit", &json!({"feature":feature,"set":{"to":u64::MAX}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
let copied = run(&mut s, "component.paste_new", json!({"component":b}))["component"].as_u64().unwrap();
|
||||
assert_eq!(s.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
let mut reopened = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
assert_eq!(reopened.model.state().body("Block").unwrap().component, b);
|
||||
assert_eq!(reopened.model.state().body("Block (copy)").unwrap().component, copied);
|
||||
}
|
||||
+561
-11
@@ -10,21 +10,90 @@ use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("config.configure", "Configure", configure).at("SOLID", "CONFIGURE").icon("params").params(
|
||||
"params?: [parameter names]; features?: [feature names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
|
||||
"params?: [parameter names]; features?: [feature names]; materials?: [body names]; appearances?: [body names] (columns to add; with no rows yet, a \"Default\" row takes the current values)",
|
||||
),
|
||||
CommandSpec::new("config.table", "Display Configuration Table", show)
|
||||
.at("SOLID", "CONFIGURE")
|
||||
.icon("params")
|
||||
.noundo()
|
||||
.params("→ columns (param:<name> or suppress:<feature>), rows, the active row and whether the design still matches it"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? (name); delete?: bool"),
|
||||
CommandSpec::new("config.column", "Configuration Column", column).params("param? | feature? | material? | appearance? (target name); delete?: bool"),
|
||||
CommandSpec::new("config.row", "Configuration Row", row).params(
|
||||
"name; from? (row to copy; default the current values); values?: {param name or feature name: expression or true/false}; rename?; delete?: bool",
|
||||
"name; from? (row to copy; default current values); values?: {column key: expression, suppression, material name, appearance name or Look object}; rename?; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("config.activate", "Activate Configuration", activate).params("row: name — sets its parameter values and suppressions"),
|
||||
CommandSpec::new("config.preview", "Preview Configuration", preview).noundo().params("row: name — validates a rebuild without changing the design or history"),
|
||||
CommandSpec::new("config.rule", "Configuration Rule", rule).params("rule?: {id,name,enabled,condition,effect}; id? + delete?; id? + index? (ordered move)"),
|
||||
CommandSpec::new("config.rules", "Configuration Rules", show).noundo().params("ordered typed rules, rows and active state"),
|
||||
];
|
||||
|
||||
fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
if ["param", "feature", "material", "appearance", "component_appearance", "face_appearance"].iter().filter(|k| p.get(**k).is_some()).count() != 1
|
||||
{
|
||||
return Err(bad(cmd, "give exactly one configuration target"));
|
||||
}
|
||||
if let Some(value) = p.get("component_appearance") {
|
||||
let key = value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.or_else(|| value.as_u64().map(|id| id.to_string()))
|
||||
.ok_or_else(|| bad(cmd, "component name or ID required"))?;
|
||||
if bool_(p, "delete") == Some(true)
|
||||
&& let Some(column) = s.doc.configs.columns.iter().find(|column| matches!(column,Column::ComponentAppearance(id) if id.to_string()==key))
|
||||
{
|
||||
return Ok(column.clone());
|
||||
}
|
||||
let id = s
|
||||
.doc
|
||||
.find_component(&key)
|
||||
.or_else(|| s.doc.occurrences.iter().find(|occurrence| occurrence.name == key).map(|occurrence| occurrence.component))
|
||||
.filter(|id| *id != 0)
|
||||
.ok_or_else(|| bad(cmd, "no component definition"))?;
|
||||
return Ok(Column::ComponentAppearance(id));
|
||||
}
|
||||
if let Some(value) = p.get("face_appearance") {
|
||||
if bool_(p, "delete") == Some(true) {
|
||||
let key = value.as_str().ok_or_else(|| bad(cmd, "face column key required for deletion"))?;
|
||||
return s
|
||||
.doc
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.find(|column| matches!(column, Column::FaceAppearance(_)) && s.doc.config_key(column) == key)
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, "no face column"));
|
||||
}
|
||||
if let Some(target) = value.get("target") {
|
||||
solvecraft_doc::config::validate_face_target_json(target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let target: solvecraft_doc::config::ConfigFaceTarget =
|
||||
serde_json::from_value(target.clone()).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
let state = s.model.state();
|
||||
let body = state.body(&target.body).ok_or_else(|| bad(cmd, "no face definition"))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
}
|
||||
let body_name = value.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "face body required"))?;
|
||||
let point = value.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "finite face point required"))?;
|
||||
let world = s.world_state();
|
||||
let body = world.body(body_name).ok_or_else(|| bad(cmd, "no face body"))?;
|
||||
if body.body.is_mesh() {
|
||||
return Err(bad(cmd, "mesh faces have no supported semantic lineage"));
|
||||
}
|
||||
let index = solvecraft_doc::appearance::face_index_at(body, point).ok_or_else(|| bad(cmd, "no face at picked point"))?;
|
||||
let target = s.doc.configuration_face_target(body, index, point).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
s.doc.certify_config_face(&target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
s.doc.configuration_face_indices(body, &target).map_err(|error| bad(cmd, error.to_string()))?;
|
||||
return Ok(Column::FaceAppearance(target));
|
||||
}
|
||||
for (key, appearance) in [("material", false), ("appearance", true)] {
|
||||
if let Some(body) = str_(p, key) {
|
||||
let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) };
|
||||
if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
return Ok(c);
|
||||
}
|
||||
}
|
||||
if let Some(n) = str_(p, "param") {
|
||||
if !s.doc.all_param_exprs().iter().any(|(m, _, _)| m == n) {
|
||||
return Err(bad(cmd, format!("no parameter `{n}`")));
|
||||
@@ -38,7 +107,9 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
|
||||
/// Add a column, filling existing rows with the design's current value.
|
||||
fn add_column(s: &mut Session, c: Column, cmd: &str) -> Result<()> {
|
||||
if s.doc.configs.columns.contains(&c) {
|
||||
if s.doc.configs.columns.iter().any(|existing| {
|
||||
*existing == c || matches!((existing,&c),(Column::FaceAppearance(a),Column::FaceAppearance(b)) if a.body == b.body && a.name == b.name)
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
if s.doc.configs.columns.len() >= MAX_COLUMNS {
|
||||
@@ -63,6 +134,25 @@ fn configure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = column_of(s, &json!({ "feature": n }), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
for key in ["materials", "appearances"] {
|
||||
for n in string_list(p, key) {
|
||||
let field = if key == "materials" { "material" } else { "appearance" };
|
||||
let c = column_of(s, &json!({ field: n }), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
}
|
||||
for (plural, singular) in [("component_appearances", "component_appearance"), ("face_appearances", "face_appearance")] {
|
||||
if let Some(values) = p.get(plural) {
|
||||
let values = values
|
||||
.as_array()
|
||||
.filter(|values| values.len() <= MAX_COLUMNS)
|
||||
.ok_or_else(|| bad(cmd, "configuration targets must be a bounded array"))?;
|
||||
for value in values {
|
||||
let c = column_of(s, &json!({singular:value}), cmd)?;
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.doc.configs.rows.is_empty() {
|
||||
let cells = s.doc.configs.columns.iter().map(|c| s.doc.config_current(c).unwrap_or_default()).collect();
|
||||
let t = &mut s.doc_mut().configs;
|
||||
@@ -80,7 +170,26 @@ fn show(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|r| json!({"name": r.name, "values": keys.iter().zip(&r.cells).map(|(k, v)| (k.clone(), json!(v))).collect::<serde_json::Map<_, _>>()}))
|
||||
.collect();
|
||||
Ok(json!({"columns": keys, "rows": rows, "active": t.active, "matches": s.doc.configuration_matches()}))
|
||||
let targets: Vec<Value> = t
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
let label = match column {
|
||||
Column::ComponentAppearance(id) => {
|
||||
format!("Component: {}", s.doc.components.iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| id.to_string()))
|
||||
}
|
||||
Column::FaceAppearance(target) => {
|
||||
format!("Face: {} / {}", target.body, target.name.split_once(':').map(|(_, role)| role).unwrap_or(&target.name))
|
||||
}
|
||||
_ => s.doc.config_key(column),
|
||||
};
|
||||
json!({"key":s.doc.config_key(column),"label":label,"target":column})
|
||||
})
|
||||
.collect();
|
||||
let styles = s.doc.configuration_style_status(&s.model.state());
|
||||
Ok(
|
||||
json!({"columns": keys, "targets":targets, "rows": rows, "rules": t.rules, "active": t.active, "matches": s.doc.configuration_matches() && styles.is_ok(), "style_status":styles.as_ref().ok(),"style_error":styles.err().map(|error|error.to_string())}),
|
||||
)
|
||||
}
|
||||
|
||||
fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -98,6 +207,9 @@ fn column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
add_column(s, c, cmd)?;
|
||||
}
|
||||
if !s.doc.configs.rules.is_empty() {
|
||||
validate_rows(s, cmd)?;
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
@@ -107,6 +219,9 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let idx = s.doc.configs.rows.iter().position(|r| r.name == name);
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
let i = idx.ok_or_else(|| bad(cmd, format!("no row `{name}`")))?;
|
||||
if s.doc.configs.rows.len() == 1 && !s.doc.configs.rules.is_empty() {
|
||||
return Err(bad(cmd, "delete rules before deleting the last configuration row"));
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
t.rows.remove(i);
|
||||
if t.active.as_deref() == Some(name.as_str()) {
|
||||
@@ -122,33 +237,58 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(vals) = p.get("values") {
|
||||
let vals = vals.as_object().ok_or_else(|| bad(cmd, "`values` must map columns to values"))?;
|
||||
for (k, v) in vals {
|
||||
let text = match v {
|
||||
let mut text = match v {
|
||||
Value::String(t) => t.clone(),
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Object(_) if k.starts_with("appearance:") || k.starts_with("component_appearance:") || k.starts_with("face_appearance:") => {
|
||||
serde_json::to_string(v).map_err(|e| bad(cmd, e.to_string()))?
|
||||
}
|
||||
_ => return Err(bad(cmd, format!("value for `{k}` must be an expression or true/false"))),
|
||||
};
|
||||
if text.len() > 4096 {
|
||||
return Err(bad(cmd, "value too long"));
|
||||
}
|
||||
let key = k.strip_prefix("param:").or_else(|| k.strip_prefix("suppress:")).unwrap_or(k);
|
||||
let col = s
|
||||
.doc
|
||||
.configs
|
||||
.columns
|
||||
.iter()
|
||||
.position(|c| match c {
|
||||
Column::Param(n) => n == key,
|
||||
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == key),
|
||||
.position(|c| {
|
||||
s.doc.config_key(c) == *k
|
||||
|| match c {
|
||||
Column::Param(n) => n == k,
|
||||
Column::Suppress(id) => s.doc.feature(*id).is_some_and(|f| f.name == *k),
|
||||
Column::Material(_) | Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_) => false,
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| bad(cmd, format!("`{k}` is not a column (config.column adds one)")))?;
|
||||
match s.doc.configs.columns.get(col) {
|
||||
Some(Column::Material(_)) => {
|
||||
text = solvecraft_doc::config::material_cell(&text).map_err(|e| bad(cmd, e.to_string()))?.unwrap_or_else(|| "Default".into());
|
||||
}
|
||||
Some(Column::Appearance(_) | Column::ComponentAppearance(_) | Column::FaceAppearance(_)) => {
|
||||
text = match solvecraft_doc::config::appearance_cell(&text).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
Some(look) => serde_json::to_string(&look).map_err(|e| bad(cmd, e.to_string()))?,
|
||||
None => String::new(),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(cell) = cells.get_mut(col) {
|
||||
*cell = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rename = str_(p, "rename").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).map(str::to_string);
|
||||
if p.get("rename").is_some() && (rename.is_none() || idx.is_none()) {
|
||||
return Err(bad(cmd, "rename requires an existing row and a nonempty name up to 128 bytes"));
|
||||
}
|
||||
if rename.as_ref().is_some_and(|n| n != &name && s.doc.configs.rows.iter().any(|r| &r.name == n)) {
|
||||
return Err(bad(cmd, "configuration name already exists"));
|
||||
}
|
||||
let t = &mut s.doc_mut().configs;
|
||||
let validated_name = rename.clone().unwrap_or_else(|| name.clone());
|
||||
match idx {
|
||||
Some(i) => {
|
||||
if let Some(r) = t.rows.get_mut(i) {
|
||||
@@ -168,20 +308,118 @@ fn row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.rows.push(ConfigRow { name, cells });
|
||||
}
|
||||
}
|
||||
if !s.doc.configs.rules.is_empty() {
|
||||
let mut trial = s.scratch();
|
||||
activate(&mut trial, &json!({"row":validated_name})).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
}
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn validate_rows(s: &Session, cmd: &str) -> Result<()> {
|
||||
if !s.doc.configs.rules.is_empty() && s.doc.configs.rows.is_empty() {
|
||||
return Err(bad(cmd, "rules require at least one configuration row"));
|
||||
}
|
||||
for row in &s.doc.configs.rows {
|
||||
let mut trial = s.scratch();
|
||||
activate(&mut trial, &json!({"row":row.name})).map_err(|e| bad(cmd, format!("row `{}`: {e}", row.name)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.rule";
|
||||
if let Some(value) = p.get("rule") {
|
||||
solvecraft_doc::config::validate_rules_json(&json!({"configs":{"rules":[value]}})).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut rule: solvecraft_doc::config::ConfigRule = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if rule.length_unit.is_none() {
|
||||
rule.length_unit = s
|
||||
.doc
|
||||
.configs
|
||||
.rules
|
||||
.iter()
|
||||
.find(|existing| existing.id == rule.id)
|
||||
.and_then(|existing| existing.length_unit.clone())
|
||||
.or_else(|| Some(s.doc.units.clone()));
|
||||
}
|
||||
let rules = &mut s.doc_mut().configs.rules;
|
||||
if let Some(index) = rules.iter().position(|r| r.id == rule.id) {
|
||||
rules[index] = rule;
|
||||
} else {
|
||||
if rules.len() >= solvecraft_doc::config::MAX_RULES {
|
||||
return Err(bad(cmd, "too many rules"));
|
||||
}
|
||||
rules.push(rule);
|
||||
}
|
||||
} else {
|
||||
let id = str_(p, "id").ok_or_else(|| bad(cmd, "give rule or id"))?;
|
||||
let rules = &mut s.doc_mut().configs.rules;
|
||||
let index = rules.iter().position(|r| r.id == id).ok_or_else(|| bad(cmd, "unknown rule"))?;
|
||||
if bool_(p, "delete").unwrap_or(false) {
|
||||
rules.remove(index);
|
||||
} else {
|
||||
let position = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.filter(|v| *v < rules.len())
|
||||
.ok_or_else(|| bad(cmd, "valid index required"))?;
|
||||
let rule = rules.remove(index);
|
||||
rules.insert(position, rule);
|
||||
}
|
||||
}
|
||||
validate_rows(s, cmd)?;
|
||||
show(s, p)
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "config.activate";
|
||||
let r = str_(p, "row").ok_or_else(|| bad(cmd, "`row` is required"))?.to_string();
|
||||
s.doc_mut().apply_configuration(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let mut trial = s.scratch();
|
||||
let trace = trial.doc_mut().apply_configuration_with_trace(&r).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
trial.refresh();
|
||||
let new_errors: Vec<String> = trial
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|result| {
|
||||
result
|
||||
.error
|
||||
.as_ref()
|
||||
.filter(|error| s.model.result(result.id).is_none_or(|before| before.error.as_ref() != Some(*error)))
|
||||
.map(|error| format!("{}: {error}", result.name))
|
||||
})
|
||||
.collect();
|
||||
if !new_errors.is_empty() {
|
||||
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
|
||||
}
|
||||
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
|
||||
let mut out = show(s, p)?;
|
||||
out["errors"] = json!(errors);
|
||||
out["trace"] = json!(trace);
|
||||
out["styles"] = json!(styles);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut trial = s.scratch();
|
||||
let mut out = activate(&mut trial, p)?;
|
||||
out["preview"] = json!(true);
|
||||
out["parameters"] = json!(trial.doc.param_rows());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_certificate_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod certificate_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "config_style_tests.rs"]
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
mod style_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
@@ -199,6 +437,186 @@ mod tests {
|
||||
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_activation_tolerates_identical_existing_errors_but_rejects_reenabled_failure() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"radius","expression":"10 mm"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius":"radius"}));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
run(&mut s, "config.configure", json!({"params":["radius"],"features":[feature]}));
|
||||
run(&mut s, "config.row", json!({"name":"Existing error","values":{"radius":"-1 mm"}}));
|
||||
run(&mut s, "parameters.change", json!({"name":"radius","expression":"-1 mm"}));
|
||||
assert!(s.model.results[0].error.is_some());
|
||||
let existing = run(&mut s, "config.activate", json!({"row":"Existing error"}));
|
||||
assert_eq!(existing["errors"].as_array().unwrap().len(), 1);
|
||||
s.doc_mut().features[0].suppressed = true;
|
||||
s.refresh();
|
||||
assert!(s.model.results[0].error.is_none());
|
||||
run(&mut s, "config.row", json!({"name":"Reenabled failure","values":{"radius":"-1 mm",format!("suppress:{feature}"):false}}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Reenabled failure"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!(s.model.results[0].error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rows_and_literal_rule_thresholds_keep_physical_units_on_switch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length]}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"limit","name":"Metric stock","condition":{"type":"parameter","parameter":length,"operator":"le","expression":"20"},"effect":{"type":"parameter_range","parameter":length,"minimum":"5","maximum":"20"}}}),
|
||||
);
|
||||
run(&mut s, "document.units", json!({"units":"in"}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm");
|
||||
let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap();
|
||||
assert_eq!(rule["condition"]["expression"], "20 mm");
|
||||
assert_eq!(rule["effect"]["minimum"], "5 mm");
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][0]["matched"], true);
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.units, "mm");
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_mixed_rule_expressions_keep_authoring_units_without_rewriting_source() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":20,"height":10}));
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
let width = s.doc.features[0].param_names[1].clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length,width]}));
|
||||
let expression = format!("{width} + 1");
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"drive","name":"Stock extra","condition":{"type":"always"},"effect":{"type":"drive_parameter","parameter":length,"expression":expression}}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"range","name":"Stock bound","condition":{"type":"parameter","parameter":length,"operator":"lt","expression":format!("{width} + 2")},"effect":{"type":"parameter_range","parameter":length,"minimum":width,"maximum":format!("{width} + 2")}}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
let before = volume(&mut s);
|
||||
assert!((before - 4200.0).abs() < 1e-6);
|
||||
run(&mut s, "document.units", json!({"units":"in"}));
|
||||
assert!((volume(&mut s) - before).abs() < 1e-6, "matched row rederives drives in their authoring unit");
|
||||
assert!(s.doc.configuration_matches());
|
||||
let rule = serde_json::to_value(&s.doc.configs.rules[0]).unwrap();
|
||||
assert_eq!(rule["effect"]["expression"], expression);
|
||||
assert_eq!(rule["length_unit"], "mm");
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][1]["matched"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_rule_edits_enforce_existing_rows_and_are_undoable() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"length","expression":"10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length":"length","width":10,"height":10}));
|
||||
run(&mut s, "config.configure", json!({"params":["length"]}));
|
||||
let rule = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"5 mm","maximum":"20 mm"}});
|
||||
run(&mut s, "config.rule", json!({"rule":rule}));
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.row", &json!({"name":"Default","delete":true})).is_err());
|
||||
assert_eq!(s.doc, before, "last row remains while rules exist");
|
||||
assert!(s.execute("config.row", &json!({"name":"Too large","values":{"length":"30 mm"}})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
let impossible = json!({"id":"limit","name":"Length limit","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":"length","minimum":"15 mm","maximum":"20 mm"}});
|
||||
assert!(s.execute("config.rule", &json!({"rule":impossible})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Default"}));
|
||||
assert_eq!(preview["trace"][0]["name"], "Length limit");
|
||||
assert_eq!(preview["trace"][0]["matched"], true);
|
||||
assert_eq!(s.doc, before);
|
||||
run(&mut s, "config.rule", json!({"id":"limit","delete":true}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, before);
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.configs.rules, s.doc.configs.rules);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_preview_and_failed_geometry_leave_design_unchanged() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"length", "expression":"10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length":"length", "width":10, "height":10}));
|
||||
run(&mut s, "config.configure", json!({"params":["length"]}));
|
||||
run(&mut s, "config.row", json!({"name":"Large", "values":{"length":"20 mm"}}));
|
||||
run(&mut s, "config.row", json!({"name":"Degenerate", "values":{"length":"0 mm"}}));
|
||||
let before = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
assert_eq!(run(&mut s, "config.preview", json!({"row":"Large"}))["preview"], true);
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert!(s.execute("config.preview", &json!({"row":"Degenerate"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!(s.execute("config.activate", &json!({"row":"Degenerate"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6);
|
||||
run(&mut s, "config.activate", json!({"row":"Large"}));
|
||||
assert!((volume(&mut s) - 2000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_feature_inputs_and_suppression_match_typed_cells() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10, "width":10, "height":10}));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
let length = s.doc.features[0].param_names[0].clone();
|
||||
s.doc_mut().units = "in".into();
|
||||
run(&mut s, "config.configure", json!({"params":[length], "features":[feature]}));
|
||||
assert_eq!(s.doc.configs.rows[0].cells[0], "10 mm", "Default captures stored feature literals in their base unit");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "captured Default preserves geometry in an inch design");
|
||||
run(&mut s, "config.row", json!({"name":"Inches", "values":{length.clone():"2", format!("suppress:{feature}"):"NO"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Inches"}));
|
||||
assert_eq!(s.doc.config_current(&solvecraft_doc::config::Column::Param(length.clone())).as_deref(), Some("2 in"));
|
||||
assert!(s.doc.configuration_matches());
|
||||
assert!((volume(&mut s) - 5080.0).abs() < 1e-6);
|
||||
let feature_id = s.doc.features[0].id;
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"inch-condition","name":"Inch condition","condition":{"type":"parameter","parameter":length,"operator":"gt","expression":"5"},"effect":{"type":"drive_suppression","feature":feature_id,"value":true}}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"config.rule",
|
||||
json!({"rule":{"id":"inch-range","name":"Inch range","condition":{"type":"always"},"effect":{"type":"parameter_range","parameter":length,"minimum":"0","maximum":"5"}}}),
|
||||
);
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Inches"}));
|
||||
assert_eq!(preview["trace"][0]["matched"], false, "bare comparison means inches for a feature length");
|
||||
assert_eq!(preview["trace"][1]["matched"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_parameters_cannot_be_deleted_and_deleted_features_prune_columns() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name":"spare", "expression":"10 mm"}));
|
||||
run(&mut s, "config.configure", json!({"params":["spare"]}));
|
||||
assert!(s.doc_mut().remove_param("spare").is_err());
|
||||
run(&mut s, "config.column", json!({"param":"spare", "delete":true}));
|
||||
s.doc_mut().remove_param("spare").unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let feature = &s.doc.features[0];
|
||||
let id = feature.id;
|
||||
let length = feature.param_names[0].clone();
|
||||
let name = feature.name.clone();
|
||||
run(&mut s, "config.configure", json!({"params":[length], "features":[name]}));
|
||||
s.doc_mut().delete_feature(id).unwrap();
|
||||
assert!(s.doc.configs.columns.is_empty());
|
||||
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_switch_sizes_and_suppressions() {
|
||||
let mut s = Session::default();
|
||||
@@ -236,4 +654,136 @@ mod tests {
|
||||
run(&mut s, "config.column", json!({"feature": fillet, "delete": true}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_materials_and_looks_are_undoable_and_persistent() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
|
||||
let material = format!("material:{body}");
|
||||
let appearance = format!("appearance:{body}");
|
||||
run(&mut s, "config.row", json!({"name": "Metal", "values": {material.clone(): "steel", appearance.clone(): "ABS - Red"}}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name": "Custom", "from": "Metal", "values": {
|
||||
material.clone(): "Aluminum", appearance.clone(): {"name": "Ocean", "color": [1, 2, 3], "opacity": 0.4}
|
||||
}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row": "Metal"}));
|
||||
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().color, [196, 40, 40]);
|
||||
let mass = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 7.85).abs() < 1e-6);
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["matches"], true);
|
||||
run(&mut s, "config.activate", json!({"row": "Custom"}));
|
||||
let mass = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!((mass["bodies"][0]["mass_g"].as_f64().unwrap() - 2.7).abs() < 1e-6);
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().opacity, 0.4);
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
assert!(reopened.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.materials.get(&body).map(String::as_str), Some("Steel"));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
assert!(!s.doc.materials.contains_key(&body));
|
||||
assert!(!s.doc.appearances.bodies.contains_key(&body));
|
||||
run(&mut s, "config.activate", json!({"row": "Metal"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": "ABS - Blue"}));
|
||||
assert!(!s.doc.configuration_matches());
|
||||
run(&mut s, "config.column", json!({"appearance": body, "delete": true}));
|
||||
assert_eq!(s.doc.configs.columns.len(), 1);
|
||||
assert!(s.doc.configs.rows.iter().all(|r| r.cells.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_styles_reject_hostile_values_without_changes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
run(&mut s, "config.configure", json!({"materials": [body], "appearances": [body]}));
|
||||
let material = format!("material:{body}");
|
||||
let appearance = format!("appearance:{body}");
|
||||
for params in [
|
||||
json!({"name": "Bad", "values": {material: "Unobtainium"}}),
|
||||
json!({"name": "Bad", "values": {appearance.clone(): "#oops"}}),
|
||||
json!({"name": "Bad", "values": {appearance.clone(): {"name": "Invalid", "color": [1, 2, 300], "opacity": 1}}}),
|
||||
json!({"name": "Bad", "values": {appearance: {"name": "Invalid", "color": [1, 2, 3], "opacity": -1}}}),
|
||||
json!({"name": "Default", "rename": ""}),
|
||||
] {
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.row", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
assert!(s.execute("config.column", &json!({"appearance": "missing"})).is_err());
|
||||
assert!(s.execute("config.column", &json!({"appearance": body, "material": body})).is_err());
|
||||
run(&mut s, "config.row", json!({"name": "Other"}));
|
||||
assert!(s.execute("config.row", &json!({"name": "Other", "rename": "Default"})).is_err());
|
||||
// A malformed persisted row must fail atomically rather than silently apply its prefix.
|
||||
s.doc_mut().configs.rows.first_mut().unwrap().cells.pop();
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row": "Default"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_captures_unicode_custom_appearance_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let body = s.model.state().bodies.first().unwrap().name.clone();
|
||||
let name = "é".repeat(128);
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body], "appearance": name, "color": [40, 90, 186]}));
|
||||
run(&mut s, "config.configure", json!({"appearances": [body]}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies": [body]}));
|
||||
run(&mut s, "config.activate", json!({"row": "Default"}));
|
||||
assert_eq!(s.doc.appearances.bodies.get(&body).unwrap().name, name);
|
||||
assert!(s.doc.configuration_matches());
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert!(reopened.configuration_matches());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_styles_survive_parameter_and_body_renames() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "Steel", "expression": "10 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "double", "expression": "Steel * 2"}));
|
||||
run(&mut s, "solid.box", json!({"length": "double", "width": 10, "height": 10, "body_name": "Block"}));
|
||||
run(&mut s, "config.configure", json!({"params": ["Steel", "double"], "materials": ["Block"], "appearances": ["Block"]}));
|
||||
run(
|
||||
&mut s,
|
||||
"config.row",
|
||||
json!({"name": "Styled", "values": {
|
||||
"param:Steel": "20 mm", "param:double": "Steel * 3", "material:Block": "Steel", "appearance:Block": "Steel - Satin"
|
||||
}}),
|
||||
);
|
||||
run(&mut s, "config.activate", json!({"row": "Styled"}));
|
||||
let styles: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
|
||||
run(&mut s, "parameters.rename", json!({"name": "Steel", "new_name": "gauge"}));
|
||||
let after: Vec<Vec<String>> = s.doc.configs.rows.iter().map(|r| r.cells.iter().skip(2).cloned().collect()).collect();
|
||||
assert_eq!(after, styles, "renaming an expression reference must preserve style cells verbatim");
|
||||
let table = run(&mut s, "config.table", json!({}));
|
||||
assert_eq!(table["columns"][0], "param:gauge");
|
||||
assert_eq!(table["rows"][1]["values"]["param:double"], "gauge * 3");
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "body.rename", json!({"body": "Block", "name": "Housing"}));
|
||||
let table = run(&mut s, "config.table", json!({}));
|
||||
assert_eq!(table["columns"][2], "material:Housing");
|
||||
assert_eq!(table["columns"][3], "appearance:Housing");
|
||||
assert!(s.doc.configuration_matches());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"][2], "material:Block");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
|
||||
run(&mut reopened, "config.activate", json!({"row": "Default"}));
|
||||
assert!(!reopened.doc.materials.contains_key("Housing"));
|
||||
assert!(!reopened.doc.appearances.bodies.contains_key("Housing"));
|
||||
run(&mut reopened, "config.activate", json!({"row": "Styled"}));
|
||||
assert_eq!(reopened.doc.materials.get("Housing").map(String::as_str), Some("Steel"));
|
||||
assert_eq!(reopened.doc.appearances.bodies.get("Housing").unwrap().name, "Steel - Satin");
|
||||
assert!(!reopened.doc.materials.contains_key("Block"));
|
||||
assert!(!reopened.doc.appearances.bodies.contains_key("Block"));
|
||||
assert!((volume(&mut reopened) - 6000.0).abs() < 1e-6);
|
||||
assert!(reopened.doc.configuration_matches());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{
|
||||
FeatureKind,
|
||||
config::{Column, FaceCertificate},
|
||||
};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn paint(session: &mut Session, body: &str, point: Value) {
|
||||
let table = run(session, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}));
|
||||
let key = table["columns"][0].as_str().unwrap();
|
||||
run(session, "config.row", json!({"name":"Painted","values":{key:"ABS - Red"}}));
|
||||
run(session, "config.activate", json!({"row":"Painted"}));
|
||||
}
|
||||
fn painted_radius(session: &Session, name: &str, radius: f64) -> usize {
|
||||
let state = session.model.state();
|
||||
let body =
|
||||
state.body(name).unwrap_or_else(|| panic!("missing {name}; bodies {:?}", state.bodies.iter().map(|body| &body.name).collect::<Vec<_>>()));
|
||||
let painted = session.doc.face_colors(body);
|
||||
let mesh = body.body.tessellate(0.001).unwrap();
|
||||
let inverse = solvecraft_doc::mat_inverse(&body.source_transform).unwrap();
|
||||
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if painted.iter().any(|(index, _)| *index == *face as usize) {
|
||||
for vertex in mesh.tri(triangle).unwrap() {
|
||||
let vertex = solvecraft_doc::apply_point(&inverse, vertex);
|
||||
assert!((vertex.x.hypot(vertex.y) - radius).abs() < 1e-7, "{name}: {vertex:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!painted.is_empty());
|
||||
painted.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_annulus_side_rows_follow_radius_edits_and_clone() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"RingPart"}))["component"].as_u64().unwrap();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["outer"],"type":"radius","value":10}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities":["inner"],"type":"radius","value":9}));
|
||||
let FeatureKind::Sketch { sketch: source_sketch, .. } = &s.doc.feature(sketch).unwrap().kind else { panic!() };
|
||||
let dimensions: Vec<_> = source_sketch.constraints.iter().filter_map(|dimension| dimension.param.clone()).collect();
|
||||
assert_eq!(dimensions.len(), 2);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"profiles":[{"point":[9.5,0]}],"body_name":"Ring"}));
|
||||
paint(&mut s, "Ring", json!([10, 0, 5]));
|
||||
assert_eq!(painted_radius(&s, "Ring", 10.), 2, "both constructed arc pieces share outer curve");
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert_eq!(target.certificate, Some(FaceCertificate::ExtrudeSide { sketch, curve: "outer".into() }));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[0],"expression":"12 mm"}));
|
||||
run(&mut s, "parameters.change", json!({"name":dimensions[1],"expression":"9.25 mm"}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring", 12.), 2);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
assert_eq!(s.doc.configs.columns.len(), 2);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Ring (copy)", 12.), 2);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut reopened = Session::new(reopened);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&reopened, "Ring", 12.), 2);
|
||||
assert_eq!(painted_radius(&reopened, "Ring (copy)", 12.), 2);
|
||||
run(&mut reopened, "config.activate", json!({"row":"Default"}));
|
||||
assert!(reopened.doc.appearances.faces.is_empty());
|
||||
}
|
||||
|
||||
fn imported(session: &mut Session, step: String, name: &str) -> u64 {
|
||||
let owner = session.active_component;
|
||||
let id = session.doc_mut().add_feature(FeatureKind::Import { file: "fixture.step".into(), step, components: vec![] }, Some(name)).unwrap();
|
||||
let feature = session.doc_mut().feature_mut(id).unwrap();
|
||||
feature.body_names = vec![name.into()];
|
||||
feature.component = owner;
|
||||
session.refresh();
|
||||
assert!(session.model.result(id).unwrap().error.is_none());
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renaming() {
|
||||
let mut s = Session::default();
|
||||
let cylinder = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 20.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&cylinder], "legacy anchored cylinder").unwrap();
|
||||
let source = imported(&mut s, step, "Sleeve");
|
||||
run(&mut s, "material.assign", json!({"bodies":["Sleeve"],"material":"Steel"}));
|
||||
let before_volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
let legacy_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
assert!(legacy_names.iter().all(|name| name.starts_with(&format!("F{source}:face:"))));
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":"Sleeve","point":[6,0,20]}],"appearance":"ABS - Blue"}));
|
||||
assert!(s.doc.appearances.faces[0].certificate.is_none());
|
||||
let badge = run(&mut s, "sketch.create", json!({"plane":{"face":[0,0,20]},"name":"Badge","project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
let FeatureKind::Sketch { plane: saved_plane, .. } = &s.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
let saved_plane = saved_plane.clone();
|
||||
assert!(matches!(&saved_plane, solvecraft_doc::PlaneRef::Face { name: Some(name), .. } if legacy_names.contains(name)));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,-2],"p1":[2,2]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
|
||||
let anchored = s.doc.find_feature("Raised Badge").unwrap().clone();
|
||||
assert!(matches!(anchored.kind, FeatureKind::Emboss { face: None, .. }));
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 2000. - 16.).abs() < 0.1, "volume {volume}");
|
||||
assert!((volume - before_volume - 16.).abs() < 0.01, "addedvolume {} baseline {before_volume}", volume - before_volume);
|
||||
let paint = s.doc.appearances.faces.clone();
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
assert_eq!(plane, &saved_plane);
|
||||
assert_eq!(reopened.doc.appearances.faces, paint);
|
||||
assert_eq!(reopened.doc.materials.get("Sleeve"), Some(&"Steel".to_string()));
|
||||
assert!(reopened.model.result(anchored.id).unwrap().error.is_none());
|
||||
let state = reopened.model.state();
|
||||
let body = state.body("Sleeve").unwrap();
|
||||
assert!((body.body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
assert!(!reopened.doc.face_colors(body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_refuses_reused_body_name_from_a_new_producer() {
|
||||
let mut s = Session::default();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let Column::FaceAppearance(target) = s.doc.configs.columns[0].clone() else { panic!() };
|
||||
let mut replacement = s.doc.feature(source).unwrap().clone();
|
||||
replacement.id = s.doc.next_id;
|
||||
assert!(matches!(replacement.kind, FeatureKind::Extrude { sketch: original, .. } if original == sketch));
|
||||
s.doc_mut().feature_mut(source).unwrap().suppressed = true;
|
||||
s.doc_mut().next_id += 1;
|
||||
s.doc_mut().features.push(replacement);
|
||||
s.refresh();
|
||||
let state = s.model.state();
|
||||
let body = state.body("Round").unwrap();
|
||||
assert!(s.doc.configuration_face_indices(body, &target).is_err());
|
||||
assert!(s.doc.face_colors(body).is_empty());
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"outer"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":9,"id":"inner"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":20,"profiles":[{"point":[9.5,0]}],"body_name":"Sleeve"}));
|
||||
let names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
// Preserve the legacy centroid-family name even though strict paint uses
|
||||
// the separately certified outer source curve.
|
||||
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
|
||||
assert!(name.contains("side:inner"));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"],"name":"Raised Badge"}));
|
||||
let feature = s.doc.find_feature("Raised Badge").unwrap();
|
||||
assert_eq!(feature.face_names, vec![name]);
|
||||
assert!(feature.face_names.iter().all(|name| names.contains(name)));
|
||||
let feature_id = feature.id;
|
||||
let volume = s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
assert!((volume - std::f64::consts::PI * 380. - 21.).abs() < 0.2, "volume {volume}");
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/9");
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
assert!(reopened.model.result(feature_id).unwrap().error.is_none());
|
||||
assert!((reopened.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - volume).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atomically() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap();
|
||||
paint(&mut s, "Round", json!([10, 0, 5]));
|
||||
let created = run(&mut s, "component.create", json!({"name":"Placed"}));
|
||||
let destination = created["component"].as_u64().unwrap();
|
||||
let occurrence = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false}));
|
||||
run(&mut s, "component.move_bodies", json!({"component":destination,"bodies":["Round"]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
let before = s.world_state().body("Round").unwrap().mesh().bounds();
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Round", 10.), 2);
|
||||
assert!(
|
||||
s.world_state().body("Round").unwrap().mesh().bounds().min.dist(before.min) < 1e-8,
|
||||
"before {before:?}, reopened {:?}",
|
||||
s.world_state().body("Round").unwrap().mesh().bounds()
|
||||
);
|
||||
// A successful ordinary modifier has no exact untapered source certificate.
|
||||
let FeatureKind::Extrude { extent, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
extent.taper = Some("5 deg".into());
|
||||
s.refresh();
|
||||
assert!(s.model.result(source).unwrap().error.is_none());
|
||||
assert!(s.doc.face_colors(s.model.state().body("Round").unwrap()).is_empty());
|
||||
let document = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, document);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_import_payload_binding_roundtrip_clone_and_replacement_refusal() {
|
||||
let geometry = solvecraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 10., 10.).unwrap();
|
||||
let step = solvecraft_kernel::step_export(&[&geometry], "certificate fixture").unwrap();
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"ImportedPart"}))["component"].as_u64().unwrap();
|
||||
let source = imported(&mut s, step.clone(), "Imported");
|
||||
paint(&mut s, "Imported", json!([10, 0, 5]));
|
||||
let pieces = painted_radius(&s, "Imported", 10.);
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert!(
|
||||
matches!(&target.certificate, Some(FaceCertificate::Imported { item, face, payload_sha256 }) if *item != 0 && *face != 0 && payload_sha256.len() == 64)
|
||||
);
|
||||
run(&mut s, "component.paste_new", json!({"component":part,"translate":[30,0,0]}));
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let reopened = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut s = Session::new(reopened);
|
||||
run(&mut s, "config.activate", json!({"row":"Painted"}));
|
||||
assert_eq!(painted_radius(&s, "Imported", 10.), pieces);
|
||||
// Identical geometry, entity IDs and item IDs, but a replaced embedded payload.
|
||||
let FeatureKind::Import { step: payload, .. } = &mut s.doc_mut().feature_mut(source).unwrap().kind else { panic!() };
|
||||
*payload = step.replace("certificate fixture", "replacement fixture");
|
||||
assert_ne!(*payload, step);
|
||||
s.refresh();
|
||||
assert!(s.doc.face_colors(s.model.state().body("Imported").unwrap()).is_empty());
|
||||
assert_eq!(painted_radius(&s, "Imported (copy)", 10.), pieces);
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.preview", &json!({"row":"Painted"})).is_err());
|
||||
assert!(s.execute("config.activate", &json!({"row":"Painted"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::config::{Column, ConfigFaceTarget};
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command} {params}: {error}"))
|
||||
}
|
||||
fn block(s: &mut Session) -> String {
|
||||
run(s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
s.model.state().bodies[0].name.clone()
|
||||
}
|
||||
fn face_column(s: &mut Session, body: &str, point: Value) -> String {
|
||||
run(s, "config.configure", json!({"face_appearances":[{"body":body,"point":point}]}))["columns"][0].as_str().unwrap().into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_appearance_shared_definition_inheritance_and_delete() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap();
|
||||
let body = block(&mut s);
|
||||
run(&mut s, "material.assign", json!({"bodies":[body],"material":"Steel"}));
|
||||
run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]}));
|
||||
let table = run(&mut s, "config.configure", json!({"component_appearances":[component]}));
|
||||
let key = table["columns"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "config.row", json!({"name":"Red","values":{key.clone():"ABS - Red"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Red"}));
|
||||
let world = s.world_state();
|
||||
assert_eq!(world.bodies.len(), 2);
|
||||
assert!(world.bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Red"));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":[body],"appearance":"ABS - Yellow"}));
|
||||
assert!(s.world_state().bodies.iter().all(|body| s.doc.model_body_look(body).unwrap().name == "ABS - Yellow"));
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!(!s.doc.appearances.components.contains_key(&component));
|
||||
assert_eq!(s.doc.appearances.bodies[&body].name, "ABS - Yellow");
|
||||
run(&mut s, "component.rename", json!({"component":component,"name":"Renamed"}));
|
||||
assert_eq!(run(&mut s, "config.table", json!({}))["columns"][0], key);
|
||||
run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"appearance":"ABS - Blue"}));
|
||||
run(&mut s, "component.delete", json!({"component":component}));
|
||||
assert!(s.doc.configs.columns.is_empty());
|
||||
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
|
||||
assert!(!s.doc.appearances.components.contains_key(&component));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_face_rows_resize_rename_preview_suppress_and_roundtrip() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let key = face_column(&mut s, &body, json!([5, 5, 10]));
|
||||
let feature = s.doc.features[0].name.clone();
|
||||
let height = s.doc.features[0].param_names[2].clone();
|
||||
run(&mut s, "config.column", json!({"param":height}));
|
||||
run(&mut s, "config.column", json!({"feature":feature}));
|
||||
run(&mut s, "config.row", json!({"name":"Tall","values":{key:"ABS - Red",height:"20 mm"}}));
|
||||
let before = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
let history = s.undo.len();
|
||||
let preview = run(&mut s, "config.preview", json!({"row":"Tall"}));
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
assert_eq!(preview["styles"][0]["dormant"], false);
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
let state = s.model.state();
|
||||
let mb = state.body(&body).unwrap();
|
||||
let colors = s.doc.face_colors(mb);
|
||||
assert_eq!(colors.len(), 1);
|
||||
let names = solvecraft_doc::naming::face_names(mb);
|
||||
assert!(names[colors[0].0].ends_with(":+z"));
|
||||
assert!(s.doc.appearances.faces[0].strict_name);
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":body,"point":[5,5,20]}]}));
|
||||
assert!(s.doc.appearances.faces.is_empty(), "clear resolves semantic name after configured resize");
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
run(&mut s, "body.rename", json!({"body":body,"name":"Housing"}));
|
||||
let key = s.doc.config_key(&s.doc.configs.columns[0]);
|
||||
let suppress = format!("suppress:{feature}");
|
||||
run(&mut s, "config.row", json!({"name":"Hidden","values":{key:"ABS - Blue",suppress:true}}));
|
||||
let hidden = run(&mut s, "config.activate", json!({"row":"Hidden"}));
|
||||
assert_eq!(hidden["styles"][0]["dormant"], true);
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
let reopened = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
assert_eq!(s.doc.face_colors(s.model.state().body("Housing").unwrap())[0].1.name, "ABS - Red");
|
||||
run(&mut s, "config.activate", json!({"row":"Default"}));
|
||||
assert!(s.doc.appearances.faces.is_empty());
|
||||
let before_delete = s.doc.clone();
|
||||
run(&mut s, "timeline.delete", json!({"feature":feature}));
|
||||
assert!(s.doc.configs.columns.is_empty());
|
||||
assert!(s.doc.configs.rows.iter().all(|row| row.cells.is_empty()));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc, before_delete);
|
||||
run(&mut s, "config.activate", json!({"row":"Tall"}));
|
||||
assert!(s.doc.appearances.faces[0].strict_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_face_split_pieces_share_lineage_without_index_targets() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let key = face_column(&mut s, &body, json!([5, 5, 10]));
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
|
||||
run(&mut s, "solid.split_face", json!({"body":body,"faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Paint"}));
|
||||
let state = s.model.state();
|
||||
let body = state.body(&body).unwrap();
|
||||
let colors = s.doc.face_colors(body);
|
||||
let names = solvecraft_doc::naming::face_names(body);
|
||||
assert!(colors.len() >= 2, "split should create multiple painted pieces: {names:?}");
|
||||
assert!(colors.iter().all(|(index, look)| solvecraft_doc::naming::strip_piece(&names[*index]).ends_with(":+z") && look.name == "ABS - Red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_face_missing_lineage_never_retargets_and_rolls_back() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let key = face_column(&mut s, &body, json!([5, 5, 10]));
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[30,0,0],"operation":"new"}));
|
||||
let other = s.doc.features.last().unwrap().id;
|
||||
let Column::FaceAppearance(target) = &mut s.doc_mut().configs.columns[0] else { panic!() };
|
||||
target.origin_feature = other;
|
||||
target.name = format!("F{other}:+z");
|
||||
let before = s.doc.clone();
|
||||
let revision = s.revision;
|
||||
let history = s.undo.len();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err());
|
||||
assert!(s.execute("config.preview", &json!({"row":"Paint"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.revision, revision);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
let look = solvecraft_doc::appearance::library("ABS - Red").unwrap();
|
||||
s.doc_mut().appearances.faces.push(solvecraft_doc::appearance::FaceLook {
|
||||
body: body.clone(),
|
||||
point: solvecraft_geom::Vec3::new(5., 5., 10.),
|
||||
name: Some(format!("F{other}:+z")),
|
||||
strict_name: true,
|
||||
certificate: None,
|
||||
look,
|
||||
});
|
||||
assert!(s.doc.face_colors(s.model.state().body(&body).unwrap()).is_empty());
|
||||
s.doc_mut().appearances.faces[0].strict_name = false;
|
||||
assert_eq!(s.doc.face_colors(s.model.state().body(&body).unwrap()).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_occurrence_pick_uses_definition_identity_and_coordinates() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Part"}))["component"].as_u64().unwrap();
|
||||
let body = block(&mut s);
|
||||
run(&mut s, "occurrence.copy", json!({"component":component,"translate":[30,0,0]}));
|
||||
let world = s.world_state();
|
||||
let placed = world.bodies.iter().find(|body| body.name != body.definition_name()).unwrap();
|
||||
let placed_name = placed.name.clone();
|
||||
let point = solvecraft_doc::apply_point(&placed.source_transform, solvecraft_geom::Vec3::new(5., 5., 10.));
|
||||
let key = face_column(&mut s, &placed_name, json!(point));
|
||||
let Column::FaceAppearance(target) = &s.doc.configs.columns[0] else { panic!() };
|
||||
assert_eq!(target.body, body);
|
||||
assert!(target.point.dist(solvecraft_geom::Vec3::new(5., 5., 10.)) < 1e-8);
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Blue"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Paint"}));
|
||||
assert!(s.world_state().bodies.iter().all(|body| s.doc.face_colors(body).len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_cap_certification_follows_actual_positive_negative_and_symmetric_names() {
|
||||
for direction in ["positive", "negative", "symmetric"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"xy"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"direction":direction,"body_name":"Pad"}));
|
||||
let state = s.model.state();
|
||||
let body = state.body("Pad").unwrap();
|
||||
let faces = body.body.faces(0.01).unwrap();
|
||||
let names = solvecraft_doc::naming::face_names(body);
|
||||
let mut caps = 0;
|
||||
for face in faces {
|
||||
let name = solvecraft_doc::naming::strip_piece(&names[face.index]);
|
||||
let origin = name.strip_prefix('F').unwrap().split_once(':').unwrap().0.parse().unwrap();
|
||||
let target = ConfigFaceTarget { body: "Pad".into(), origin_feature: origin, name: name.into(), point: face.centroid, certificate: None };
|
||||
if name.ends_with(":start") || name.ends_with(":top") || name.ends_with(":bottom") {
|
||||
s.doc.certify_config_face(&target).unwrap();
|
||||
s.doc.configuration_face_indices(body, &target).unwrap();
|
||||
caps += 1;
|
||||
} else {
|
||||
assert!(s.doc.certify_config_face(&target).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(caps, 2, "{direction}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_extent_edits_never_transfer_strict_cap_paint_to_ambiguous_caps() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"xy"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Pad","name":"PadFeature","start_offset":"0 mm"}));
|
||||
let key = face_column(&mut s, "Pad", json!([5, 5, 10]));
|
||||
run(&mut s, "config.row", json!({"name":"Paint","values":{key:"ABS - Red"}}));
|
||||
run(&mut s, "config.activate", json!({"row":"Paint"}));
|
||||
assert_eq!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).len(), 1);
|
||||
run(&mut s, "timeline.redefine", json!({"feature":"PadFeature","command":"solid.extrude","params":{"distance":10,"start_offset":5}}));
|
||||
assert!(s.doc.face_colors(s.model.state().body("Pad").unwrap()).is_empty(), "ordinary offset edit must not paint both new top-named caps");
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("config.activate", &json!({"row":"Paint"})).is_err());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_and_uncertified_face_targets_are_rejected() {
|
||||
let mut s = Session::default();
|
||||
let body = block(&mut s);
|
||||
let id = s.doc.features[0].id;
|
||||
let target = ConfigFaceTarget {
|
||||
body: body.clone(),
|
||||
origin_feature: id,
|
||||
name: format!("F{id}:face:0"),
|
||||
point: solvecraft_geom::Vec3::ZERO,
|
||||
certificate: None,
|
||||
};
|
||||
assert!(s.doc.certify_config_face(&target).is_err());
|
||||
for value in [
|
||||
json!({"body":body,"origin_feature":id,"name":"x".repeat(1_000_000),"point":[0,0,0]}),
|
||||
json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,1e20]}),
|
||||
json!({"body":body,"origin_feature":id,"name":format!("F{id}:+z"),"point":[0,0,0],"extra":[[]]}),
|
||||
] {
|
||||
assert!(solvecraft_doc::config::validate_styles_json(&json!({"configs":{"columns":[{"face_appearance":value}]}})).is_err());
|
||||
}
|
||||
assert!(s.execute("config.column", &json!({"component_appearance":0})).is_err());
|
||||
assert!(s.execute("config.column", &json!({"face_appearance":{"body":body,"point":[0,0,1e20]}})).is_err());
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Bounded, chronological checks of a joint trajectory. This samples tessellated geometry;
|
||||
//! it is deliberately not advertised as exact continuous collision detection.
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use solvecraft_doc::joints;
|
||||
use solvecraft_doc::{IDENTITY, Mat, apply_point, mat_mul};
|
||||
use solvecraft_geom::{Aabb3, Mesh, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
const MAX_SAMPLES: usize = 4096;
|
||||
const MAX_PLACEMENTS: usize = 4096;
|
||||
const MAX_BODIES: usize = 256;
|
||||
const MAX_TRIANGLES: usize = 50_000;
|
||||
const MAX_WORK: usize = 50_000_000;
|
||||
|
||||
fn limit(message: &str) -> crate::EngineError {
|
||||
bad("contact.sweep", message)
|
||||
}
|
||||
|
||||
struct CachedBody {
|
||||
path: Vec<u64>,
|
||||
offset: Mat,
|
||||
mesh: Arc<Mesh>,
|
||||
size: f64,
|
||||
}
|
||||
|
||||
pub(super) struct ContactGeometry {
|
||||
bodies: Vec<CachedBody>,
|
||||
pairs: Vec<(usize, usize, String, String)>,
|
||||
step: f64,
|
||||
}
|
||||
|
||||
impl ContactGeometry {
|
||||
pub(super) fn new(s: &Session) -> Result<Self> {
|
||||
let assembly = &s.doc.assembly;
|
||||
let sets: Vec<Vec<u64>> = if assembly.contact_all {
|
||||
vec![s.doc.occurrences.iter().map(|o| o.id).collect()]
|
||||
} else {
|
||||
assembly.contact_sets.iter().filter(|set| !set.suppressed).map(|set| set.occurrences.clone()).collect()
|
||||
};
|
||||
let mut out = Self { bodies: Vec::new(), pairs: Vec::new(), step: f64::INFINITY };
|
||||
if sets.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
let state = s.model.state();
|
||||
let mut meshes: HashMap<String, Arc<Mesh>> = HashMap::new();
|
||||
let mut stack = vec![(0, Vec::<u64>::new())];
|
||||
let mut count = 0;
|
||||
while let Some((component, path)) = stack.pop() {
|
||||
count += 1;
|
||||
if count > MAX_PLACEMENTS || path.len() > 64 {
|
||||
return Err(limit("assembly exceeds contact sweep placement budget"));
|
||||
}
|
||||
for occurrence in s.doc.occurrences.iter().filter(|o| o.parent == component) {
|
||||
let mut child = path.clone();
|
||||
child.push(occurrence.id);
|
||||
stack.push((occurrence.component, child));
|
||||
if stack.len() > MAX_PLACEMENTS {
|
||||
return Err(limit("assembly exceeds contact sweep placement budget"));
|
||||
}
|
||||
}
|
||||
if !sets.iter().any(|set| path.iter().any(|id| set.contains(id))) {
|
||||
continue;
|
||||
}
|
||||
for body in state.bodies.iter().filter(|b| s.doc.model_body_component(b) == component) {
|
||||
if body.body.is_mesh() {
|
||||
return Err(limit("contact sweep does not support mesh bodies; disable their contact set to move them"));
|
||||
}
|
||||
if out.bodies.len() >= MAX_BODIES {
|
||||
return Err(limit("too many bodies for a contact sweep"));
|
||||
}
|
||||
let mesh = if let Some(mesh) = meshes.get(&body.name) {
|
||||
mesh.clone()
|
||||
} else {
|
||||
let mesh = body.body.tessellate((body.body.size() * 1e-3).max(1e-3))?;
|
||||
if mesh.triangles.len() > MAX_TRIANGLES || mesh.positions.len().saturating_add(mesh.triangles.len().saturating_mul(4)) > 200_000 {
|
||||
return Err(limit("body exceeds contact sweep tessellation budget"));
|
||||
}
|
||||
let extent = mesh.bounds().size();
|
||||
// Whole-body widths along bounded distinct facet normals catch tilted
|
||||
// plates without interpreting skinny mesh triangles as thin material.
|
||||
// Local thin walls of a wider body remain a sampling limitation.
|
||||
let mut thickness = extent.x.min(extent.y).min(extent.z);
|
||||
let mut directions: Vec<Vec3> = Vec::new();
|
||||
for triangle in &mesh.triangles {
|
||||
let Some([a, b, c]) = mesh.tri(triangle) else {
|
||||
return Err(limit("invalid contact tessellation"));
|
||||
};
|
||||
let Some(direction) = (b - a).cross(c - a).normalized() else { continue };
|
||||
if directions.len() < 32 && !directions.iter().any(|d| d.dot(direction).abs() > 1.0 - 1e-6) {
|
||||
directions.push(direction);
|
||||
}
|
||||
}
|
||||
for direction in directions {
|
||||
let (mut low, mut high) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for point in &mesh.positions {
|
||||
let projected = point.dot(direction);
|
||||
low = low.min(projected);
|
||||
high = high.max(projected);
|
||||
}
|
||||
thickness = thickness.min(high - low);
|
||||
}
|
||||
if !thickness.is_finite() || thickness <= 1e-9 {
|
||||
return Err(limit("body is too thin for a bounded contact sweep"));
|
||||
}
|
||||
out.step = out.step.min(thickness * 0.25);
|
||||
let mesh = Arc::new(mesh);
|
||||
meshes.insert(body.name.clone(), mesh.clone());
|
||||
mesh
|
||||
};
|
||||
out.bodies.push(CachedBody {
|
||||
path: path.clone(),
|
||||
offset: s.doc.model_body_offset(body).copied().unwrap_or(IDENTITY),
|
||||
mesh,
|
||||
size: body.body.size(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut pairs = BTreeSet::new();
|
||||
for set in &sets {
|
||||
let tagged: Vec<(usize, u64)> =
|
||||
out.bodies.iter().enumerate().filter_map(|(i, b)| b.path.iter().rev().find(|id| set.contains(id)).map(|id| (i, *id))).collect();
|
||||
for (i, (a, oa)) in tagged.iter().enumerate() {
|
||||
for (b, ob) in tagged.iter().skip(i + 1) {
|
||||
if oa != ob {
|
||||
pairs.insert((*a, *b, *oa, *ob));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = |id| s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
|
||||
out.pairs = pairs.into_iter().map(|(a, b, oa, ob)| (a, b, name(oa), name(ob))).collect();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn has_pairs(&self) -> bool {
|
||||
!self.pairs.is_empty()
|
||||
}
|
||||
|
||||
fn frame(body: &CachedBody, s: &Session) -> Result<Mat> {
|
||||
let mut frame = IDENTITY;
|
||||
for id in &body.path {
|
||||
let occurrence = s.doc.occurrences.iter().find(|o| o.id == *id).ok_or_else(|| limit("contact occurrence disappeared"))?;
|
||||
frame = mat_mul(&frame, &occurrence.transform);
|
||||
}
|
||||
Ok(mat_mul(&frame, &body.offset))
|
||||
}
|
||||
|
||||
fn bounds(&self, s: &Session) -> Result<Aabb3> {
|
||||
let mut bounds = Aabb3::EMPTY;
|
||||
for body in &self.bodies {
|
||||
let frame = Self::frame(body, s)?;
|
||||
for point in &body.mesh.positions {
|
||||
bounds.add(apply_point(&frame, *point));
|
||||
}
|
||||
}
|
||||
Ok(bounds)
|
||||
}
|
||||
|
||||
pub(super) fn samples(&self, start: &Session, end: &Session) -> Result<usize> {
|
||||
let a = joints::joint_values(&start.doc.assembly);
|
||||
let b = joints::joint_values(&end.doc.assembly);
|
||||
let bounds = self.bounds(start)?.union(&self.bounds(end)?);
|
||||
let state = start.model.state();
|
||||
let (mut linear, mut angular, mut radius) = (0.0, 0.0, 0.0_f64);
|
||||
for joint in start.doc.assembly.joints.iter().filter(|j| !j.suppressed) {
|
||||
for (index, _) in joint.kind.dofs().iter().enumerate() {
|
||||
let delta = (a.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0)
|
||||
- b.get(&joint.id).and_then(|q| q.get(index)).copied().unwrap_or(0.0))
|
||||
.abs();
|
||||
if joint.kind.is_angle(index) {
|
||||
angular += delta;
|
||||
if delta == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for session in [start, end] {
|
||||
for origin in [&joint.a, &joint.b] {
|
||||
let frame = session.doc.origin_world(&state, origin)?;
|
||||
let pivot = apply_point(&frame, Vec3::ZERO);
|
||||
let far = Vec3::new(
|
||||
(bounds.min.x - pivot.x).abs().max((bounds.max.x - pivot.x).abs()),
|
||||
(bounds.min.y - pivot.y).abs().max((bounds.max.y - pivot.y).abs()),
|
||||
(bounds.min.z - pivot.z).abs().max((bounds.max.z - pivot.z).abs()),
|
||||
);
|
||||
radius = radius.max(far.len());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
linear += delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
let required = ((linear + angular * (radius + linear)) / self.step).max(angular / 1_f64.to_radians()).ceil().max(1.0);
|
||||
if !required.is_finite() || required > MAX_SAMPLES as f64 {
|
||||
return Err(limit("motion exceeds contact sweep sampling budget; drive the joint in smaller increments"));
|
||||
}
|
||||
let vertices: usize = self.bodies.iter().map(|body| body.mesh.positions.len()).sum();
|
||||
if (required as usize).saturating_mul(vertices.saturating_add(self.pairs.len())) > MAX_WORK {
|
||||
return Err(limit("motion exceeds contact sweep transform budget; simplify the contact set or use smaller increments"));
|
||||
}
|
||||
Ok(required as usize)
|
||||
}
|
||||
|
||||
pub(super) fn first_hit(&self, s: &Session, work: &mut usize) -> Result<Option<(String, String, f64)>> {
|
||||
let meshes: Vec<Mesh> = self
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let frame = Self::frame(body, s)?;
|
||||
let positions = body.mesh.positions.iter().map(|p| apply_point(&frame, *p)).collect();
|
||||
Ok(Mesh { positions, triangles: body.mesh.triangles.clone(), ..Mesh::default() })
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
for (a, b, aname, bname) in &self.pairs {
|
||||
let (Some(ma), Some(mb), Some(ba), Some(bb)) = (meshes.get(*a), meshes.get(*b), self.bodies.get(*a), self.bodies.get(*b)) else {
|
||||
return Err(limit("invalid contact geometry pair"));
|
||||
};
|
||||
let (aabb, bbb) = (ma.bounds(), mb.bounds());
|
||||
if aabb.max.x < bbb.min.x
|
||||
|| bbb.max.x < aabb.min.x
|
||||
|| aabb.max.y < bbb.min.y
|
||||
|| bbb.max.y < aabb.min.y
|
||||
|| aabb.max.z < bbb.min.z
|
||||
|| bbb.max.z < aabb.min.z
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let cost = ma
|
||||
.positions
|
||||
.len()
|
||||
.saturating_add(ma.triangles.len().saturating_mul(4))
|
||||
.saturating_mul(mb.triangles.len())
|
||||
.saturating_add(mb.positions.len().saturating_add(mb.triangles.len().saturating_mul(4)).saturating_mul(ma.triangles.len()));
|
||||
*work = work.saturating_add(cost);
|
||||
if *work > MAX_WORK {
|
||||
return Err(limit("motion exceeds contact sweep collision budget; simplify the contact set or use smaller increments"));
|
||||
}
|
||||
if let Some(depth) = super::mesh_overlap(ma, mb, 1e-4 * ba.size.max(bb.size).max(1.0)) {
|
||||
return Ok(Some((aname.clone(), bname.clone(), depth)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "contact_sweep_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, command: &str, p: Value) -> Value {
|
||||
s.execute(command, &p).unwrap()
|
||||
}
|
||||
fn occurrence(s: &Session, name: &str) -> u64 {
|
||||
s.doc.occurrence_of(s.doc.find_component(name).unwrap()).unwrap().id
|
||||
}
|
||||
fn slider() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20)] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
let a = occurrence(&s, "A");
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [10, 5, 5]}, "b": {"face": [20, 5, 5]}, "name": "Rail"}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["A", "B"]}));
|
||||
s
|
||||
}
|
||||
fn rail_value(s: &Session) -> f64 {
|
||||
s.doc.assembly.joints.iter().find(|j| j.name == "Rail").unwrap().values[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_cannot_tunnel_to_a_collision_free_endpoint() {
|
||||
let mut s = slider();
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -30}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
assert!(rail_value(&s).abs() < 2e-3, "{}", rail_value(&s));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6, "can move away from contact");
|
||||
run(&mut s, "contact.disable_all", json!({}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -100000}));
|
||||
assert!((rail_value(&s) + 100000.0).abs() < 1e-6, "disabled contact skips sweep budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolute_cannot_cross_a_blocker_between_clear_endpoints() {
|
||||
let mut s = Session::default();
|
||||
for (name, corner) in [("Arm", [20, -2, -2]), ("Blocker", [-2, 20, -2])] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner}));
|
||||
}
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "revolute", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Arm", "point": [0,0,0]}, "flip": true, "name": "Pivot"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Arm", "Blocker"]}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Pivot", "value": "180 deg"}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let angle = s.doc.assembly.joints.iter().find(|j| j.name == "Pivot").unwrap().values[0];
|
||||
assert!(angle > 60_f64.to_radians() && angle < 90_f64.to_radians(), "angle={angle}");
|
||||
let arm = solvecraft_doc::apply_point(&solvecraft_doc::joints::occurrence_world(&s.doc, occurrence(&s, "Arm")), Vec3::new(22.0, 0.0, 0.0));
|
||||
assert!(arm.y > 20.0 && arm.x > 2.0, "{arm:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_slider_uses_effective_travel_for_contact() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": "Input"}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.create",
|
||||
json!({"type": "slider", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Input", "point": [0,0,0]}, "flip": true, "name": "Input Joint"}),
|
||||
);
|
||||
run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": 1}));
|
||||
run(&mut s, "joint.motion_link", json!({"a": "Input Joint", "b": "Rail", "ratio": 10}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Input Joint", "value": -3}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let input = s.doc.assembly.joints.iter().find(|j| j.name == "Input Joint").unwrap().values[0];
|
||||
assert!(input.abs() < 2e-4, "{input}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_play_and_sparse_export_share_swept_contact_checks() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "motion.study", json!({"name": "Crossing", "steps": 100, "keys": [{"joint": "Rail", "points": [[0,10], [100,-30]]}]}));
|
||||
let doc = s.doc.clone();
|
||||
let exported = run(&mut s, "motion.export", json!({"study": "Crossing", "samples": 2}));
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc), "export leaves document untouched");
|
||||
assert!(exported["samples"][1]["stopped_by_contact"].is_object(), "{exported}");
|
||||
let played = run(&mut s, "motion.play", json!({"study": "Crossing", "step": 100}));
|
||||
assert!(played["stopped_by_contact"].is_object(), "{played}");
|
||||
assert!(rail_value(&s).abs() < 2e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_sweep_errors_and_restores_document() {
|
||||
let mut s = slider();
|
||||
let doc = s.doc.clone();
|
||||
let error = s.execute("joint.drive", &json!({"joint": "Rail", "value": -100000})).unwrap_err();
|
||||
assert!(error.to_string().contains("sampling budget"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_tilted_plates_cannot_cross_between_axis_bound_samples() {
|
||||
let mut s = Session::default();
|
||||
let normal = solvecraft_doc::apply_vector(&solvecraft_doc::rigid(Vec3::ZERO, Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), 55_f64.to_radians()), Vec3::Z);
|
||||
for name in ["Plate A", "Plate B"] {
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 0.05, "corner": [-5,-5,-0.025], "body_name": name}));
|
||||
run(&mut s, "solid.move", json!({"bodies": [name], "axis": [1,1,0], "angle": "55 deg"}));
|
||||
}
|
||||
let (a, b) = (occurrence(&s, "Plate A"), occurrence(&s, "Plate B"));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": normal * 2.0}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "slider", "a": "Plate A", "b": "Plate B", "at": {"occurrence": 0, "point": [0,0,0], "z": normal}, "name": "Slide"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Plate A", "Plate B"]}));
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -4}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{result}");
|
||||
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
|
||||
assert!(value > -2.0 && value < -1.9, "{value}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_curved_bodies_keep_a_practical_sampling_step() {
|
||||
for curved in ["sphere", "fillet"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Blocker"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0,-5,-5]}));
|
||||
run(&mut s, "component.activate", json!({"component": 0}));
|
||||
run(&mut s, "component.create", json!({"name": "Curved"}));
|
||||
if curved == "sphere" {
|
||||
run(&mut s, "solid.sphere", json!({"radius": 5, "center": [20,0,0]}));
|
||||
} else {
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [15,-5,-5]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[15,-5,0]], "radius": 2}));
|
||||
}
|
||||
run(
|
||||
&mut s,
|
||||
"joint.as_built",
|
||||
json!({"type": "slider", "a": 0, "b": "Curved", "at": {"occurrence": 0, "point": [0,0,0], "z": [1,0,0]}, "name": "Slide"}),
|
||||
);
|
||||
run(&mut s, "contact.create", json!({"occurrences": ["Blocker", "Curved"]}));
|
||||
let geometry = super::ContactGeometry::new(&s).unwrap();
|
||||
assert!(geometry.step > 1.0, "{curved}: tessellation must not force tiny steps: {}", geometry.step);
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Slide", "value": -30}));
|
||||
assert!(result["stopped_by_contact"].is_object(), "{curved}: {result}");
|
||||
let value = s.doc.assembly.joints.iter().find(|j| j.name == "Slide").unwrap().values[0];
|
||||
assert!(value > -5.02 && value < -4.9, "{curved}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initially_overlapping_assembly_can_move_out_for_recovery() {
|
||||
let mut s = slider();
|
||||
run(&mut s, "contact.disable_all", json!({}));
|
||||
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": -5}));
|
||||
run(&mut s, "contact.enable_sets", json!({"enabled": true}));
|
||||
assert!(!run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
|
||||
let result = run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 10}));
|
||||
assert!(result["stopped_by_contact"].is_null(), "recovery must not stick: {result}");
|
||||
assert!((rail_value(&s) - 10.0).abs() < 1e-6);
|
||||
assert!(run(&mut s, "contact.check", json!({}))["collisions"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -65,8 +65,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut features: Vec<u64> = Vec::new();
|
||||
let mut bodies: Vec<String> = Vec::new();
|
||||
let mut faces: Vec<(String, Vec<Vec3>)> = Vec::new();
|
||||
let mut selected_occurrences = Vec::new();
|
||||
for x in &items {
|
||||
match x {
|
||||
Sel::Occurrence { path } => {
|
||||
selected_occurrences.push(s.occurrence_placement(path)?.occurrence.to_string());
|
||||
}
|
||||
Sel::SketchCurve { id } | Sel::SketchPoint { id } | Sel::SketchConstraint { id } => entities.push(id.clone()),
|
||||
Sel::Feature { id } => {
|
||||
if s.doc.feature(*id).is_none() {
|
||||
@@ -118,7 +122,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// An occurrence goes alone while its component has others; the last one takes the component.
|
||||
let mut components: Vec<u64> = Vec::new();
|
||||
for k in keys(p, "occurrences") {
|
||||
selected_occurrences.extend(keys(p, "occurrences"));
|
||||
selected_occurrences.sort();
|
||||
selected_occurrences.dedup();
|
||||
for k in selected_occurrences {
|
||||
let Some(o) = s.doc.occurrences.iter().find(|o| o.id.to_string() == k || o.name == k).cloned() else {
|
||||
return Err(bad(cmd, format!("no occurrence `{k}`")));
|
||||
};
|
||||
|
||||
+245
-20
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).params(
|
||||
"feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}} → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused",
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).noundo().params(
|
||||
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
CommandSpec::new("select.set", "Select", select_set)
|
||||
.noundo()
|
||||
@@ -47,9 +52,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.pending_moves = prev.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"undone": label}))
|
||||
@@ -58,9 +64,10 @@ fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch });
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.pending_moves = next.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
Ok(json!({"redone": label}))
|
||||
@@ -188,18 +195,92 @@ fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `profiles` (also a loft section's) given as the feature commands take it (`"all"`,
|
||||
/// `[index]`, `[{curves}]`, `[{point}]`...) becomes the stored form; the stored form passes.
|
||||
fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
|
||||
let conv = |v: &mut Value| -> Result<()> {
|
||||
if let Some(pv) = v.get("profiles").filter(|x| x.get("type").is_none()) {
|
||||
let sel = super::features::profiles(&json!({"profiles": pv}), cmd)?;
|
||||
let sv = serde_json::to_value(sel).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("profiles".into(), sv);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut set = set.clone();
|
||||
conv(&mut set)?;
|
||||
if let Some(secs) = set.get_mut("sections").and_then(Value::as_array_mut) {
|
||||
for sec in secs.iter_mut().take(10_000) {
|
||||
conv(sec)?;
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Fields of a stored feature left out of its definition: bookkeeping timeline.edit recomputes, and
|
||||
/// the name and suppression, which timeline.rename and timeline.suppress change.
|
||||
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
|
||||
|
||||
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.inspect";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut def = serde_json::to_value(f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut omitted = Vec::new();
|
||||
if let Some(o) = def.as_object_mut() {
|
||||
for k in NOT_EDITABLE {
|
||||
o.remove(*k);
|
||||
}
|
||||
// Large payloads that timeline.edit cannot change.
|
||||
let heavy: &[(&str, &str)] = match &f.kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
};
|
||||
for (k, why) in heavy {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let inputs: Vec<Value> = f
|
||||
.kind
|
||||
.inputs()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, e, k))| {
|
||||
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(usize::MAX);
|
||||
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
||||
"definition": def, "omitted": omitted, "inputs": inputs,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.edit";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let set = p.get("set").filter(|v| v.is_object()).ok_or_else(|| bad(cmd, "`set` must be an object of fields"))?;
|
||||
let f = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let set = &command_profiles(set, cmd)?;
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind {
|
||||
solvecraft_doc::validate_body_transfer(&s.doc, bodies, *from, *to, transform)?;
|
||||
nf.component = *to;
|
||||
}
|
||||
if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind)
|
||||
&& a != b
|
||||
{
|
||||
@@ -216,6 +297,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nf.edge_refs = solvecraft_doc::edge_refs(&before, body, a);
|
||||
nf.edge_names = solvecraft_doc::edge_names_for(&before, body, a);
|
||||
}
|
||||
// Profiles picked by index or point are kept by their curves, as when the feature was made.
|
||||
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
|
||||
super::features::stable_profiles(s, &mut nf.kind)?;
|
||||
}
|
||||
// New face points: their faces' names now.
|
||||
if nf.kind.face_points() != f.kind.face_points() {
|
||||
nf.face_names = solvecraft_doc::naming::face_names_at(&s.model.state_before(id), &nf.kind.face_points());
|
||||
@@ -238,6 +323,9 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut sel: Vec<Sel> = Vec::new();
|
||||
for it in items {
|
||||
let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
if let Sel::Occurrence { path } = &one {
|
||||
s.occurrence_bodies(path, true).map_err(|e| bad("select.set", e.to_string()))?;
|
||||
}
|
||||
sel.push(one);
|
||||
}
|
||||
if !bool_(p, "add").unwrap_or(false) {
|
||||
@@ -296,36 +384,169 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"feature": id, "position": to}))
|
||||
}
|
||||
|
||||
fn input_keys(kind: &FeatureKind) -> Vec<(String, solvecraft_doc::expr::Kind)> {
|
||||
let mut keys: Vec<_> = kind.inputs().into_iter().map(|(label, _, unit)| (label.to_string(), unit)).collect();
|
||||
if let FeatureKind::Fillet { style, .. } = kind {
|
||||
if let Some((key, _)) = keys.first_mut() {
|
||||
*key = "fillet.radius".into();
|
||||
}
|
||||
if let solvecraft_doc::FilletStyle::Variable { mid, .. } = style {
|
||||
for ((key, _), (position, _)) in keys.iter_mut().skip(2).zip(mid) {
|
||||
*key = format!("fillet.mid.{}", position.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.redefine";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?;
|
||||
let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?;
|
||||
if spec.id.starts_with("timeline.") || spec.id.starts_with("select.") || spec.id.starts_with("sketch.") {
|
||||
// Only modeling constructors may run here. In particular, a scratch session does not
|
||||
// isolate filesystem writes performed by export commands.
|
||||
let constructor = super::features::COMMANDS
|
||||
.iter()
|
||||
.chain(super::features_more::COMMANDS)
|
||||
.chain(super::construct::COMMANDS)
|
||||
.chain(super::surface::COMMANDS)
|
||||
.any(|candidate| candidate.id == spec.id)
|
||||
|| matches!(
|
||||
spec.id,
|
||||
"sheet.flange"
|
||||
| "sheet.hem"
|
||||
| "sheet.fold"
|
||||
| "sheet.unfold"
|
||||
| "sheet.refold"
|
||||
| "sheet.convert"
|
||||
| "plastic.boss"
|
||||
| "plastic.lip"
|
||||
| "plastic.snap_fit"
|
||||
| "plastic.rest"
|
||||
);
|
||||
if !constructor {
|
||||
return Err(bad(cmd, format!("`{cid}` does not make a feature")));
|
||||
}
|
||||
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let marker = s.doc.marker;
|
||||
// Build the new definition where the feature sits, against the model before it.
|
||||
s.doc_mut().marker = Some(idx);
|
||||
s.refresh();
|
||||
let r = (spec.run)(s, ¶ms);
|
||||
let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64);
|
||||
let new = made.and_then(|nid| s.doc.feature(nid).cloned());
|
||||
if let Some(nid) = made {
|
||||
s.doc_mut().features.retain(|f| f.id != nid);
|
||||
if let FeatureKind::Revolve { convention, .. } = &old.kind
|
||||
&& cid == "solid.revolve"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
if !old.participants.is_empty()
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
parameters.entry("participants").or_insert_with(|| json!(old.participants));
|
||||
}
|
||||
// Run the constructor on a scratch session: failed edits must not disturb sketch mode,
|
||||
// selections, component authoring state or the original feature's definition.
|
||||
let mut trial = s.scratch();
|
||||
trial.active_component = old.component;
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &old.kind {
|
||||
trial.active_occurrence_path = (!face.author_path.is_empty()).then(|| face.author_path.clone());
|
||||
}
|
||||
trial.active_sketch = None;
|
||||
trial.selection = s.selection.clone();
|
||||
// Keep the original identity as a suppressed placeholder. The constructor and
|
||||
// any ownership boundary it adds go AFTER this slot, so discarding its temporary
|
||||
// feature leaves auxiliary history after the rewritten original.
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = true;
|
||||
}
|
||||
trial.doc_mut().marker = Some(idx + 1);
|
||||
trial.refresh();
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
|
||||
&& before.body == after.body
|
||||
&& before.path == after.path
|
||||
&& before.author_path == after.author_path
|
||||
&& before.at.dist(after.at) < 1e-8
|
||||
{
|
||||
*after = before.clone();
|
||||
}
|
||||
s.doc_mut().marker = marker;
|
||||
r?;
|
||||
let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
|
||||
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
|
||||
}
|
||||
if let Some(slot) = s.doc_mut().feature_mut(id) {
|
||||
slot.kind = new.kind;
|
||||
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
|
||||
// Keep each existing model parameter attached to its semantic input, rather than its
|
||||
// old index. Constructor-assigned names for new inputs cannot reuse the old dimensions.
|
||||
let mut old_inputs: Vec<_> = input_keys(&old.kind).into_iter().zip(&old.param_names).collect();
|
||||
let param_names: Vec<String> = input_keys(&new.kind)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, kind))| {
|
||||
if let Some(k) = old_inputs.iter().position(|((old_label, old_kind), _)| old_label == label && old_kind == kind) {
|
||||
old_inputs.remove(k).1.clone()
|
||||
} else {
|
||||
new.param_names.get(i).cloned().unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
trial.doc_mut().features.retain(|f| f.id != made);
|
||||
let added = trial.doc.features.len().saturating_sub(s.doc.features.len());
|
||||
trial.doc_mut().marker = s.doc.marker.map(|marker| if marker > idx { marker.saturating_add(added) } else { marker });
|
||||
if made == s.doc.next_id && trial.doc.next_id == made.saturating_add(1) {
|
||||
trial.doc_mut().next_id = s.doc.next_id;
|
||||
}
|
||||
if let Some(slot) = trial.doc_mut().feature_mut(id) {
|
||||
slot.suppressed = old.suppressed;
|
||||
slot.kind = new.kind;
|
||||
slot.edge_refs = new.edge_refs;
|
||||
slot.edge_names = new.edge_names;
|
||||
slot.face_names = new.face_names;
|
||||
slot.param_names = param_names;
|
||||
if params.get("participants").is_some() {
|
||||
slot.participants = new.participants;
|
||||
}
|
||||
}
|
||||
// New optional inputs need model parameter names, while existing dimensions keep theirs.
|
||||
trial.doc_mut().name_feature_inputs(id);
|
||||
trial.refresh();
|
||||
if let Some(e) = trial.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
let original_ids: std::collections::BTreeSet<u64> = s.doc.features.iter().map(|feature| feature.id).collect();
|
||||
let added_ids: std::collections::BTreeSet<u64> =
|
||||
trial.doc.features.iter().filter(|feature| !original_ids.contains(&feature.id)).map(|feature| feature.id).collect();
|
||||
let adds_transfer =
|
||||
trial.doc.features.iter().any(|feature| added_ids.contains(&feature.id) && matches!(feature.kind, FeatureKind::BodyTransfer { .. }));
|
||||
let baseline = if adds_transfer && s.doc.marker.is_some() {
|
||||
let mut baseline = s.scratch();
|
||||
baseline.doc_mut().marker = None;
|
||||
baseline.refresh();
|
||||
Some(baseline)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let proof = if (adds_transfer && trial.doc.marker.is_some()) || added_ids.iter().any(|id| trial.model.result(*id).is_none()) {
|
||||
let mut proof = trial.scratch();
|
||||
proof.doc_mut().marker = None;
|
||||
proof.refresh();
|
||||
Some(proof)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let validated = proof.as_ref().map(|proof| &proof.model).unwrap_or(&trial.model);
|
||||
let original_model = baseline.as_ref().map(|baseline| &baseline.model).unwrap_or(&s.model);
|
||||
for feature in &trial.doc.features {
|
||||
let auxiliary = added_ids.contains(&feature.id);
|
||||
let protected_transfer = matches!(feature.kind, FeatureKind::BodyTransfer { .. })
|
||||
&& original_model.result(feature.id).is_some_and(|result| !result.skipped && result.error.is_none());
|
||||
if (auxiliary || protected_transfer)
|
||||
&& let Some(error) = validated.result(feature.id).and_then(|result| result.error.as_ref())
|
||||
{
|
||||
return Err(bad(cmd, format!("{}: {error}", feature.name)));
|
||||
}
|
||||
}
|
||||
s.pending_moves = trial.pending_moves;
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
@@ -344,3 +565,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
|
||||
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "edit_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Edit Feature must replace the selections as well as the numerical definition.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Sel, Session};
|
||||
|
||||
fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_a_blend_replaces_persistent_edge_selection_and_survives_resize() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 20]], "radius": 2, "name": "Round"}));
|
||||
let round_id = s.doc.find_feature("Round").unwrap().id;
|
||||
let body_name = s.model.state().bodies[0].name.clone();
|
||||
s.doc_mut().feature_mut(round_id).unwrap().participants = vec![body_name];
|
||||
s.refresh();
|
||||
let old = s.doc.find_feature("Round").unwrap().clone();
|
||||
let before = (*s.doc).clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[0, 15, 20]], "radius": 2}}));
|
||||
let edited = s.doc.find_feature("Round").unwrap().clone();
|
||||
assert_eq!(edited.id, old.id);
|
||||
assert_eq!(edited.param_names, old.param_names);
|
||||
assert_eq!(edited.participants, old.participants);
|
||||
assert_ne!(edited.edge_names, old.edge_names, "the new edge must be the reference used on recompute");
|
||||
assert_ne!(edited.edge_refs, old.edge_refs);
|
||||
assert_eq!(s.doc.next_id, before.next_id, "temporary definitions must not consume feature ids");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.find_feature("Round").unwrap().edge_names, edited.edge_names);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60", "height": "25"}}));
|
||||
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let expected = 60.0 * 30.0 * 25.0 - (4.0 - std::f64::consts::PI) * 30.0;
|
||||
assert!((volume - expected).abs() < expected * 1e-4, "{volume} != {expected}");
|
||||
assert!(s.model.result(old.id).unwrap().warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_preserves_body_names_participants_and_marker_without_temporary_features() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base", "body_name": "Housing"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "name": "Other"}));
|
||||
let id = s.doc.find_feature("Base").unwrap().id;
|
||||
run(&mut s, "timeline.rollback", json!({"position": 1}));
|
||||
let before = s.doc.feature(id).unwrap().clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": id, "command": "solid.box", "params": {"length": 25, "width": 10, "height": 5}}));
|
||||
let after = s.doc.feature(id).unwrap();
|
||||
assert_eq!(s.doc.features.len(), 2);
|
||||
assert_eq!(s.doc.marker, Some(1));
|
||||
assert_eq!(after.body_names, before.body_names);
|
||||
assert_eq!(after.participants, before.participants);
|
||||
assert_eq!(after.param_names, before.param_names);
|
||||
assert_eq!(after.name, before.name);
|
||||
assert!(s.model.state().body("Housing").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_or_non_feature_redefinitions_leave_session_and_history_intact() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 5, "name": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s.selection = vec![Sel::Feature { id: s.doc.find_feature("Base").unwrap().id }];
|
||||
let doc = s.doc.clone();
|
||||
let sketch = s.active_sketch;
|
||||
let selection = s.selection.clone();
|
||||
let undo = s.undo.len();
|
||||
for (command, params) in [
|
||||
("solid.cylinder", json!({"radius": 5, "height": 10})),
|
||||
("solid.box", json!({"length": "unknown_parameter", "width": 10, "height": 5})),
|
||||
("file.export", json!({"path": "should-not-be-created.step"})),
|
||||
("edit.undo", json!({})),
|
||||
] {
|
||||
assert!(s.execute("timeline.redefine", &json!({"feature": "Base", "command": command, "params": params})).is_err());
|
||||
assert_eq!(*s.doc, *doc, "{command}");
|
||||
assert_eq!(s.active_sketch, sketch, "{command}");
|
||||
assert_eq!(s.selection, selection, "{command}");
|
||||
assert_eq!(s.undo.len(), undo, "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_an_extrude_names_new_optional_inputs() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Pad"}));
|
||||
let distance = s.doc.find_feature("Pad").unwrap().param_names[0].clone();
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "start_offset": 2}}));
|
||||
let f = s.doc.find_feature("Pad").unwrap();
|
||||
assert_eq!(f.param_names.len(), f.kind.inputs().len());
|
||||
assert_eq!(f.param_names[0], distance);
|
||||
assert!(f.param_names.iter().all(|name| !name.is_empty()));
|
||||
assert_eq!(s.doc.features.len(), 2);
|
||||
let offset = s.doc.find_feature("Pad").unwrap().param_names.last().unwrap().clone();
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Pad", "command": "solid.extrude", "params": {"distance": 5, "distance2": 2, "start_offset": 2}}),
|
||||
);
|
||||
let f = s.doc.find_feature("Pad").unwrap();
|
||||
let inputs = f.kind.inputs();
|
||||
let offset_index = inputs.iter().position(|(label, _, _)| *label == "Start offset").unwrap();
|
||||
assert_eq!(f.param_names[offset_index], offset, "adding Distance 2 must not steal the offset dimension's parameter name");
|
||||
let distance2_index = inputs.iter().position(|(label, _, _)| *label == "Distance 2").unwrap();
|
||||
assert_ne!(f.param_names[distance2_index], offset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_a_shell_replaces_persistent_face_selection() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[20, 15, 20]], "thickness": 2, "name": "Hollow"}));
|
||||
let before = s.doc.find_feature("Hollow").unwrap().face_names.clone();
|
||||
assert!(!before.is_empty());
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Hollow", "command": "solid.shell", "params": {"faces": [[20, 15, 0]], "thickness": 2}}));
|
||||
let after = s.doc.find_feature("Hollow").unwrap().face_names.clone();
|
||||
assert!(!after.is_empty());
|
||||
assert_ne!(before, after);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30"}}));
|
||||
let id = s.doc.find_feature("Hollow").unwrap().id;
|
||||
assert!(s.model.result(id).unwrap().error.is_none());
|
||||
assert!(s.model.result(id).unwrap().warning.is_none());
|
||||
assert_eq!(s.doc.find_feature("Hollow").unwrap().face_names, after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_rejects_sheet_exports_before_they_touch_any_files() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"name": "Plate"}));
|
||||
let doc = s.doc.clone();
|
||||
let base = std::env::temp_dir().join(format!("solvecraft-redefine-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
for command in ["sheet.export_dxf", "sheet.export_bend_table"] {
|
||||
for existing in [false, true] {
|
||||
let path = base.join(format!("{}-{existing}.out", command.replace('.', "-")));
|
||||
if existing {
|
||||
std::fs::write(&path, b"original file").unwrap();
|
||||
} else if path.exists() {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"path": path}})).unwrap_err();
|
||||
assert!(error.to_string().contains("does not make a feature"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original file");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
} else {
|
||||
assert!(!path.exists(), "{command} must not create a file before refusing the edit");
|
||||
}
|
||||
}
|
||||
}
|
||||
for command in ["sheet.manage_rules", "sheet.rules", "sheet.flat_pattern", "parameters.list", "plastic.manage_rules"] {
|
||||
let error = s.execute("timeline.redefine", &json!({"feature": "Plate", "command": command, "params": {"name": "unexpected"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("does not make a feature"), "{error}");
|
||||
assert_eq!(*s.doc, *doc);
|
||||
}
|
||||
std::fs::remove_dir(&base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_keeps_allocator_above_component_and_occurrence_ids() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Base"}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [10, 0, 0], "body_name": "Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Tool"], "name": "Join"}));
|
||||
let original = s.doc.clone();
|
||||
let original_feature = s.doc.find_feature("Join").unwrap().id;
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Join", "command": "solid.combine", "params": {"target": "Base", "tools": ["Tool"], "new_component": true, "component_name": "Merged"}}),
|
||||
);
|
||||
let destination = s.doc.find_component("Merged").unwrap();
|
||||
assert_eq!(s.model.state().body("Base").unwrap().component, destination);
|
||||
assert_eq!(s.doc.find_feature("Join").unwrap().id, original_feature);
|
||||
let combine_index = s.doc.feature_index(original_feature).unwrap();
|
||||
assert!(
|
||||
matches!(&s.doc.features[combine_index + 1].kind, solvecraft_doc::FeatureKind::BodyTransfer { bodies, to, .. } if bodies == &["Base"] && *to == destination)
|
||||
);
|
||||
assert!((solvecraft_kernel::measure(&s.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
run(&mut s, "timeline.rollback", json!({"position":combine_index + 1}));
|
||||
assert_eq!(s.world_state().body("Base").unwrap().component, 0, "the result transfers only AFTER Combine");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc, *original);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, destination);
|
||||
assert!((solvecraft_kernel::measure(&reopened.world_state().body("Base").unwrap().body).unwrap().volume - 6000.0).abs() < 1e-6);
|
||||
let max_id =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).max().unwrap();
|
||||
assert!(s.doc.next_id > max_id);
|
||||
run(&mut s, "component.create", json!({"name": "Next", "parent": 0}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
|
||||
let ids: Vec<u64> =
|
||||
s.doc.components.iter().map(|c| c.id).chain(s.doc.occurrences.iter().map(|o| o.id)).chain(s.doc.features.iter().map(|f| f.id)).collect();
|
||||
let unique: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
|
||||
assert_eq!(ids.len(), unique.len(), "feature, component and occurrence ids must remain unique: {ids:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_cannot_publish_a_second_transfer_that_breaks_existing_ownership() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Base"}));
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[1,0,0],"body_name":"Tool"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Base","tools":["Tool"],"name":"Join","new_component":true,"component_name":"Original"}));
|
||||
let full = s.doc.to_json();
|
||||
let join_index = s.doc.features.iter().position(|feature| feature.name == "Join").unwrap();
|
||||
for marker in [None, Some(join_index), Some(join_index + 1)] {
|
||||
let mut s = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
s.doc_mut().marker = marker;
|
||||
s.refresh();
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let error = s.execute("timeline.redefine", &json!({"feature":"Join","command":"solid.combine","params":{"target":"Base","tools":["Tool"],"new_component":true,"component_name":"Duplicate"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("transfer source component"), "marker {marker:?}: {error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&full).unwrap());
|
||||
assert_eq!(reopened.world_state().body("Base").unwrap().component, reopened.doc.find_component("Original").unwrap());
|
||||
assert!(reopened.doc.features.iter().all(|feature| reopened.model.result(feature.id).is_some_and(|result| result.error.is_none())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_maps_placement_inputs_in_the_feature_owner_frame() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "name": "RootBox", "body_name": "RootBody"}));
|
||||
let owner = run(&mut s, "component.create", json!({"name": "Owner"}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "name": "OwnedBox", "body_name": "OwnedBody"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[12, 0, 4]], "radius": 0.5, "body": "OwnedBody", "name": "OwnerRound"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": owner["occurrence"], "translate": [100, 0, 0]}));
|
||||
let other = run(&mut s, "component.create", json!({"name": "Other", "parent": 0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": other["occurrence"], "translate": [200, 0, 0]}));
|
||||
let active = s.active_component;
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "RootBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [2, 0, 0]}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "OwnedBox", "command": "solid.box", "params": {"length": 4, "width": 4, "height": 4, "corner": [110, 0, 0]}}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "OwnerRound", "command": "solid.fillet", "params": {"edges": [[112, 0, 4]], "radius": 0.75, "body": "OwnedBody"}}),
|
||||
);
|
||||
assert_eq!(s.active_component, active, "editing must not activate the owner in the real session");
|
||||
assert_eq!(s.doc.find_feature("OwnedBox").unwrap().component, owner["component"].as_u64().unwrap());
|
||||
let root = run(&mut s, "inspect.measure", json!({"bodies": ["RootBody"]}));
|
||||
let owned = run(&mut s, "inspect.measure", json!({"bodies": ["OwnedBody"]}));
|
||||
assert!((root["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 2.0).abs() < 1e-8);
|
||||
assert!((owned["bodies"][0]["bbox"]["min"][0].as_f64().unwrap() - 110.0).abs() < 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_fillet_style_keeps_primary_parameter_and_downstream_references() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[10, 10, 30]], "radius": 2, "name": "Round"}));
|
||||
let parameter = s.doc.find_feature("Round").unwrap().param_names[0].clone();
|
||||
run(&mut s, "solid.box", json!({"length": parameter, "width": 2, "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 3, "type": "chord"}}),
|
||||
);
|
||||
assert_eq!(s.doc.find_feature("Round").unwrap().param_names[0], parameter);
|
||||
let dependent = s.doc.find_feature("Dependent").unwrap().id;
|
||||
assert!(s.model.result(dependent).unwrap().error.is_none());
|
||||
let value = run(&mut s, "expr.evaluate", json!({"expression": parameter}));
|
||||
assert_eq!(value["value"], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_variable_fillet_control_preserves_parameters_at_existing_positions() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 30}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.25, 3], [0.75, 4]], "name": "Round"}),
|
||||
);
|
||||
let parameters = s.doc.find_feature("Round").unwrap().param_names.clone();
|
||||
run(&mut s, "solid.box", json!({"length": parameters[2], "width": parameters[3], "height": 2, "corner": [50, 0, 0], "name": "Dependent"}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[10, 10, 30]], "radius": 2, "radius2": 2, "type": "variable", "start": [10, 0, 30], "mid": [[0.1, 2.5], [0.25, 3], [0.75, 4]]}}),
|
||||
);
|
||||
let edited = s.doc.find_feature("Round").unwrap();
|
||||
assert_eq!(edited.param_names[0..2], parameters[0..2]);
|
||||
assert_eq!(edited.param_names[3..5], parameters[2..4]);
|
||||
assert!(!parameters.contains(&edited.param_names[2]), "the inserted control needs a new dimension");
|
||||
let dependent = s.doc.find_feature("Dependent").unwrap().id;
|
||||
assert!(s.model.result(dependent).unwrap().error.is_none());
|
||||
let length = run(&mut s, "expr.evaluate", json!({"expression": parameters[2]}));
|
||||
let width = run(&mut s, "expr.evaluate", json!({"expression": parameters[3]}));
|
||||
assert_eq!(length["value"], 3.0);
|
||||
assert_eq!(width["value"], 4.0);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! edges become lines, circular edges circles or arcs, anything else a chain of lines.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul};
|
||||
use solvecraft_geom::{Mesh, Vec2, Vec3};
|
||||
|
||||
use crate::params::bad;
|
||||
@@ -19,7 +20,7 @@ fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
|
||||
/// The outward normal of the planar body face at `p`.
|
||||
pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -34,7 +35,7 @@ pub(super) fn face_normal(s: &Session, p: Vec3) -> Option<Vec3> {
|
||||
|
||||
/// Is the body face at `p` planar (`None`: no face there)?
|
||||
pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
let (m, f, d) = face_at(s, p)?;
|
||||
let (m, f, d, _) = face_at(s, p)?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return None;
|
||||
@@ -52,9 +53,10 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance).
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64)> = None;
|
||||
let mut best: Option<(std::sync::Arc<Mesh>, u32, f64, String)> = None;
|
||||
let mut ambiguous = false;
|
||||
for b in &st.bodies {
|
||||
let m = b.mesh();
|
||||
let mut near: Option<(u32, f64)> = None;
|
||||
@@ -65,13 +67,16 @@ fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64)> {
|
||||
near = Some((*f, d));
|
||||
}
|
||||
}
|
||||
if let Some((f, d)) = near
|
||||
&& best.as_ref().is_none_or(|x| d < x.2)
|
||||
{
|
||||
best = Some((m.clone(), f, d));
|
||||
if let Some((f, d)) = near {
|
||||
if best.as_ref().is_none_or(|x| d < x.2 - 1e-8) {
|
||||
best = Some((m.clone(), f, d, b.name.clone()));
|
||||
ambiguous = false;
|
||||
} else if best.as_ref().is_some_and(|x| (d - x.2).abs() <= 1e-8) {
|
||||
ambiguous = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
if ambiguous { None } else { best }
|
||||
}
|
||||
|
||||
/// Circle through three points.
|
||||
@@ -93,7 +98,7 @@ fn xy(v: Vec2) -> Value {
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<(u64, Vec2)> {
|
||||
let (m, f, d) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let (m, f, d, body) = face_at(s, point).ok_or_else(|| bad(cmd, "no body face at `face`"))?;
|
||||
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
|
||||
if d > tol * 10.0 {
|
||||
return Err(bad(cmd, "no body face at `face`"));
|
||||
@@ -118,14 +123,45 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
|
||||
let spec = crate::find_command(id).ok_or_else(|| bad(cmd, id))?;
|
||||
(spec.run)(s, &p)
|
||||
};
|
||||
let r = run(s, "sketch.create", json!({"plane": {"face": [inside.x, inside.y, inside.z]}, "project_edges": true}))?;
|
||||
// Command frame normalization already made `point` definition-local. Capture takes
|
||||
// world coordinates; convert exactly once using the picked body's actual occurrence.
|
||||
let state = s.model.state();
|
||||
let doc = s.placed_document();
|
||||
let picked = state.body(&body).ok_or_else(|| bad(cmd, "picked body disappeared"))?;
|
||||
let component = doc.model_body_component(picked);
|
||||
let path = if component == s.active_component {
|
||||
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
|
||||
} else {
|
||||
// The command mapper discarded the original placement when making this pick
|
||||
// definition-local. A repeated cross-owner source cannot be re-found safely.
|
||||
let placements = super::component::properties::placements(&doc, cmd)?;
|
||||
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
|
||||
return Err(bad(cmd, "activate the picked occurrence before generating a face profile from a repeated component"));
|
||||
}
|
||||
doc.first_path(component)
|
||||
};
|
||||
let placement = EmbossFace::component_frame(&doc, component, &path)?;
|
||||
let placement = doc.model_body_offset(picked).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
|
||||
let world_inside = apply_point(&placement, inside);
|
||||
let world_body = doc.world_body_name(&state, &body, component, &path);
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
|
||||
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
|
||||
let author_inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
|
||||
let author_inside = apply_point(&author_inverse, world_inside);
|
||||
drop(doc);
|
||||
let r = super::sketch::create_face_profile(
|
||||
s,
|
||||
&json!({"plane": {"face": [world_inside.x, world_inside.y, world_inside.z], "body": world_body, "index": f, "path":path}, "project_edges": true}),
|
||||
&body,
|
||||
f as usize,
|
||||
)?;
|
||||
let sid = r.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
s.refresh();
|
||||
let plane = s.model.state().sketch(sid).map(|ss| ss.plane).ok_or_else(|| bad(cmd, "could not start a sketch on the face"))?;
|
||||
// The face's edges came in as linked projections: they follow the face when earlier
|
||||
// features change, so nothing is copied.
|
||||
if r.get("projected").is_some_and(|x| !x.is_null()) {
|
||||
return Ok((sid, plane.to_local(inside)));
|
||||
return Ok((sid, plane.to_local(author_inside)));
|
||||
}
|
||||
for e in &edges {
|
||||
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
|
||||
@@ -163,5 +199,9 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str) -> Result<
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((sid, plane.to_local(inside)))
|
||||
Ok((sid, plane.to_local(author_inside)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "face_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, PlaneRef};
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
fn check(s: &mut Session, want: f64, hole: bool, height: f64) {
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
|
||||
let state = s.model.state();
|
||||
let got: f64 = state.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum();
|
||||
// Legacy linked outlines fit tessellated curves; enforce the existing 1e-4 material envelope.
|
||||
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
|
||||
assert!(state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height - 1.0))));
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(5.0, 5.0, height + 1.0))));
|
||||
if hole {
|
||||
assert!(!state.bodies.iter().any(|b| b.body.contains(Vec3::new(20.0, 15.0, height - 1.0))));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn generated_face_profiles_keep_exact_links_live_after_edit_and_reopen() {
|
||||
for hole in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let base = run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base"}))["feature"].as_u64().unwrap();
|
||||
if hole {
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"}));
|
||||
}
|
||||
let result = run(&mut s, "solid.extrude", json!({"face":[5,5,20],"distance":10,"operation":"new","name":"Pad"}));
|
||||
let pad = s.doc.feature(result["feature"].as_u64().unwrap()).unwrap();
|
||||
let FeatureKind::Extrude { sketch, .. } = pad.kind else { panic!("extrude") };
|
||||
let generated = s.doc.feature(sketch).unwrap();
|
||||
let FeatureKind::Sketch { plane, sketch: profile } = &generated.kind else { panic!("sketch") };
|
||||
let PlaneRef::Face { anchor: Some(anchor), .. } = plane else { panic!("the original Box cap remains exact after a hole cut") };
|
||||
assert_eq!(anchor.target.origin_feature, base);
|
||||
assert!(matches!(anchor.target.certificate, Some(solvecraft_doc::config::FaceCertificate::Box { axis: 2, high: true })));
|
||||
assert!(!profile.links.is_empty());
|
||||
assert!(profile.links.iter().all(|link| link.auto_face && !link.lost));
|
||||
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Base","set":{"height":"25","length":"50"}}));
|
||||
let area = 1500.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
let edited = s.model.state();
|
||||
assert!(edited.bodies.iter().any(|body| body.body.contains(Vec3::new(49.0, 5.0, 34.0))));
|
||||
assert!(!edited.bodies.iter().any(|body| body.body.contains(Vec3::new(51.0, 5.0, 34.0))));
|
||||
assert!(edited.sketch(sketch).unwrap().sketch.links.iter().all(|link| link.auto_face && !link.lost));
|
||||
let solved = edited.sketch(sketch).unwrap().plane;
|
||||
assert!((solved.origin.z - 25.0).abs() < 1e-6);
|
||||
let before = s.model.state();
|
||||
let before_volume: f64 = before.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
let before_bounds: Vec<_> = before.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect();
|
||||
let mut reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&mut reopened, area * 35.0, hole, 35.0);
|
||||
let after = reopened.model.state();
|
||||
let after_volume: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
assert!((before_volume - after_volume).abs() < 1e-6);
|
||||
for (name, bounds) in before_bounds {
|
||||
let cold = after.body(&name).unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn internal_capture_does_not_bypass_missing_ambiguous_or_invalid_path_picks() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"A"}));
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"B"}));
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute("solid.extrude", &json!({"face":[5,5,20],"distance":10,"operation":"new"})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
for pick in [json!({"face":[5,5,20]}), json!({"face":[500,5,20]}), json!({"face":[5,5,20],"body":"A","index":0,"path":[999999]})] {
|
||||
assert!(super::super::sketch::face::capture_internal(&s, &pick, "internal").is_err(), "{pick}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() {
|
||||
for hole in [false, true] {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Arm"}));
|
||||
let cid = component["component"].as_u64().unwrap();
|
||||
let occ = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base","body_name":"Stock"}));
|
||||
if hole {
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,15],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all":true,"operation":"cut"}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occ,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true}));
|
||||
run(&mut s, "component.activate", json!({"component":cid,"occurrence":occ}));
|
||||
run(&mut s, "solid.extrude", json!({"face":[35,0,15],"distance":10,"operation":"join"}));
|
||||
let area = 1200.0 - if hole { 25.0 * PI } else { 0.0 };
|
||||
check(&mut s, area * 30.0, hole, 30.0);
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-10,15],"distance":5}));
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
let before_cut = s.doc.to_json();
|
||||
run(&mut s, "solid.press_pull", json!({"face":[35,-15,15],"distance":-5}));
|
||||
let height = 30.0;
|
||||
check(&mut s, area * height, hole, height);
|
||||
let after_cut = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before_cut);
|
||||
check(&mut s, area * 35.0, hole, 35.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), after_cut);
|
||||
check(&mut s, area * height, hole, height);
|
||||
let world = s.world_state();
|
||||
let occupied = Vec3::new(35.0, 21.0 - height, 15.0);
|
||||
let absent = Vec3::new(35.0, 19.0 - height, 15.0);
|
||||
assert!(world.bodies.iter().any(|body| body.body.contains(occupied)));
|
||||
assert!(!world.bodies.iter().any(|body| body.body.contains(absent)));
|
||||
if hole {
|
||||
assert!(!world.bodies.iter().any(|body| body.body.contains(Vec3::new(50.0, 21.0 - height, 25.0))));
|
||||
}
|
||||
let before_volume: f64 = world.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
let before_bounds: Vec<_> = world.bodies.iter().map(|body| (body.name.clone(), body.mesh().bounds())).collect();
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let after = reopened.world_state();
|
||||
assert!(after.bodies.iter().any(|body| body.body.contains(occupied)));
|
||||
let v: f64 = after.bodies.iter().map(|body| solvecraft_kernel::measure(&body.body).unwrap().volume).sum();
|
||||
assert!((v - area * height).abs() < area * height * 1e-4);
|
||||
assert!((v - before_volume).abs() < 1e-6);
|
||||
for (name, bounds) in before_bounds {
|
||||
let cold = after.body(&name).unwrap().mesh().bounds();
|
||||
assert!(bounds.min.dist(cold.min) < 1e-9 && bounds.max.dist(cold.max) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}));
|
||||
let occurrence = component["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Stock"}));
|
||||
run(&mut s, "solid.press_pull", json!({"face":[5,5,20],"distance":5}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":false}));
|
||||
let state = s.model.state();
|
||||
let index = state
|
||||
.body("Stock")
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(1e-4)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.999) && (face.centroid.z - 25.0).abs() < 1e-6)
|
||||
.unwrap()
|
||||
.index;
|
||||
let before = s.doc.to_json();
|
||||
let error =
|
||||
super::super::sketch::face::capture_internal(&s, &json!({"face":[35,5,25],"body":"Stock","index":index,"path":[occurrence]}), "internal")
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("capture the source occurrence position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Repeated"}));
|
||||
let cid = component["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let copied = run(&mut s, "occurrence.copy", json!({"component":cid,"translate":[50,0,0]}));
|
||||
let occurrence = copied["occurrence"].as_u64().unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.extrude", &json!({"face":[55,5,10],"distance":5,"operation":"new"})).unwrap_err();
|
||||
assert!(error.to_string().contains("activate the picked occurrence"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "component.activate", json!({"component":cid,"occurrence":occurrence}));
|
||||
run(&mut s, "solid.extrude", json!({"face":[55,5,10],"distance":5,"operation":"new"}));
|
||||
let state = s.world_state();
|
||||
for x in [5.0, 55.0] {
|
||||
assert!(state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 14.0))));
|
||||
assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0))));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile), operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
@@ -124,17 +124,9 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
ProfileSel::All => ps.first()?.region.interior_point(),
|
||||
ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(),
|
||||
ProfileSel::Points { points } => *points.first()?,
|
||||
ProfileSel::Curves { loops } => {
|
||||
let mut want: Vec<&String> = loops.first()?.iter().collect();
|
||||
want.sort();
|
||||
ps.iter()
|
||||
.find(|x| {
|
||||
let mut have: Vec<&String> = x.outer_curves.iter().collect();
|
||||
have.sort();
|
||||
have == want
|
||||
})?
|
||||
.region
|
||||
.interior_point()
|
||||
selection @ (ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. }) => {
|
||||
let indices = solvecraft_doc::profile_selection_indices(ss, &selection).ok()?;
|
||||
ps.get(*indices.first()?)?.region.interior_point()
|
||||
}
|
||||
};
|
||||
Some((ss.plane.to_world(q), ss.plane.normal()))
|
||||
@@ -158,7 +150,7 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) })
|
||||
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
@@ -212,40 +204,114 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
|
||||
}
|
||||
|
||||
/// Profile indices become the curve ids of each profile's loops as the sketch is now solved.
|
||||
fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
/// Profiles picked by index or point are stored by the curve ids of each profile's outer loop
|
||||
/// as the sketch is now solved, so they follow the geometry when parameters move it. A pick
|
||||
/// whose curves would not find that same profile again (two profiles bounded by the same
|
||||
/// curves) stays as given. Curve picks are checked: one that names no profile, or several, is
|
||||
/// refused now rather than failing later.
|
||||
pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
|
||||
use solvecraft_doc::sketch::profile_at_point;
|
||||
let st = s.model.state();
|
||||
'sels: for (sketch, sel) in kind.profile_sels_mut() {
|
||||
let ProfileSel::Indices { indices } = sel else { continue };
|
||||
for (sketch, sel) in kind.profile_sels_mut() {
|
||||
let Some(ss) = st.sketch(sketch) else { continue };
|
||||
let mut loops = Vec::with_capacity(indices.len());
|
||||
for i in indices.iter() {
|
||||
let pr = ss
|
||||
.profiles
|
||||
.get(*i)
|
||||
.ok_or_else(|| EngineError::Other(format!("no profile {i} in sketch `{}` (it has {})", ss.name, ss.profiles.len())))?;
|
||||
// Two profiles with the same outer curves (one inside the other's holes cannot be):
|
||||
// keep the index then.
|
||||
if pr.outer_curves.is_empty() || ss.profiles.iter().filter(|q| same_loop(&q.outer_curves, &pr.outer_curves)).count() != 1 {
|
||||
continue 'sels;
|
||||
let indices = match sel {
|
||||
ProfileSel::Indices { .. } => solvecraft_doc::profile_selection_indices(ss, sel)?,
|
||||
ProfileSel::Points { points } => {
|
||||
let Some(indices) = points.iter().map(|point| profile_at_point(&ss.profiles, *point)).collect::<Option<Vec<_>>>() else { continue };
|
||||
indices
|
||||
}
|
||||
loops.push(pr.outer_curves.clone());
|
||||
}
|
||||
*sel = ProfileSel::Curves { loops };
|
||||
ProfileSel::Curves { loops } => {
|
||||
// The command's ordinary curve lists are sets, including repeated
|
||||
// caller IDs. Saved graph-span multisets retain their legacy meaning.
|
||||
// Reserved offset roles never use this ordinary normalization.
|
||||
let mut normalized = loops.clone();
|
||||
let reserved = ss
|
||||
.sketch
|
||||
.offsets
|
||||
.iter()
|
||||
.filter_map(|offset| offset.topology.as_ref())
|
||||
.flat_map(|topology| topology.roles.iter().map(|role| role.output.as_str()))
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
for curves in &mut normalized {
|
||||
let dynamic = curves.iter().any(|curve| reserved.contains(curve.as_str()));
|
||||
if !dynamic {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
curves.retain(|curve| seen.insert(curve.clone()));
|
||||
}
|
||||
}
|
||||
let selection = ProfileSel::Curves { loops: normalized };
|
||||
solvecraft_doc::profile_selection_indices(ss, &selection)?;
|
||||
*sel = selection;
|
||||
continue;
|
||||
}
|
||||
ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
|
||||
// Explicit identities retain the caller's semantics. Validate through the
|
||||
// same resolver as recomputation, including reserved-role precedence.
|
||||
solvecraft_doc::profile_selection_indices(ss, sel)?;
|
||||
continue;
|
||||
}
|
||||
ProfileSel::All => continue,
|
||||
};
|
||||
let captured = solvecraft_doc::capture_profile_selection(ss, &indices)?;
|
||||
*sel = match captured {
|
||||
ProfileSel::OffsetBoundaries { associations }
|
||||
if associations.iter().any(|id| {
|
||||
s.doc
|
||||
.sketch(sketch)
|
||||
.is_ok_and(|raw| !raw.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.id == *id))
|
||||
}) =>
|
||||
{
|
||||
// Legacy upgrade descriptors belong to the solved copy. Persist the
|
||||
// complete original role IDs instead of an unsaved association ID.
|
||||
let loops = associations
|
||||
.iter()
|
||||
.map(|id| {
|
||||
ss.sketch
|
||||
.offsets
|
||||
.iter()
|
||||
.filter_map(|offset| offset.topology.as_ref())
|
||||
.find(|topology| topology.id == *id)
|
||||
.map(|topology| topology.roles.iter().map(|role| role.output.clone()).collect())
|
||||
.ok_or_else(|| EngineError::Other(format!("offset association `{id}` changed during profile capture")))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
ProfileSel::Curves { loops }
|
||||
}
|
||||
captured => captured,
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_loop(a: &[String], b: &[String]) -> bool {
|
||||
let (mut a, mut b) = (a.to_vec(), b.to_vec());
|
||||
a.sort();
|
||||
b.sort();
|
||||
a == b
|
||||
}
|
||||
|
||||
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
|
||||
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
|
||||
match v {
|
||||
Value::Object(fields) => {
|
||||
if fields.get("type").and_then(Value::as_str) == Some("whole_boundaries") {
|
||||
return solvecraft_doc::whole_profile_selection_from_value(v).map_err(|error| bad(cmd, error.to_string()));
|
||||
}
|
||||
let associations = fields
|
||||
.get("offset_boundaries")
|
||||
.or_else(|| (fields.get("type").and_then(Value::as_str) == Some("offset_boundaries")).then(|| fields.get("associations")).flatten())
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| bad(cmd, "profile object must provide `offset_boundaries: [association ids]`"))?;
|
||||
if associations.is_empty() || associations.len() > 128 {
|
||||
return Err(bad(cmd, "offset profile selection needs 1 to 128 association ids"));
|
||||
}
|
||||
let associations = associations
|
||||
.iter()
|
||||
.map(|id| {
|
||||
id.as_str()
|
||||
.filter(|id| !id.is_empty() && id.len() <= 512)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| bad(cmd, "offset association ids must be nonempty strings of at most 512 bytes"))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if associations.iter().collect::<std::collections::BTreeSet<_>>().len() != associations.len() {
|
||||
return Err(bad(cmd, "offset association ids must be unique"));
|
||||
}
|
||||
Ok(ProfileSel::OffsetBoundaries { associations })
|
||||
}
|
||||
Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(ProfileSel::All),
|
||||
Value::Number(n) => Ok(ProfileSel::Indices { indices: vec![n.as_u64().ok_or_else(|| bad(cmd, "profile index"))? as usize] }),
|
||||
Value::Array(a) if a.is_empty() => Err(bad(cmd, "`profiles` is empty")),
|
||||
@@ -321,7 +387,24 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
};
|
||||
let face_names = {
|
||||
let pts = kind.face_points();
|
||||
if pts.is_empty() { Vec::new() } else { solvecraft_doc::naming::face_names_at(&s.model.state(), &pts) }
|
||||
if let FeatureKind::Emboss { face: Some(face), .. } = &kind {
|
||||
let state = s.model.state();
|
||||
let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?;
|
||||
let frame = face.body_frame(&s.doc, body).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?;
|
||||
let at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at);
|
||||
let mut scoped = solvecraft_doc::ModelState::default();
|
||||
scoped.bodies.push(body.clone());
|
||||
let names = solvecraft_doc::naming::face_names_at(&scoped, &[at]);
|
||||
if names.len() != 1 || names.first().is_none_or(|name| name.is_empty()) {
|
||||
return Err(EngineError::Other("selected cylinder face has no persistent identity".into()));
|
||||
}
|
||||
names
|
||||
} else if pts.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
solvecraft_doc::naming::face_names_at(&s.model.state(), &pts)
|
||||
}
|
||||
};
|
||||
let participants = string_list(p, "participants");
|
||||
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
|
||||
@@ -465,6 +548,10 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let symmetric =
|
||||
matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false);
|
||||
let convention = match p.get("convention") {
|
||||
None => solvecraft_doc::RevolveConvention::SketchNormal,
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?,
|
||||
};
|
||||
let kind = FeatureKind::Revolve {
|
||||
sketch,
|
||||
profiles,
|
||||
@@ -474,6 +561,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
targets: string_list(p, "targets"),
|
||||
angle2,
|
||||
symmetric,
|
||||
convention,
|
||||
to,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
@@ -710,7 +798,7 @@ fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_(p, "new_component").unwrap_or(false) {
|
||||
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
|
||||
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
|
||||
s.execute("component.move_bodies", &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
super::component::transfer_constructor_result(s, &json!({"bodies": [target], "component": c["component"]}))?;
|
||||
out["component"] = c["component"].clone();
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1325,3 +1413,19 @@ fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "patterns_tests.rs"]
|
||||
mod patterns_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_topology_tests.rs"]
|
||||
mod offset_topology_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_identity_tests.rs"]
|
||||
mod offset_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_edit_guard_tests.rs"]
|
||||
mod offset_edit_guard_tests;
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.emboss", "Emboss", emboss)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("emboss")
|
||||
.params("sketch (on a planar face), profiles?; depth; mode?: emboss|deboss (default emboss); targets?"),
|
||||
.params("sketch, profiles?; depth; mode?: emboss|deboss; targets?; face?: world point [x,y,z] selecting a planar or complete exterior cylindrical wall (inferred when exactly one supported tangent cylinder matches the selected profiles)"),
|
||||
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
@@ -143,14 +143,169 @@ fn emboss(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("deboss") => true,
|
||||
Some(m) => return Err(bad(cmd, format!("unknown mode `{m}` (emboss or deboss)"))),
|
||||
};
|
||||
if let Some(fp) = p.get("face").and_then(vec3)
|
||||
&& super::face::face_normal(s, fp).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "not supported yet: embossing onto a curved face"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets: string_list(p, "targets") })
|
||||
let mut targets = string_list(p, "targets");
|
||||
let face = if let Some(value) = p.get("face") {
|
||||
let local = vec3(value).ok_or_else(|| bad(cmd, "`face` must be a finite point [x,y,z]"))?;
|
||||
let at = solvecraft_doc::apply_point(&s.active_component_frame(), local);
|
||||
let state = s.world_state();
|
||||
let mut candidates = Vec::new();
|
||||
for body in &state.bodies {
|
||||
let definition = body.definition_name();
|
||||
if !targets.is_empty() && !targets.iter().any(|n| n == definition || n == &body.name) {
|
||||
continue;
|
||||
}
|
||||
let mesh = body.mesh();
|
||||
let closest = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter_map(|(tri, face)| {
|
||||
let [a, b, c] = mesh.tri(tri)?;
|
||||
let n = (b - a).cross(c - a).normalized()?;
|
||||
let q = at - n * (at - a).dot(n);
|
||||
let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-9);
|
||||
inside.then_some(((at - a).dot(n).abs(), *face as usize))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if let Some((distance, index)) = closest
|
||||
&& distance < (body.body.size() * 1e-3).max(1e-3)
|
||||
{
|
||||
candidates.push((body, index));
|
||||
}
|
||||
}
|
||||
if candidates.len() != 1 {
|
||||
return Err(bad(cmd, "`face` must identify exactly one target body surface"));
|
||||
}
|
||||
let (selected, index) = candidates.first().copied().ok_or_else(|| bad(cmd, "no target face"))?;
|
||||
let name = selected.definition_name();
|
||||
let body = &selected.body;
|
||||
match solvecraft_kernel::face_surface(body, index) {
|
||||
Some(solvecraft_kernel::FaceSurface::Plane { .. }) => {
|
||||
targets = vec![name.to_owned()];
|
||||
None
|
||||
}
|
||||
Some(solvecraft_kernel::FaceSurface::Cylinder { .. }) => {
|
||||
if targets.len() > 1 {
|
||||
return Err(bad(cmd, "cylinder emboss needs one selected target body"));
|
||||
}
|
||||
// Viewport picks lie on tessellated chords. Keep their axial and
|
||||
// angular position, and certify their distance before storing an
|
||||
// exact surface anchor for later replay.
|
||||
let at = solvecraft_kernel::cylinder_emboss_anchor(body, index, at).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let face = emboss_selection(s, selected, at)?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
targets = vec![name.to_owned()];
|
||||
Some(face)
|
||||
}
|
||||
_ => return Err(bad(cmd, "only planar faces and complete exterior cylindrical walls support emboss")),
|
||||
}
|
||||
} else {
|
||||
let inferred = infer_emboss_selection(s, sketch, &profiles, &depth, deboss, &targets)?;
|
||||
if let Some(face) = &inferred {
|
||||
targets = vec![face.body.clone()];
|
||||
}
|
||||
inferred
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face })
|
||||
}
|
||||
|
||||
fn emboss_selection(s: &Session, selected: &solvecraft_doc::ModelBody, at: Vec3) -> Result<solvecraft_doc::EmbossFace> {
|
||||
let cmd = "solid.emboss";
|
||||
let name = selected.definition_name();
|
||||
let original = s.model.state();
|
||||
let owner = s.doc.model_body_component(selected);
|
||||
let paths: Vec<_> = s
|
||||
.doc
|
||||
.placements()
|
||||
.into_iter()
|
||||
.filter(|(comp, path, _)| *comp == owner && s.doc.world_body_name(&original, name, owner, path) == selected.name)
|
||||
.map(|(_, path, _)| path)
|
||||
.collect();
|
||||
if paths.len() != 1 {
|
||||
return Err(bad(cmd, "selected cylinder placement is ambiguous"));
|
||||
}
|
||||
let path = paths.into_iter().next().ok_or_else(|| bad(cmd, "selected cylinder placement"))?;
|
||||
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(s.active_component));
|
||||
if path.len() > 64 || author_path.len() > 64 {
|
||||
return Err(bad(cmd, "cylinder placement is too deep"));
|
||||
}
|
||||
let inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid active frame"))?;
|
||||
Ok(solvecraft_doc::EmbossFace { body: name.to_owned(), at: solvecraft_doc::apply_point(&inverse, at), path, author_path })
|
||||
}
|
||||
|
||||
fn check_emboss_positions(s: &Session, sketch: u64, body: &solvecraft_doc::ModelBody, face: &solvecraft_doc::EmbossFace) -> Result<()> {
|
||||
let cmd = "solid.emboss";
|
||||
let owner = s.doc.model_body_component(body);
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let sketch_path = if sketch_owner == owner {
|
||||
face.path.clone()
|
||||
} else if sketch_owner == s.active_component {
|
||||
face.author_path.clone()
|
||||
} else {
|
||||
s.doc.first_path(sketch_owner)
|
||||
};
|
||||
if face.path.iter().chain(&face.author_path).chain(&sketch_path).any(|id| s.pending_moves.contains_key(id)) {
|
||||
return Err(bad(cmd, "capture pending source, author and profile positions with Capture Position before cylinder emboss"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn infer_emboss_selection(
|
||||
s: &Session,
|
||||
sketch: u64,
|
||||
profiles: &solvecraft_doc::ProfileSel,
|
||||
depth: &str,
|
||||
deboss: bool,
|
||||
targets: &[String],
|
||||
) -> Result<Option<solvecraft_doc::EmbossFace>> {
|
||||
let cmd = "solid.emboss";
|
||||
let original = s.model.state();
|
||||
let ss = original.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
let regions = solvecraft_doc::select_profiles(ss, profiles).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let sketch_owner = s.doc.feature(sketch).map(|f| f.component).ok_or_else(|| bad(cmd, "profile sketch no longer exists"))?;
|
||||
let d = s.doc.eval(&s.doc.with_design_unit(depth, Kind::Length), Kind::Length).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let world = s.world_state();
|
||||
let placed = s.placed_document();
|
||||
let mut chosen = None;
|
||||
let mut faces = 0usize;
|
||||
for selected in &world.bodies {
|
||||
let name = selected.definition_name();
|
||||
if !targets.is_empty() && !targets.iter().any(|n| n == name || n == &selected.name) {
|
||||
continue;
|
||||
}
|
||||
let raw = original.body(name).ok_or_else(|| bad(cmd, "target definition no longer exists"))?;
|
||||
faces = faces.saturating_add(raw.body.face_count());
|
||||
if faces > 50_000 {
|
||||
return Err(bad(cmd, "cylinder target search exceeds face work limit"));
|
||||
}
|
||||
if !solvecraft_kernel::has_cylinder_emboss_wall(&raw.body).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
continue;
|
||||
}
|
||||
let mut face = emboss_selection(s, selected, Vec3::ZERO)?;
|
||||
let body_frame = face.body_frame(&placed, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let inverse = solvecraft_doc::mat_inverse(&body_frame).ok_or_else(|| bad(cmd, "invalid cylinder body frame"))?;
|
||||
let displayed = world.sketch(sketch).ok_or_else(|| bad(cmd, "the profile sketch is not displayed"))?;
|
||||
let plane = solvecraft_geom::Plane::new(
|
||||
solvecraft_doc::apply_point(&inverse, displayed.plane.origin),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.x),
|
||||
solvecraft_doc::apply_vector(&inverse, displayed.plane.y),
|
||||
)
|
||||
.ok_or_else(|| bad(cmd, "invalid cylinder sketch frame"))?;
|
||||
if let Some((_, at)) =
|
||||
solvecraft_kernel::infer_cylinder_emboss(&raw.body, &plane, ®ions, if deboss { -d } else { d }).map_err(|e| bad(cmd, e.to_string()))?
|
||||
{
|
||||
face.sketch_frame(&placed, s.active_component, sketch_owner, raw).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
check_emboss_positions(s, sketch, selected, &face)?;
|
||||
let author_inverse = solvecraft_doc::mat_inverse(&s.active_component_frame()).ok_or_else(|| bad(cmd, "invalid author frame"))?;
|
||||
face.at = solvecraft_doc::apply_point(&solvecraft_doc::mat_mul(&author_inverse, &body_frame), at);
|
||||
if chosen.is_some() {
|
||||
return Err(bad(cmd, "multiple tangent cylinders match the selected profiles; select a target face or body"));
|
||||
}
|
||||
chosen = Some(face);
|
||||
}
|
||||
}
|
||||
Ok(chosen)
|
||||
}
|
||||
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
|
||||
@@ -9,6 +9,17 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_emboss_params(mut p: Value, auto: bool) -> Value {
|
||||
if auto {
|
||||
if let Some(params) = p.get_mut("params").and_then(Value::as_object_mut) {
|
||||
params.remove("face");
|
||||
} else if let Some(object) = p.as_object_mut() {
|
||||
object.remove("face");
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn measure(s: &mut Session) -> Value {
|
||||
run(s, "inspect.measure", json!({}))
|
||||
}
|
||||
@@ -81,6 +92,293 @@ fn emboss_and_deboss_on_a_face() {
|
||||
assert!(s.execute("solid.emboss", &json!({"sketch": "Logo", "depth": 1, "mode": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
fn cylinder_badge() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1 mm"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius":"10 mm","height":"20 mm","body_name":"Sleeve"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
fn exact_cylinder_volume(s: &Session) -> f64 {
|
||||
s.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_emboss_persists_selection_and_replays_radial_depth() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.emboss",
|
||||
json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":"badge_depth","targets":["Sleeve"],"name":"Raised Badge"}),
|
||||
);
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.0).abs() < 0.02);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
let mesh = body.tessellate_with(0.001, true).unwrap();
|
||||
assert!(mesh.contains(Vec3::new(-10.5 * 0.15_f64.sin(), 10.5 * 0.15_f64.cos(), 7.5)));
|
||||
let feature = s.doc.find_feature("Raised Badge").unwrap();
|
||||
assert!(
|
||||
matches!(&feature.kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"&&face.at.dist(Vec3::new(0.,10.,7.5))<1e-10)
|
||||
);
|
||||
let saved = s.doc.to_json();
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&saved).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 21.0).abs() < 0.02);
|
||||
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2 mm"}));
|
||||
assert!(loaded.model.results.iter().all(|f| f.error.is_none()), "{:?}", loaded.model.results);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 44.0).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_deboss_is_connected_and_unsupported_profile_failure_is_atomic() {
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","face":[0,10,7.5],"depth":1,"mode":"deboss","targets":["Sleeve"]}));
|
||||
assert!((exact_cylinder_volume(&s) - before + 19.0).abs() < 0.02);
|
||||
assert_eq!(s.model.state().body("Sleeve").unwrap().body.shell_count(), 1);
|
||||
let mut bad = cylinder_badge();
|
||||
run(&mut bad, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Circle Badge"}));
|
||||
run(&mut bad, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
|
||||
run(&mut bad, "sketch.finish", json!({}));
|
||||
let saved = bad.doc.to_json();
|
||||
let v = exact_cylinder_volume(&bad);
|
||||
assert!(bad.execute("solid.emboss", &json!({"sketch":"Circle Badge","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]})).is_err());
|
||||
assert_eq!(bad.doc.to_json(), saved);
|
||||
assert_eq!(exact_cylinder_volume(&bad), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_small_chord_pick_is_saved_on_the_exact_surface() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":"0.1mm","height":"0.2mm","body_name":"Tiny"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,0.1,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.02,0.05],"p1":[0.02,0.1]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mesh = s.model.state().bodies.first().unwrap().mesh();
|
||||
let pick = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.filter_map(|tri| mesh.tri(tri))
|
||||
.map(|[a, b, c]| (a + b + c) / 3.)
|
||||
.find(|p| p.y > 0.08 && p.z > 0.04 && p.z < 0.16)
|
||||
.unwrap();
|
||||
assert!(((pick.x * pick.x + pick.y * pick.y).sqrt() - 0.1).abs() > 1e-7);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[pick.x,pick.y,pick.z],"depth":"0.01mm","targets":["Tiny"]}));
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
|
||||
panic!("missing face");
|
||||
};
|
||||
assert!(((face.at.x * face.at.x + face.at.y * face.at.y).sqrt() - 0.1).abs() < 1e-12);
|
||||
assert_eq!(s.model.state().bodies.first().unwrap().body.shell_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() {
|
||||
for (pick_offset, auto) in [(0., false), (80., false), (0., true), (80., true)] {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine")));
|
||||
let mut s = Session::default();
|
||||
let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let source_feature = s.model.state().bodies.first().unwrap().feature;
|
||||
let source_names = solvecraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap());
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":a,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":"root"}));
|
||||
let author = run(&mut s, "component.create", json!({"name":"Author"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Author:1","axis":[1,0,0],"angle":"90 deg","translate":[-100,40,30]}));
|
||||
let author_second = run(&mut s, "occurrence.copy", json!({"component":author,"translate":[0,100,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":author,"occurrence":author_second}));
|
||||
run(&mut s, "parameters.add", json!({"name":"badge_depth","expression":"1mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[40.+pick_offset,0,0],"x_dir":[0,-1,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let placements = serde_json::to_value(&s.doc.occurrences).unwrap();
|
||||
let target = s
|
||||
.world_state()
|
||||
.bodies
|
||||
.iter()
|
||||
.find(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(40.5 + pick_offset, 0., 7.5)))
|
||||
.unwrap()
|
||||
.name
|
||||
.clone();
|
||||
run(
|
||||
&mut s,
|
||||
"solid.emboss",
|
||||
json!({"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}),
|
||||
);
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
|
||||
panic!("missing selection");
|
||||
};
|
||||
assert_eq!(face.path, if pick_offset == 0. { s.doc.first_path(a) } else { vec![second] });
|
||||
assert_eq!(face.author_path, vec![author_second]);
|
||||
let state = s.model.state();
|
||||
let body = state.body("Sleeve").unwrap();
|
||||
assert_eq!(body.feature, source_feature);
|
||||
assert_eq!(s.doc.body_component("Sleeve", body.feature), a);
|
||||
assert_eq!(serde_json::to_value(&s.doc.occurrences).unwrap(), placements);
|
||||
let selected = s.doc.features.last().unwrap().face_names.first().unwrap();
|
||||
assert!(!selected.is_empty() && source_names.contains(selected), "{selected:?} not in {source_names:?}");
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
let world = s.world_state();
|
||||
for x in [39.5, 119.5] {
|
||||
assert!(world.bodies.iter().any(|b| b.body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(x, 0., 7.5))));
|
||||
}
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
|
||||
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 44.).abs() < 0.02);
|
||||
assert_eq!(loaded.doc.body_component("Sleeve", loaded.model.state().body("Sleeve").unwrap().feature), a);
|
||||
assert_eq!(serde_json::to_value(&loaded.doc.occurrences).unwrap(), placements);
|
||||
let feature = loaded.doc.features.last().unwrap().clone();
|
||||
run(&mut loaded, "component.activate", json!({"component":"root"}));
|
||||
run(
|
||||
&mut loaded,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"badge_depth","targets":[target.clone()]}}),
|
||||
);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
|
||||
run(
|
||||
&mut loaded,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","face":[40.+pick_offset,0,7.5],"depth":"3mm","targets":[target]}}),
|
||||
);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 69.).abs() < 0.02);
|
||||
let mut broken = loaded.doc.as_ref().clone();
|
||||
let id = broken.features.last().unwrap().id;
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &mut broken.feature_mut(id).unwrap().kind else {
|
||||
panic!("missing selection");
|
||||
};
|
||||
face.path = vec![u64::MAX];
|
||||
let mut invalid = Session { doc: broken.into(), ..Default::default() };
|
||||
invalid.refresh();
|
||||
assert!(invalid.model.results.iter().find(|r| r.id == id).unwrap().error.as_ref().unwrap().contains("placement"));
|
||||
assert!((exact_cylinder_volume(&invalid) - before).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_active_second_source_occurrence_authors_shared_geometry() {
|
||||
check_active_second_source(false);
|
||||
check_active_second_source(true);
|
||||
}
|
||||
fn check_active_second_source(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[80,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[80,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[80,10,7.5],"depth":1}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), .. } = &s.doc.features.last().unwrap().kind else {
|
||||
panic!("missing selection");
|
||||
};
|
||||
assert_eq!(face.path, vec![second]);
|
||||
assert_eq!(face.author_path, vec![second]);
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
|
||||
assert!((exact_cylinder_volume(&loaded) - before - 21.).abs() < 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() {
|
||||
for (ancestor, auto) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let mut s = Session::default();
|
||||
let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap();
|
||||
let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":if ancestor {parent}else{leaf},"translate":[5,0,0],"capture":false}));
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s
|
||||
.execute("solid.emboss", &auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[5,10,7.5],"depth":1}), auto))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_pending_third_component_profile_requires_capture_atomically() {
|
||||
check_pending_third_profile(false);
|
||||
check_pending_third_profile(true);
|
||||
}
|
||||
fn check_pending_third_profile(auto: bool) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "component.activate", json!({"component":"root"}));
|
||||
run(&mut s, "component.create", json!({"name":"Author"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[5,0,0],"capture":false}));
|
||||
let before = s.doc.to_json();
|
||||
let pending = s.pending_moves.clone();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s
|
||||
.execute(
|
||||
"solid.emboss",
|
||||
&auto_emboss_params(json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}), auto),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Capture Position"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_body_offset_keeps_exact_profile_and_anchor_frames() {
|
||||
check_body_offset(false);
|
||||
check_body_offset(true);
|
||||
}
|
||||
fn check_body_offset(auto: bool) {
|
||||
let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && id == "solid.emboss"));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]}));
|
||||
std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder);
|
||||
std::sync::Arc::make_mut(&mut s.doc)
|
||||
.body_offsets
|
||||
.insert("Sleeve".into(), [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., -50., 0., 1.]]);
|
||||
s.refresh();
|
||||
assert!(s.doc.body_offsets.contains_key("Sleeve"));
|
||||
let offset = s.doc.body_offsets["Sleeve"];
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","face":[0,10,7.5],"depth":1,"targets":["Sleeve"]}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
assert_eq!(s.doc.body_offsets["Sleeve"], offset);
|
||||
assert_eq!(s.doc.body_component("Sleeve", s.model.state().body("Sleeve").unwrap().feature), holder);
|
||||
assert!(s.world_state().body("Sleeve").unwrap().body.tessellate_with(0.001, true).unwrap().contains(solvecraft_geom::Vec3::new(0., 10.5, 7.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_face_align_remove() {
|
||||
let mut s = Session::default();
|
||||
@@ -137,3 +435,170 @@ fn coil_and_loft_to_a_point() {
|
||||
assert!(rel(volume(&mut s), 400.0 * 30.0 / 3.0) < 1e-6);
|
||||
assert!(s.execute("solid.loft", &json!({"sections": [{"sketch": "Base"}, {"sketch": "Top", "point": "nope"}]})).is_err());
|
||||
}
|
||||
|
||||
/// A 3 mm shelled basin (100 x 60 x 30, open top) with a fall lofted from its whole outline
|
||||
/// 10 up down to a drain pad (`pad`: corners, 1 up), in either section order. The loft's top
|
||||
/// edges lie on the outer walls; the cut used to fail for one order of the sections (an
|
||||
/// `Option::unwrap()` panic inside the kernel, or "shell not oriented and closed").
|
||||
fn basin_fall(pad: [f64; 4], reverse: bool, op: &str) -> (Session, std::result::Result<Value, String>) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "corner": [0, 0, 0]}));
|
||||
run(&mut s, "solid.shell", json!({"faces": [[50, 30, 30]], "thickness": 3}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Rim"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Pad"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [pad[0], pad[1]], "p1": [pad[2], pad[3]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut secs = vec![json!({"sketch": "Rim"}), json!({"sketch": "Pad"})];
|
||||
if reverse {
|
||||
secs.reverse();
|
||||
}
|
||||
let r = s.execute("solid.loft", &json!({"sections": secs, "operation": op})).map_err(|e| e.to_string());
|
||||
(s, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loft_cut_through_a_shelled_basin_in_either_order() {
|
||||
// What is left after the cut: the basin less the loft's part of the floor and walls
|
||||
// (integrated over the height), the same for a pad in any corner.
|
||||
let want = 41582.028;
|
||||
for pad in [[80.0, 40.0, 90.0, 50.0], [10.0, 40.0, 20.0, 50.0], [10.0, 10.0, 20.0, 20.0], [80.0, 10.0, 90.0, 20.0]] {
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall(pad, reverse, "cut");
|
||||
assert!(r.is_ok(), "{pad:?} reverse {reverse}: {r:?}");
|
||||
let v = volume(&mut s);
|
||||
assert!((v - want).abs() < 1.0, "{pad:?} reverse {reverse}: {v} vs {want}");
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
}
|
||||
// As a new body and joined: a valid solid of positive volume either way.
|
||||
for op in ["new", "join"] {
|
||||
let mut vols = Vec::new();
|
||||
for reverse in [false, true] {
|
||||
let (mut s, r) = basin_fall([80.0, 40.0, 90.0, 50.0], reverse, op);
|
||||
assert!(r.is_ok(), "{op} reverse {reverse}: {r:?}");
|
||||
vols.push(volume(&mut s));
|
||||
}
|
||||
assert!(vols[0] > 42948.0 && rel(vols[0], vols[1]) < 1e-6, "{op}: {vols:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_saves_a_profile_anchor_and_replays_both_depth_signs() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let initial = s.doc.to_json();
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode,"name":"Auto Badge"}));
|
||||
let feature = s.doc.find_feature("Auto Badge").unwrap().clone();
|
||||
let solvecraft_doc::FeatureKind::Emboss { face: Some(face), targets, .. } = &feature.kind else {
|
||||
panic!("missing inferred association");
|
||||
};
|
||||
assert_eq!(targets, &["Sleeve"]);
|
||||
assert_eq!(face.body, "Sleeve");
|
||||
assert!(face.at.z > 0. && face.at.z < 20.);
|
||||
assert!((face.at.x.hypot(face.at.y) - 10.).abs() < 1e-10);
|
||||
assert!(!feature.face_names.first().unwrap().is_empty());
|
||||
let expected = if mode == "emboss" { 21. } else { -19. };
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert_eq!(
|
||||
body.tessellate_with(0.001, true).unwrap().contains(Vec3::new(0., if mode == "emboss" { 10.5 } else { 9.5 }, 7.5)),
|
||||
mode == "emboss"
|
||||
);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), initial);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.find_feature("Auto Badge").unwrap().kind, feature.kind);
|
||||
let mut loaded = Session { doc: solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Default::default() };
|
||||
loaded.refresh();
|
||||
assert!(loaded.model.results.iter().all(|r| r.error.is_none()), "{:?}", loaded.model.results);
|
||||
run(
|
||||
&mut loaded,
|
||||
"timeline.redefine",
|
||||
json!({"feature":feature.id,"command":"solid.emboss","params":{"sketch":"Badge","profiles":"all","depth":"badge_depth","mode":mode}}),
|
||||
);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().kind, feature.kind);
|
||||
assert_eq!(loaded.doc.feature(feature.id).unwrap().face_names, feature.face_names);
|
||||
run(&mut loaded, "parameters.change", json!({"name":"badge_depth","expression":"2mm"}));
|
||||
assert!((exact_cylinder_volume(&loaded) - before - if mode == "emboss" { 44. } else { -36. }).abs() < 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_ignores_remote_finite_tangent_walls_in_each_body_order() {
|
||||
for remote_first in [false, true] {
|
||||
let mut s = Session::default();
|
||||
for (name, z) in if remote_first { [("Remote", 100.), ("Sleeve", 0.)] } else { [("Sleeve", 0.), ("Remote", 100.)] } {
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"base":[0,0,z],"body_name":name}));
|
||||
}
|
||||
run(&mut s, "solid.cylinder", json!({"radius":8,"height":10,"base":[0,0,120],"body_name":"Remote Step"}));
|
||||
run(&mut s, "solid.combine", json!({"target":"Remote","tools":["Remote Step"],"operation":"join"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
let remote = s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1}));
|
||||
assert!((exact_cylinder_volume(&s) - before - 21.).abs() < 0.02);
|
||||
assert_eq!(s.model.state().body("Remote").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume, remote);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_retains_selected_holes_and_nested_islands() {
|
||||
use solvecraft_geom::Vec3;
|
||||
for mode in ["emboss", "deboss"] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.edit", json!({"sketch":"Badge"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-1,6],"p1":[1,9]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-0.5,6.5],"p1":[0.5,8.5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = exact_cylinder_volume(&s);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","profiles":[{"point":[1.5,7.5]},{"point":[0,7.5]}],"depth":1,"mode":mode}));
|
||||
let depth = if mode == "emboss" { 1. } else { -1. };
|
||||
let expected = 16. * (depth + depth * depth / 20.);
|
||||
assert!((exact_cylinder_volume(&s) - before - expected).abs() < 0.02);
|
||||
let st = s.model.state();
|
||||
let body = &st.body("Sleeve").unwrap().body;
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
let mesh = body.tessellate_with(0.001, true).unwrap();
|
||||
for (s, raised) in [(0., true), (0.75, false), (1.5, true)] {
|
||||
let theta = s / 10.0_f64;
|
||||
let radius = if mode == "emboss" { 10.5 } else { 9.5 };
|
||||
assert_eq!(mesh.contains(Vec3::new(-radius * theta.sin(), radius * theta.cos(), 7.5)), if mode == "emboss" { raised } else { !raised });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically() {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Coincident"}));
|
||||
let before = s.doc.to_json();
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Badge","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains("multiple tangent cylinders"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "solid.emboss", json!({"sketch":"Badge","depth":1,"targets":["Sleeve"]}));
|
||||
assert!(matches!(&s.doc.features.last().unwrap().kind,solvecraft_doc::FeatureKind::Emboss{face:Some(face),..} if face.body=="Sleeve"));
|
||||
for circle in [false, true] {
|
||||
let mut s = cylinder_badge();
|
||||
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Unsupported"}));
|
||||
if circle {
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,7.5],"radius":1}));
|
||||
} else {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-20,5],"p1":[20,10]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
let volume = exact_cylinder_volume(&s);
|
||||
let error = s.execute("solid.emboss", &json!({"sketch":"Unsupported","depth":1})).unwrap_err().to_string();
|
||||
assert!(error.contains(if circle { "orthogonal line" } else { "seam" }), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(exact_cylinder_volume(&s), volume);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,11 +51,7 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
|
||||
/// Read a STEP file as an Import feature.
|
||||
fn read_step(path: &str) -> Result<solvecraft_io::StepFeature> {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta > solvecraft_io::MAX_STEP_BYTES as u64 {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = solvecraft_io::vfs::read(path, solvecraft_io::MAX_STEP_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
@@ -80,11 +76,7 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
|
||||
/// Add a MeshImport feature for a 3MF or STL file.
|
||||
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta > solvecraft_io::MAX_3MF_BYTES as u64 {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = solvecraft_io::vfs::read(path, solvecraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
|
||||
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
|
||||
@@ -132,11 +124,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
|
||||
}
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta as usize > solvecraft_io::MAX_DESIGN_BYTES {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = solvecraft_io::vfs::read(path, solvecraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = solvecraft_io::read_design(&bytes)?;
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
|
||||
@@ -62,13 +62,14 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let mut bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
|
||||
let selected: Vec<_> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).collect();
|
||||
let mut bodies: Vec<Value> = selected.iter().map(|b| measure_json(b)).collect();
|
||||
// Mass from each body's material.
|
||||
for b in &mut bodies {
|
||||
let name = b["name"].as_str().unwrap_or_default().to_string();
|
||||
for (b, body) in bodies.iter_mut().zip(selected) {
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let rho = s.doc.density(&name);
|
||||
b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into()));
|
||||
let source = body.definition_name();
|
||||
let rho = s.doc.density(source);
|
||||
b["material"] = json!(s.doc.materials.get(source).cloned().unwrap_or_else(|| "Default".into()));
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
}
|
||||
@@ -118,7 +119,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Spline { ref pts, control, degree, periodic } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "periodic": periodic, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fixed": sk.curve_locked(i), "fully_constrained": det}),
|
||||
};
|
||||
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
|
||||
@@ -214,7 +215,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.map(|b| {
|
||||
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(!s.visibility.hidden_bodies.contains(&b.name)));
|
||||
o.insert("visible".into(), json!(s.body_visible(&b.name)));
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -400,7 +401,7 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "too many face appearances"));
|
||||
}
|
||||
let name = st_names.iter().find(|(b, p, _)| b == body && p.dist(*pt) < 1e-12).and_then(|(_, _, n)| n.clone());
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name });
|
||||
a.faces.push(FaceLook { body: body.clone(), point: *pt, look: l.clone(), name, strict_name: false, certificate: None });
|
||||
}
|
||||
}
|
||||
for c in &comps {
|
||||
@@ -413,11 +414,27 @@ fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if look.is_none() && !faces.is_empty() {
|
||||
let st = s.model.state();
|
||||
let doc = s.doc_mut();
|
||||
let picked: Vec<(String, Option<usize>)> =
|
||||
faces.iter().map(|(b, pt)| (b.clone(), st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)))).collect();
|
||||
let picked: Vec<(String, Option<usize>, Option<String>)> = faces
|
||||
.iter()
|
||||
.map(|(b, pt)| {
|
||||
(
|
||||
b.clone(),
|
||||
st.body(b).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, *pt)),
|
||||
st_names.iter().find(|(name, p, _)| name == b && p.dist(*pt) < 1e-12).and_then(|(_, _, name)| name.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
doc.appearances.faces.retain(|f| {
|
||||
let idx = st.body(&f.body).and_then(|mb| solvecraft_doc::appearance::face_index_at(mb, f.point));
|
||||
!picked.iter().any(|(b, i)| *b == f.body && i.is_some() && *i == idx)
|
||||
!picked.iter().any(|(b, i, name)| {
|
||||
*b == f.body
|
||||
&& (f
|
||||
.name
|
||||
.as_ref()
|
||||
.zip(name.as_ref())
|
||||
.is_some_and(|(a, b)| solvecraft_doc::naming::strip_piece(a) == solvecraft_doc::naming::strip_piece(b))
|
||||
|| (!f.strict_name && i.is_some() && *i == idx))
|
||||
})
|
||||
});
|
||||
}
|
||||
Ok(json!({
|
||||
@@ -495,7 +512,7 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = st.body(name).ok_or_else(|| bad("appearance.list", format!("no body `{name}`")))?;
|
||||
out["body"] = json!({
|
||||
"name": name,
|
||||
"appearance": s.doc.body_look(&b.name, b.feature).map(|l| look_json(&l)),
|
||||
"appearance": s.doc.model_body_look(b).map(|l| look_json(&l)),
|
||||
"color": s.doc.body_color(b),
|
||||
"faces": s.doc.face_colors(b).iter().map(|(i, l)| json!({"face": i, "appearance": look_json(l)})).collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option<u64> {
|
||||
let st = s.model.state();
|
||||
let mut best: Option<(f64, u64)> = None;
|
||||
for b in &st.bodies {
|
||||
let comp = s.doc.body_component(&b.name, b.feature);
|
||||
let comp = s.doc.model_body_component(b);
|
||||
let occs: Vec<u64> = if comp == 0 { vec![0] } else { s.doc.occurrences.iter().filter(|o| o.component == comp).map(|o| o.id).collect() };
|
||||
for occ in occs {
|
||||
let t = if occ == 0 { solvecraft_doc::IDENTITY } else { joints::occurrence_world(&s.doc, occ) };
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
|
||||
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
|
||||
|
||||
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
|
||||
macro_rules! created_doc {
|
||||
() => {
|
||||
". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)"
|
||||
};
|
||||
}
|
||||
|
||||
mod browser;
|
||||
mod clipboard;
|
||||
mod component;
|
||||
mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod edit;
|
||||
@@ -27,6 +36,8 @@ mod parameters;
|
||||
mod plastic;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
pub(crate) use sketch::author_path_for_rebind;
|
||||
pub(crate) use sketch::created as sketch_created;
|
||||
mod sketch_3d;
|
||||
mod sketch_canvas;
|
||||
mod sketch_constraints;
|
||||
|
||||
@@ -12,6 +12,9 @@ use super::joints::{joint_id, occurrence_id, resolve, value};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "contact_sweep.rs"]
|
||||
mod contact_sweep;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("contact.enable_sets", "Enable Contact Sets", enable_sets).at("SOLID", "ASSEMBLE").icon("joint").params(
|
||||
"enabled?: bool (default true) — driving joints and motion studies stop where bodies in one contact set would pass through each other",
|
||||
@@ -86,8 +89,12 @@ fn placed(s: &Session) -> Vec<(Vec<u64>, Body)> {
|
||||
if comp == 0 {
|
||||
continue;
|
||||
}
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp && !b.body.is_mesh()) {
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, m) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp && !b.body.is_mesh()) {
|
||||
// Moving a body into another component preserves its world placement using an
|
||||
// offset from its authoring frame. Contact and explode centres must use that
|
||||
// same offset as display and physical properties.
|
||||
let frame = s.doc.model_body_offset(b).map(|offset| solvecraft_doc::mat_mul(&m, offset)).unwrap_or(m);
|
||||
if let Ok(t) = solvecraft_kernel::transform_matrix(&b.body, frame) {
|
||||
out.push((path.clone(), t));
|
||||
}
|
||||
}
|
||||
@@ -131,8 +138,11 @@ pub fn collisions(s: &Session) -> Vec<(String, String, f64)> {
|
||||
/// tested against the other closed mesh: inside by ray parity, and deeper than the tolerance.
|
||||
fn overlap(a: &Body, b: &Body) -> Option<f64> {
|
||||
let (ma, mb) = (a.tessellate((a.size() * 1e-3).max(1e-3)).ok()?, b.tessellate((b.size() * 1e-3).max(1e-3)).ok()?);
|
||||
mesh_overlap(&ma, &mb, 1e-4 * a.size().max(b.size()).max(1.0))
|
||||
}
|
||||
|
||||
fn mesh_overlap(ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh, tol: f64) -> Option<f64> {
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
let tol = 1e-4 * a.size().max(b.size()).max(1.0);
|
||||
if ba.max.x < bb.min.x + tol
|
||||
|| bb.max.x < ba.min.x + tol
|
||||
|| ba.max.y < bb.min.y + tol
|
||||
@@ -142,7 +152,7 @@ fn overlap(a: &Body, b: &Body) -> Option<f64> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let depth = deepest(&ma, &mb, tol).max(deepest(&mb, &ma, tol));
|
||||
let depth = deepest(ma, mb, tol).max(deepest(mb, ma, tol));
|
||||
(depth > tol).then_some(depth)
|
||||
}
|
||||
|
||||
@@ -247,9 +257,12 @@ fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
/// Set joint values, stopping short where contact sets would collide: the values are moved
|
||||
/// from where they are toward `want` as far as they go without a collision.
|
||||
fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value> {
|
||||
let baseline = s.doc.clone();
|
||||
let start: Vec<(u64, Vec<f64>)> =
|
||||
want.iter().map(|(id, _)| (*id, s.doc.assembly.joints.iter().find(|j| j.id == *id).map(|j| j.values.clone()).unwrap_or_default())).collect();
|
||||
let set = |s: &mut Session, t: f64| {
|
||||
// Trial order must not affect free anchors or linked joint placements.
|
||||
s.doc = baseline.clone();
|
||||
let doc = s.doc_mut();
|
||||
for ((id, a), (_, b)) in start.iter().zip(want) {
|
||||
if let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == *id) {
|
||||
@@ -260,25 +273,45 @@ fn drive_with_contact(s: &mut Session, want: &[(u64, Vec<f64>)]) -> Result<Value
|
||||
}
|
||||
resolve(s)
|
||||
};
|
||||
let mut sol = set(s, 1.0);
|
||||
let cache = if s.doc.assembly.contact_enabled { Some(contact_sweep::ContactGeometry::new(s)?) } else { None };
|
||||
let sol;
|
||||
let mut stopped = None;
|
||||
if s.doc.assembly.contact_enabled
|
||||
&& let Some(hit) = collisions(s).into_iter().next()
|
||||
{
|
||||
// Already colliding where it started: don't search, just report.
|
||||
if let Some(cache) = cache.filter(|cache| cache.has_pairs()) {
|
||||
let mut end = s.scratch();
|
||||
set(&mut end, 1.0);
|
||||
let samples = cache.samples(s, &end)?;
|
||||
let mut work = 0;
|
||||
set(s, 0.0);
|
||||
if collisions(s).is_empty() {
|
||||
let (mut lo, mut hi) = (0.0, 1.0);
|
||||
for _ in 0..14 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
set(s, mid);
|
||||
if collisions(s).is_empty() { lo = mid } else { hi = mid }
|
||||
// Keep the existing recovery behaviour: an initially overlapping assembly may move
|
||||
// out of its invalid placement. Sweeps constrain motions that start collision-free.
|
||||
if cache.first_hit(s, &mut work)?.is_none() {
|
||||
let mut lo = 0.0;
|
||||
let mut bracket = None;
|
||||
for sample in 1..=samples {
|
||||
let hi = sample as f64 / samples as f64;
|
||||
set(s, hi);
|
||||
if let Some(hit) = cache.first_hit(s, &mut work)? {
|
||||
bracket = Some((lo, hi, hit));
|
||||
break;
|
||||
}
|
||||
lo = hi;
|
||||
}
|
||||
if let Some((mut lo, mut hi, hit)) = bracket {
|
||||
for _ in 0..14 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
set(s, mid);
|
||||
if cache.first_hit(s, &mut work)?.is_none() { lo = mid } else { hi = mid }
|
||||
}
|
||||
sol = set(s, lo);
|
||||
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
sol = set(s, lo);
|
||||
stopped = Some(json!({"a": hit.0, "b": hit.1, "fraction": lo}));
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
} else {
|
||||
sol = set(s, 1.0);
|
||||
}
|
||||
let values: Vec<Value> = want
|
||||
.iter()
|
||||
|
||||
@@ -71,6 +71,40 @@ fn contact_sets_stop_a_driven_joint_at_touching() {
|
||||
assert!(s.execute("contact.create", &json!({"occurrences": ["A"]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_uses_preserved_body_placements_after_component_reassignment() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
|
||||
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let probe = run(&mut s, "component.create", json!({"name": "Probe"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0]}));
|
||||
run(&mut s, "contact.create", json!({"occurrences": [component["occurrence"], probe["occurrence"]]}));
|
||||
let collisions = run(&mut s, "contact.check", json!({}));
|
||||
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(1), "body stayed at x=0: {collisions}");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": probe["occurrence"], "translate": [100, 0, 0]}));
|
||||
let collisions = run(&mut s, "contact.check", json!({}));
|
||||
assert_eq!(collisions["collisions"].as_array().map(Vec::len), Some(0), "probe moved away: {collisions}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_explode_centres_follow_reassigned_bodies() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"}));
|
||||
let component = run(&mut s, "component.create", json!({"name": "Carrier"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": "Neighbour"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
let exploded = run(&mut s, "explode.create", json!({"scale": 1}));
|
||||
let moves = exploded["moves"].as_array().unwrap();
|
||||
let carrier = moves.iter().find(|m| m["occurrence"] == component["occurrence"]).unwrap();
|
||||
assert!((carrier["translate"][0].as_f64().unwrap() + 10.0).abs() < 1e-6, "{exploded}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_study_plays_and_exports() {
|
||||
let mut s = rail();
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// Exact occurrence workflows through the same commands used by the Browser.
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, p: Value) -> Value {
|
||||
s.execute(command, &p).unwrap()
|
||||
}
|
||||
fn created(s: &mut Session, name: &str) -> (u64, u64) {
|
||||
let c = run(s, "component.create", json!({"name": name}));
|
||||
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap())
|
||||
}
|
||||
fn near(value: &Value, expected: f64) {
|
||||
assert!((value.as_f64().unwrap() - expected).abs() < 1e-6, "{value} != {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_copy_uses_selected_source_transform() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [30,0,0]}));
|
||||
let third = run(&mut s, "occurrence.copy", json!({"occurrence": second, "translate": [5,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let placement = s.doc.occurrences.iter().find(|o| o.id == third).unwrap();
|
||||
assert!((placement.transform[3][0] - 55.0).abs() < 1e-6);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == first).unwrap().transform[3][0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_world_move_under_rotated_parent_uses_selected_path() {
|
||||
let mut s = Session::default();
|
||||
let (_, parent) = created(&mut s, "Parent");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": parent, "axis": [0,0,1], "angle": "90 deg"}));
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": child, "path": [parent, child], "space": "world", "translate": [10,0,0]}));
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence": child, "path": [parent,child]}));
|
||||
near(&result["total"]["center_of_mass"][0], 9.5);
|
||||
near(&result["total"]["center_of_mass"][1], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_authoring_at_second_placement_keeps_definition_shared() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component": component, "occurrence": second, "path": [second]}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [20,0,0], "operation": "new"}));
|
||||
for (occurrence, x) in [(first, 1.0), (second, 21.0)] {
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence": occurrence, "path": [occurrence]}));
|
||||
near(&result["total"]["center_of_mass"][0], x);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_selection_survives_rename_move_capture_and_serde() {
|
||||
let mut s = Session::default();
|
||||
let (component, _) = created(&mut s, "Block");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let selected = json!({"type": "occurrence", "path": [second]});
|
||||
run(&mut s, "select.set", json!({"items": [selected]}));
|
||||
run(&mut s, "component.rename", json!({"component": component, "name": "Renamed"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [5,0,0], "capture": false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.selection).unwrap(), json!([selected]));
|
||||
let roundtrip: Vec<crate::Sel> = serde_json::from_value(json!([selected])).unwrap();
|
||||
assert_eq!(roundtrip, s.selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_selection_delete_removes_only_selected_instance() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Block");
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"items": [{"type": "occurrence", "path": [second]}]}));
|
||||
let items = s.selection.clone();
|
||||
run(&mut s, "selection.delete", json!({"items": items}));
|
||||
assert!(s.doc.occurrences.iter().any(|o| o.id == first));
|
||||
assert!(!s.doc.occurrences.iter().any(|o| o.id == second));
|
||||
assert_eq!(s.world_state().bodies.len(), 1);
|
||||
assert!(s.doc.components.iter().any(|c| c.id == component));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_move_includes_pending_parent_and_leaf_and_shared_scope() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":parent,"axis":[0,0,1],"angle":"90 deg","translate":[3,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child,"translate":[2,0,0],"capture":false}));
|
||||
let before = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":child,"path":[parent,child],"space":"world","translate":[10,0,0],"capture":false}));
|
||||
let after = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
assert!((after[3][0] - before[3][0] - 10.0).abs() < 1e-8);
|
||||
assert!((after[3][1] - before[3][1]).abs() < 1e-8);
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":child,"path":[parent,child],"space":"world","axis":[0,0,1],"angle":"90 deg","origin":[0,0,0],"capture":false}),
|
||||
);
|
||||
let rotated = s.occurrence_placement(&[parent, child]).unwrap().world;
|
||||
assert!((rotated[3][0] + after[3][1]).abs() < 1e-8);
|
||||
assert!((rotated[3][1] - after[3][0]).abs() < 1e-8);
|
||||
run(&mut s, "component.activate", json!({"component":parent_component}));
|
||||
let second_parent = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[30,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
assert_eq!(s.occurrence_scope_count(&[second_parent, child]).unwrap(), 2);
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":child,"space":"world","translate":[1,0,0]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_paths_are_atomic_for_session_state() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let active = s.active_occurrence_path.clone();
|
||||
let vis = s.visibility.clone();
|
||||
let doc = s.doc.clone();
|
||||
for (cmd, p) in [
|
||||
("occurrence.move", json!({"occurrence":second,"path":[first],"space":"world","translate":[10,0,0]})),
|
||||
("occurrence.move", json!({"occurrence":second,"space":17,"translate":[10,0,0]})),
|
||||
("component.activate", json!({"component":component,"occurrence":second,"path":[first,second]})),
|
||||
("browser.visibility", json!({"visible":true,"paths":[[second],[9999]]})),
|
||||
("select.set", json!({"items":[{"type":"occurrence","path":[first,second]}]})),
|
||||
("occurrence.copy", json!({"occurrence":second,"path":[first]})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}");
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.active_occurrence_path, active);
|
||||
assert_eq!(s.visibility, vis);
|
||||
assert_eq!(s.doc, doc);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_visibility_isolate_rename_and_async_accept() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let first_body = s.occurrence_bodies(&[first], true).unwrap()[0].clone();
|
||||
let second_body = s.occurrence_bodies(&[second], true).unwrap()[0].clone();
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
assert!(s.body_visible(&first_body));
|
||||
assert!(!s.body_visible(&second_body));
|
||||
let mut scratch = s.scratch();
|
||||
assert_eq!(scratch.visibility, s.visibility);
|
||||
run(&mut scratch, "component.rename", json!({"component":component,"name":"Changed"}));
|
||||
s.accept_computed(scratch);
|
||||
assert!(!s.occurrence_visible(&[second]));
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[second]],"isolate":true}));
|
||||
assert!(!s.body_visible(&first_body));
|
||||
assert!(s.body_visible(&second_body));
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"folder":"bodies"}));
|
||||
assert!(s.body_visible(&first_body));
|
||||
assert!(s.body_visible(&second_body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthesized_name_collision_keeps_source_and_instance_separate() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let root = s.model.state().bodies[0].name.clone();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
run(&mut s, "body.rename", json!({"body":root,"name":candidate}));
|
||||
let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
assert_ne!(instance, candidate);
|
||||
let frames = s.doc.body_frames(&s.model.state());
|
||||
let world = s.world_state();
|
||||
assert!(frames.iter().all(|(name, _, _, _)| world.body(name).is_some()));
|
||||
assert_eq!(world.bodies.len(), world.bodies.iter().map(|b| &b.name).collect::<std::collections::BTreeSet<_>>().len());
|
||||
run(&mut s, "browser.visibility", json!({"visible":false,"paths":[[second]]}));
|
||||
assert!(s.body_visible(&candidate));
|
||||
assert!(!s.body_visible(&instance));
|
||||
let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone();
|
||||
run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"}));
|
||||
assert_eq!(s.doc.materials.get(&source).map(String::as_str), Some("Brass"));
|
||||
assert!(!s.doc.materials.contains_key(&candidate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_nested_labels_use_full_path_and_reopen_undo_preserve_placements() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let (_, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let parent2 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap();
|
||||
let parent3 = s.doc_mut().add_occurrence(parent_component, 0, solvecraft_doc::IDENTITY).unwrap();
|
||||
for o in s.doc_mut().occurrences.iter_mut().filter(|o| o.id == parent2 || o.id == parent3) {
|
||||
o.name = "Same".into();
|
||||
}
|
||||
s.refresh();
|
||||
let a = s.occurrence_bodies(&[parent2, child], false).unwrap()[0].clone();
|
||||
let b = s.occurrence_bodies(&[parent3, child], false).unwrap()[0].clone();
|
||||
assert_ne!(a, b);
|
||||
let mut reopened = Session::new(solvecraft_doc::format::read(&s.doc.to_json()).unwrap());
|
||||
assert_eq!(reopened.occurrence_bodies(&[parent2, child], false).unwrap()[0], a);
|
||||
assert_eq!(reopened.occurrence_bodies(&[parent3, child], false).unwrap()[0], b);
|
||||
run(&mut reopened, "selection.delete", json!({"occurrences":[parent3]}));
|
||||
assert!(reopened.occurrence_placement(&[parent3, child]).is_err());
|
||||
run(&mut reopened, "edit.undo", json!({}));
|
||||
assert!(reopened.occurrence_placement(&[parent3, child]).is_ok());
|
||||
assert!(reopened.occurrence_placement(&[parent, child]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_duplicated_assembly_exact_resolution_and_expansion_limit() {
|
||||
let mut s = Session::default();
|
||||
let mut parent = 0;
|
||||
let mut path = Vec::new();
|
||||
for i in 0..18 {
|
||||
let component = s.doc_mut().add_component(Some(&format!("Level{i}")), parent).unwrap();
|
||||
let occurrence = s.doc.occurrence_of(component).unwrap().id;
|
||||
path.push(occurrence);
|
||||
s.doc_mut().add_occurrence(component, parent, solvecraft_doc::IDENTITY).unwrap();
|
||||
parent = component;
|
||||
}
|
||||
assert!(s.occurrence_placement(&path).is_ok());
|
||||
let top = [path[0]];
|
||||
assert!(s.occurrence_bodies(&top, true).is_err());
|
||||
assert!(s.execute("select.set", &json!({"items":[{"type":"occurrence","path":top}]})).is_err());
|
||||
assert!(s.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_instances_map_world_keys_to_shared_sources() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
let placement = s.occurrence_placement(&[second]).unwrap();
|
||||
let body = &placement.bodies[0];
|
||||
assert_ne!(body.source, body.world);
|
||||
run(&mut s, "material.assign", json!({"bodies":[body.world],"material":"Brass"}));
|
||||
assert_eq!(s.doc.materials.get(&body.source).map(String::as_str), Some("Brass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_baseline_move_preserves_other_pending_positions_without_drift() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[5,0,0],"capture":false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[3,0,0],"capture":false}));
|
||||
let baseline = s.occurrence_placement(&[second]).unwrap().local;
|
||||
for offset in [10.0, 15.0, 0.0] {
|
||||
run(
|
||||
&mut s,
|
||||
"occurrence.move",
|
||||
json!({"occurrence":second,"path":[second],"space":"world","base_transform":baseline,"translate":[offset,0,0],"capture":false}),
|
||||
);
|
||||
assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0);
|
||||
assert_eq!(s.pending_moves.get(&second).unwrap()[3][0], 23.0 + offset);
|
||||
}
|
||||
let pending = s.pending_moves.clone();
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":[[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,1]]})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
for base in [
|
||||
[[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
|
||||
[[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]],
|
||||
] {
|
||||
assert!(s.execute("occurrence.move", &json!({"occurrence":second,"base_transform":base})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
}
|
||||
assert!(s.execute("component.revert_position", &json!({"occurrences":[second,9999]})).is_err());
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
run(&mut s, "component.revert_position", json!({"occurrences":[second]}));
|
||||
assert!(!s.pending_moves.contains_key(&second));
|
||||
assert_eq!(s.pending_moves.get(&first).unwrap()[3][0], 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_moves_copies_and_reused_ids_do_not_leak_state() {
|
||||
let mut s = Session::default();
|
||||
let (_, first) = created(&mut s, "Part");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":first,"translate":[10,0,0],"capture":false}));
|
||||
let pending = s.pending_moves.clone();
|
||||
let doc = s.doc.clone();
|
||||
for (cmd, p) in [
|
||||
("occurrence.move", json!({"occurrence":first,"origin":[1e308,0,0],"angle":"180 deg"})),
|
||||
("occurrence.move", json!({"occurrence":first,"translate":[1e9,0,0],"capture":false})),
|
||||
("occurrence.move", json!({"occurrence":first,"translate":[1,2]})),
|
||||
("occurrence.move", json!({"occurrence":first,"angle":false})),
|
||||
("occurrence.move", json!({"occurrence":first,"axis":[1e308,0,0],"angle":"90 deg"})),
|
||||
("occurrence.move", json!({"occurrence":first,"axis":[0,0,0],"angle":"90 deg"})),
|
||||
("occurrence.copy", json!({"occurrence":first,"translate":[1e9,0,0]})),
|
||||
("occurrence.copy", json!({"occurrence":first,"translate":"oops"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd}");
|
||||
assert_eq!(s.pending_moves, pending);
|
||||
assert_eq!(s.doc, doc);
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
let (_, other) = created(&mut s, "Other");
|
||||
assert_eq!(first, other);
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 0.0);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":other,"translate":[20,0,0],"capture":false}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.pending_moves.get(&other).unwrap()[3][0], 20.0);
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 0.0);
|
||||
assert_eq!(s.occurrence_placement(&[other]).unwrap().world[3][0], 20.0);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.pending_moves.is_empty());
|
||||
assert_eq!(s.doc.occurrences.iter().find(|occurrence| occurrence.id == other).unwrap().transform[3][0], 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolate_nested_child_hides_ancestor_material_and_sibling() {
|
||||
let mut s = Session::default();
|
||||
let (_, parent) = created(&mut s, "Parent");
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10}));
|
||||
let (child_component, child) = created(&mut s, "Child");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let parent_component = s.doc.occurrences.iter().find(|o| o.id == child).unwrap().parent;
|
||||
let sibling = s
|
||||
.doc_mut()
|
||||
.add_occurrence(
|
||||
child_component,
|
||||
parent_component,
|
||||
solvecraft_doc::rigid(crate::geom::Vec3::new(20.0, 0.0, 0.0), crate::geom::Vec3::ZERO, crate::geom::Vec3::Z, 0.0),
|
||||
)
|
||||
.unwrap();
|
||||
s.refresh();
|
||||
run(&mut s, "browser.visibility", json!({"visible":true,"paths":[[parent,child]],"isolate":true}));
|
||||
for (path, expected) in [(vec![parent], false), (vec![parent, child], true), (vec![parent, sibling], false)] {
|
||||
let body = s.occurrence_bodies(&path, false).unwrap()[0].clone();
|
||||
assert_eq!(s.body_visible(&body), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_new_child_uses_inactive_parent_placement() {
|
||||
let mut s = Session::default();
|
||||
let (parent_component, parent) = created(&mut s, "Parent");
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":parent,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let child =
|
||||
run(&mut s, "component.create", json!({"parent":parent_component,"parent_path":[second],"name":"Child"}))["occurrence"].as_u64().unwrap();
|
||||
assert_eq!(s.active_occurrence_path, Some(vec![second, child]));
|
||||
assert_eq!(s.active_component_frame()[3][0], 20.0);
|
||||
let doc = s.doc.clone();
|
||||
let active = s.active_occurrence_path.clone();
|
||||
assert!(s.execute("component.create", &json!({"parent":parent_component,"parent_path":[999],"name":"Invalid"})).is_err());
|
||||
assert_eq!(s.doc, doc);
|
||||
assert_eq!(s.active_occurrence_path, active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_new_uses_selected_pending_placement() {
|
||||
let mut s = Session::default();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[30,0,0],"capture":false}));
|
||||
let copied =
|
||||
run(&mut s, "component.paste_new", json!({"component":component,"occurrence":second,"path":[second],"translate":[5,0,0]}))["component"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(s.doc.occurrence_of(copied).unwrap().transform[3][0], 55.0);
|
||||
let result = run(&mut s, "component.properties", json!({"component":copied}));
|
||||
near(&result["total"]["center_of_mass"][0], 55.5);
|
||||
let result = run(&mut s, "occurrence.properties", json!({"occurrence":first,"path":[first]}));
|
||||
near(&result["total"]["center_of_mass"][0], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_styles_follow_source_identity_after_collision_rename_and_pending_move() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":1,"width":1,"height":1}));
|
||||
let root = s.model.state().bodies[0].name.clone();
|
||||
let (component, first) = created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2}));
|
||||
let second = run(&mut s, "occurrence.copy", json!({"occurrence":first,"translate":[20,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
let candidate = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
run(&mut s, "body.rename", json!({"body":root,"name":candidate}));
|
||||
let instance = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
let source = s.occurrence_placement(&[second]).unwrap().bodies[0].source.clone();
|
||||
run(&mut s, "material.assign", json!({"bodies":[instance],"material":"Brass"}));
|
||||
let world = s.world_state();
|
||||
assert_eq!(s.doc.body_color(world.body(&instance).unwrap()), s.doc.body_color(world.body(&source).unwrap()));
|
||||
run(&mut s, "appearance.assign", json!({"components":[component.to_string()],"color":"#0000ff","opacity":0.7}));
|
||||
assert_eq!(s.doc.body_color(s.world_state().body(&instance).unwrap()), Some([0.0, 0.0, 1.0]));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":[instance],"color":"#ff0000","opacity":0.5}));
|
||||
run(&mut s, "appearance.assign", json!({"faces":[{"body":instance,"point":[21,1,2]}],"color":"#00ff00","opacity":0.4}));
|
||||
// Exercise the point-based face fallback as well as persistent face-name matching.
|
||||
for face in &mut s.doc_mut().appearances.faces {
|
||||
face.name = None;
|
||||
}
|
||||
run(&mut s, "body.rename", json!({"body":source,"name":"Shared Renamed"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":false}));
|
||||
let world = s.world_state();
|
||||
for id in [first, second] {
|
||||
let bodyname = s.occurrence_bodies(&[id], false).unwrap()[0].clone();
|
||||
let body = world.body(&bodyname).unwrap();
|
||||
assert_eq!(body.definition_name(), "Shared Renamed");
|
||||
assert_eq!(body.component, component);
|
||||
let look = s.doc.model_body_look(body).unwrap();
|
||||
assert_eq!(look.color, [255, 0, 0]);
|
||||
assert_eq!(look.opacity, 0.5);
|
||||
assert!(s.doc.face_colors(body).iter().any(|(_, l)| l.color == [0, 255, 0] && l.opacity == 0.4));
|
||||
let measured = run(&mut s, "inspect.measure", json!({"bodies":[bodyname]}));
|
||||
assert_eq!(measured["bodies"][0]["material"], "Brass");
|
||||
assert!((measured["bodies"][0]["mass_g"].as_f64().unwrap() - 0.068).abs() < 1e-8);
|
||||
}
|
||||
let definition = s.model.state();
|
||||
assert_eq!(solvecraft_kernel::measure(&definition.body("Shared Renamed").unwrap().body).unwrap().bbox.min, crate::geom::Vec3::ZERO);
|
||||
let painted = s.doc.painted(&world);
|
||||
let secondname = s.occurrence_bodies(&[second], false).unwrap()[0].clone();
|
||||
let body = painted.body(&secondname).unwrap();
|
||||
assert_eq!(body.body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(body.body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
let bytes = solvecraft_io::export(&painted, &[secondname], solvecraft_io::Format::Step, "InstanceStyles").unwrap();
|
||||
let imported = solvecraft_kernel::step_import(&String::from_utf8(bytes).unwrap()).unwrap();
|
||||
assert_eq!(imported.bodies.len(), 1);
|
||||
assert_eq!(imported.bodies[0].body.color(), Some([1.0, 0.0, 0.0]));
|
||||
assert!(imported.bodies[0].body.paint().unwrap().faces.iter().any(|f| f.color == [0.0, 1.0, 0.0]));
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, ProfileSel};
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{CurveKind, Sketch};
|
||||
|
||||
use crate::{Session, Snapshot};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
fn rounded() -> (Session, u64, String) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||
let sketch = s.active_sketch.unwrap();
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]}));
|
||||
let fillet = run(&mut s, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"}));
|
||||
(s, sketch, fillet["param"].as_str().unwrap().into())
|
||||
}
|
||||
fn offset(s: &mut Session, curve: &str, distance: f64) -> (String, Vec<String>) {
|
||||
let result = run(s, "sketch.offset", json!({"curves":[curve],"distance":distance,"side":[0,0]}));
|
||||
(result["association"].as_str().unwrap().into(), result["curves"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().into()).collect())
|
||||
}
|
||||
fn solved(s: &Session, id: u64) -> Sketch {
|
||||
s.model.state().sketch(id).unwrap().sketch.clone()
|
||||
}
|
||||
fn arc_centres(sk: &Sketch, curves: &[String]) -> Vec<(String, usize, Vec2)> {
|
||||
curves
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let CurveKind::Arc { c, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None };
|
||||
Some((id.clone(), c, sk.point(c)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn history(stack: &[Snapshot]) -> Vec<(String, String, Option<u64>, String)> {
|
||||
stack.iter().map(|entry| (entry.label.clone(), entry.doc.to_json(), entry.active_sketch, format!("{:?}", entry.pending_moves))).collect()
|
||||
}
|
||||
fn refuses_atomically(s: &mut Session, command: &str, params: Value) -> String {
|
||||
let document = s.doc.to_json();
|
||||
let undo = history(&s.undo);
|
||||
let redo = history(&s.redo);
|
||||
let active = s.active_sketch;
|
||||
let error = s.execute(command, ¶ms).unwrap_err().to_string();
|
||||
assert_eq!(s.doc.to_json(), document);
|
||||
assert_eq!(history(&s.undo), undo);
|
||||
assert_eq!(history(&s.redo), redo);
|
||||
assert_eq!(s.active_sketch, active);
|
||||
error
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_retired_parent_refuses_new_child_without_destroying_redo() {
|
||||
let (mut s, sketch, _) = rounded();
|
||||
let (_, outputs) = offset(&mut s, "l1", 1.4);
|
||||
assert_eq!(outputs.len(), 4);
|
||||
assert_eq!(solved(&s, sketch).offsets[0].topology.as_ref().unwrap().roles.iter().filter(|r| r.retired).count(), 4);
|
||||
run(&mut s, "sketch.point", json!({"point":[100,100]}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.redo.is_empty());
|
||||
let error = refuses_atomically(&mut s, "sketch.offset", json!({"curves":[outputs[0]],"distance":0.2,"side":[0,0]}));
|
||||
assert!(error.contains("retired corner") && error.contains("break its offset association"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_live_child_follows_radius_then_retirement_is_honest() {
|
||||
let (mut s, sketch, radius) = rounded();
|
||||
let (_, parent) = offset(&mut s, "l1", 0.2);
|
||||
let (child_association, child) = offset(&mut s, &parent[0], 0.2);
|
||||
let arc_radius = |s: &Session| {
|
||||
let sk = solved(s, sketch);
|
||||
child
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let CurveKind::Arc { c, a, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None };
|
||||
Some(sk.point(c)?.dist(sk.point(a)?))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(arc_radius(&s).len(), 4);
|
||||
assert!(arc_radius(&s).iter().all(|r| (*r - 0.6).abs() < 1e-6));
|
||||
run(&mut s, "parameters.change", json!({"name":radius,"expression":"2 mm"}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
assert!(arc_radius(&s).iter().all(|r| (*r - 1.6).abs() < 1e-6));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[child_association]},"distance":2,"operation":"new","body_name":"Child"}),
|
||||
);
|
||||
let before = s.doc.to_json();
|
||||
let undo = history(&s.undo);
|
||||
let redo = history(&s.redo);
|
||||
match s.execute("parameters.change", &json!({"name":radius,"expression":"0.1 mm"})) {
|
||||
Err(error) => {
|
||||
assert!(error.to_string().contains("offset"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(history(&s.undo), undo);
|
||||
assert_eq!(history(&s.redo), redo);
|
||||
}
|
||||
Ok(_) => {
|
||||
assert!(s.model.results.iter().any(|r| r.error.is_some()), "retired parent must not keep a stale successful child");
|
||||
assert!(s.world_state().body("Child").is_none());
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.iter().any(|r| r.error.is_some()));
|
||||
assert!(reopened.world_state().body("Child").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_partial_delete_requires_break_and_surviving_arc_centres_detach() {
|
||||
let (mut s, sketch, _) = rounded();
|
||||
let (association, outputs) = offset(&mut s, "l1", 0.2);
|
||||
let error = refuses_atomically(&mut s, "sketch.delete", json!({"entities":[outputs[0]]}));
|
||||
assert!(error.contains("break the offset association"), "{error}");
|
||||
let source = solved(&s, sketch);
|
||||
let source_ids: Vec<_> = source.curves.iter().filter(|c| !outputs.contains(&c.id)).map(|c| c.id.clone()).collect();
|
||||
let source_centres = arc_centres(&source, &source_ids);
|
||||
assert_eq!(source_centres.len(), 4);
|
||||
run(&mut s, "sketch.offset.break", json!({"curve":association}));
|
||||
let released = solved(&s, sketch);
|
||||
let independent = arc_centres(&released, &outputs);
|
||||
assert_eq!(independent.len(), 4);
|
||||
assert!(independent.iter().all(|(_, centre, _)| source_centres.iter().all(|(_, source, _)| centre != source)));
|
||||
// Released geometry is editable: anchor its new centres independently, then
|
||||
// move a source centre. Shared source nodes would block that actual move.
|
||||
let released_centres: Vec<_> = independent.iter().map(|(id, _, _)| format!("{id}.center")).collect();
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":released_centres,"fixed":true}));
|
||||
run(&mut s, "sketch.delete", json!({"entities":[outputs[0]]}));
|
||||
let deleted = solved(&s, sketch);
|
||||
assert!(deleted.curve_index(&outputs[0]).is_none());
|
||||
let survivors = arc_centres(&deleted, &outputs);
|
||||
let (source_id, _, position) = source_centres.first().unwrap();
|
||||
let target = *position + Vec2::new(5.0, 5.0);
|
||||
run(&mut s, "sketch.move_point", json!({"point":format!("{source_id}.center"),"to":[target.x,target.y]}));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
let changed = solved(&s, sketch);
|
||||
for (id, _, position) in independent {
|
||||
let after = arc_centres(&changed, std::slice::from_ref(&id));
|
||||
assert_eq!(after.len(), 1);
|
||||
assert!(
|
||||
after[0].2.dist(position) < 1e-8,
|
||||
"{id}: released {position:?}, after deletion {:?}, after source move {:?}; deleted curve {}",
|
||||
survivors.iter().find(|(curve, _, _)| curve == &id),
|
||||
after[0],
|
||||
outputs[0]
|
||||
);
|
||||
}
|
||||
let moved = arc_centres(&changed, std::slice::from_ref(source_id));
|
||||
assert_eq!(moved.len(), 1);
|
||||
assert!(moved[0].2.dist(target) < 1e-8);
|
||||
assert!(moved[0].2.dist(*position) > 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_guard_complete_delete_keeps_dangling_profile_loadable_without_circle_retarget() {
|
||||
let (mut s, sketch, _) = rounded();
|
||||
let (association, outputs) = offset(&mut s, "l1", 0.2);
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[100,0],"radius":3,"id":"Unrelated"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new","name":"OffsetBody","body_name":"Selected"}),
|
||||
);
|
||||
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
|
||||
run(&mut s, "sketch.delete", json!({"entities":outputs}));
|
||||
let saved = s.doc.to_json();
|
||||
let FeatureKind::Extrude { profiles: ProfileSel::OffsetBoundaries { associations }, .. } = &s.doc.find_feature("OffsetBody").unwrap().kind else {
|
||||
panic!("explicit offset identity lost")
|
||||
};
|
||||
assert_eq!(associations, &vec![association]);
|
||||
let check = |s: &Session| {
|
||||
assert!(solved(s, sketch).curve_index("Unrelated").is_some());
|
||||
assert!(s.model.results.iter().find(|r| r.id == s.doc.find_feature("OffsetBody").unwrap().id).unwrap().error.is_some());
|
||||
assert!(s.world_state().body("Selected").is_none(), "dangling selection must never adopt the unrelated circle");
|
||||
};
|
||||
check(&s);
|
||||
let reopened = Session::new(Document::from_json(&saved).unwrap());
|
||||
check(&reopened);
|
||||
assert_eq!(reopened.doc.to_json(), saved);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Document, FeatureKind, ProfileSel};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
fn rounded_offset(radius: f64) -> (Session, u64, String, String) {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||
let sketch = session.active_sketch.unwrap();
|
||||
run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-37.-radius,-32.-radius],"p1":[37.+radius,32.+radius]}));
|
||||
let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":radius}));
|
||||
// The radius is the only intended source freedom: keep the four exact arc
|
||||
// centres at (+/-37,+/-32), independently of the corner radius.
|
||||
let centers = fillet["curves"].as_array().unwrap().iter().map(|id| format!("{}.center", id.as_str().unwrap())).collect::<Vec<_>>();
|
||||
run(&mut session, "sketch.constraint.fix", json!({"entities":centers,"fixed":true}));
|
||||
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":1.4,"side":[0,0]}));
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
(session, sketch, fillet["param"].as_str().unwrap().into(), offset["association"].as_str().unwrap().into())
|
||||
}
|
||||
|
||||
fn volume(session: &Session, feature: u64) -> f64 {
|
||||
let state = session.model.state();
|
||||
let body = state.bodies.iter().find(|body| body.feature == feature).unwrap();
|
||||
solvecraft_kernel::measure(&body.body).unwrap().volume
|
||||
}
|
||||
|
||||
fn healthy(session: &Session) {
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()), "{:?}", session.model.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_offset_capture_persists_reserved_curves_without_a_transient_association() {
|
||||
let (session, sketch, radius, _) = rounded_offset(2.);
|
||||
let mut document = (*session.doc).clone();
|
||||
let raw = document.sketch_mut(sketch).unwrap();
|
||||
let reserved = raw.offsets[0].outputs.clone();
|
||||
assert_eq!(reserved.len(), 8);
|
||||
raw.offsets[0].topology = None;
|
||||
document.format = "solvecraft/11".into();
|
||||
let mut session = Session::new(Document::from_json(&document.to_json()).unwrap());
|
||||
assert!(session.doc.sketch(sketch).unwrap().offsets[0].topology.is_none());
|
||||
let state = session.model.state();
|
||||
let solved = state.sketch(sketch).unwrap();
|
||||
assert!(solved.sketch.offsets[0].topology.is_some(), "legacy upgrade belongs only to the evaluated copy");
|
||||
let index = solvecraft_doc::profile_selection_indices(solved, &ProfileSel::Curves { loops: vec![reserved.clone()] }).unwrap()[0];
|
||||
let extrusion = run(&mut session, "solid.extrude", json!({"sketch":sketch,"profiles":[index],"distance":2,"name":"Legacy inner"}))["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&session.doc.feature(extrusion).unwrap().kind,FeatureKind::Extrude {profiles:ProfileSel::Curves {loops},..} if loops == &vec![reserved.clone()])
|
||||
);
|
||||
assert!(session.doc.sketch(sketch).unwrap().offsets[0].topology.is_none());
|
||||
run(&mut session, "parameters.change", json!({"name":radius,"expression":"1 mm"}));
|
||||
healthy(&session);
|
||||
assert_eq!(session.model.state().sketch(sketch).unwrap().sketch.offsets[0].outputs.len(), 4);
|
||||
assert!(
|
||||
(volume(&session, extrusion) - 73.2 * 63.2 * 2.).abs() < 1e-6,
|
||||
"legacy sharp volume {} bounds {:?}",
|
||||
volume(&session, extrusion),
|
||||
session.model.state().bodies.last().unwrap().mesh().bounds()
|
||||
);
|
||||
let saved = session.doc.to_json();
|
||||
let mut reopened = Session::new(Document::from_json(&saved).unwrap());
|
||||
healthy(&reopened);
|
||||
assert_eq!(reopened.doc.to_json(), saved);
|
||||
assert!(reopened.doc.sketch(sketch).unwrap().offsets[0].topology.is_none());
|
||||
assert!((volume(&reopened, extrusion) - 73.2 * 63.2 * 2.).abs() < 1e-6);
|
||||
run(&mut reopened, "parameters.change", json!({"name":radius,"expression":"2 mm"}));
|
||||
healthy(&reopened);
|
||||
let state = reopened.model.state();
|
||||
assert_eq!(state.sketch(sketch).unwrap().sketch.offsets[0].outputs, reserved);
|
||||
let expected = 2. * (75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6);
|
||||
assert!((volume(&reopened, extrusion) - expected).abs() < 0.002);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_offset_boundary_has_scoped_identity_and_independent_dimensions_and_material() {
|
||||
let (mut session, sketch, radius, association) = rounded_offset(1.);
|
||||
let extrusion = run(
|
||||
&mut session,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"name":"Inner"}),
|
||||
)["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let source_body = session.model.state().bodies[0].name.clone();
|
||||
run(&mut session, "material.assign", json!({"bodies":[source_body],"material":"Brass"}));
|
||||
run(&mut session, "timeline.copy", json!({"features":[sketch,extrusion]}));
|
||||
let pasted = run(&mut session, "timeline.paste", json!({"translate":[100,0,0]}));
|
||||
assert_eq!(pasted["errors"], json!([]));
|
||||
let copied_sketch = pasted["pasted"][0]["id"].as_u64().unwrap();
|
||||
let copied_extrusion = pasted["pasted"][1]["id"].as_u64().unwrap();
|
||||
assert_ne!(copied_sketch, sketch);
|
||||
assert!(
|
||||
matches!(&session.doc.feature(copied_extrusion).unwrap().kind,FeatureKind::Extrude {sketch:source,profiles:ProfileSel::OffsetBoundaries {associations},..} if *source==copied_sketch && associations == &vec![association.clone()])
|
||||
);
|
||||
let source = session.doc.sketch(sketch).unwrap();
|
||||
let copy = session.doc.sketch(copied_sketch).unwrap();
|
||||
assert_eq!(source.offsets[0].topology.as_ref().unwrap().id, copy.offsets[0].topology.as_ref().unwrap().id);
|
||||
let dimension = source.constraints.iter().position(|constraint| constraint.param.as_deref() == Some(&radius)).unwrap();
|
||||
let copied_radius = copy.constraints[dimension].param.clone().unwrap();
|
||||
assert_ne!(copied_radius, radius);
|
||||
let copied_body = session.model.state().bodies.iter().find(|body| body.feature == copied_extrusion).unwrap().name.clone();
|
||||
run(&mut session, "material.assign", json!({"bodies":[copied_body],"material":"Aluminum"}));
|
||||
let sharp = 73.2 * 63.2 * 2.;
|
||||
assert!((volume(&session, extrusion) - sharp).abs() < 1e-6);
|
||||
assert!((volume(&session, copied_extrusion) - sharp).abs() < 1e-6);
|
||||
run(&mut session, "parameters.change", json!({"name":copied_radius,"expression":"2 mm"}));
|
||||
healthy(&session);
|
||||
let state = session.model.state();
|
||||
assert_eq!(state.sketch(sketch).unwrap().sketch.offsets[0].outputs.len(), 4);
|
||||
assert_eq!(state.sketch(copied_sketch).unwrap().sketch.offsets[0].outputs.len(), 8);
|
||||
assert!((volume(&session, extrusion) - sharp).abs() < 1e-6);
|
||||
let rounded = 2. * (75.2 * 65.2 - (4. - std::f64::consts::PI) * 0.6 * 0.6);
|
||||
assert!(
|
||||
(volume(&session, copied_extrusion) - rounded).abs() < 0.002,
|
||||
"copied rounded volume {} expected {rounded} bounds {:?}",
|
||||
volume(&session, copied_extrusion),
|
||||
session.model.state().bodies.last().unwrap().mesh().bounds()
|
||||
);
|
||||
assert_eq!(session.doc.materials.get(&source_body).map(String::as_str), Some("Brass"));
|
||||
assert_eq!(session.doc.materials.get(&copied_body).map(String::as_str), Some("Aluminum"));
|
||||
let reopened = Session::new(Document::from_json(&session.doc.to_json()).unwrap());
|
||||
healthy(&reopened);
|
||||
assert!((volume(&reopened, extrusion) - sharp).abs() < 1e-6);
|
||||
assert!((volume(&reopened, copied_extrusion) - rounded).abs() < 0.002);
|
||||
let state = reopened.world_state();
|
||||
let bounds = state.body(&copied_body).unwrap().mesh().bounds();
|
||||
assert!((bounds.min.x - (100. - 37.6)).abs() < 1e-6);
|
||||
assert!((bounds.max.x - (100. + 37.6)).abs() < 1e-6);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, ProfileSel};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breaking_offset_preserves_downstream_material_history_and_source_independence() {
|
||||
for distance in [0.2, 1.4] {
|
||||
let (mut session, sketch, radius) = rounded_source();
|
||||
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":distance,"side":[0,0]}));
|
||||
let association = offset["association"].as_str().unwrap().to_string();
|
||||
let curves = session.model.state().sketch(sketch).unwrap().sketch.offset_boundary(&association).unwrap();
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
run(&mut session, "solid.extrude", json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new"}));
|
||||
let before_volume = session.world_state().bodies.last().unwrap().mesh().measure().volume.abs();
|
||||
run(&mut session, "sketch.edit", json!({"sketch":sketch}));
|
||||
let before = session.doc.to_json();
|
||||
let history = session.undo.len();
|
||||
run(&mut session, "sketch.offset.break", json!({"curve":association}));
|
||||
assert_eq!(session.undo.len(), history + 1);
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!(
|
||||
matches!(&session.doc.features.last().unwrap().kind, FeatureKind::Extrude { profiles: ProfileSel::Curves { loops }, .. } if loops == &vec![curves.clone()])
|
||||
);
|
||||
let frozen = session.doc.to_json();
|
||||
assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
assert_eq!(session.doc.to_json(), before);
|
||||
run(&mut session, "edit.redo", json!({}));
|
||||
assert_eq!(session.doc.to_json(), frozen);
|
||||
run(&mut session, "parameters.change", json!({"name":radius,"expression":"2 mm"}));
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((session.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
assert!(reopened.model.results.iter().all(|result| result.error.is_none()));
|
||||
assert!((reopened.world_state().bodies.last().unwrap().mesh().measure().volume.abs() - before_volume).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
fn rounded_source() -> (Session, u64, String) {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Rounded"}));
|
||||
let sketch = session.active_sketch.unwrap();
|
||||
run(&mut session, "sketch.rectangle.two_point", json!({"p0":[-38,-33],"p1":[38,33]}));
|
||||
let fillet = run(&mut session, "sketch.fillet", json!({"points":["l1.start","l1.end","l2.end","l3.end"],"radius":"1 mm"}));
|
||||
(session, sketch, fillet["param"].as_str().unwrap().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inset_past_corner_radius_creates_a_real_boundary_and_saved_profile_identity() {
|
||||
let (mut session, sketch, _) = rounded_source();
|
||||
let offset = run(&mut session, "sketch.offset", json!({"curves":["l1"],"distance":1.4,"side":[0,0]}));
|
||||
assert_eq!(offset["curves"].as_array().unwrap().len(), 4);
|
||||
let state = session.model.state();
|
||||
let solved = state.sketch(sketch).unwrap();
|
||||
let record = solved.sketch.offsets.last().unwrap();
|
||||
let topology = record.topology.as_ref().unwrap();
|
||||
let association = topology.id.clone();
|
||||
assert_eq!(topology.roles.len(), 8);
|
||||
assert_eq!(topology.roles.iter().filter(|role| role.retired).count(), 4);
|
||||
assert_eq!(solved.sketch.offset_boundary(&association).unwrap(), record.outputs);
|
||||
assert!(topology.roles.iter().filter(|role| role.retired).all(|role| solved.sketch.curve_index(&role.output).is_none()));
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut session,
|
||||
"solid.extrude",
|
||||
json!({"sketch":sketch,"profiles":{"offset_boundaries":[association]},"distance":2,"operation":"new","name":"Inner"}),
|
||||
);
|
||||
let feature = session.doc.features.last().unwrap();
|
||||
assert!(matches!(&feature.kind, FeatureKind::Extrude { profiles: ProfileSel::OffsetBoundaries { associations }, .. } if associations.len() == 1));
|
||||
let check = |session: &Session| {
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
let state = session.world_state();
|
||||
let body = state.bodies.last().unwrap();
|
||||
// The exact inward parallel set is a sharp 73.2 by 63.2 rectangle.
|
||||
let mesh = body.mesh();
|
||||
assert!((mesh.measure().volume.abs() - 73.2 * 63.2 * 2.0).abs() < 1e-6);
|
||||
assert!(mesh.contains(Vec3::new(36.5, 31.5, 1.0)));
|
||||
assert!(!mesh.contains(Vec3::new(36.7, 31.5, 1.0)));
|
||||
assert!(!mesh.contains(Vec3::new(36.5, 31.7, 1.0)));
|
||||
};
|
||||
check(&session);
|
||||
let saved = session.doc.to_json();
|
||||
let reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
|
||||
check(&reopened);
|
||||
assert_eq!(reopened.doc.to_json(), saved);
|
||||
let undo = session.undo.len();
|
||||
let redo = session.redo.len();
|
||||
for params in [
|
||||
json!({"profiles":{"offset_boundaries":[]}}),
|
||||
json!({"profiles":{"offset_boundaries":["missing"]}}),
|
||||
json!({"profiles":{"offset_boundaries":[association,association]}}),
|
||||
json!({"profiles":[[record.outputs[0],record.outputs[1],record.outputs[2]]]}),
|
||||
] {
|
||||
let mut params = params;
|
||||
params["sketch"] = json!(sketch);
|
||||
params["distance"] = json!(1);
|
||||
assert!(session.execute("solid.extrude", ¶ms).is_err());
|
||||
assert_eq!(session.doc.to_json(), saved);
|
||||
assert_eq!(session.undo.len(), undo);
|
||||
assert_eq!(session.redo.len(), redo);
|
||||
}
|
||||
}
|
||||
@@ -226,16 +226,15 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"format": format, "path": path, "text": text}))
|
||||
}
|
||||
|
||||
/// Largest parameter table we read from a file.
|
||||
const MAX_PARAMETER_BYTES: usize = 16 << 20;
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.import";
|
||||
let text = match (str_(p, "text"), str_(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(path)) => {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta > 16 << 20 {
|
||||
return Err(bad(cmd, "file too large"));
|
||||
}
|
||||
solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
solvecraft_io::vfs::read_to_string(path, MAX_PARAMETER_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
}
|
||||
_ => return Err(bad(cmd, "give `text` or `path`")),
|
||||
};
|
||||
|
||||
@@ -464,7 +464,7 @@ mod tests {
|
||||
let st = s.world_state();
|
||||
let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0);
|
||||
let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN));
|
||||
for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) {
|
||||
for b in st.bodies.iter().filter(|b| s.doc.model_body_component(b) == comp) {
|
||||
let m = b.mesh().bounds();
|
||||
lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z));
|
||||
hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z));
|
||||
|
||||
@@ -267,6 +267,7 @@ fn snap_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use solvecraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -352,6 +353,40 @@ mod tests {
|
||||
assert!(s.execute("plastic.snap_fit", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_snap_fit_follows_resized_front_wall() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
let feature = s.doc.find_feature("SnapFit1").unwrap().clone();
|
||||
run(&mut s, "parameters.change", json!({"name":"board_d","expression":"50 mm"}));
|
||||
let before = s.model.state_before(feature.id);
|
||||
let name = feature.face_names.first().unwrap();
|
||||
let point = solvecraft_doc::naming::point_on_face(&before, name, Vec3::new(0., -28., 20.))
|
||||
.expect("the original wall identity survives merged pieces")
|
||||
.0;
|
||||
assert!((point - Vec3::new(0., -33., 20.)).len() < 1e-8, "{point:?}");
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none()));
|
||||
let check = |session: &Session| {
|
||||
let before = session.model.state_before(feature.id);
|
||||
let after = &session.model.result(feature.id).unwrap().state;
|
||||
let old = before.body("Case").unwrap();
|
||||
let body = after.body("Case").unwrap();
|
||||
let added = solvecraft_kernel::measure(&body.body).unwrap().volume - solvecraft_kernel::measure(&old.body).unwrap().volume;
|
||||
// Rectangular arm + triangular catch; the small attachment overlap lies inside the wall.
|
||||
assert!((added - (8. * 1.2 + 0.8 * 1.5 / 2.) * 6.).abs() < 0.05, "{added}");
|
||||
let mesh = body.mesh();
|
||||
assert!(mesh.contains(Vec3::new(0., -29., 19.4)), "the arm follows the inner wall");
|
||||
assert!(mesh.contains(Vec3::new(0., -25.9, 20.2)), "the catch follows the arm");
|
||||
assert!(!mesh.contains(Vec3::new(0., -22., 19.4)), "the old arm placement is empty");
|
||||
assert!(!mesh.contains(Vec3::new(0., -24., 19.4)), "the arm ends at its specified length");
|
||||
};
|
||||
check(&s);
|
||||
let cold = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!(cold.model.results.iter().all(|result| result.error.is_none()));
|
||||
check(&cold);
|
||||
}
|
||||
|
||||
fn plate() -> (Session, f64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 60, "height": 3}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user